From 2a24b2ff5650bba95308d7620f245809093fd964 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Wed, 7 Oct 2026 05:10:59 +0000 Subject: [PATCH] feat: release 2.7.0-rc3 --- README.md | 56 +++++-- bun.lock | 24 ++- changelogs/2.7.0.md | 6 + command/add.ts | 184 ++++---------------- command/auth.ts | 163 ++++++++++++------ command/init.ts | 60 +++++-- command/main.ts | 2 +- lib/scaffold-prompts.ts | 335 +++++++++++++++++++++++++++++++++++++ lib/scaffold.ts | 18 +- package.json | 6 +- tests/command/add.test.ts | 225 +++++++++++++++++++++++++ tests/command/auth.test.ts | 180 ++++++++++++++++++++ tests/command/init.test.ts | 288 ++++++++++++++++++++++++++++++- tsconfig.json | 4 + 14 files changed, 1302 insertions(+), 249 deletions(-) create mode 100644 lib/scaffold-prompts.ts create mode 100644 tests/command/auth.test.ts diff --git a/README.md b/README.md index b64bfdf..21cce0a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,13 @@ https://kuber.astrxl.dev/api/v2 ## Authentication +`kuber login [username] [--persist]` opens an interactive BasicAuth prompt for +the password (the optional username pre-fills the username field). Commands +that need authentication, including `init`, log in this way when no session is +available; `init` also retries login if the server reports an expired or +unauthorized session. Login requires an interactive terminal when credentials +are needed. + ```bash kuber login dmgnr kuber login dmgnr --persist @@ -468,7 +475,10 @@ replay the command; rerun it yourself if needed. `kuber init` creates a project Compose file and adds its first app. In an empty directory, the default app uses `nginx:stable` as its image; initialization -always adds a service. Choose a different source or image with the app options: +always adds a service. With an interactive terminal, the first prompt selects +the app source. The selected source then determines which follow-up fields are +shown: an image URI for `image`, a CNB buildpack URI for `auto`, or an embedded +Dockerfile template for `template`. ```bash kuber init @@ -476,12 +486,19 @@ kuber init --project my-site --name web --path apps/web --source image --image n ``` With an interactive terminal, `init` prompts for missing project and app -details, and its default flow logs in automatically when needed. The project -trust is granted for the current directory as part of initialization, so a -first `kuber up` can proceed without separately running `kuber trust`. Use -`--non-interactive` to disable prompts; provide required values as flags. The -project name is optional and defaults from the current project context. If -project configuration already fixes a project name, `--project` must match it. +details. If multiple app roots are detected, an AutoComplete prompt lets you +choose the app path; pass `--path` to select one directly. A YAML Snippet prompt +then edits the project, service, claims, resources, and replica count together; +the replica count defaults to `1`. Blank optional values are omitted from the +generated configuration. The `managedBy` documentation header is added when the +Compose file is written. Initialization logs in automatically when needed and +trusts the current directory for the selected project, so a first `kuber up` +does not require a separate `kuber trust`. + +Use `--non-interactive` to disable prompts and provide the required values as +flags. The project name is optional and defaults from the current project +context. If project configuration already fixes a project name, `--project` +must match it. `kuber add app` adds another service to an existing project without replacing existing services. Its syntax and the shared app options are: @@ -503,15 +520,22 @@ For example, add an app using a published starter image: kuber add app --name web --path apps/web --source image --image nginx:stable --non-interactive ``` -In interactive mode, omitted values are prompted for. The app path is chosen -from detected app roots when there is exactly one; when there are several, the -prompt asks you to select one. Supply `--path` to select a subproject explicitly. -Non-interactive mode never prompts: supply values needed for the selected -source, and for multiple detected roots explicitly supply `--path`. With no -detected app roots the path defaults to `.`. If no source is supplied, a -repository with detected app roots defaults to `auto`; otherwise it defaults to -`image` (`nginx:stable`). Optional Postgres/S3 claims and CPU, memory, and -replica settings can be provided by flags or interactively. +In interactive mode, the first prompt selects the app source, followed by +source-specific input: an image URI, a CNB buildpack URI, or a Dockerfile +template. When multiple app roots are detected, an AutoComplete prompt selects +the path; with one detected root it is selected automatically. A YAML Snippet +prompt edits the service, claims, resources, and replicas (default `1`) in +`compose.yml`. Existing services are presented literally as `name: ...` and +remain read-only while the new service is configured. Blank optional values +are omitted when the Compose YAML is written. The `managedBy` documentation +header is added to the written file. + +`--non-interactive` retains the no-prompt behavior: supply values needed for +the selected source, and for multiple detected roots explicitly supply +`--path`. With no detected app roots the path defaults to `.`. If no source is +supplied, a repository with detected app roots defaults to `auto`; otherwise it +defaults to `image` (`nginx:stable`). Optional Postgres/S3 claims and CPU, +memory, and replica settings can be provided by flags or interactively. #### App sources diff --git a/bun.lock b/bun.lock index 80b6c38..e256952 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "kuber", + "dependencies": { + "enquirer": "^2.4.1", + }, "devDependencies": { "@bomb.sh/tab": "^0.0.22", "@cliffy/table": "npm:@jsr/cliffy__table", @@ -15,6 +18,7 @@ "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "typescript": "^5", + "undici-types": "~7.18.0", "zod": "^4.4.3", }, }, @@ -128,9 +132,11 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -170,6 +176,8 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -284,7 +292,7 @@ "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "tar-fs": ["tar-fs@3.1.3", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ=="], @@ -322,10 +330,22 @@ "bun-types/@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "log-update/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "@types/node-fetch/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "@types/stream-buffers/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "bun-types/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], } } diff --git a/changelogs/2.7.0.md b/changelogs/2.7.0.md index 3873d17..1cb4201 100644 --- a/changelogs/2.7.0.md +++ b/changelogs/2.7.0.md @@ -1,3 +1,9 @@ +# 2.7.0-rc3 + +- Use source-first Enquirer Select prompts with conditional image, CNB, or template choices, plus app-path AutoComplete. +- Render `kuber init` and `kuber add` previews as YAML Snippets, preserving literal existing `name: ...` lines; omit blank optional values and default replicas to 1. +- Use real BasicAuth login, handle ambiguous trust identities safely, and fix type resolution. + # 2.7.0-rc2 - Simplify the cross-platform passive updater with explicit inherited environment and `process.execPath`, a bounded wait for the install outcome before CLI exit, and no TTY workaround or external timeout helper. diff --git a/command/add.ts b/command/add.ts index c519147..9bebead 100644 --- a/command/add.ts +++ b/command/add.ts @@ -1,167 +1,28 @@ -import { createInterface } from "node:readline/promises"; -import { stdin, stdout } from "node:process"; -import { basename } from "node:path"; import { defineCommand } from "citty"; import { ctx } from "../lib/context"; +import { addAppToCompose, type AppOptions } from "../lib/scaffold"; +import { readCompose, resolveComposeFile } from "../lib/yaml"; import { - addAppToCompose, - checkedAppPath, - detectAppPaths, - type AppOptions, - type Source, -} from "../lib/scaffold"; -import { - listDockerfileTemplates, - templateIds, -} from "../lib/scaffold-templates"; + resolveScaffoldInput, + terminalPrompt, + type AppInput, + type ScaffoldPrompt, +} from "../lib/scaffold-prompts"; -export type ScaffoldPrompt = ( - question: string, - defaultValue?: string, -) => Promise; - -export const terminalPrompt: ScaffoldPrompt = async ( - question, - defaultValue, -) => { - if (!stdin.isTTY) - throw new Error(`${question}: supply flags or use an interactive terminal`); - const readline = createInterface({ input: stdin, output: stdout }); - try { - const result = ( - await readline.question( - `${question}${defaultValue ? ` [${defaultValue}]` : ""}: `, - ) - ).trim(); - return result || defaultValue || ""; - } finally { - readline.close(); - } -}; - -export type AppInput = Partial> & { - source?: "image" | "auto" | "template"; - image?: string; - template?: string; - nonInteractive?: boolean; -}; - -async function answer( - value: string | undefined, - question: string, - fallback: string | undefined, - prompt: ScaffoldPrompt, - nonInteractive: boolean, -): Promise { - if (value !== undefined) return value; - if (nonInteractive) { - if (fallback !== undefined) return fallback; - throw new Error(`${question} is required in non-interactive mode`); - } - return prompt(question, fallback); -} +export { terminalPrompt, type AppInput, type ScaffoldPrompt }; export async function resolveAppInput( root: string, input: AppInput, prompt: ScaffoldPrompt = terminalPrompt, + composeFile?: string, ): Promise { - const detected = await detectAppPaths(root); - let path: string; - if (input.path !== undefined) path = input.path; - else if (detected.length === 0) path = "."; - else if (detected.length === 1) path = detected[0]!; - else { - if (input.nonInteractive) - throw new Error( - `Multiple app roots found (${detected.join(", ")}); specify --path`, - ); - path = await prompt(`App path (${detected.join(", ")})`); - if (!detected.includes(path)) - throw new Error( - `Select one of the detected app roots: ${detected.join(", ")}`, - ); - } - path = await checkedAppPath(root, path); - const suggestedName = - basename(path === "." ? root : path) - .toLowerCase() - .replace(/[^a-z0-9-]/g, "-") - .replace(/^-+|-+$/g, "") || "app"; - const name = await answer( - input.name, - "Service name", - /^[a-z]/.test(suggestedName) ? suggestedName : `app-${suggestedName}`, - prompt, - !!input.nonInteractive, - ); - const defaultSource = detected.length === 0 ? "image" : "auto"; - const selectedSource = await answer( - input.source, - "Source (image/auto/template)", - defaultSource, - prompt, - !!input.nonInteractive, - ); - let source: Source; - if (selectedSource === "image") - source = { - kind: "image", - image: await answer( - input.image, - "Image", - "nginx:stable", - prompt, - !!input.nonInteractive, - ), - }; - else if (selectedSource === "auto") source = { kind: "auto" }; - else if (selectedSource === "template") { - const choices = listDockerfileTemplates() - .map(({ id, label }) => `${id} (${label})`) - .join(", "); - const template = await answer( - input.template, - `Dockerfile template (${choices})`, - undefined, - prompt, - !!input.nonInteractive, - ); - if (!templateIds.includes(template as (typeof templateIds)[number])) - throw new Error(`Unknown Dockerfile template: ${template}`); - source = { - kind: "template", - template: template as (typeof templateIds)[number], - }; - } else throw new Error("Source must be image, auto, or template"); - const postgres = input.nonInteractive - ? input.postgres - : await prompt( - "Postgres claim (name or user/database; blank for none)", - input.postgres, - ); - const s3 = input.nonInteractive - ? input.s3 - : await prompt("S3 claim (name or key/bucket; blank for none)", input.s3); - const cpu = input.nonInteractive - ? input.cpu - : await prompt("CPU limit in cores (blank for default)", input.cpu); - const memory = input.nonInteractive - ? input.memory - : await prompt("Memory limit (blank for default)", input.memory); - const replicasAnswer = input.nonInteractive - ? input.replicas?.toString() - : await prompt("Replicas (blank for default)", input.replicas?.toString()); - return { - name, - path, - source, - ...(postgres ? { postgres } : {}), - ...(s3 ? { s3 } : {}), - ...(cpu ? { cpu } : {}), - ...(memory ? { memory } : {}), - ...(replicasAnswer ? { replicas: Number(replicasAnswer) } : {}), - }; + const file = composeFile ?? (await resolveComposeFile(root)); + const existingServices = file + ? Object.keys((await readCompose(file)).services ?? {}) + : []; + return (await resolveScaffoldInput(root, input, prompt, { existingServices })) + .app; } export const appArgs = { @@ -178,6 +39,10 @@ export const appArgs = { type: "string" as const, description: "Image reference when source=image", }, + cnb: { + type: "string" as const, + description: "Optional CNB URI when source=auto", + }, template: { type: "string" as const, description: "Dockerfile template ID when source=template", @@ -205,6 +70,7 @@ export function appInputFromArgs(args: Record): AppInput { "source", "image", "template", + "cnb", "postgres", "s3", "cpu", @@ -229,8 +95,14 @@ export const add = defineCommand({ args: appArgs, async run({ args }) { const { cwd, path } = ctx(); - const app = await resolveAppInput(cwd, appInputFromArgs(args)); - const composePath = await addAppToCompose(cwd, app, await path()); + const file = await path(); + const app = await resolveAppInput( + cwd, + appInputFromArgs(args), + terminalPrompt, + file, + ); + const composePath = await addAppToCompose(cwd, app, file); console.log(`Added ${app.name} to ${composePath}`); }, }), diff --git a/command/auth.ts b/command/auth.ts index ba0f06c..917df14 100644 --- a/command/auth.ts +++ b/command/auth.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; -import { createInterface } from "node:readline/promises"; -import { stdin, stdout } from "node:process"; +import Enquirer from "enquirer"; +import { stdin } from "node:process"; import { apiRequest } from "../lib/api"; import { readSession, @@ -11,50 +11,108 @@ import { type LoginResponse = KuberSession; -async function promptUsername(): Promise { - const readline = createInterface({ input: stdin, output: stdout }); - try { - return (await readline.question("Username: ")).trim(); - } finally { - readline.close(); - } +type Credentials = { username: string; password: string }; +type AuthChoice = { + name: string; + input: string; + value: string; + initial?: string; + cursor?: number; +}; +export interface LoginPrompt { + choices: AuthChoice[]; + values: Credentials; + state: { submitted: boolean; cancelled: boolean; closed: boolean }; + initialize(): Promise; + render(): Promise; + submit(): Promise; + cancel(): Promise; + run(): Promise; } -async function promptPassword(): Promise { - if (!stdin.isTTY || !stdin.setRawMode) { - throw new Error("Password input requires an interactive terminal"); +// Enquirer's declarations omit its runtime BasicAuth factory. +const BasicAuth = ( + Enquirer as typeof Enquirer & { + BasicAuth: { + create( + authenticate: (values: Credentials) => Promise, + ): new (options: Record) => LoginPrompt; + }; } +).BasicAuth; - stdout.write("Password: "); - stdin.setRawMode(true); - stdin.resume(); - return new Promise((resolve, reject) => { - let password = ""; - const cleanup = () => { - stdin.off("data", onData); - stdin.setRawMode(false); - stdin.pause(); - stdout.write("\n"); - }; - const onData = (chunk: Buffer) => { - const value = chunk.toString("utf8"); - if (value === "\u0003") { - cleanup(); - reject(new Error("Login cancelled")); - return; +/** Actual BasicAuth prompt; injectable authentication keeps tests offline. */ +export function createLoginPrompt( + username = "", + persistent = false, + authenticate: typeof loginUser = loginUser, + options: { show?: boolean; stdout?: NodeJS.WriteStream } = {}, +): LoginPrompt { + let failure: unknown; + let authentication: Promise | undefined; + const Auth = BasicAuth.create((values) => { + // Repeated Enter presses must never issue a second login or session write. + authentication ??= (async () => { + try { + const name = values.username.trim(); + if (!name) throw new Error("Username is required"); + return await authenticate(name, values.password, persistent); + } catch (error) { + // BasicAuth doesn't forward rejected authenticate callbacks to run(). + failure = error; + return false as const; } - if (value === "\r" || value === "\n") { - cleanup(); - resolve(password); - return; + })(); + return authentication; + }); + class KuberBasicAuth extends Auth { + private submitting = false; + + override async initialize() { + await super.initialize(); + const choice = this.choices.find((item) => item.name === "username"); + if (choice) { + choice.input = choice.value = username; + choice.cursor = username.length; } - if (value === "\u007f" || value === "\b") { - password = password.slice(0, -1); - return; + await this.render(); + } + + override async submit() { + if (this.submitting || this.state.closed) return; + this.submitting = true; + await super.submit(); + } + + override async render() { + if (this.state.submitted) { + // FormPrompt skips choice.format on submission and cancellation. + const password = this.choices.find( + (choice) => choice.name === "password", + ); + if (password) password.input = password.value = password.initial = ""; + this.values.password = ""; } - password += value; - }; - stdin.on("data", onData); + await super.render(); + } + + override async run(): Promise { + let session: LoginResponse; + try { + session = await super.run(); + } catch { + throw new Error("Login cancelled"); + } + if (failure !== undefined) throw failure; + return session; + } + } + return new KuberBasicAuth({ + name: "login", + message: "Log in to kuber.astrxl.dev", + initial: { username }, + showPassword: false, + ...options, }); } @@ -76,14 +134,22 @@ export async function loginUser( } /** Share the CLI's credential prompt with onboarding without exposing passwords. */ -export async function interactiveLogin(): Promise { - if (!stdin.isTTY) +export async function interactiveLogin( + username = "", + persistent = false, + dependencies: { + isTTY?: boolean; + prompt?: typeof createLoginPrompt; + } = {}, +): Promise { + if (!(dependencies.isTTY ?? stdin.isTTY)) throw new Error( "Login requires an interactive terminal; run kuber login first", ); - const username = await promptUsername(); - if (!username) throw new Error("Username is required"); - const session = await loginUser(username, await promptPassword(), false); + const session = await (dependencies.prompt ?? createLoginPrompt)( + username.trim(), + persistent, + ).run(); console.log(`Logged in as ${session.user.username}`); return session; } @@ -100,14 +166,7 @@ export const login = defineCommand({ }, }, async run({ args }) { - const username = String(args._[0] ?? "").trim() || (await promptUsername()); - if (!username) throw new Error("Username is required"); - const session = await loginUser( - username, - await promptPassword(), - Boolean(args.persist), - ); - console.log(`Logged in as ${session.user.username}`); + await interactiveLogin(String(args._[0] ?? ""), Boolean(args.persist)); }, }); diff --git a/command/init.ts b/command/init.ts index a15b774..d60455b 100644 --- a/command/init.ts +++ b/command/init.ts @@ -8,11 +8,11 @@ import { createCompose, projectName } from "../lib/scaffold"; import { appArgs, appInputFromArgs, - resolveAppInput, terminalPrompt, type AppInput, type ScaffoldPrompt, } from "./add"; +import { resolveScaffoldInput } from "../lib/scaffold-prompts"; import { resolveComposeFile } from "../lib/yaml"; import { basename } from "node:path"; import { join } from "node:path"; @@ -62,13 +62,13 @@ export async function initializeProject( const defaultProject = projectName( config.projectConfigured ? config.project : basename(root), ); - const project = projectName( - input.project ?? - (input.nonInteractive - ? defaultProject - : await prompt("Project name", defaultProject)), - ); - const app = await resolveAppInput(root, input, prompt); + const resolved = await resolveScaffoldInput(root, input, prompt, { + defaultProject, + project: + input.project ?? (config.projectConfigured ? config.project : undefined), + }); + const project = resolved.project!; + const app = resolved.app; const login = dependencies.login ?? interactiveLogin; let session = await (dependencies.session ?? readSession)(); if (!session) session = await login(); @@ -90,11 +90,45 @@ export async function initializeProject( ); // The server grant is idempotent. A GET cannot establish ownership of it: // another client may grant the same fingerprint before our POST. - if (!alreadyTrusted) - await request(trustPath, { - method: "POST", - json: { fingerprint: identity.fingerprint }, - }); + if (!alreadyTrusted) { + try { + await request(trustPath, { + method: "POST", + json: { fingerprint: identity.fingerprint }, + }); + } catch (error) { + // A 401 is an explicit authentication denial; let the normal login + // retry run without probing using the rejected session. + if (error instanceof KuberApiError && error.status === 401) + throw error; + + // Client errors (other than auth denial) are definite rejection. + const definitelyRejected = + error instanceof KuberApiError && + error.status >= 400 && + error.status < 500; + if (definitelyRejected) throw error; + + let registration: boolean | undefined; + try { + const current = await request(trustPath); + if (Array.isArray(current?.fingerprints)) + registration = current.fingerprints.includes( + identity.fingerprint, + ); + } catch { + // The POST may have committed despite its failed response. + } + + if (registration !== false) { + remoteRegistered = true; + throw new Error( + "Could not confirm whether server registration succeeded. Generated project files were kept; check kuber trust status and run kuber trust to repair local trust if needed.", + ); + } + throw error; + } + } remoteRegistered = true; try { await (dependencies.persistTrust ?? updateTrust)((records) => [ diff --git a/command/main.ts b/command/main.ts index aa861b6..a8c174c 100644 --- a/command/main.ts +++ b/command/main.ts @@ -37,7 +37,7 @@ function cloneCommand(command: T): T { export const main = defineCommand({ meta: { name: "kuber", - version: "2.7.0-rc2", + version: "2.7.0-rc3", description: "Docker Compose -> K8s translation layer", }, args: { diff --git a/lib/scaffold-prompts.ts b/lib/scaffold-prompts.ts new file mode 100644 index 0000000..4cd4697 --- /dev/null +++ b/lib/scaffold-prompts.ts @@ -0,0 +1,335 @@ +import Enquirer from "enquirer"; +import { validateBuildpackUri } from "../shared/build-protocol"; +import { stdin } from "node:process"; +import { basename } from "node:path"; +import { + checkedAppPath, + detectAppPaths, + projectName, + serviceForApp, + type AppOptions, + type Source, +} from "./scaffold"; +import { listDockerfileTemplates, templateIds } from "./scaffold-templates"; + +export type AppInput = Partial> & { + source?: "image" | "auto" | "template"; + image?: string; + template?: string; + cnb?: string; + nonInteractive?: boolean; +}; + +type Choice = { name: string; message: string }; +export type PromptField = { + name: string; + initial?: string; + validate?: (value: unknown) => boolean | string; +}; +export type ScaffoldQuestion = { + type: "select" | "input" | "autocomplete" | "snippet"; + name: string; + message: string; + initial?: string | number; + choices?: Choice[]; + template?: string; + fields?: PromptField[]; + required?: string[]; + validate?: (value: unknown) => boolean | string | Promise; + suggest?: (input: string, choices: Choice[]) => Promise; +}; +export type ScaffoldPrompt = (question: ScaffoldQuestion) => Promise; + +export const terminalPrompt: ScaffoldPrompt = async (question) => { + if (!stdin.isTTY) + throw new Error( + `${question.message}: supply flags or use an interactive terminal`, + ); + // Enquirer's bundled types omit Snippet fields and required field-name arrays. + const answers = await new Enquirer().prompt( + question as unknown as Parameters[0], + ); + return (answers as Record)[question.name]; +}; + +function text(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function validation(check: () => unknown): boolean | string { + try { + check(); + return true; + } catch (error) { + return error instanceof Error ? error.message : "Invalid value"; + } +} + +// Literal preview values must never become Snippet tab stops. +function literal(value: string): string { + return JSON.stringify(value).replace(/([${#])/g, "\\$1"); +} + +export type ScaffoldContext = { + project?: string; + defaultProject?: string; + existingServices?: string[]; +}; + +/** The preview is presentation only; only validated typed field values leave here. */ +export async function resolveScaffoldInput( + root: string, + input: AppInput, + prompt: ScaffoldPrompt = terminalPrompt, + context: ScaffoldContext = {}, +): Promise<{ app: AppOptions; project?: string }> { + const detected = await detectAppPaths(root); + const interactive = !input.nonInteractive; + const ask = async (question: ScaffoldQuestion, fallback = "") => + interactive ? text(await prompt(question)) : fallback; + const defaultSource = detected.length ? "auto" : "image"; + const selected = + input.source ?? + (await ask( + { + type: "select", + name: "source", + message: "App source", + initial: defaultSource === "auto" ? 1 : 0, + choices: [ + { name: "image", message: "Container image" }, + { name: "auto", message: "Buildpacks (auto)" }, + { name: "template", message: "Dockerfile template" }, + ], + }, + defaultSource, + )); + let source: Source; + if (selected === "image") { + const image = + input.image ?? + (await ask( + { + type: "input", + name: "image", + message: "Container image", + initial: "nginx:stable", + validate: (value) => !!text(value) || "Image must not be empty", + }, + "nginx:stable", + )); + source = { kind: "image", image }; + } else if (selected === "auto") { + const uri = + input.cnb ?? + (await ask({ + type: "input", + name: "cnb", + message: "CNB link (optional; blank for automatic detection)", + validate: (value) => + validation(() => { + if (text(value)) validateBuildpackUri(text(value)); + }), + })); + source = { kind: "auto", ...(uri.trim() ? { uri: uri.trim() } : {}) }; + } else if (selected === "template") { + const template = + input.template ?? + (await ask({ + type: "select", + name: "template", + message: "Dockerfile template", + initial: 0, + choices: listDockerfileTemplates().map(({ id, label }) => ({ + name: id, + message: label, + })), + })); + if (!templateIds.includes(template as (typeof templateIds)[number])) + throw new Error( + !interactive && input.template === undefined + ? "Dockerfile template is required in non-interactive mode" + : `Unknown Dockerfile template: ${template}`, + ); + source = { + kind: "template", + template: template as (typeof templateIds)[number], + }; + } else throw new Error("Source must be image, auto, or template"); + + let path = input.path; + if (path === undefined) { + if (!interactive && detected.length > 1) + throw new Error( + `Multiple app roots found (${detected.join(", ")}); specify --path`, + ); + const suggested = detected.length === 1 ? detected[0]! : "."; + if (interactive) { + const paths = [...new Set([suggested, ".", ...detected])]; + path = + (await ask({ + type: "autocomplete", + name: "path", + message: "App path (optional; . is project root)", + choices: paths.map((name) => ({ name, message: name })), + initial: 0, + validate: async (value) => { + try { + await checkedAppPath(root, text(value) || suggested); + return true; + } catch (error) { + return error instanceof Error + ? error.message + : "Invalid app path"; + } + }, + suggest: async (value, choices) => { + const matches = choices.filter((choice) => + choice.message.toLowerCase().includes(value.toLowerCase()), + ); + if (value && !choices.some((choice) => choice.name === value)) { + try { + const valid = await checkedAppPath(root, value); + matches.push({ name: valid, message: valid }); + } catch { + /* Only valid custom directories become choices. */ + } + } + return matches; + }, + })) || suggested; + } else path = suggested; + } + path = await checkedAppPath(root, path); + const suggestedName = + basename(path === "." ? root : path) + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/^-+|-+$/g, "") || "app"; + const existing = context.existingServices ?? []; + const baseName = ( + /^[a-z]/.test(suggestedName) ? suggestedName : `app-${suggestedName}` + ) + .slice(0, 63) + .replace(/-+$/, ""); + let defaultName = baseName; + for (let suffix = 2; existing.includes(defaultName); suffix++) + defaultName = `${baseName.slice(0, 62 - String(suffix).length)}-${suffix}`; + const defaults: Record = { + name: input.name ?? defaultName, + postgres: input.postgres ?? "", + s3: input.s3 ?? "", + cpu: input.cpu ?? "", + memory: input.memory ?? "", + replicas: input.replicas?.toString() ?? (interactive ? "1" : ""), + ...(context.defaultProject + ? { project: context.project ?? context.defaultProject } + : {}), + }; + const locked: Record = { + name: input.name, + postgres: input.postgres, + s3: input.s3, + cpu: input.cpu, + memory: input.memory, + replicas: input.replicas?.toString(), + project: context.project, + }; + const toApp = (values: Record): AppOptions => ({ + name: values.name!, + path, + source, + ...(values.postgres ? { postgres: values.postgres } : {}), + ...(values.s3 ? { s3: values.s3 } : {}), + ...(values.cpu ? { cpu: values.cpu } : {}), + ...(values.memory ? { memory: values.memory } : {}), + ...(values.replicas ? { replicas: Number(values.replicas) } : {}), + }); + const checkField = (name: string, value: unknown) => + validation(() => { + const valueText = text(value); + if (name === "project") { + if (!valueText || projectName(valueText) !== valueText) + throw new Error( + "Project must be a lowercase Kubernetes name (max 63)", + ); + } else { + if (name === "name" && existing.includes(valueText)) + throw new Error(`Service ${valueText} already exists`); + serviceForApp(toApp({ ...defaults, [name]: valueText })); + } + }); + const placeholder = (name: string) => + locked[name] !== undefined ? literal(locked[name]!) : `\${${name}}`; + const service = serviceForApp({ name: "app", path, source }); + const sourceLine = + source.kind === "image" + ? `image: ${literal(String(service.image))}` + : `build: ${literal(String(service.build))}`; + const template = [ + ...(context.defaultProject ? [`name: ${placeholder("project")}`] : []), + "services:", + ...existing.map( + (name) => + ` ${/^[a-z][a-z0-9-]*$/.test(name) ? name : literal(name)}: ...`, + ), + ` ${placeholder("name")}:`, + ` ${sourceLine}`, + ...(service["x-kuber-build-context"] + ? [` x-kuber-build-context: ${literal(path)}`] + : []), + " volumes:", + ` - postgresql:${placeholder("postgres")}`, + ` - s3:${placeholder("s3")}`, + " deploy:", + ` replicas: ${placeholder("replicas")}`, + " resources:", + " limits:", + ` cpus: ${placeholder("cpu")}`, + ` memory: ${placeholder("memory")}`, + ].join("\n"); + let values = defaults; + if (interactive) { + const fields = Object.keys(defaults) + .filter((name) => locked[name] === undefined) + .map((name) => ({ + name, + initial: defaults[name], + validate: (value: unknown) => checkField(name, value), + })); + if (fields.length) { + const response = await prompt({ + type: "snippet", + name: "compose", + message: "Compose service (blank optional fields are omitted)", + template, + fields, + required: fields + .filter(({ name }) => name === "name" || name === "project") + .map(({ name }) => name), + }); + if ( + !response || + typeof response !== "object" || + !("values" in response) || + !response.values || + typeof response.values !== "object" + ) + throw new Error("Snippet did not return field values"); + const supplied = response.values as Record; + values = { ...defaults }; + for (const field of fields) + values[field.name] = text(supplied[field.name]); + } + } + const app = toApp(values); + serviceForApp(app); + if (existing.includes(app.name)) + throw new Error(`Service ${app.name} already exists`); + const project = values.project; + if (project && interactive && projectName(project) !== project) + throw new Error("Project must be a lowercase Kubernetes name (max 63)"); + if (context.defaultProject && !project) + throw new Error("Project name must not be empty"); + return { app, ...(project ? { project: projectName(project) } : {}) }; +} diff --git a/lib/scaffold.ts b/lib/scaffold.ts index f12777d..736acaf 100644 --- a/lib/scaffold.ts +++ b/lib/scaffold.ts @@ -11,6 +11,7 @@ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; import { randomUUID } from "node:crypto"; import { YAML } from "bun"; import type { Service } from "../schema/docker.d"; +import { validateBuildpackUri } from "../shared/build-protocol"; import { readCompose, resolveComposeFile } from "./yaml"; import { renderDockerfileTemplate, @@ -44,7 +45,7 @@ const serviceNamePattern = /^[a-z][a-z0-9-]*$/; export type Source = | { kind: "image"; image: string } - | { kind: "auto" } + | { kind: "auto"; uri?: string } | { kind: "template"; template: DockerfileTemplateId }; export type AppOptions = { name: string; @@ -131,12 +132,19 @@ export function serviceForApp(app: AppOptions): Service { if (!app.source.image.trim()) throw new Error("Image must not be empty"); service.image = app.source.image.trim(); } else { + if (app.source.kind === "auto" && app.source.uri?.trim()) + validateBuildpackUri(app.source.uri.trim()); if ( app.source.kind === "template" && !templateIds.includes(app.source.template) ) throw new Error(`Unknown Dockerfile template: ${app.source.template}`); - service.build = app.source.kind === "auto" ? "auto" : path; + service.build = + app.source.kind === "auto" + ? app.source.uri?.trim() + ? `auto:${app.source.uri.trim()}` + : "auto" + : path; if (app.source.kind === "auto" && path !== ".") service["x-kuber-build-context"] = path; } @@ -221,13 +229,13 @@ export async function addAppToCompose( throw new Error("Compose file cannot be found. Run kuber init first."); await checkedAppPath(root, app.path); const service = serviceForApp(app); - const compose = await readCompose(path); - if (Object.hasOwn(compose.services ?? {}, app.name)) - throw new Error(`Service ${app.name} already exists`); const generated: string[] = []; const lock = await open(`${path}.kuber.lock`, "wx"); try { const original = await readFile(path, "utf8"); + const compose = await readCompose(path); + if (Object.hasOwn(compose.services ?? {}, app.name)) + throw new Error(`Service ${app.name} already exists`); const source = app.source; if (source.kind === "template") { const templates = renderDockerfileTemplate(source.template); diff --git a/package.json b/package.json index 0cc66ac..f2a27bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dmgnr/kuber", - "version": "2.7.0-rc2", + "version": "2.7.0-rc3", "description": "Docker Compose to Kubernetes translation layer", "bin": { "kuber": "dist/index.js" @@ -20,6 +20,9 @@ "test": "bun test tests", "typecheck": "tsc --noEmit" }, + "dependencies": { + "enquirer": "^2.4.1" + }, "devDependencies": { "@bomb.sh/tab": "^0.0.22", "@cliffy/table": "npm:@jsr/cliffy__table", @@ -31,6 +34,7 @@ "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "typescript": "^5", + "undici-types": "~7.18.0", "zod": "^4.4.3" }, "engines": { diff --git a/tests/command/add.test.ts b/tests/command/add.test.ts index df83aea..2655e92 100644 --- a/tests/command/add.test.ts +++ b/tests/command/add.test.ts @@ -10,6 +10,26 @@ import { } from "../../lib/scaffold"; import { resolveAppInput } from "../../command/add"; import { readCompose } from "../../lib/yaml"; +import type { ScaffoldQuestion } from "../../lib/scaffold-prompts"; +import { listDockerfileTemplates } from "../../lib/scaffold-templates"; + +function snippetValues( + question: ScaffoldQuestion, + overrides: Record = {}, +) { + return { + values: { + ...Object.fromEntries( + (question.fields ?? []).map((field) => [ + field.name, + field.initial ?? "", + ]), + ), + ...overrides, + }, + result: "services:\n malicious:\n image: ignored-preview", + }; +} const roots: string[] = []; afterEach(async () => { @@ -156,3 +176,208 @@ test("rejects path traversal and symlink outside the project", async () => { }), ).rejects.toThrow(); }); + +test("interactive image flow selects source before path and omits blank fields from generated YAML", async () => { + const cwd = await root(); + const questions: ScaffoldQuestion[] = []; + const app = await resolveAppInput(cwd, {}, async (question) => { + questions.push(question); + if (question.name === "source") { + expect(question.initial).toBe(0); + return "image"; + } + if (question.name === "image") { + expect(question.initial).toBe("nginx:stable"); + return "example/web:1"; + } + if (question.name === "path") return "."; + expect(question.type).toBe("snippet"); + expect(question.template).not.toContain("managedBy"); + expect(question.template).toContain('image: "example/web:1"'); + expect(question.fields?.some((field) => field.name === "image")).toBe( + false, + ); + return snippetValues(question, { + name: "web", + postgres: "", + s3: "", + cpu: "", + memory: "", + }); + }); + expect(questions.map(({ type }) => type)).toEqual([ + "select", + "input", + "autocomplete", + "snippet", + ]); + await createCompose(cwd, "demo", app); + const compose = await readCompose(join(cwd, "compose.yml")); + expect(compose.services?.web).toEqual({ + image: "example/web:1", + deploy: { replicas: 1 }, + }); + expect(compose.services?.malicious).toBeUndefined(); +}); + +test("auto flow accepts CNB URI and valid custom directory while keeping the root context rule", async () => { + const cwd = await root(); + await mkdir(join(cwd, "web")); + await mkdir(join(cwd, "custom")); + await writeFile(join(cwd, "web", "package.json"), "{}"); + const names: string[] = []; + const app = await resolveAppInput(cwd, {}, async (question) => { + names.push(question.name); + if (question.name === "source") { + expect(question.initial).toBe(1); + return "auto"; + } + if (question.name === "cnb") { + expect(question.validate?.("https://example.com/buildpack.tgz")).not.toBe( + true, + ); + expect(question.validate?.("")).toBe(true); + return "https://github.com/example/buildpack/releases/download/v1/web.cnb"; + } + if (question.name === "path") { + expect(question.choices?.map(({ name }) => name)).toEqual(["web", "."]); + expect(await question.validate?.("../outside")).not.toBe(true); + expect(await question.validate?.("custom")).toBe(true); + expect( + await question.suggest?.("custom", question.choices!), + ).toContainEqual({ name: "custom", message: "custom" }); + return "custom"; + } + return snippetValues(question, { name: "custom" }); + }); + expect(names).toEqual(["source", "cnb", "path", "compose"]); + await createCompose(cwd, "demo", app); + expect( + (await readCompose(join(cwd, "compose.yml"))).services?.custom, + ).toMatchObject({ + build: + "auto:https://github.com/example/buildpack/releases/download/v1/web.cnb", + "x-kuber-build-context": "custom", + }); + const rootApp = await resolveAppInput(cwd, { + source: "auto", + path: ".", + name: "root", + nonInteractive: true, + cnb: "https://github.com/example/buildpack/releases/download/v1/root.cnb", + }); + await addAppToCompose(cwd, rootApp); + expect((await readCompose(join(cwd, "compose.yml"))).services?.root).toEqual({ + build: + "auto:https://github.com/example/buildpack/releases/download/v1/root.cnb", + }); +}); + +test("template branch exposes all eight paired templates and locks explicitly flagged fields", async () => { + const cwd = await root(); + const names: string[] = []; + const app = await resolveAppInput( + cwd, + { path: ".", name: "fixed", cpu: "0.5" }, + async (question) => { + names.push(question.name); + if (question.name === "source") return "template"; + if (question.name === "template") { + expect(question.choices?.map(({ name }) => name)).toEqual( + listDockerfileTemplates().map(({ id }) => id), + ); + expect(question.choices).toHaveLength(8); + return "bun-service"; + } + expect( + question.fields?.some(({ name }) => name === "name" || name === "cpu"), + ).toBe(false); + return snippetValues(question, { + name: "ignored", + cpu: "99", + postgres: "user/db", + s3: "key/bucket", + memory: "512m", + replicas: "2", + }); + }, + ); + expect(names).toEqual(["source", "template", "compose"]); + expect(app).toMatchObject({ + name: "fixed", + cpu: "0.5", + source: { kind: "template", template: "bun-service" }, + postgres: "user/db", + s3: "key/bucket", + memory: "512m", + replicas: 2, + }); +}); + +test("add preview contains literal existing names; collision is corrected in Snippet and existing content is preserved", async () => { + const cwd = await root(); + const original = + "name: demo\nservices:\n existing:\n image: private:1 # retain\n app:\n image: private:2\nx-owner: same\n"; + const file = join(cwd, "compose.yml"); + await writeFile(file, original); + const app = await resolveAppInput( + cwd, + { source: "image", image: "nginx", path: "." }, + async (question) => { + expect(question.type).toBe("snippet"); + expect(question.template).toContain( + "services:\n existing: ...\n app: ...\n ${name}:", + ); + expect(question.template).not.toContain("private:1"); + const name = question.fields?.find(({ name }) => name === "name"); + expect(name?.validate?.("app")).toBe("Service app already exists"); + expect(name?.validate?.("worker")).toBe(true); + expect( + question.fields + ?.find(({ name }) => name === "replicas") + ?.validate?.("0"), + ).toContain("positive integer"); + return snippetValues(question, { name: "worker" }); + }, + ); + await addAppToCompose(cwd, app); + const updated = await readFile(file, "utf8"); + expect(updated).toContain( + " existing:\n image: private:1 # retain\n app:\n image: private:2\n", + ); + expect(updated).not.toContain("..."); + expect((await readCompose(file)).services?.worker?.image).toBe("nginx"); + await expect( + resolveAppInput( + cwd, + { source: "image", image: "nginx", path: "." }, + async (question) => snippetValues(question, { name: "app" }), + ), + ).rejects.toThrow("already exists"); + expect(await readFile(file, "utf8")).toBe(updated); +}); + +test("cancelled add and a service appearing after the preview do not overwrite Compose", async () => { + const cwd = await root(); + const file = await createCompose(cwd, "demo", { + name: "original", + path: ".", + source: { kind: "image", image: "nginx" }, + }); + const original = await readFile(file, "utf8"); + await expect( + resolveAppInput(cwd, {}, async () => { + throw new Error("cancelled"); + }), + ).rejects.toThrow("cancelled"); + expect(await readFile(file, "utf8")).toBe(original); + const app = await resolveAppInput( + cwd, + { source: "image", image: "nginx", path: "." }, + async (question) => snippetValues(question, { name: "worker" }), + ); + await addAppToCompose(cwd, app); + const updated = await readFile(file, "utf8"); + await expect(addAppToCompose(cwd, app)).rejects.toThrow("already exists"); + expect(await readFile(file, "utf8")).toBe(updated); +}); diff --git a/tests/command/auth.test.ts b/tests/command/auth.test.ts new file mode 100644 index 0000000..3940f80 --- /dev/null +++ b/tests/command/auth.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { Writable } from "node:stream"; +import { + createLoginPrompt, + interactiveLogin, + type LoginPrompt, +} from "../../command/auth"; +import { KuberApiError } from "../../lib/api"; +import type { KuberSession } from "../../lib/session"; + +const session: KuberSession = { + token: "test-token", + expiresAt: "2099-01-01T00:00:00Z", + user: { username: "alice", roles: ["user"] }, +}; + +async function start(prompt: LoginPrompt) { + const ready = new Promise((resolve) => { + (prompt as unknown as EventEmitter).once("run", resolve); + }); + const result = prompt.run(); + await ready; + return { result }; +} + +async function credentials( + prompt: LoginPrompt, + username: string, + password: string, +) { + for (const choice of prompt.choices) { + choice.input = choice.value = + choice.name === "username" ? username : password; + } + await prompt.render(); +} + +describe("BasicAuth login", () => { + test("authenticates once against the supplied API and preserves persistence", async () => { + const calls: unknown[][] = []; + const prompt = createLoginPrompt( + "alice", + true, + async (...args) => { + calls.push(args); + return session; + }, + { show: false }, + ); + const { result } = await start(prompt); + expect(prompt.values.username).toBe("alice"); + await credentials(prompt, " alice ", "sensitive-password"); + await Promise.all([prompt.submit(), prompt.submit()]); + expect(await result).toEqual(session); + expect(calls).toEqual([["alice", "sensitive-password", true]]); + expect(prompt.values.password).toBe(""); + }); + + test("incorrect authentication closes the prompt and retains the CLI API error", async () => { + const error = new KuberApiError("Incorrect credentials", 401); + const prompt = createLoginPrompt( + "", + false, + async () => { + throw error; + }, + { show: false }, + ); + const { result } = await start(prompt); + const rejected = result.catch((error: unknown) => error); + await credentials(prompt, "alice", "secret"); + await prompt.submit(); + expect(await rejected).toBe(error); + expect(prompt.state.closed).toBe(true); + expect(prompt.values.password).toBe(""); + }); + + test("cancellation rejects without calling authentication", async () => { + let calls = 0; + const prompt = createLoginPrompt( + "", + false, + async () => { + calls++; + return session; + }, + { show: false }, + ); + const { result } = await start(prompt); + const rejected = result.catch((error: unknown) => error); + await credentials(prompt, "alice", "secret"); + await prompt.cancel(); + expect(await rejected).toMatchObject({ message: "Login cancelled" }); + expect(calls).toBe(0); + expect(prompt.values.password).toBe(""); + }); + + test.each(["submit", "cancel"] as const)( + "masks password while editing and never prints it on %s", + async (action) => { + let output = ""; + const stdout = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString(); + callback(); + }, + }) as unknown as NodeJS.WriteStream; + const prompt = createLoginPrompt("", false, async () => session, { + show: false, + stdout, + }); + const { result } = await start(prompt); + const settled = result.catch(() => undefined); + await credentials(prompt, "alice", "never-print-this"); + // show:false disables terminal listeners; enable writes only after initialization. + (prompt as unknown as { state: { show: boolean } }).state.show = true; + await prompt.render(); + expect(output).toContain("password"); + expect(output).toContain("*".repeat("never-print-this".length)); + expect(output).not.toContain("never-print-this"); + await prompt[action](); + await settled; + expect(output).not.toContain("never-print-this"); + }, + ); + + test("empty username fails before contacting authentication", async () => { + let calls = 0; + const prompt = createLoginPrompt( + "", + false, + async () => { + calls++; + return session; + }, + { show: false }, + ); + const { result } = await start(prompt); + const rejected = result.catch((error: unknown) => error); + await prompt.submit(); + expect(await rejected).toMatchObject({ message: "Username is required" }); + expect(calls).toBe(0); + }); + + test("noninteractive onboarding and login fail before creating a prompt", async () => { + let calls = 0; + await expect( + interactiveLogin("alice", true, { + isTTY: false, + prompt: () => { + calls++; + throw new Error("Unexpected prompt"); + }, + }), + ).rejects.toThrow("Login requires an interactive terminal"); + expect(calls).toBe(0); + }); + + test("onboarding returns the session using the shared prompt", async () => { + const result = await interactiveLogin(" alice ", true, { + isTTY: true, + prompt: (username, persistent) => { + expect(username).toBe("alice"); + expect(persistent).toBe(true); + const prompt = createLoginPrompt( + username, + persistent, + async () => session, + { show: false }, + ); + (prompt as unknown as EventEmitter).once("run", () => { + void prompt.submit(); + }); + return prompt; + }, + }); + expect(result).toEqual(session); + }); +}); diff --git a/tests/command/init.test.ts b/tests/command/init.test.ts index 7612213..9fd9f17 100644 --- a/tests/command/init.test.ts +++ b/tests/command/init.test.ts @@ -7,6 +7,7 @@ import { initializeProject } from "../../command/init"; import { managedHeader } from "../../lib/scaffold"; import { readTrust, resolveTrustIdentity, updateTrust } from "../../lib/trust"; import { KuberApiError } from "../../lib/api"; +import type { ScaffoldQuestion } from "../../lib/scaffold-prompts"; const roots: string[] = []; const originalConfig = process.env.XDG_CONFIG_HOME; @@ -24,6 +25,189 @@ async function root() { } describe("kuber init", () => { + test("interactive YAML fields finish before login and automatic trust", async () => { + const cwd = await root(); + const events: string[] = []; + const session = { + token: "test", + expiresAt: "2099-01-01", + user: { username: "a", roles: [] }, + }; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + await initializeProject( + cwd, + {}, + { + prompt: async (question) => { + events.push(question.name); + expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe( + false, + ); + if (question.name === "source") return "image"; + if (question.name === "image") return "nginx:stable"; + if (question.name === "path") return "."; + expect(question.type).toBe("snippet"); + expect(question.template).toStartWith( + "name: ${project}\nservices:\n ${name}:", + ); + expect(question.template).not.toContain("managedBy"); + const project = question.fields?.find( + ({ name }) => name === "project", + ); + expect(project?.validate?.("Bad Project")).not.toBe(true); + expect(project?.validate?.("demo")).toBe(true); + return { + values: { project: "demo", name: "web", replicas: "1" }, + result: "malicious preview", + }; + }, + session: async () => { + events.push("session"); + return undefined; + }, + login: async () => { + events.push("login"); + expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe( + false, + ); + return session; + }, + request: async (_path, init) => { + events.push(init?.method ?? "GET"); + return (init?.method ? undefined : { fingerprints: [] }) as never; + }, + persistTrust: async () => { + events.push("trust"); + }, + }, + ); + expect(events).toEqual([ + "source", + "image", + "path", + "compose", + "session", + "login", + "GET", + "POST", + "trust", + ]); + const content = await readFile(join(cwd, "compose.yml"), "utf8"); + expect(content.split("\n")[0]).toBe(managedHeader); + expect(YAML.parse(content)).toEqual({ + managedBy: "kuber", + name: "demo", + services: { web: { image: "nginx:stable", deploy: { replicas: 1 } } }, + }); + expect(content).not.toContain("malicious"); + } finally { + log.mockRestore(); + } + }); + + test("flagged project and configured project bypass editable project field; existing login is reused", async () => { + for (const configured of [false, true]) { + const cwd = await root(); + if (configured) + await writeFile( + join(cwd, ".kuberrc.ts"), + 'export default { project: "fixed" };\n', + ); + let logins = 0; + const questions: ScaffoldQuestion[] = []; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + await initializeProject( + cwd, + { + source: "auto", + cnb: "", + path: ".", + ...(configured ? {} : { project: "fixed" }), + }, + { + prompt: async (question) => { + questions.push(question); + expect(question.template).toStartWith('name: "fixed"'); + expect( + question.fields?.some(({ name }) => name === "project"), + ).toBe(false); + return { + values: { project: "ignored", name: "web", replicas: "1" }, + }; + }, + session: async () => ({ + token: "test", + expiresAt: "2099-01-01", + user: { username: "a", roles: [] }, + }), + login: async () => { + logins++; + throw new Error("unneeded login"); + }, + request: async (_path, init) => + (init?.method ? undefined : { fingerprints: [] }) as never, + persistTrust: async () => {}, + }, + ); + expect(questions.map(({ type }) => type)).toEqual(["snippet"]); + expect(logins).toBe(0); + expect( + YAML.parse(await readFile(join(cwd, "compose.yml"), "utf8")), + ).toMatchObject({ + name: "fixed", + services: { web: { build: "auto" } }, + }); + } finally { + log.mockRestore(); + } + } + }); + + test("cancel or invalid snippet performs no login, trust, or filesystem writes", async () => { + for (const failure of ["cancel", "invalid"]) { + const cwd = await root(); + let downstream = 0; + await expect( + initializeProject( + cwd, + { source: "template", template: "bun-service", path: "." }, + { + prompt: async () => { + if (failure === "cancel") throw new Error("cancelled"); + return { + values: { + project: "demo", + name: "web", + cpu: "-1", + replicas: "1", + }, + }; + }, + session: async () => { + downstream++; + return undefined; + }, + login: async () => { + downstream++; + throw new Error("unexpected login"); + }, + request: async () => { + downstream++; + return undefined as never; + }, + }, + ), + ).rejects.toThrow( + failure === "cancel" ? "cancelled" : "CPU must be a positive", + ); + expect(downstream).toBe(0); + for (const name of ["compose.yml", "Dockerfile", ".dockerignore"]) + expect(await Bun.file(join(cwd, name)).exists()).toBe(false); + } + }); + test("uses an existing configured project for Compose and trust", async () => { const cwd = await root(); await writeFile( @@ -146,14 +330,14 @@ describe("kuber init", () => { }; }, request: async (_path, init) => { - if (++attempts === 1) throw new KuberApiError("revoked", 401); + if (++attempts === 2) throw new KuberApiError("revoked", 401); return ( init?.method === "POST" ? undefined : { fingerprints: [] } ) as never; }, }, ); - expect([attempts, logins]).toEqual([3, 1]); + expect([attempts, logins]).toEqual([4, 1]); expect(await readTrust()).toHaveLength(1); } finally { log.mockRestore(); @@ -361,7 +545,7 @@ describe("kuber init", () => { request: async (_path, init) => { calls.push(init?.method ?? "GET"); if (init?.method === "POST") - throw new KuberApiError("grant failed", 503); + throw new KuberApiError("grant failed", 400); return { fingerprints: [] } as never; }, }, @@ -374,6 +558,104 @@ describe("kuber init", () => { expect(await Bun.file(join(cwd, ".dockerignore")).exists()).toBe(false); }); + test("committed POST with lost response keeps generated files after reconciliation", async () => { + const cwd = await root(); + const identity = await resolveTrustIdentity("demo", cwd); + const trusted = new Set(); + const calls: string[] = []; + await expect( + initializeProject( + cwd, + { + nonInteractive: true, + project: "demo", + source: "template", + template: "static-nginx", + }, + { + session: async () => ({ + token: "test", + expiresAt: "2099-01-01", + user: { username: "a", roles: [] }, + }), + request: async (_path, init) => { + calls.push(init?.method ?? "GET"); + if (init?.method === "POST") { + trusted.add(identity.fingerprint); + throw new Error("socket closed after commit"); + } + return { fingerprints: [...trusted] } as never; + }, + }, + ), + ).rejects.toThrow(/registration succeeded.*files were kept/); + expect(calls).toEqual(["GET", "POST", "GET"]); + expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe(true); + expect(await Bun.file(join(cwd, "Dockerfile")).exists()).toBe(true); + expect(await Bun.file(join(cwd, ".dockerignore")).exists()).toBe(true); + }); + + test("unavailable reconciliation keeps generated files and explains repair", async () => { + const cwd = await root(); + const calls: string[] = []; + await expect( + initializeProject( + cwd, + { + nonInteractive: true, + project: "demo", + }, + { + session: async () => ({ + token: "test", + expiresAt: "2099-01-01", + user: { username: "a", roles: [] }, + }), + request: async (_path, init) => { + calls.push(init?.method ?? "GET"); + if (init?.method === "POST") throw new Error("response lost"); + if (calls.length === 3) + throw new Error("trust service unavailable"); + return { fingerprints: [] } as never; + }, + }, + ), + ).rejects.toThrow(/Could not confirm.*kuber trust status.*kuber trust/); + expect(calls).toEqual(["GET", "POST", "GET"]); + expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe(true); + }); + + test("concurrent grant found during reconciliation keeps generated files", async () => { + const cwd = await root(); + const identity = await resolveTrustIdentity("demo", cwd); + const calls: string[] = []; + await expect( + initializeProject( + cwd, + { + nonInteractive: true, + project: "demo", + }, + { + session: async () => ({ + token: "test", + expiresAt: "2099-01-01", + user: { username: "a", roles: [] }, + }), + request: async (_path, init) => { + calls.push(init?.method ?? "GET"); + if (init?.method === "POST") throw new Error("response lost"); + return { + fingerprints: calls.length === 3 ? [identity.fingerprint] : [], + } as never; + }, + }, + ), + ).rejects.toThrow(/registration succeeded.*files were kept/); + expect(calls).toEqual(["GET", "POST", "GET"]); + expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe(true); + }); + test("existing Compose file is not overwritten and no grant is attempted", async () => { const cwd = await root(); const composePath = join(cwd, "compose.yml"); diff --git a/tsconfig.json b/tsconfig.json index a2bb93c..5650733 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,10 @@ "jsx": "react-jsx", "allowJs": true, "types": ["bun"], + // bun-types imports Undici directly; isolated installs hide Node's copy. + "paths": { + "undici-types": ["./node_modules/undici-types"] + }, // Bundler mode "moduleResolution": "bundler",