feat: release 2.7.0-rc2

This commit is contained in:
2026-10-06 15:31:51 +00:00 Unverified
parent cd9fb512cd
commit c8de9ddcba
42 changed files with 6127 additions and 593 deletions
+238
View File
@@ -0,0 +1,238 @@
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { basename } from "node:path";
import { defineCommand } from "citty";
import { ctx } from "../lib/context";
import {
addAppToCompose,
checkedAppPath,
detectAppPaths,
type AppOptions,
type Source,
} from "../lib/scaffold";
import {
listDockerfileTemplates,
templateIds,
} from "../lib/scaffold-templates";
export 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(
root: string,
input: AppInput,
prompt: ScaffoldPrompt = terminalPrompt,
): Promise<AppOptions> {
const detected = await detectAppPaths(root);
let path: string;
if (input.path !== undefined) path = input.path;
else if (detected.length === 0) path = ".";
else if (detected.length === 1) path = detected[0]!;
else {
if (input.nonInteractive)
throw new Error(
`Multiple app roots found (${detected.join(", ")}); specify --path`,
);
path = await prompt(`App path (${detected.join(", ")})`);
if (!detected.includes(path))
throw new Error(
`Select one of the detected app roots: ${detected.join(", ")}`,
);
}
path = await checkedAppPath(root, path);
const suggestedName =
basename(path === "." ? root : path)
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/^-+|-+$/g, "") || "app";
const name = await answer(
input.name,
"Service name",
/^[a-z]/.test(suggestedName) ? suggestedName : `app-${suggestedName}`,
prompt,
!!input.nonInteractive,
);
const defaultSource = detected.length === 0 ? "image" : "auto";
const selectedSource = await answer(
input.source,
"Source (image/auto/template)",
defaultSource,
prompt,
!!input.nonInteractive,
);
let source: Source;
if (selectedSource === "image")
source = {
kind: "image",
image: await answer(
input.image,
"Image",
"nginx:stable",
prompt,
!!input.nonInteractive,
),
};
else if (selectedSource === "auto") source = { kind: "auto" };
else if (selectedSource === "template") {
const choices = listDockerfileTemplates()
.map(({ id, label }) => `${id} (${label})`)
.join(", ");
const template = await answer(
input.template,
`Dockerfile template (${choices})`,
undefined,
prompt,
!!input.nonInteractive,
);
if (!templateIds.includes(template as (typeof templateIds)[number]))
throw new Error(`Unknown Dockerfile template: ${template}`);
source = {
kind: "template",
template: template as (typeof templateIds)[number],
};
} else throw new Error("Source must be image, auto, or template");
const postgres = input.nonInteractive
? input.postgres
: await prompt(
"Postgres claim (name or user/database; blank for none)",
input.postgres,
);
const s3 = input.nonInteractive
? input.s3
: await prompt("S3 claim (name or key/bucket; blank for none)", input.s3);
const cpu = input.nonInteractive
? input.cpu
: await prompt("CPU limit in cores (blank for default)", input.cpu);
const memory = input.nonInteractive
? input.memory
: await prompt("Memory limit (blank for default)", input.memory);
const replicasAnswer = input.nonInteractive
? input.replicas?.toString()
: await prompt("Replicas (blank for default)", input.replicas?.toString());
return {
name,
path,
source,
...(postgres ? { postgres } : {}),
...(s3 ? { s3 } : {}),
...(cpu ? { cpu } : {}),
...(memory ? { memory } : {}),
...(replicasAnswer ? { replicas: Number(replicasAnswer) } : {}),
};
}
export const appArgs = {
name: { type: "string" as const, description: "Service name" },
path: {
type: "string" as const,
description: "App directory relative to the project",
},
source: {
type: "string" as const,
description: "image, auto (Buildpacks), or template",
},
image: {
type: "string" as const,
description: "Image reference when source=image",
},
template: {
type: "string" as const,
description: "Dockerfile template ID when source=template",
},
postgres: {
type: "string" as const,
description: "Postgres claim name or user/database",
},
s3: { type: "string" as const, description: "S3 claim name or key/bucket" },
cpu: { type: "string" as const, description: "CPU limit in cores" },
memory: { type: "string" as const, description: "Memory limit, e.g. 512m" },
replicas: { type: "string" as const, description: "Replica count" },
"non-interactive": {
type: "boolean" as const,
description: "Use defaults and require explicit choices when ambiguous",
},
};
export function appInputFromArgs(args: Record<string, unknown>): AppInput {
return {
...Object.fromEntries(
[
"name",
"path",
"source",
"image",
"template",
"postgres",
"s3",
"cpu",
"memory",
]
.filter((key) => args[key] !== undefined)
.map((key) => [key, String(args[key])]),
),
...(args.replicas !== undefined ? { replicas: Number(args.replicas) } : {}),
nonInteractive: Boolean(args["non-interactive"]),
};
}
export const add = defineCommand({
meta: { name: "add", description: "Add a resource to this project" },
subCommands: {
app: defineCommand({
meta: {
name: "app",
description: "Add a service without changing existing services",
},
args: appArgs,
async run({ args }) {
const { cwd, path } = ctx();
const app = await resolveAppInput(cwd, appInputFromArgs(args));
const composePath = await addAppToCompose(cwd, app, await path());
console.log(`Added ${app.name} to ${composePath}`);
},
}),
},
});
+13
View File
@@ -75,6 +75,19 @@ export async function loginUser(
return session;
}
/** Share the CLI's credential prompt with onboarding without exposing passwords. */
export async function interactiveLogin(): Promise<LoginResponse> {
if (!stdin.isTTY)
throw new Error(
"Login requires an interactive terminal; run kuber login first",
);
const username = await promptUsername();
if (!username) throw new Error("Username is required");
const session = await loginUser(username, await promptPassword(), false);
console.log(`Logged in as ${session.user.username}`);
return session;
}
export const login = defineCommand({
meta: {
name: "login",
+183
View File
@@ -0,0 +1,183 @@
import { defineCommand } from "citty";
import { apiRequest, KuberApiError, type ApiRequestInit } from "../lib/api";
import { readSession, type KuberSession } from "../lib/session";
import { resolveTrustIdentity, updateTrust } from "../lib/trust";
import type { WorkspaceTrustResponse } from "../shared/api";
import { interactiveLogin } from "./auth";
import { createCompose, projectName } from "../lib/scaffold";
import {
appArgs,
appInputFromArgs,
resolveAppInput,
terminalPrompt,
type AppInput,
type ScaffoldPrompt,
} from "./add";
import { resolveComposeFile } from "../lib/yaml";
import { basename } from "node:path";
import { join } from "node:path";
import { readFile, rm } from "node:fs/promises";
import { renderDockerfileTemplate } from "../lib/scaffold-templates";
import { loadConfig } from "../lib/config";
type Requester = <T>(path: string, init?: ApiRequestInit) => Promise<T>;
export type InitDependencies = {
configPath?: string;
prompt?: ScaffoldPrompt;
session?: () => Promise<KuberSession | undefined>;
login?: () => Promise<KuberSession>;
request?: Requester;
persistTrust?: typeof updateTrust;
};
/** Creates a project and registers trust after a successful authenticated server grant. */
export async function initializeProject(
root: string,
input: AppInput & { project?: string },
dependencies: InitDependencies = {},
): Promise<string> {
if (await resolveComposeFile(root))
throw new Error("A Compose file already exists; use kuber add app instead");
const config = await loadConfig(root, dependencies.configPath);
if (config.composeFile && config.composeFile !== join(root, "compose.yml"))
throw new Error(
`Existing config expects ${config.composeFile}; kuber init creates compose.yml`,
);
if (
config.projectConfigured &&
projectName(config.project) !== config.project
)
throw new Error(
`Configured project ${config.project} must be a lowercase Kubernetes name`,
);
const prompt = dependencies.prompt ?? terminalPrompt;
if (
config.projectConfigured &&
input.project &&
input.project !== config.project
)
throw new Error(
`Existing config selects project ${config.project}; use that project for init`,
);
const defaultProject = projectName(
config.projectConfigured ? config.project : basename(root),
);
const project = projectName(
input.project ??
(input.nonInteractive
? defaultProject
: await prompt("Project name", defaultProject)),
);
const app = await resolveAppInput(root, input, prompt);
const login = dependencies.login ?? interactiveLogin;
let session = await (dependencies.session ?? readSession)();
if (!session) session = await login();
const composePath = await createCompose(root, project, app);
const createdCompose = await readFile(composePath, "utf8");
const identity = await resolveTrustIdentity(project, root);
const request = dependencies.request ?? apiRequest;
const trustPath = `/workspaces/${encodeURIComponent(project)}/trust`;
let retried = false;
let remoteRegistered = false;
try {
while (true) {
try {
const remote = await request<WorkspaceTrustResponse>(trustPath);
if (!Array.isArray(remote?.fingerprints))
throw new Error("Could not verify existing server trust");
const alreadyTrusted = remote.fingerprints.includes(
identity.fingerprint,
);
// The server grant is idempotent. A GET cannot establish ownership of it:
// another client may grant the same fingerprint before our POST.
if (!alreadyTrusted)
await request(trustPath, {
method: "POST",
json: { fingerprint: identity.fingerprint },
});
remoteRegistered = true;
try {
await (dependencies.persistTrust ?? updateTrust)((records) => [
...records.filter(
(record) =>
record.project !== identity.project ||
record.fingerprint !== identity.fingerprint,
),
identity,
]);
} catch {
throw new Error(
"Server registration exists, but local trust could not be confirmed. Generated project files were kept; run kuber trust to repair local trust.",
);
}
break;
} catch (error) {
if (
retried ||
!(error instanceof KuberApiError) ||
error.status !== 401
)
throw error;
retried = true;
session = await login();
}
}
} catch (error) {
// Once registered remotely, keep the files needed to repair local trust.
// On remote failure, only roll back files still identical to ours.
if (
!remoteRegistered &&
(await readFile(composePath, "utf8").catch(() => undefined)) ===
createdCompose
) {
await rm(composePath, { force: true });
if (app.source.kind === "template") {
const { dockerfile, dockerignore } = renderDockerfileTemplate(
app.source.template,
);
for (const [name, content] of [
["Dockerfile", dockerfile],
[".dockerignore", dockerignore],
] as const) {
const file = join(root, app.path, name);
if ((await readFile(file, "utf8").catch(() => undefined)) === content)
await rm(file, { force: true });
}
}
}
throw error;
}
console.log(`Trusted this directory for namespace ${identity.project}`);
console.log(`Initialized ${project} in ${composePath}`);
return composePath;
}
export const init = defineCommand({
meta: {
name: "init",
description: "Initialize a Compose project and trust this directory",
},
args: {
...appArgs,
project: {
type: "string",
description: "Kubernetes namespace/project name",
},
"config-path": {
type: "string",
description: "Project config path (usually passed as global --config)",
},
},
async run({ args }) {
await initializeProject(
process.cwd(),
{
...appInputFromArgs(args),
...(args.project !== undefined
? { project: String(args.project) }
: {}),
},
{ configPath: args["config-path"] },
);
},
});
+4 -2
View File
@@ -37,7 +37,7 @@ function cloneCommand<T extends object>(command: T): T {
export const main = defineCommand({
meta: {
name: "kuber",
version: "2.6.2-rc2",
version: "2.7.0-rc2",
description: "Docker Compose -> K8s translation layer",
},
args: {
@@ -47,12 +47,14 @@ export const main = defineCommand({
},
},
subCommands: {
add: lazyCommand(() => import("./add").then((m) => m.add)),
audit: lazyCommand(() => import("./audit").then((m) => m.audit)),
ci: lazyCommand(() => import("./ci").then((m) => m.ci)),
db: lazyCommand(() => import("./db").then((m) => m.db)),
down: lazyCommand(() => import("./down").then((m) => m.down)),
export: lazyCommand(() => import("./export").then((m) => m.exportCommand)),
exec: lazyCommand(() => import("./exec").then((m) => m.exec)),
init: lazyCommand(() => import("./init").then((m) => m.init)),
logs: lazyCommand(() => import("./logs").then((m) => m.logs)),
maintenance: lazyCommand(() =>
import("./maintenance").then((m) => m.maintenance),
@@ -76,7 +78,7 @@ export const main = defineCommand({
});
type Completion = Awaited<
ReturnType<typeof import("@bomb.sh/tab/citty")["default"]>
ReturnType<(typeof import("@bomb.sh/tab/citty"))["default"]>
>;
let completion: Promise<Completion> | undefined;
+71 -14
View File
@@ -625,7 +625,12 @@ export async function reconcileResources(
request: ApiRequester = apiRequest,
resumeOptions: OperationResumeOptions = {},
): Promise<ResourcePlan> {
const plan = await planResources(project, resources, request, resumeOptions.signal);
const plan = await planResources(
project,
resources,
request,
resumeOptions.signal,
);
await applyResourcePlan(
project,
plan,
@@ -792,6 +797,30 @@ export async function runUp(
new Promise((resolve) => pending.set(name, resolve)),
]),
);
let settleUpload!: (error?: unknown) => void;
const uploaded = new Promise<unknown>((resolve) => {
settleUpload = resolve;
});
let uploadChild: ListrTaskWrapper<UpContext, any, any> | undefined;
let uploadedBytes = 0;
let totalBytes = 0;
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
const unit = bytes < 1024 ** 2 ? "KiB" : "MiB";
const divisor = unit === "KiB" ? 1024 : 1024 ** 2;
return `${Number((bytes / divisor).toFixed(1))} ${unit}`;
};
const uploadTitle = (
bytes: number,
total: number,
finished = false,
): string =>
`Uploading context: ${formatBytes(bytes)}/${formatBytes(total)} (${finished ? 100 : total === 0 ? 0 : Math.min(99, Math.floor((100 * bytes) / total))}%)`;
const finishUpload = (error?: unknown) => {
if (!error && uploadChild)
uploadChild.title = uploadTitle(uploadedBytes, totalBytes, true);
settleUpload(error);
};
let result: Awaited<ReturnType<typeof buildServices>> | undefined;
let operationFailure: Error | undefined;
let operation: Promise<void> | undefined;
@@ -801,19 +830,27 @@ export async function runUp(
taskCtx.compose!,
cwd,
{
progress: (message) => {
if (
!/^Build (?:queued|creating|starting|running|done)\b/.test(message) &&
task.output !== message
) task.output = message;
upload: (bytes, total) => {
uploadedBytes = bytes;
totalBytes = total;
if (uploadChild)
uploadChild.title = uploadTitle(bytes, total);
},
uploadSettled: finishUpload,
service: (name) => {
let stream: ReturnType<ListrTaskWrapper<UpContext, any, any>["stdout"]> | undefined;
let stream:
| ReturnType<
ListrTaskWrapper<UpContext, any, any>["stdout"]
>
| undefined;
return {
progress: (message) => {
const child = children.get(name);
if (!child) return;
const phase = /^Build (queued|creating|starting|running|done)\b/.exec(message)?.[1];
const phase =
/^Build (queued|creating|starting|running|done)\b/.exec(
message,
)?.[1];
if (phase) child.title = `Build ${name}: ${phase}`;
// A failed status includes the full error; the parent bottom bar owns it.
},
@@ -845,6 +882,7 @@ export async function runUp(
error instanceof Error ? error : new Error(String(error));
operationFailure = failure;
task.output = failure.stack ?? failure.message;
finishUpload(failure);
for (const resolve of pending.values()) resolve(failure);
pending.clear();
},
@@ -853,6 +891,19 @@ export async function runUp(
};
return task.newListr<UpContext>(
[
{
title: uploadTitle(0, 0),
exitOnError: false,
task: async (_ctx, child) => {
uploadChild = child;
if (children.size === services.length) start();
const error = await uploaded;
if (error) {
child.title = "Uploading context: failed";
throw error;
}
},
},
...services.map((name) => ({
title: `Build ${name}`,
rendererOptions: { outputBar: 10, persistentOutput: true },
@@ -863,7 +914,7 @@ export async function runUp(
) => {
children.set(name, child);
child.title = `Build ${name}: queued`;
if (children.size === services.length) start();
if (children.size === services.length && uploadChild) start();
const error = await completed.get(name);
if (error) {
child.title = `Build ${name}: failed`;
@@ -948,7 +999,8 @@ export async function runUp(
{ method: "POST", json: { compose: taskCtx.compose } },
);
} catch (error) {
task.output = error instanceof Error ? error.message : String(error);
task.output =
error instanceof Error ? error.message : String(error);
throw error;
}
taskCtx.databaseEnv = operationEnvironment(response);
@@ -957,8 +1009,7 @@ export async function runUp(
},
{
title: "Reconcile S3 storage",
enabled: () =>
getComposeS3Claims(taskCtx.compose!).length > 0,
enabled: () => getComposeS3Claims(taskCtx.compose!).length > 0,
task: async (_ctx, child) => {
let response: Record<string, unknown>;
try {
@@ -969,7 +1020,8 @@ export async function runUp(
{ method: "POST", json: { compose: taskCtx.compose } },
);
} catch (error) {
task.output = error instanceof Error ? error.message : String(error);
task.output =
error instanceof Error ? error.message : String(error);
throw error;
}
taskCtx.storageEnv = operationEnvironment(response);
@@ -1110,7 +1162,12 @@ export async function runUp(
renderer: "default",
// Only redraw when a task changes; a queued build should not generate
// another frame on each spinner tick. Keep the renderer's task tree.
rendererOptions: { collapseErrors: false, collapseSubtasks: false, showErrorMessage: false, lazy: true },
rendererOptions: {
collapseErrors: false,
collapseSubtasks: false,
showErrorMessage: false,
lazy: true,
},
},
).run();