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, terminalPrompt, type AppInput, type ScaffoldPrompt, } from "./add"; import { resolveScaffoldInput } from "../lib/scaffold-prompts"; import { resolveComposeFile } from "../lib/yaml"; import { basename } from "node:path"; import { join } from "node:path"; import { readFile, rm } from "node:fs/promises"; import { renderDockerfileTemplate } from "../lib/scaffold-templates"; import { loadConfig } from "../lib/config"; type Requester = (path: string, init?: ApiRequestInit) => Promise; export type InitDependencies = { configPath?: string; prompt?: ScaffoldPrompt; session?: () => Promise; login?: () => Promise; 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 { 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 resolved = await resolveScaffoldInput(root, input, prompt, { defaultProject, project: input.project ?? (config.projectConfigured ? config.project : undefined), }); const project = resolved.project!; const app = resolved.app; const login = dependencies.login ?? interactiveLogin; let session = await (dependencies.session ?? readSession)(); if (!session) session = await login(); 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(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) { try { await request(trustPath, { method: "POST", json: { fingerprint: identity.fingerprint }, }); } catch (error) { // A 401 is an explicit authentication denial; let the normal login // retry run without probing using the rejected session. if (error instanceof KuberApiError && error.status === 401) throw error; // Client errors (other than auth denial) are definite rejection. const definitelyRejected = error instanceof KuberApiError && error.status >= 400 && error.status < 500; if (definitelyRejected) throw error; let registration: boolean | undefined; try { const current = await request(trustPath); if (Array.isArray(current?.fingerprints)) registration = current.fingerprints.includes( identity.fingerprint, ); } catch { // The POST may have committed despite its failed response. } if (registration !== false) { remoteRegistered = true; throw new Error( "Could not confirm whether server registration succeeded. Generated project files were kept; check kuber trust status and run kuber trust to repair local trust if needed.", ); } throw error; } } remoteRegistered = true; try { await (dependencies.persistTrust ?? updateTrust)((records) => [ ...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"] }, ); }, });