Files
kuber/command/init.ts
T
2026-10-06 15:31:51 +00:00

184 lines
6.2 KiB
TypeScript

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"] },
);
},
});