feat: release 2.7.0-rc3
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user