From 3a3ed0b0a5e0ecf96ca42880d3b0cbe9614e1c16 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Fri, 3 Apr 2026 23:02:22 +1100 Subject: [PATCH 1/2] CLI Initial draft --- cli/.gitignore | 2 + cli/.mise.toml | 2 + cli/bun.lock | 91 +++ cli/package.json | 24 + cli/src/config.ts | 64 ++ cli/src/main.ts | 444 ++++++++++++ cli/src/manifest.ts | 89 +++ cli/tsconfig.json | 14 + web/app/(auth)/register/page.tsx | 122 +--- web/app/api/v1/cli/auth/exchange/route.ts | 57 ++ web/app/api/v1/cli/auth/whoami/route.ts | 18 + web/app/api/v1/manifest/apply/route.ts | 32 + web/app/api/v1/manifest/deploy/route.ts | 32 + web/app/api/v1/manifest/status/route.ts | 45 ++ web/app/device/approve/page.tsx | 17 + web/app/device/page.tsx | 17 + web/app/page.tsx | 129 +--- web/components/auth/device-approval-page.tsx | 119 ++++ .../auth/device-authorization-page.tsx | 119 ++++ web/components/auth/register-page.tsx | 122 ++++ web/components/auth/sign-in-page.tsx | 133 ++++ web/db/schema.ts | 73 ++ web/lib/api-auth.ts | 23 + web/lib/auth-client.ts | 8 +- web/lib/auth.ts | 17 + web/lib/cli-manifest.ts | 99 +++ web/lib/cli-service.ts | 634 ++++++++++++++++++ 27 files changed, 2318 insertions(+), 228 deletions(-) create mode 100644 cli/.gitignore create mode 100644 cli/.mise.toml create mode 100644 cli/bun.lock create mode 100644 cli/package.json create mode 100644 cli/src/config.ts create mode 100644 cli/src/main.ts create mode 100644 cli/src/manifest.ts create mode 100644 cli/tsconfig.json create mode 100644 web/app/api/v1/cli/auth/exchange/route.ts create mode 100644 web/app/api/v1/cli/auth/whoami/route.ts create mode 100644 web/app/api/v1/manifest/apply/route.ts create mode 100644 web/app/api/v1/manifest/deploy/route.ts create mode 100644 web/app/api/v1/manifest/status/route.ts create mode 100644 web/app/device/approve/page.tsx create mode 100644 web/app/device/page.tsx create mode 100644 web/components/auth/device-approval-page.tsx create mode 100644 web/components/auth/device-authorization-page.tsx create mode 100644 web/components/auth/register-page.tsx create mode 100644 web/components/auth/sign-in-page.tsx create mode 100644 web/lib/api-auth.ts create mode 100644 web/lib/cli-manifest.ts create mode 100644 web/lib/cli-service.ts diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..44d646d --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist/ diff --git a/cli/.mise.toml b/cli/.mise.toml new file mode 100644 index 0000000..a94d1ed --- /dev/null +++ b/cli/.mise.toml @@ -0,0 +1,2 @@ +[tools] +bun = "latest" diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 0000000..4b10c3f --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,91 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "techulus-cli", + "dependencies": { + "yaml": "^2.8.2", + "zod": "^4.3.5", + }, + "devDependencies": { + "@types/node": "^22.17.0", + "tsx": "^4.19.2", + "typescript": "^5.9.2", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@types/node": ["@types/node@22.19.16", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-K6csxIjY+9RoDxdP6/wzaJzXaCf4znBz0/y0rrQDsbqmzQ5QFsOjubbsYWZhj6ZCgz3mjlyDZS+EJkhA9jWl9Q=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "0.27.7", "get-tsconfig": "4.13.7" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..1b8a94f --- /dev/null +++ b/cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "techulus-cli", + "version": "0.1.0", + "private": true, + "type": "module", +"scripts": { + "dev": "node --import tsx src/main.ts", + "build": "bun build src/main.ts --compile --outfile dist/tcloud", + "build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/tcloud-linux-x64", + "build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/tcloud-linux-arm64", + "build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/tcloud-darwin-x64", + "build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/tcloud-darwin-arm64", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "yaml": "^2.8.2", + "zod": "^4.3.5" + }, + "devDependencies": { + "@types/node": "^22.17.0", + "tsx": "^4.19.2", + "typescript": "^5.9.2" + } +} diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..c4b5c15 --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,64 @@ +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export type CliConfig = { + host: string; + apiKey: string; + keyId?: string; + keyName?: string | null; + user?: { + id: string; + email: string; + name: string; + }; +}; + +function getConfigRoot() { + if (process.env.XDG_CONFIG_HOME) { + return process.env.XDG_CONFIG_HOME; + } + + if (process.platform === "darwin") { + return path.join(os.homedir(), "Library", "Application Support"); + } + + if (process.platform === "win32" && process.env.APPDATA) { + return process.env.APPDATA; + } + + return path.join(os.homedir(), ".config"); +} + +export function getConfigDir() { + return path.join(getConfigRoot(), "techulus-cloud-cli"); +} + +export function getConfigPath() { + return path.join(getConfigDir(), "config.json"); +} + +export async function readConfig(): Promise { + try { + const contents = await readFile(getConfigPath(), "utf8"); + return JSON.parse(contents) as CliConfig; + } catch { + return null; + } +} + +export async function writeConfig(config: CliConfig) { + const dir = getConfigDir(); + const file = getConfigPath(); + + await mkdir(dir, { recursive: true, mode: 0o700 }); + await writeFile(file, JSON.stringify(config, null, 2), { + encoding: "utf8", + mode: 0o600, + }); + await chmod(file, 0o600); +} + +export async function deleteConfig() { + await rm(getConfigPath(), { force: true }); +} diff --git a/cli/src/main.ts b/cli/src/main.ts new file mode 100644 index 0000000..1b1796f --- /dev/null +++ b/cli/src/main.ts @@ -0,0 +1,444 @@ +import { access, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { constants as fsConstants } from "node:fs"; +import { spawn } from "node:child_process"; +import { deleteConfig, readConfig, writeConfig } from "./config.js"; +import { loadManifest, slugify, type TechulusManifest } from "./manifest.js"; + +const CLI_VERSION = "0.1.0"; +const CLI_CLIENT_ID = "techulus-cli"; + +type JsonRequestOptions = { + method?: string; + headers?: Record; + body?: unknown; +}; + +function normalizeHost(host: string) { + const trimmed = host.trim().replace(/\/$/, ""); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return `https://${trimmed}`; + } + + return trimmed; +} + +async function requestJson(url: string, options: JsonRequestOptions = {}) { + const response = await fetch(url, { + method: options.method ?? "GET", + headers: { + "content-type": "application/json", + ...(options.headers ?? {}), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + + const text = await response.text(); + const data = text ? (JSON.parse(text) as T | { error?: string }) : null; + + if (!response.ok) { + const message = + data && typeof data === "object" && "error" in data && data.error + ? data.error + : `Request failed with ${response.status}`; + throw new Error(message); + } + + return data as T; +} + +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseOption(args: string[], name: string) { + const index = args.indexOf(name); + if (index === -1) { + return null; + } + + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for ${name}`); + } + + return value; +} + +function printUsage() { + console.log(`Usage: + tcloud auth login --host + tcloud auth logout + tcloud auth whoami + tcloud init + tcloud apply + tcloud deploy + tcloud status`); +} + +function openBrowser(url: string) { + const command = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = + process.platform === "win32" ? ["/c", "start", "", url] : [url]; + + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + }); + + child.unref(); +} + +async function ensureManifest(cwd: string) { + try { + return await loadManifest(cwd); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + throw new Error( + "No techulus.yml found in the current directory. Run `tcloud init` to create one.", + ); + } + throw new Error( + error instanceof Error + ? `Invalid techulus.yml: ${error.message}` + : "Failed to load techulus.yml", + ); + } +} + +function authHeaders(apiKey: string) { + return { + "x-api-key": apiKey, + }; +} + +async function requireConfig() { + const config = await readConfig(); + if (!config) { + throw new Error("Not logged in. Run `tcloud auth login --host ` first."); + } + + return config; +} + +async function commandAuthLogin(args: string[]) { + const existingConfig = await readConfig(); + const rawHost = parseOption(args, "--host") ?? existingConfig?.host; + + if (!rawHost) { + throw new Error("Missing --host"); + } + + const host = normalizeHost(rawHost); + + const deviceCode = await requestJson<{ + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; + }>(`${host}/api/auth/device/code`, { + method: "POST", + body: { + client_id: CLI_CLIENT_ID, + scope: "cli", + }, + }); + + console.log(`Visit ${deviceCode.verification_uri}`); + console.log(`Enter code: ${deviceCode.user_code}`); + + try { + openBrowser(deviceCode.verification_uri_complete || deviceCode.verification_uri); + console.log("Opened your browser for approval."); + } catch { + console.log("Could not open the browser automatically."); + } + + let accessToken = ""; + let intervalMs = deviceCode.interval * 1000; + + while (!accessToken) { + await sleep(intervalMs); + + const response = await fetch(`${host}/api/auth/device/token`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode.device_code, + client_id: CLI_CLIENT_ID, + }), + }); + + const data = (await response.json()) as + | { + access_token: string; + } + | { + error: string; + error_description?: string; + }; + + if (response.ok && "access_token" in data) { + accessToken = data.access_token; + break; + } + + if (!("error" in data)) { + throw new Error("Unexpected response from device token endpoint"); + } + + switch (data.error) { + case "authorization_pending": + process.stdout.write("."); + break; + case "slow_down": + intervalMs += 5000; + break; + case "access_denied": + throw new Error(data.error_description || "Device authorization was denied"); + case "expired_token": + throw new Error(data.error_description || "Device authorization expired"); + default: + throw new Error(data.error_description || data.error); + } + } + + console.log("\nDevice login approved. Creating a CLI API key..."); + + const machineName = os.hostname(); + const platform = `${process.platform}/${process.arch}`; + const exchange = await requestJson<{ + apiKey: string; + keyId: string; + name: string | null; + user: { id: string; email: string; name: string }; + }>(`${host}/api/v1/cli/auth/exchange`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + }, + body: { + machineName, + platform, + cliVersion: CLI_VERSION, + }, + }); + + await writeConfig({ + host, + apiKey: exchange.apiKey, + keyId: exchange.keyId, + keyName: exchange.name, + user: exchange.user, + }); + + console.log(`Signed in as ${exchange.user.email}`); +} + +async function commandAuthLogout() { + await deleteConfig(); + console.log("Signed out."); +} + +async function commandAuthWhoAmI() { + const config = await requireConfig(); + const whoami = await requestJson<{ + user: { id: string; email: string; name: string }; + }>(`${config.host}/api/v1/cli/auth/whoami`, { + headers: authHeaders(config.apiKey), + }); + + console.log(`Signed in as ${whoami.user.email}`); + console.log(`Name: ${whoami.user.name}`); + console.log(`Host: ${config.host}`); +} + +async function commandInit(cwd: string) { + const manifestPath = path.join(cwd, "techulus.yml"); + try { + await access(manifestPath, fsConstants.F_OK); + throw new Error("techulus.yml already exists"); + } catch (error) { + if (error instanceof Error && error.message === "techulus.yml already exists") { + throw error; + } + } + + const folderName = slugify(path.basename(cwd)) || "my-service"; + const manifest = `apiVersion: v1 +project: ${folderName} +environment: production +service: + name: ${folderName} + source: + type: image + image: nginx:latest + replicas: + count: 1 + ports: + - port: 80 + public: false +`; + + await writeFile(manifestPath, manifest, "utf8"); + console.log(`Created ${manifestPath}`); +} + +function printApplyResult(result: { + action: "created" | "updated" | "noop"; + serviceId: string; + changes: Array<{ field: string; from: string; to: string }>; +}) { + console.log(`Action: ${result.action}`); + console.log(`Service ID: ${result.serviceId}`); + + if (result.changes.length === 0) { + console.log("No changes."); + return; + } + + console.log("Changes:"); + for (const change of result.changes) { + console.log(`- ${change.field}: ${change.from} -> ${change.to}`); + } +} + +async function commandApply(cwd: string) { + const config = await requireConfig(); + const { manifest } = await ensureManifest(cwd); + const result = await requestJson<{ + action: "created" | "updated" | "noop"; + serviceId: string; + changes: Array<{ field: string; from: string; to: string }>; + }>(`${config.host}/api/v1/manifest/apply`, { + method: "POST", + headers: authHeaders(config.apiKey), + body: manifest, + }); + + printApplyResult(result); +} + +async function commandDeploy(cwd: string) { + const config = await requireConfig(); + const { manifest } = await ensureManifest(cwd); + const result = await requestJson<{ + serviceId: string; + rolloutId: string | null; + status: string; + }>(`${config.host}/api/v1/manifest/deploy`, { + method: "POST", + headers: authHeaders(config.apiKey), + body: manifest, + }); + + console.log(`Service ID: ${result.serviceId}`); + console.log(`Status: ${result.status}`); + if (result.rolloutId) { + console.log(`Rollout ID: ${result.rolloutId}`); + } +} + +async function commandStatus(cwd: string) { + const config = await requireConfig(); + const { manifest } = await ensureManifest(cwd); + const params = new URLSearchParams({ + project: manifest.project, + environment: manifest.environment, + service: manifest.service.name, + }); + const status = await requestJson<{ + service: { + id: string; + image: string; + hostname: string | null; + replicas: number; + }; + latestRollout: { + id: string; + status: string; + currentStage: string | null; + } | null; + deployments: Array<{ + id: string; + status: string; + serverId: string; + }>; + }>(`${config.host}/api/v1/manifest/status?${params.toString()}`, { + headers: authHeaders(config.apiKey), + }); + + console.log(`Service ID: ${status.service.id}`); + console.log(`Image: ${status.service.image}`); + console.log(`Hostname: ${status.service.hostname ?? "(none)"}`); + console.log(`Replicas: ${status.service.replicas}`); + if (status.latestRollout) { + console.log( + `Latest rollout: ${status.latestRollout.id} (${status.latestRollout.status}${status.latestRollout.currentStage ? `, ${status.latestRollout.currentStage}` : ""})`, + ); + } else { + console.log("Latest rollout: none"); + } + console.log(`Deployments: ${status.deployments.length}`); + for (const deployment of status.deployments) { + console.log(`- ${deployment.id}: ${deployment.status} on ${deployment.serverId}`); + } +} + +async function main() { + const [command, subcommand, ...rest] = process.argv.slice(2); + const cwd = process.cwd(); + + if (!command) { + printUsage(); + return; + } + + switch (command) { + case "auth": + switch (subcommand) { + case "login": + await commandAuthLogin(rest); + return; + case "logout": + await commandAuthLogout(); + return; + case "whoami": + await commandAuthWhoAmI(); + return; + default: + printUsage(); + return; + } + case "init": + await commandInit(cwd); + return; + case "apply": + await commandApply(cwd); + return; + case "deploy": + await commandDeploy(cwd); + return; + case "status": + await commandStatus(cwd); + return; + default: + printUsage(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : "Unknown error"); + process.exit(1); +}); diff --git a/cli/src/manifest.ts b/cli/src/manifest.ts new file mode 100644 index 0000000..2c91a93 --- /dev/null +++ b/cli/src/manifest.ts @@ -0,0 +1,89 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import YAML from "yaml"; +import { z } from "zod"; + +const manifestPortSchema = z + .object({ + port: z.number().int().min(1).max(65535), + public: z.boolean().default(false), + domain: z.string().trim().min(1).optional(), + }) + .strict(); + +const manifestHealthCheckSchema = z + .object({ + cmd: z.string().trim().min(1), + interval: z.number().int().min(1).default(10), + timeout: z.number().int().min(1).default(5), + retries: z.number().int().min(1).default(3), + startPeriod: z.number().int().min(0).default(30), + }) + .strict(); + +const manifestResourcesSchema = z + .object({ + cpuCores: z.number().min(0.1).max(64).nullable().optional(), + memoryMb: z.number().int().min(64).max(65536).nullable().optional(), + }) + .strict() + .superRefine((value, ctx) => { + const hasCpu = value.cpuCores !== undefined && value.cpuCores !== null; + const hasMemory = value.memoryMb !== undefined && value.memoryMb !== null; + + if (hasCpu !== hasMemory) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "resources must set both cpuCores and memoryMb together", + }); + } + }); + +export const techulusManifestSchema = z + .object({ + apiVersion: z.literal("v1"), + project: z.string().trim().min(1), + environment: z.string().trim().min(1), + service: z + .object({ + name: z.string().trim().min(1), + source: z + .object({ + type: z.literal("image"), + image: z.string().trim().min(1), + }) + .strict(), + hostname: z.string().trim().min(1).optional(), + ports: z.array(manifestPortSchema).default([]), + replicas: z + .object({ + count: z.number().int().min(1).max(10).default(1), + }) + .strict() + .default({ count: 1 }), + healthCheck: manifestHealthCheckSchema.optional(), + startCommand: z.string().trim().min(1).optional(), + resources: manifestResourcesSchema.optional(), + }) + .strict(), + }) + .strict(); + +export type TechulusManifest = z.infer; + +export async function loadManifest(cwd: string) { + const manifestPath = path.join(cwd, "techulus.yml"); + const raw = await readFile(manifestPath, "utf8"); + const parsed = YAML.parse(raw); + return { + path: manifestPath, + manifest: techulusManifestSchema.parse(parsed), + }; +} + +export function slugify(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..0005191 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/web/app/(auth)/register/page.tsx b/web/app/(auth)/register/page.tsx index 097d904..583f9b9 100644 --- a/web/app/(auth)/register/page.tsx +++ b/web/app/(auth)/register/page.tsx @@ -1,113 +1,17 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { signUp } from "@/lib/auth-client"; - -export default function RegisterPage() { - const router = useRouter(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(""); - setLoading(true); - - const { error } = await signUp.email({ - name, - email, - password, - }); - - setLoading(false); - - if (error) { - setError(error.message || "Failed to create account"); - return; - } - - router.push("/dashboard"); - } +import { Suspense } from "react"; +import { RegisterPage } from "@/components/auth/register-page"; +import { Spinner } from "@/components/ui/spinner"; +export default function Page() { return ( -
- - - Create Account - - Enter your details to create a new account - - -
- - {error && ( -
- {error} -
- )} -
- - setName(e.target.value)} - required - /> -
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - minLength={8} - /> -
-
- - -

- Already have an account?{" "} - - Sign in - -

-
-
-
-
+ + + + } + > + + ); } diff --git a/web/app/api/v1/cli/auth/exchange/route.ts b/web/app/api/v1/cli/auth/exchange/route.ts new file mode 100644 index 0000000..03778ee --- /dev/null +++ b/web/app/api/v1/cli/auth/exchange/route.ts @@ -0,0 +1,57 @@ +export const dynamic = "force-dynamic"; + +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { requireRequestSession } from "@/lib/api-auth"; + +const exchangeSchema = z + .object({ + machineName: z.string().trim().min(1).max(128).optional(), + platform: z.string().trim().min(1).max(128).optional(), + cliVersion: z.string().trim().min(1).max(64).optional(), + }) + .strict(); + +export async function POST(request: Request) { + const sessionResult = await requireRequestSession(request); + if (!sessionResult.ok) { + return sessionResult.response; + } + + const body = await request.json().catch(() => ({})); + const parsed = exchangeSchema.safeParse(body); + + if (!parsed.success) { + return Response.json( + { error: parsed.error.issues[0]?.message || "Invalid request" }, + { status: 400 }, + ); + } + + const metadata = { + creationSource: "techulus-cli", + machineName: parsed.data.machineName ?? null, + platform: parsed.data.platform ?? null, + cliVersion: parsed.data.cliVersion ?? null, + host: new URL(request.url).origin, + }; + + const name = parsed.data.machineName + ? `CLI - ${parsed.data.machineName}`.slice(0, 32) + : "CLI"; + + const apiKey = await auth.api.createApiKey({ + headers: request.headers, + body: { + name, + metadata, + }, + }); + + return Response.json({ + apiKey: apiKey.key, + keyId: apiKey.id, + name: apiKey.name, + user: sessionResult.session.user, + }); +} diff --git a/web/app/api/v1/cli/auth/whoami/route.ts b/web/app/api/v1/cli/auth/whoami/route.ts new file mode 100644 index 0000000..d7e00fe --- /dev/null +++ b/web/app/api/v1/cli/auth/whoami/route.ts @@ -0,0 +1,18 @@ +export const dynamic = "force-dynamic"; + +import { requireRequestSession } from "@/lib/api-auth"; + +export async function GET(request: Request) { + const sessionResult = await requireRequestSession(request); + if (!sessionResult.ok) { + return sessionResult.response; + } + + return Response.json({ + user: sessionResult.session.user, + session: { + id: sessionResult.session.session.id, + expiresAt: sessionResult.session.session.expiresAt, + }, + }); +} diff --git a/web/app/api/v1/manifest/apply/route.ts b/web/app/api/v1/manifest/apply/route.ts new file mode 100644 index 0000000..9d8b1b2 --- /dev/null +++ b/web/app/api/v1/manifest/apply/route.ts @@ -0,0 +1,32 @@ +export const dynamic = "force-dynamic"; + +import { techulusManifestSchema } from "@/lib/cli-manifest"; +import { applyManifest } from "@/lib/cli-service"; +import { requireRequestSession } from "@/lib/api-auth"; + +export async function POST(request: Request) { + const sessionResult = await requireRequestSession(request); + if (!sessionResult.ok) { + return sessionResult.response; + } + + const body = await request.json().catch(() => null); + const parsed = techulusManifestSchema.safeParse(body); + + if (!parsed.success) { + return Response.json( + { error: parsed.error.issues[0]?.message || "Invalid manifest" }, + { status: 400 }, + ); + } + + try { + const result = await applyManifest(parsed.data); + return Response.json(result); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "Failed to apply manifest" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/v1/manifest/deploy/route.ts b/web/app/api/v1/manifest/deploy/route.ts new file mode 100644 index 0000000..bcf0696 --- /dev/null +++ b/web/app/api/v1/manifest/deploy/route.ts @@ -0,0 +1,32 @@ +export const dynamic = "force-dynamic"; + +import { techulusManifestSchema } from "@/lib/cli-manifest"; +import { deployManifest } from "@/lib/cli-service"; +import { requireRequestSession } from "@/lib/api-auth"; + +export async function POST(request: Request) { + const sessionResult = await requireRequestSession(request); + if (!sessionResult.ok) { + return sessionResult.response; + } + + const body = await request.json().catch(() => null); + const parsed = techulusManifestSchema.safeParse(body); + + if (!parsed.success) { + return Response.json( + { error: parsed.error.issues[0]?.message || "Invalid manifest" }, + { status: 400 }, + ); + } + + try { + const result = await deployManifest(parsed.data); + return Response.json(result); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "Failed to deploy manifest" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/v1/manifest/status/route.ts b/web/app/api/v1/manifest/status/route.ts new file mode 100644 index 0000000..5cf919d --- /dev/null +++ b/web/app/api/v1/manifest/status/route.ts @@ -0,0 +1,45 @@ +export const dynamic = "force-dynamic"; + +import { z } from "zod"; +import { getManifestStatus } from "@/lib/cli-service"; +import { requireRequestSession } from "@/lib/api-auth"; +import { slugify } from "@/lib/utils"; + +const querySchema = z.object({ + project: z.string().trim().min(1), + environment: z.string().trim().min(1), + service: z.string().trim().min(1), +}); + +export async function GET(request: Request) { + const sessionResult = await requireRequestSession(request); + if (!sessionResult.ok) { + return sessionResult.response; + } + + const { searchParams } = new URL(request.url); + const parsed = querySchema.safeParse({ + project: searchParams.get("project"), + environment: searchParams.get("environment"), + service: searchParams.get("service"), + }); + + if (!parsed.success) { + return Response.json( + { error: parsed.error.issues[0]?.message || "Invalid request" }, + { status: 400 }, + ); + } + + const status = await getManifestStatus({ + project: slugify(parsed.data.project), + environment: parsed.data.environment, + service: parsed.data.service, + }); + + if (!status) { + return Response.json({ error: "Service not found" }, { status: 404 }); + } + + return Response.json(status); +} diff --git a/web/app/device/approve/page.tsx b/web/app/device/approve/page.tsx new file mode 100644 index 0000000..12eb557 --- /dev/null +++ b/web/app/device/approve/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from "react"; +import { DeviceApprovalPage } from "@/components/auth/device-approval-page"; +import { Spinner } from "@/components/ui/spinner"; + +export default function Page() { + return ( + + + + } + > + + + ); +} diff --git a/web/app/device/page.tsx b/web/app/device/page.tsx new file mode 100644 index 0000000..eb9592d --- /dev/null +++ b/web/app/device/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from "react"; +import { DeviceAuthorizationPage } from "@/components/auth/device-authorization-page"; +import { Spinner } from "@/components/ui/spinner"; + +export default function Page() { + return ( + + + + } + > + + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx index f7a776a..bdc6fe0 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,124 +1,17 @@ -"use client"; - -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { Suspense } from "react"; +import { SignInPage } from "@/components/auth/sign-in-page"; import { Spinner } from "@/components/ui/spinner"; -import { signIn, useSession } from "@/lib/auth-client"; export default function Page() { - const router = useRouter(); - const { data: session, isPending } = useSession(); - - useEffect(() => { - if (!isPending && session) { - router.push("/dashboard"); - } - }, [session, isPending, router]); - - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(""); - setLoading(true); - - const { error } = await signIn.email({ - email, - password, - }); - - setLoading(false); - - if (error) { - setError(error.message || "Failed to sign in"); - return; - } - - router.push("/dashboard"); - } - - if (isPending || session) { - return ( -
- -
- ); - } - return ( -
- Logo - - - Sign In - - Enter your credentials to access your account - - -
- - {error && ( -
- {error} -
- )} -
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
-
- - -

- Don't have an account?{" "} - - Sign up - -

-
-
-
-
+ + + + } + > + + ); } diff --git a/web/components/auth/device-approval-page.tsx b/web/components/auth/device-approval-page.tsx new file mode 100644 index 0000000..62d2659 --- /dev/null +++ b/web/components/auth/device-approval-page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { authClient, useSession } from "@/lib/auth-client"; + +export function DeviceApprovalPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { data: session, isPending } = useSession(); + const userCode = useMemo( + () => searchParams.get("user_code") || searchParams.get("userCode") || "", + [searchParams], + ); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + useEffect(() => { + if (isPending || session || !userCode) { + return; + } + + router.replace(`/?redirect=${encodeURIComponent(`/device/approve?user_code=${userCode}`)}`); + }, [isPending, router, session, userCode]); + + async function handleDecision(type: "approve" | "deny") { + if (!userCode) { + setError("Missing device code"); + return; + } + + setIsProcessing(true); + setError(""); + setSuccessMessage(""); + + try { + if (type === "approve") { + await authClient.device.approve({ + userCode, + }); + setSuccessMessage("Device approved. You can return to the terminal."); + } else { + await authClient.device.deny({ + userCode, + }); + setSuccessMessage("Device denied. You can close this page."); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update device request"); + } finally { + setIsProcessing(false); + } + } + + if (isPending) { + return ( +
+ +
+ ); + } + + return ( +
+ + + Device Authorization Request + + Review the pending terminal sign-in request for your account. + + + +
+

Code

+

{userCode || "Unavailable"}

+
+ {error ? ( +
+ {error} +
+ ) : null} + {successMessage ? ( +
+ {successMessage} +
+ ) : null} +
+ + + + +
+
+ ); +} diff --git a/web/components/auth/device-authorization-page.tsx b/web/components/auth/device-authorization-page.tsx new file mode 100644 index 0000000..21c4c88 --- /dev/null +++ b/web/components/auth/device-authorization-page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth-client"; + +function normalizeUserCode(value: string) { + return value.trim().replace(/-/g, "").toUpperCase(); +} + +export function DeviceAuthorizationPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const initialUserCode = useMemo( + () => searchParams.get("user_code") || "", + [searchParams], + ); + const [userCode, setUserCode] = useState(initialUserCode); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setUserCode(initialUserCode); + }, [initialUserCode]); + + async function verifyCode(code: string) { + const formatted = normalizeUserCode(code); + if (!formatted) { + setError("Enter the device code to continue"); + return; + } + + setLoading(true); + setError(""); + + try { + const response = await authClient.device({ + query: { user_code: formatted }, + }); + + if (response.error || !response.data) { + setError(response.error?.error_description || "Invalid or expired code"); + return; + } + + router.push(`/device/approve?user_code=${encodeURIComponent(formatted)}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Invalid or expired code"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (initialUserCode) { + void verifyCode(initialUserCode); + } + // initialUserCode is intentionally the only trigger here so a shared + // verification link can continue without another submit. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialUserCode]); + + return ( +
+ + + Authorize Device + + Enter the code shown in your terminal to continue signing in. + + +
{ + event.preventDefault(); + void verifyCode(userCode); + }} + > + +
+ + { + setUserCode(event.target.value); + setError(""); + }} + placeholder="ABCD1234" + autoFocus + autoComplete="one-time-code" + /> +
+ {error ? ( +
+ {error} +
+ ) : null} +
+ + + +
+
+
+ ); +} diff --git a/web/components/auth/register-page.tsx b/web/components/auth/register-page.tsx new file mode 100644 index 0000000..a2a8966 --- /dev/null +++ b/web/components/auth/register-page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { signUp } from "@/lib/auth-client"; + +export function RegisterPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const redirectTo = searchParams.get("redirect") || "/dashboard"; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + const { error } = await signUp.email({ + name, + email, + password, + }); + + setLoading(false); + + if (error) { + setError(error.message || "Failed to create account"); + return; + } + + router.push(redirectTo); + } + + return ( +
+ + + Create Account + + Enter your details to create a new account + + +
+ + {error && ( +
+ {error} +
+ )} +
+ + setName(e.target.value)} + required + /> +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+
+ + +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+
+ ); +} diff --git a/web/components/auth/sign-in-page.tsx b/web/components/auth/sign-in-page.tsx new file mode 100644 index 0000000..1301327 --- /dev/null +++ b/web/components/auth/sign-in-page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { signIn, useSession } from "@/lib/auth-client"; + +export function SignInPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { data: session, isPending } = useSession(); + const redirectTo = searchParams.get("redirect") || "/dashboard"; + + useEffect(() => { + if (!isPending && session) { + router.push(redirectTo); + } + }, [session, isPending, redirectTo, router]); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + const { error } = await signIn.email({ + email, + password, + }); + + setLoading(false); + + if (error) { + setError(error.message || "Failed to sign in"); + return; + } + + router.push(redirectTo); + } + + if (isPending || session) { + return ( +
+ +
+ ); + } + + return ( +
+ Logo + + + Sign In + + Enter your credentials to access your account + + +
+ + {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + +

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+
+ ); +} diff --git a/web/db/schema.ts b/web/db/schema.ts index c3e60b8..e8e83bb 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -84,9 +84,68 @@ export const verification = pgTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); +export const deviceCode = pgTable( + "deviceCode", + { + id: text("id").primaryKey(), + deviceCode: text("device_code").notNull(), + userCode: text("user_code").notNull(), + userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), + expiresAt: timestamp("expires_at").notNull(), + status: text("status").notNull(), + lastPolledAt: timestamp("last_polled_at"), + pollingInterval: integer("polling_interval"), + clientId: text("client_id"), + scope: text("scope"), + }, + (table) => [ + index("device_code_device_code_idx").on(table.deviceCode), + index("device_code_user_code_idx").on(table.userCode), + index("device_code_user_id_idx").on(table.userId), + ], +); + +export const apikey = pgTable( + "apikey", + { + id: text("id").primaryKey(), + name: text("name"), + start: text("start"), + prefix: text("prefix"), + key: text("key").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + refillInterval: integer("refill_interval"), + refillAmount: integer("refill_amount"), + lastRefillAt: timestamp("last_refill_at"), + enabled: boolean("enabled").default(true), + rateLimitEnabled: boolean("rate_limit_enabled").default(true), + rateLimitTimeWindow: integer("rate_limit_time_window"), + rateLimitMax: integer("rate_limit_max"), + requestCount: integer("request_count").default(0), + remaining: integer("remaining"), + lastRequest: timestamp("last_request"), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + permissions: text("permissions"), + metadata: text("metadata"), + }, + (table) => [ + index("apikey_key_idx").on(table.key), + index("apikey_user_id_idx").on(table.userId), + ], +); + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), + apiKeys: many(apikey), + deviceCodes: many(deviceCode), })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -103,6 +162,20 @@ export const accountRelations = relations(account, ({ one }) => ({ }), })); +export const deviceCodeRelations = relations(deviceCode, ({ one }) => ({ + user: one(user, { + fields: [deviceCode.userId], + references: [user.id], + }), +})); + +export const apiKeyRelations = relations(apikey, ({ one }) => ({ + user: one(user, { + fields: [apikey.userId], + references: [user.id], + }), +})); + type ServerMeta = { arch?: string; os?: string; diff --git a/web/lib/api-auth.ts b/web/lib/api-auth.ts new file mode 100644 index 0000000..2e12ff9 --- /dev/null +++ b/web/lib/api-auth.ts @@ -0,0 +1,23 @@ +import { auth } from "@/lib/auth"; + +export async function getRequestSession(request: Request) { + return auth.api.getSession({ + headers: request.headers, + }); +} + +export async function requireRequestSession(request: Request) { + const session = await getRequestSession(request); + + if (!session) { + return { + ok: false as const, + response: Response.json({ error: "Unauthorized" }, { status: 401 }), + }; + } + + return { + ok: true as const, + session, + }; +} diff --git a/web/lib/auth-client.ts b/web/lib/auth-client.ts index dde6404..1357fba 100644 --- a/web/lib/auth-client.ts +++ b/web/lib/auth-client.ts @@ -1,5 +1,11 @@ import { createAuthClient } from "better-auth/react"; +import { + apiKeyClient, + deviceAuthorizationClient, +} from "better-auth/client/plugins"; -export const authClient = createAuthClient(); +export const authClient = createAuthClient({ + plugins: [apiKeyClient(), deviceAuthorizationClient()], +}); export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/web/lib/auth.ts b/web/lib/auth.ts index 9a1ad2a..36cf7a5 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -1,8 +1,11 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { apiKey, bearer, deviceAuthorization } from "better-auth/plugins"; import { db } from "@/db"; import * as schema from "@/db/schema"; +export const TECHULUS_CLI_CLIENT_ID = "techulus-cli"; + export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", @@ -12,4 +15,18 @@ export const auth = betterAuth({ enabled: true, disableSignUp: process.env.ALLOW_SIGNUP !== "true", }, + plugins: [ + deviceAuthorization({ + verificationUri: "/device", + validateClient: async (clientId) => clientId === TECHULUS_CLI_CLIENT_ID, + }), + apiKey({ + enableSessionForAPIKeys: true, + apiKeyHeaders: "x-api-key", + defaultPrefix: "tcl_", + enableMetadata: true, + requireName: true, + }), + bearer(), + ], }); diff --git a/web/lib/cli-manifest.ts b/web/lib/cli-manifest.ts new file mode 100644 index 0000000..00382f6 --- /dev/null +++ b/web/lib/cli-manifest.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; +import { slugify } from "@/lib/utils"; + +const manifestPortSchema = z + .object({ + port: z.number().int().min(1).max(65535), + public: z.boolean().default(false), + domain: z.string().trim().min(1).optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (value.public && !value.domain) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["domain"], + message: "Public HTTP ports require a domain", + }); + } + + if (!value.public && value.domain) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["domain"], + message: "Internal ports cannot define a domain", + }); + } + }); + +const manifestHealthCheckSchema = z + .object({ + cmd: z.string().trim().min(1), + interval: z.number().int().min(1).default(10), + timeout: z.number().int().min(1).default(5), + retries: z.number().int().min(1).default(3), + startPeriod: z.number().int().min(0).default(30), + }) + .strict(); + +const manifestResourcesSchema = z + .object({ + cpuCores: z.number().min(0.1).max(64).nullable().optional(), + memoryMb: z.number().int().min(64).max(65536).nullable().optional(), + }) + .strict() + .superRefine((value, ctx) => { + const hasCpu = value.cpuCores !== undefined && value.cpuCores !== null; + const hasMemory = value.memoryMb !== undefined && value.memoryMb !== null; + + if (hasCpu !== hasMemory) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Resources must set both cpuCores and memoryMb together", + }); + } + }); + +export const techulusManifestSchema = z + .object({ + apiVersion: z.literal("v1"), + project: z.string().trim().min(1), + environment: z.string().trim().min(1), + service: z + .object({ + name: z.string().trim().min(1), + source: z + .object({ + type: z.literal("image"), + image: z.string().trim().min(1), + }) + .strict(), + hostname: z.string().trim().min(1).optional(), + ports: z.array(manifestPortSchema).default([]), + replicas: z + .object({ + count: z.number().int().min(1).max(10).default(1), + }) + .strict() + .default({ count: 1 }), + healthCheck: manifestHealthCheckSchema.optional(), + startCommand: z.string().trim().min(1).optional(), + resources: manifestResourcesSchema.optional(), + }) + .strict(), + }) + .strict(); + +export type TechulusManifest = z.infer; + +export function getManifestProjectSlug(manifest: TechulusManifest) { + return slugify(manifest.project); +} + +export function getManifestEnvironmentName(manifest: TechulusManifest) { + return slugify(manifest.environment); +} + +export function getManifestServiceName(manifest: TechulusManifest) { + return manifest.service.name.trim(); +} diff --git a/web/lib/cli-service.ts b/web/lib/cli-service.ts new file mode 100644 index 0000000..eccd9bf --- /dev/null +++ b/web/lib/cli-service.ts @@ -0,0 +1,634 @@ +import { and, desc, eq, ne } from "drizzle-orm"; +import { db } from "@/db"; +import { + deployments, + environments, + projects, + rollouts, + servicePorts, + serviceVolumes, + services, +} from "@/db/schema"; +import type { TechulusManifest } from "@/lib/cli-manifest"; +import { + getManifestEnvironmentName, + getManifestProjectSlug, + getManifestServiceName, +} from "@/lib/cli-manifest"; +import { slugify } from "@/lib/utils"; +import { + createEnvironment, + createProject, + createService, + deployService, + updateServiceAutoPlace, + updateServiceConfig, + updateServiceReplicas, + updateServiceResourceLimits, + updateServiceStartCommand, + validateDockerImage, +} from "@/actions/projects"; + +export type ManifestChange = { + field: string; + from: string; + to: string; +}; + +export type ManifestApplyResult = { + project: { id: string; name: string; slug: string }; + environment: { id: string; name: string }; + serviceId: string; + action: "created" | "updated" | "noop"; + changes: ManifestChange[]; +}; + +type ManifestIdentity = { + project: string; + environment: string; + service: string; +}; + +function formatPort(port: { port: number; isPublic: boolean; domain: string | null }) { + return port.isPublic ? `${port.port} -> ${port.domain}` : `${port.port} (internal)`; +} + +function formatNullable(value: string | number | null | undefined, fallback = "(none)") { + if (value === null || value === undefined || value === "") { + return fallback; + } + + return String(value); +} + +function recordChange( + changes: ManifestChange[], + field: string, + from: string | number | null | undefined, + to: string | number | null | undefined, +) { + if ((from ?? null) === (to ?? null)) { + return; + } + + changes.push({ + field, + from: formatNullable(from), + to: formatNullable(to), + }); +} + +async function findProjectByManifest(manifest: TechulusManifest) { + const slug = getManifestProjectSlug(manifest); + return findProjectBySlug(slug); +} + +async function findProjectBySlug(slug: string) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.slug, slug)) + .limit(1); + + return project ?? null; +} + +async function findEnvironmentByManifest( + projectId: string, + manifest: TechulusManifest, +) { + const environmentName = getManifestEnvironmentName(manifest); + return findEnvironmentByName(projectId, environmentName); +} + +async function findEnvironmentByName(projectId: string, environmentName: string) { + const [environment] = await db + .select() + .from(environments) + .where( + and( + eq(environments.projectId, projectId), + eq(environments.name, environmentName), + ), + ) + .limit(1); + + return environment ?? null; +} + +async function findServiceByManifest( + projectId: string, + environmentId: string, + manifest: TechulusManifest, +) { + const serviceName = getManifestServiceName(manifest); + return findServiceByName(projectId, environmentId, serviceName); +} + +async function findServiceByName( + projectId: string, + environmentId: string, + serviceName: string, +) { + const [service] = await db + .select() + .from(services) + .where( + and( + eq(services.projectId, projectId), + eq(services.environmentId, environmentId), + eq(services.name, serviceName), + ), + ) + .limit(1); + + return service ?? null; +} + +async function syncHostname( + serviceId: string, + currentHostname: string | null, + desiredHostname: string | null, + changes: ManifestChange[], +) { + if (currentHostname === desiredHostname) { + return; + } + + if (desiredHostname) { + const [existing] = await db + .select({ id: services.id }) + .from(services) + .where( + and(eq(services.hostname, desiredHostname), ne(services.id, serviceId)), + ) + .limit(1); + + if (existing) { + throw new Error("Hostname is already in use"); + } + } + + await db + .update(services) + .set({ hostname: desiredHostname }) + .where(eq(services.id, serviceId)); + + recordChange(changes, "Hostname", currentHostname, desiredHostname); +} + +async function syncImage( + serviceId: string, + currentImage: string, + desiredImage: string, + changes: ManifestChange[], +) { + if (currentImage === desiredImage) { + return; + } + + const validation = await validateDockerImage(desiredImage); + if (!validation.valid) { + throw new Error(validation.error || "Invalid image"); + } + + await updateServiceConfig(serviceId, { + source: { type: "image", image: desiredImage }, + }); + + recordChange(changes, "Image", currentImage, desiredImage); +} + +async function syncPorts( + serviceId: string, + desiredPorts: TechulusManifest["service"]["ports"], + changes: ManifestChange[], +) { + const currentPorts = await db + .select() + .from(servicePorts) + .where(eq(servicePorts.serviceId, serviceId)); + + const currentKeys = new Map( + currentPorts.map((port) => [ + `${port.port}:${port.isPublic ? "public" : "internal"}:${port.domain ?? ""}`, + port, + ]), + ); + + const desiredKeys = new Map( + desiredPorts.map((port) => [ + `${port.port}:${port.public ? "public" : "internal"}:${port.domain ?? ""}`, + port, + ]), + ); + + const portsToRemove = currentPorts + .filter( + (port) => + !desiredKeys.has( + `${port.port}:${port.isPublic ? "public" : "internal"}:${port.domain ?? ""}`, + ), + ) + .map((port) => port.id); + + const portsToAdd = desiredPorts + .filter( + (port) => + !currentKeys.has( + `${port.port}:${port.public ? "public" : "internal"}:${port.domain ?? ""}`, + ), + ) + .map((port) => ({ + port: port.port, + isPublic: port.public, + domain: port.public ? port.domain ?? null : null, + protocol: "http" as const, + })); + + if (portsToRemove.length === 0 && portsToAdd.length === 0) { + return; + } + + await updateServiceConfig(serviceId, { + ports: { + remove: portsToRemove, + add: portsToAdd, + }, + }); + + for (const port of currentPorts.filter((item) => portsToRemove.includes(item.id))) { + changes.push({ + field: `Port ${port.port}`, + from: formatPort(port), + to: "(removed)", + }); + } + + for (const port of portsToAdd) { + changes.push({ + field: `Port ${port.port}`, + from: "(none)", + to: port.isPublic ? `${port.port} -> ${port.domain}` : `${port.port} (internal)`, + }); + } +} + +async function syncHealthCheck( + serviceId: string, + currentService: typeof services.$inferSelect, + manifest: TechulusManifest, + changes: ManifestChange[], +) { + const current = + currentService.healthCheckCmd === null + ? null + : { + cmd: currentService.healthCheckCmd, + interval: currentService.healthCheckInterval ?? 10, + timeout: currentService.healthCheckTimeout ?? 5, + retries: currentService.healthCheckRetries ?? 3, + startPeriod: currentService.healthCheckStartPeriod ?? 30, + }; + + const desired = manifest.service.healthCheck ?? null; + + if (JSON.stringify(current) === JSON.stringify(desired)) { + return; + } + + await updateServiceConfig(serviceId, { + healthCheck: desired, + }); + + recordChange( + changes, + "Health check", + current?.cmd ?? null, + desired?.cmd ?? null, + ); +} + +async function syncStartCommand( + serviceId: string, + currentStartCommand: string | null, + desiredStartCommand: string | null, + changes: ManifestChange[], +) { + if (currentStartCommand === desiredStartCommand) { + return; + } + + await updateServiceStartCommand(serviceId, desiredStartCommand); + recordChange( + changes, + "Start command", + currentStartCommand, + desiredStartCommand, + ); +} + +async function syncResources( + serviceId: string, + currentService: typeof services.$inferSelect, + manifest: TechulusManifest, + changes: ManifestChange[], +) { + const desiredCpu = manifest.service.resources?.cpuCores ?? null; + const desiredMemory = manifest.service.resources?.memoryMb ?? null; + + if ( + currentService.resourceCpuLimit === desiredCpu && + currentService.resourceMemoryLimitMb === desiredMemory + ) { + return; + } + + await updateServiceResourceLimits(serviceId, { + cpuCores: desiredCpu, + memoryMb: desiredMemory, + }); + + recordChange( + changes, + "CPU limit", + currentService.resourceCpuLimit, + desiredCpu, + ); + recordChange( + changes, + "Memory limit", + currentService.resourceMemoryLimitMb, + desiredMemory, + ); +} + +async function syncReplicas( + serviceId: string, + currentService: typeof services.$inferSelect, + desiredReplicas: number, + changes: ManifestChange[], +) { + if (!currentService.autoPlace) { + throw new Error( + "CLI v1 only supports auto-placement. This service uses manual placement.", + ); + } + + if (currentService.replicas === desiredReplicas) { + return; + } + + await updateServiceAutoPlace(serviceId, true); + await updateServiceReplicas(serviceId, desiredReplicas); + recordChange(changes, "Replicas", currentService.replicas, desiredReplicas); +} + +async function assertSupportedExistingService(serviceId: string) { + const [service] = await db + .select() + .from(services) + .where(eq(services.id, serviceId)) + .limit(1); + + if (!service) { + throw new Error("Service not found"); + } + + if (service.sourceType !== "image") { + throw new Error( + "CLI v1 only supports image-backed services. This service uses an unsupported source.", + ); + } + + if (service.stateful) { + throw new Error( + "CLI v1 does not support stateful services or volumes. Manage this service from the web UI.", + ); + } + + if (!service.autoPlace) { + throw new Error( + "CLI v1 only supports auto-placement. Manage this service from the web UI.", + ); + } + + const ports = await db + .select() + .from(servicePorts) + .where(eq(servicePorts.serviceId, serviceId)); + + if (ports.some((port) => port.protocol !== "http")) { + throw new Error( + "CLI v1 only supports HTTP ports. This service has TCP or UDP ports configured.", + ); + } + + const volumes = await db + .select({ id: serviceVolumes.id }) + .from(serviceVolumes) + .where(eq(serviceVolumes.serviceId, serviceId)); + + if (volumes.length > 0) { + throw new Error( + "CLI v1 does not support services with volumes. Manage this service from the web UI.", + ); + } + + return service; +} + +export async function applyManifest( + manifest: TechulusManifest, +): Promise { + let serviceCreated = false; + let project = await findProjectByManifest(manifest); + if (!project) { + await createProject(manifest.project.trim()); + project = await findProjectByManifest(manifest); + } + if (!project) { + throw new Error("Failed to create project"); + } + + let environment = await findEnvironmentByManifest(project.id, manifest); + if (!environment) { + await createEnvironment(project.id, manifest.environment.trim()); + environment = await findEnvironmentByManifest(project.id, manifest); + } + if (!environment) { + throw new Error("Failed to create environment"); + } + + let service = await findServiceByManifest(project.id, environment.id, manifest); + const changes: ManifestChange[] = []; + + if (!service) { + serviceCreated = true; + const validation = await validateDockerImage(manifest.service.source.image); + if (!validation.valid) { + throw new Error(validation.error || "Invalid image"); + } + + await createService({ + projectId: project.id, + environmentId: environment.id, + name: getManifestServiceName(manifest), + image: manifest.service.source.image, + }); + service = await findServiceByManifest(project.id, environment.id, manifest); + if (!service) { + throw new Error("Failed to create service"); + } + + recordChange(changes, "Image", null, manifest.service.source.image); + recordChange( + changes, + "Replicas", + null, + manifest.service.replicas.count, + ); + } + + const currentService = await assertSupportedExistingService(service.id); + + await syncHostname( + service.id, + currentService.hostname, + manifest.service.hostname ?? null, + changes, + ); + await syncImage( + service.id, + currentService.image, + manifest.service.source.image, + changes, + ); + await syncPorts(service.id, manifest.service.ports, changes); + await syncHealthCheck(service.id, currentService, manifest, changes); + await syncStartCommand( + service.id, + currentService.startCommand, + manifest.service.startCommand ?? null, + changes, + ); + await syncResources(service.id, currentService, manifest, changes); + await syncReplicas( + service.id, + currentService, + manifest.service.replicas.count, + changes, + ); + + const refreshedProject = await findProjectByManifest(manifest); + const refreshedEnvironment = await findEnvironmentByManifest(project.id, manifest); + + if (!refreshedProject || !refreshedEnvironment) { + throw new Error("Failed to reload manifest resources after apply"); + } + + return { + project: refreshedProject, + environment: refreshedEnvironment, + serviceId: service.id, + action: serviceCreated ? "created" : changes.length === 0 ? "noop" : "updated", + changes, + }; +} + +export async function deployManifest(manifest: TechulusManifest) { + const project = await findProjectByManifest(manifest); + if (!project) { + throw new Error("Project not found"); + } + + const environment = await findEnvironmentByManifest(project.id, manifest); + if (!environment) { + throw new Error("Environment not found"); + } + + const service = await findServiceByManifest(project.id, environment.id, manifest); + if (!service) { + throw new Error("Service not found"); + } + + const result = await deployService(service.id); + + return { + serviceId: service.id, + rolloutId: "rolloutId" in result ? result.rolloutId : null, + status: "migrationStarted" in result ? "migration_started" : "queued", + }; +} + +export async function getManifestStatus(identity: ManifestIdentity) { + const project = await findProjectBySlug(identity.project); + if (!project) { + return null; + } + + const environment = await findEnvironmentByName( + project.id, + slugify(identity.environment), + ); + if (!environment) { + return null; + } + + const service = await findServiceByName( + project.id, + environment.id, + identity.service.trim(), + ); + if (!service) { + return null; + } + + const [latestRollout] = await db + .select({ + id: rollouts.id, + status: rollouts.status, + currentStage: rollouts.currentStage, + createdAt: rollouts.createdAt, + }) + .from(rollouts) + .where(eq(rollouts.serviceId, service.id)) + .orderBy(desc(rollouts.createdAt)) + .limit(1); + + const serviceDeployments = await db + .select({ + id: deployments.id, + status: deployments.status, + serverId: deployments.serverId, + createdAt: deployments.createdAt, + }) + .from(deployments) + .where(eq(deployments.serviceId, service.id)) + .orderBy(desc(deployments.createdAt)); + + const ports = await db + .select({ + id: servicePorts.id, + port: servicePorts.port, + isPublic: servicePorts.isPublic, + domain: servicePorts.domain, + protocol: servicePorts.protocol, + }) + .from(servicePorts) + .where(eq(servicePorts.serviceId, service.id)); + + return { + service: { + id: service.id, + name: service.name, + image: service.image, + hostname: service.hostname, + replicas: service.replicas, + sourceType: service.sourceType, + }, + ports, + latestRollout: latestRollout ?? null, + deployments: serviceDeployments, + }; +} From 15578fde0048eba9075592db5fb4832307d97095 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 4 Apr 2026 10:34:45 +1100 Subject: [PATCH 2/2] Remove open browser cmd --- cli/src/main.ts | 27 +-------------------------- web/next.config.ts | 6 ++++++ 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/cli/src/main.ts b/cli/src/main.ts index 1b1796f..fe1c878 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -2,7 +2,6 @@ import { access, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { constants as fsConstants } from "node:fs"; -import { spawn } from "node:child_process"; import { deleteConfig, readConfig, writeConfig } from "./config.js"; import { loadManifest, slugify, type TechulusManifest } from "./manifest.js"; @@ -77,24 +76,6 @@ function printUsage() { tcloud status`); } -function openBrowser(url: string) { - const command = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "cmd" - : "xdg-open"; - const args = - process.platform === "win32" ? ["/c", "start", "", url] : [url]; - - const child = spawn(command, args, { - detached: true, - stdio: "ignore", - }); - - child.unref(); -} - async function ensureManifest(cwd: string) { try { return await loadManifest(cwd); @@ -154,13 +135,7 @@ async function commandAuthLogin(args: string[]) { console.log(`Visit ${deviceCode.verification_uri}`); console.log(`Enter code: ${deviceCode.user_code}`); - - try { - openBrowser(deviceCode.verification_uri_complete || deviceCode.verification_uri); - console.log("Opened your browser for approval."); - } catch { - console.log("Could not open the browser automatically."); - } + console.log("Open the verification URL in your browser to continue."); let accessToken = ""; let intervalMs = deviceCode.interval * 1000; diff --git a/web/next.config.ts b/web/next.config.ts index 398b0d5..42b02f4 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,7 +1,13 @@ import type { NextConfig } from "next"; +const allowedDevOrigins = (process.env.ALLOWED_DEV_ORIGINS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const nextConfig: NextConfig = { output: "standalone", + allowedDevOrigins, }; export default nextConfig;