feat: release 2.7.0-rc3

This commit is contained in:
2026-10-07 05:10:59 +00:00 Unverified
parent c8de9ddcba
commit 2a24b2ff56
14 changed files with 1302 additions and 249 deletions
+40 -16
View File
@@ -67,6 +67,13 @@ https://kuber.astrxl.dev/api/v2
## Authentication ## 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 ```bash
kuber login dmgnr kuber login dmgnr
kuber login dmgnr --persist 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 `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 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 ```bash
kuber init 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 With an interactive terminal, `init` prompts for missing project and app
details, and its default flow logs in automatically when needed. The project details. If multiple app roots are detected, an AutoComplete prompt lets you
trust is granted for the current directory as part of initialization, so a choose the app path; pass `--path` to select one directly. A YAML Snippet prompt
first `kuber up` can proceed without separately running `kuber trust`. Use then edits the project, service, claims, resources, and replica count together;
`--non-interactive` to disable prompts; provide required values as flags. The the replica count defaults to `1`. Blank optional values are omitted from the
project name is optional and defaults from the current project context. If generated configuration. The `managedBy` documentation header is added when the
project configuration already fixes a project name, `--project` must match it. 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 `kuber add app` adds another service to an existing project without replacing
existing services. Its syntax and the shared app options are: 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 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 In interactive mode, the first prompt selects the app source, followed by
from detected app roots when there is exactly one; when there are several, the source-specific input: an image URI, a CNB buildpack URI, or a Dockerfile
prompt asks you to select one. Supply `--path` to select a subproject explicitly. template. When multiple app roots are detected, an AutoComplete prompt selects
Non-interactive mode never prompts: supply values needed for the selected the path; with one detected root it is selected automatically. A YAML Snippet
source, and for multiple detected roots explicitly supply `--path`. With no prompt edits the service, claims, resources, and replicas (default `1`) in
detected app roots the path defaults to `.`. If no source is supplied, a `compose.yml`. Existing services are presented literally as `name: ...` and
repository with detected app roots defaults to `auto`; otherwise it defaults to remain read-only while the new service is configured. Blank optional values
`image` (`nginx:stable`). Optional Postgres/S3 claims and CPU, memory, and are omitted when the Compose YAML is written. The `managedBy` documentation
replica settings can be provided by flags or interactively. 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 #### App sources
+22 -2
View File
@@ -4,6 +4,9 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "kuber", "name": "kuber",
"dependencies": {
"enquirer": "^2.4.1",
},
"devDependencies": { "devDependencies": {
"@bomb.sh/tab": "^0.0.22", "@bomb.sh/tab": "^0.0.22",
"@cliffy/table": "npm:@jsr/cliffy__table", "@cliffy/table": "npm:@jsr/cliffy__table",
@@ -15,6 +18,7 @@
"oxfmt": "^0.62.0", "oxfmt": "^0.62.0",
"oxlint": "^1.77.0", "oxlint": "^1.77.0",
"typescript": "^5", "typescript": "^5",
"undici-types": "~7.18.0",
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
}, },
@@ -128,9 +132,11 @@
"agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-colors": ["[email protected]", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-escapes": ["[email protected]", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-escapes": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -170,6 +176,8 @@
"end-of-stream": ["[email protected]", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "end-of-stream": ["[email protected]", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"enquirer": ["[email protected]", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"environment": ["[email protected]", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], "environment": ["[email protected]", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-define-property": ["[email protected]", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-define-property": ["[email protected]", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -284,7 +292,7 @@
"string-width": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "string-width": ["[email protected]", "", { "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": ["[email protected]", "", { "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=="], "tar-fs": ["[email protected]", "", { "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/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], "bun-types/@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
"log-update/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"string-width/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"wrap-ansi/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@types/node-fetch/@types/node/undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "@types/node-fetch/@types/node/undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"@types/stream-buffers/@types/node/undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "@types/stream-buffers/@types/node/undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"bun-types/@types/node/undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "bun-types/@types/node/undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"log-update/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"string-width/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"wrap-ansi/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
} }
} }
+6
View File
@@ -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 # 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. - 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.
+28 -156
View File
@@ -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 { defineCommand } from "citty";
import { ctx } from "../lib/context"; import { ctx } from "../lib/context";
import { addAppToCompose, type AppOptions } from "../lib/scaffold";
import { readCompose, resolveComposeFile } from "../lib/yaml";
import { import {
addAppToCompose, resolveScaffoldInput,
checkedAppPath, terminalPrompt,
detectAppPaths, type AppInput,
type AppOptions, type ScaffoldPrompt,
type Source, } from "../lib/scaffold-prompts";
} from "../lib/scaffold";
import {
listDockerfileTemplates,
templateIds,
} from "../lib/scaffold-templates";
export type ScaffoldPrompt = ( export { terminalPrompt, type AppInput, type ScaffoldPrompt };
question: string,
defaultValue?: string,
) => Promise<string>;
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<Omit<AppOptions, "source">> & {
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<string> {
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 async function resolveAppInput( export async function resolveAppInput(
root: string, root: string,
input: AppInput, input: AppInput,
prompt: ScaffoldPrompt = terminalPrompt, prompt: ScaffoldPrompt = terminalPrompt,
composeFile?: string,
): Promise<AppOptions> { ): Promise<AppOptions> {
const detected = await detectAppPaths(root); const file = composeFile ?? (await resolveComposeFile(root));
let path: string; const existingServices = file
if (input.path !== undefined) path = input.path; ? Object.keys((await readCompose(file)).services ?? {})
else if (detected.length === 0) path = "."; : [];
else if (detected.length === 1) path = detected[0]!; return (await resolveScaffoldInput(root, input, prompt, { existingServices }))
else { .app;
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) } : {}),
};
} }
export const appArgs = { export const appArgs = {
@@ -178,6 +39,10 @@ export const appArgs = {
type: "string" as const, type: "string" as const,
description: "Image reference when source=image", description: "Image reference when source=image",
}, },
cnb: {
type: "string" as const,
description: "Optional CNB URI when source=auto",
},
template: { template: {
type: "string" as const, type: "string" as const,
description: "Dockerfile template ID when source=template", description: "Dockerfile template ID when source=template",
@@ -205,6 +70,7 @@ export function appInputFromArgs(args: Record<string, unknown>): AppInput {
"source", "source",
"image", "image",
"template", "template",
"cnb",
"postgres", "postgres",
"s3", "s3",
"cpu", "cpu",
@@ -229,8 +95,14 @@ export const add = defineCommand({
args: appArgs, args: appArgs,
async run({ args }) { async run({ args }) {
const { cwd, path } = ctx(); const { cwd, path } = ctx();
const app = await resolveAppInput(cwd, appInputFromArgs(args)); const file = await path();
const composePath = await addAppToCompose(cwd, app, 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}`); console.log(`Added ${app.name} to ${composePath}`);
}, },
}), }),
+111 -52
View File
@@ -1,6 +1,6 @@
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import { createInterface } from "node:readline/promises"; import Enquirer from "enquirer";
import { stdin, stdout } from "node:process"; import { stdin } from "node:process";
import { apiRequest } from "../lib/api"; import { apiRequest } from "../lib/api";
import { import {
readSession, readSession,
@@ -11,50 +11,108 @@ import {
type LoginResponse = KuberSession; type LoginResponse = KuberSession;
async function promptUsername(): Promise<string> { type Credentials = { username: string; password: string };
const readline = createInterface({ input: stdin, output: stdout }); type AuthChoice = {
try { name: string;
return (await readline.question("Username: ")).trim(); input: string;
} finally { value: string;
readline.close(); initial?: string;
} cursor?: number;
};
export interface LoginPrompt {
choices: AuthChoice[];
values: Credentials;
state: { submitted: boolean; cancelled: boolean; closed: boolean };
initialize(): Promise<void>;
render(): Promise<void>;
submit(): Promise<void>;
cancel(): Promise<void>;
run(): Promise<LoginResponse>;
} }
async function promptPassword(): Promise<string> { // Enquirer's declarations omit its runtime BasicAuth factory.
if (!stdin.isTTY || !stdin.setRawMode) { const BasicAuth = (
throw new Error("Password input requires an interactive terminal"); Enquirer as typeof Enquirer & {
BasicAuth: {
create(
authenticate: (values: Credentials) => Promise<LoginResponse | false>,
): new (options: Record<string, unknown>) => LoginPrompt;
};
} }
).BasicAuth;
stdout.write("Password: "); /** Actual BasicAuth prompt; injectable authentication keeps tests offline. */
stdin.setRawMode(true); export function createLoginPrompt(
stdin.resume(); username = "",
return new Promise((resolve, reject) => { persistent = false,
let password = ""; authenticate: typeof loginUser = loginUser,
const cleanup = () => { options: { show?: boolean; stdout?: NodeJS.WriteStream } = {},
stdin.off("data", onData); ): LoginPrompt {
stdin.setRawMode(false); let failure: unknown;
stdin.pause(); let authentication: Promise<LoginResponse | false> | undefined;
stdout.write("\n"); const Auth = BasicAuth.create((values) => {
}; // Repeated Enter presses must never issue a second login or session write.
const onData = (chunk: Buffer) => { authentication ??= (async () => {
const value = chunk.toString("utf8"); try {
if (value === "\u0003") { const name = values.username.trim();
cleanup(); if (!name) throw new Error("Username is required");
reject(new Error("Login cancelled")); return await authenticate(name, values.password, persistent);
return; } catch (error) {
// BasicAuth doesn't forward rejected authenticate callbacks to run().
failure = error;
return false as const;
} }
if (value === "\r" || value === "\n") { })();
cleanup(); return authentication;
resolve(password); });
return; 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") { await this.render();
password = password.slice(0, -1); }
return;
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; await super.render();
}; }
stdin.on("data", onData);
override async run(): Promise<LoginResponse> {
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. */ /** Share the CLI's credential prompt with onboarding without exposing passwords. */
export async function interactiveLogin(): Promise<LoginResponse> { export async function interactiveLogin(
if (!stdin.isTTY) username = "",
persistent = false,
dependencies: {
isTTY?: boolean;
prompt?: typeof createLoginPrompt;
} = {},
): Promise<LoginResponse> {
if (!(dependencies.isTTY ?? stdin.isTTY))
throw new Error( throw new Error(
"Login requires an interactive terminal; run kuber login first", "Login requires an interactive terminal; run kuber login first",
); );
const username = await promptUsername(); const session = await (dependencies.prompt ?? createLoginPrompt)(
if (!username) throw new Error("Username is required"); username.trim(),
const session = await loginUser(username, await promptPassword(), false); persistent,
).run();
console.log(`Logged in as ${session.user.username}`); console.log(`Logged in as ${session.user.username}`);
return session; return session;
} }
@@ -100,14 +166,7 @@ export const login = defineCommand({
}, },
}, },
async run({ args }) { async run({ args }) {
const username = String(args._[0] ?? "").trim() || (await promptUsername()); await interactiveLogin(String(args._[0] ?? ""), Boolean(args.persist));
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}`);
}, },
}); });
+47 -13
View File
@@ -8,11 +8,11 @@ import { createCompose, projectName } from "../lib/scaffold";
import { import {
appArgs, appArgs,
appInputFromArgs, appInputFromArgs,
resolveAppInput,
terminalPrompt, terminalPrompt,
type AppInput, type AppInput,
type ScaffoldPrompt, type ScaffoldPrompt,
} from "./add"; } from "./add";
import { resolveScaffoldInput } from "../lib/scaffold-prompts";
import { resolveComposeFile } from "../lib/yaml"; import { resolveComposeFile } from "../lib/yaml";
import { basename } from "node:path"; import { basename } from "node:path";
import { join } from "node:path"; import { join } from "node:path";
@@ -62,13 +62,13 @@ export async function initializeProject(
const defaultProject = projectName( const defaultProject = projectName(
config.projectConfigured ? config.project : basename(root), config.projectConfigured ? config.project : basename(root),
); );
const project = projectName( const resolved = await resolveScaffoldInput(root, input, prompt, {
input.project ?? defaultProject,
(input.nonInteractive project:
? defaultProject input.project ?? (config.projectConfigured ? config.project : undefined),
: await prompt("Project name", defaultProject)), });
); const project = resolved.project!;
const app = await resolveAppInput(root, input, prompt); const app = resolved.app;
const login = dependencies.login ?? interactiveLogin; const login = dependencies.login ?? interactiveLogin;
let session = await (dependencies.session ?? readSession)(); let session = await (dependencies.session ?? readSession)();
if (!session) session = await login(); 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: // The server grant is idempotent. A GET cannot establish ownership of it:
// another client may grant the same fingerprint before our POST. // another client may grant the same fingerprint before our POST.
if (!alreadyTrusted) if (!alreadyTrusted) {
await request(trustPath, { try {
method: "POST", await request(trustPath, {
json: { fingerprint: identity.fingerprint }, 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<WorkspaceTrustResponse>(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; remoteRegistered = true;
try { try {
await (dependencies.persistTrust ?? updateTrust)((records) => [ await (dependencies.persistTrust ?? updateTrust)((records) => [
+1 -1
View File
@@ -37,7 +37,7 @@ function cloneCommand<T extends object>(command: T): T {
export const main = defineCommand({ export const main = defineCommand({
meta: { meta: {
name: "kuber", name: "kuber",
version: "2.7.0-rc2", version: "2.7.0-rc3",
description: "Docker Compose -> K8s translation layer", description: "Docker Compose -> K8s translation layer",
}, },
args: { args: {
+335
View File
@@ -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<Omit<AppOptions, "source">> & {
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<boolean | string>;
suggest?: (input: string, choices: Choice[]) => Promise<Choice[]>;
};
export type ScaffoldPrompt = (question: ScaffoldQuestion) => Promise<unknown>;
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<Enquirer["prompt"]>[0],
);
return (answers as Record<string, unknown>)[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<string, string> = {
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<string, string | undefined> = {
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<string, string>): 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<string, unknown>;
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) } : {}) };
}
+13 -5
View File
@@ -11,6 +11,7 @@ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { YAML } from "bun"; import { YAML } from "bun";
import type { Service } from "../schema/docker.d"; import type { Service } from "../schema/docker.d";
import { validateBuildpackUri } from "../shared/build-protocol";
import { readCompose, resolveComposeFile } from "./yaml"; import { readCompose, resolveComposeFile } from "./yaml";
import { import {
renderDockerfileTemplate, renderDockerfileTemplate,
@@ -44,7 +45,7 @@ const serviceNamePattern = /^[a-z][a-z0-9-]*$/;
export type Source = export type Source =
| { kind: "image"; image: string } | { kind: "image"; image: string }
| { kind: "auto" } | { kind: "auto"; uri?: string }
| { kind: "template"; template: DockerfileTemplateId }; | { kind: "template"; template: DockerfileTemplateId };
export type AppOptions = { export type AppOptions = {
name: string; 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"); if (!app.source.image.trim()) throw new Error("Image must not be empty");
service.image = app.source.image.trim(); service.image = app.source.image.trim();
} else { } else {
if (app.source.kind === "auto" && app.source.uri?.trim())
validateBuildpackUri(app.source.uri.trim());
if ( if (
app.source.kind === "template" && app.source.kind === "template" &&
!templateIds.includes(app.source.template) !templateIds.includes(app.source.template)
) )
throw new Error(`Unknown Dockerfile template: ${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 !== ".") if (app.source.kind === "auto" && path !== ".")
service["x-kuber-build-context"] = 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."); throw new Error("Compose file cannot be found. Run kuber init first.");
await checkedAppPath(root, app.path); await checkedAppPath(root, app.path);
const service = serviceForApp(app); 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 generated: string[] = [];
const lock = await open(`${path}.kuber.lock`, "wx"); const lock = await open(`${path}.kuber.lock`, "wx");
try { try {
const original = await readFile(path, "utf8"); 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; const source = app.source;
if (source.kind === "template") { if (source.kind === "template") {
const templates = renderDockerfileTemplate(source.template); const templates = renderDockerfileTemplate(source.template);
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dmgnr/kuber", "name": "@dmgnr/kuber",
"version": "2.7.0-rc2", "version": "2.7.0-rc3",
"description": "Docker Compose to Kubernetes translation layer", "description": "Docker Compose to Kubernetes translation layer",
"bin": { "bin": {
"kuber": "dist/index.js" "kuber": "dist/index.js"
@@ -20,6 +20,9 @@
"test": "bun test tests", "test": "bun test tests",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": {
"enquirer": "^2.4.1"
},
"devDependencies": { "devDependencies": {
"@bomb.sh/tab": "^0.0.22", "@bomb.sh/tab": "^0.0.22",
"@cliffy/table": "npm:@jsr/cliffy__table", "@cliffy/table": "npm:@jsr/cliffy__table",
@@ -31,6 +34,7 @@
"oxfmt": "^0.62.0", "oxfmt": "^0.62.0",
"oxlint": "^1.77.0", "oxlint": "^1.77.0",
"typescript": "^5", "typescript": "^5",
"undici-types": "~7.18.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"engines": { "engines": {
+225
View File
@@ -10,6 +10,26 @@ import {
} from "../../lib/scaffold"; } from "../../lib/scaffold";
import { resolveAppInput } from "../../command/add"; import { resolveAppInput } from "../../command/add";
import { readCompose } from "../../lib/yaml"; import { readCompose } from "../../lib/yaml";
import type { ScaffoldQuestion } from "../../lib/scaffold-prompts";
import { listDockerfileTemplates } from "../../lib/scaffold-templates";
function snippetValues(
question: ScaffoldQuestion,
overrides: Record<string, string> = {},
) {
return {
values: {
...Object.fromEntries(
(question.fields ?? []).map((field) => [
field.name,
field.initial ?? "",
]),
),
...overrides,
},
result: "services:\n malicious:\n image: ignored-preview",
};
}
const roots: string[] = []; const roots: string[] = [];
afterEach(async () => { afterEach(async () => {
@@ -156,3 +176,208 @@ test("rejects path traversal and symlink outside the project", async () => {
}), }),
).rejects.toThrow(); ).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);
});
+180
View File
@@ -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<void>((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);
});
});
+285 -3
View File
@@ -7,6 +7,7 @@ import { initializeProject } from "../../command/init";
import { managedHeader } from "../../lib/scaffold"; import { managedHeader } from "../../lib/scaffold";
import { readTrust, resolveTrustIdentity, updateTrust } from "../../lib/trust"; import { readTrust, resolveTrustIdentity, updateTrust } from "../../lib/trust";
import { KuberApiError } from "../../lib/api"; import { KuberApiError } from "../../lib/api";
import type { ScaffoldQuestion } from "../../lib/scaffold-prompts";
const roots: string[] = []; const roots: string[] = [];
const originalConfig = process.env.XDG_CONFIG_HOME; const originalConfig = process.env.XDG_CONFIG_HOME;
@@ -24,6 +25,189 @@ async function root() {
} }
describe("kuber init", () => { 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 () => { test("uses an existing configured project for Compose and trust", async () => {
const cwd = await root(); const cwd = await root();
await writeFile( await writeFile(
@@ -146,14 +330,14 @@ describe("kuber init", () => {
}; };
}, },
request: async (_path, init) => { request: async (_path, init) => {
if (++attempts === 1) throw new KuberApiError("revoked", 401); if (++attempts === 2) throw new KuberApiError("revoked", 401);
return ( return (
init?.method === "POST" ? undefined : { fingerprints: [] } init?.method === "POST" ? undefined : { fingerprints: [] }
) as never; ) as never;
}, },
}, },
); );
expect([attempts, logins]).toEqual([3, 1]); expect([attempts, logins]).toEqual([4, 1]);
expect(await readTrust()).toHaveLength(1); expect(await readTrust()).toHaveLength(1);
} finally { } finally {
log.mockRestore(); log.mockRestore();
@@ -361,7 +545,7 @@ describe("kuber init", () => {
request: async (_path, init) => { request: async (_path, init) => {
calls.push(init?.method ?? "GET"); calls.push(init?.method ?? "GET");
if (init?.method === "POST") if (init?.method === "POST")
throw new KuberApiError("grant failed", 503); throw new KuberApiError("grant failed", 400);
return { fingerprints: [] } as never; return { fingerprints: [] } as never;
}, },
}, },
@@ -374,6 +558,104 @@ describe("kuber init", () => {
expect(await Bun.file(join(cwd, ".dockerignore")).exists()).toBe(false); 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<string>();
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 () => { test("existing Compose file is not overwritten and no grant is attempted", async () => {
const cwd = await root(); const cwd = await root();
const composePath = join(cwd, "compose.yml"); const composePath = join(cwd, "compose.yml");
+4
View File
@@ -8,6 +8,10 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,
"types": ["bun"], "types": ["bun"],
// bun-types imports Undici directly; isolated installs hide Node's copy.
"paths": {
"undici-types": ["./node_modules/undici-types"]
},
// Bundler mode // Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",