import { defineCommand } from "citty"; import { Listr, ListrErrorTypes, type ListrTaskWrapper } from "listr2"; import { randomUUID } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import type { ComposeSpecification } from "../schema/docker.d"; import type { KuberResource } from "../types"; import { apiRequest, KuberApiError, type ApiRequestInit, type ApiRequestOptions, } from "../lib/api"; import { buildServices, getRepoRoot, resolveBuildImages, type ApiRequester, } from "../lib/build"; import { composeToKubernetes, type KubernetesResource } from "../lib/convert"; import { ctx } from "../lib/context"; import { getComposePostgresClaims } from "../lib/database"; import { getComposeS3Claims } from "../lib/storage"; import { enumerateWorkspace, type WorkspaceSnapshot } from "../lib/workspace"; import { requireLocalTrust, resolveTrustIdentity, trustHeaders, type TrustIdentity, } from "../lib/trust"; type Workspace = { metadata: { name: string; uid: string; resourceVersion: string }; spec?: { source?: unknown; config?: unknown }; }; type ResourceIdentity = { apiVersion: string; kind: string; name: string; namespace?: string; uid: string; workspaceUid: string; }; type ResourcePlan = { desired: KubernetesResource[]; stale: ResourceIdentity[]; }; type OperationStatus = { state?: unknown; error?: { code?: unknown; message?: unknown }; }; type OperationResponse = { operationId?: unknown; operation?: { status?: OperationStatus }; }; export type ResourceOperationEvent = { sequence: number; data: { resource: { apiVersion: string; kind: string; name: string; namespace?: string; }; phase: "apply" | "wait" | "delete"; state: "started" | "succeeded" | "failed" | "aborted"; }; }; export type OperationResumeOptions = { now?: () => number; sleep?: (milliseconds: number) => Promise; onEvent?: (event: ResourceOperationEvent) => void; signal?: AbortSignal; }; type OperationEventsResponse = { items?: ResourceOperationEvent[]; retainedFirstSequence?: unknown; cursorGap?: unknown; }; type ResourceOperationTarget = { apiVersion: string; kind: string; name: string; namespace?: string; }; export type LiveResourceOperationOptions = { onTaskStarted?: ( target: ResourceOperationTarget, task: ListrTaskWrapper, ) => void; }; type UpContext = { compose?: ComposeSpecification; snapshot?: WorkspaceSnapshot; workspaceRoot?: string; buildImages?: Record; serviceEnv?: Record>; resources?: KubernetesResource[]; plan?: ResourcePlan; }; export const WORKSPACE_ADOPTION_METHOD = "POST"; const OPERATION_RESUME_INITIAL_BACKOFF_MS = 250; const OPERATION_RESUME_MAX_BACKOFF_MS = 5_000; const OPERATION_RESUME_GRACE_MS = 60_000; const RECOVERABLE_API_ERROR_CODES = new Set([ "HTTP_502", "HTTP_503", "HTTP_504", ]); class OperationProgressCursorGapError extends Error { constructor() { super( "Operation progress history was truncated; refusing to report an incomplete resource stream", ); } } export function workspaceAdoptionRoute(project: string): string { return `/workspaces/${encodeURIComponent(project)}/adopt`; } export function mergeServiceEnv( current: Record> | undefined, next: Record>, ): Record> { const merged = { ...current }; for (const [service, environment] of Object.entries(next)) merged[service] = { ...merged[service], ...environment }; return merged; } export function getDeploymentNames(resources: KubernetesResource[]): string[] { return resources .filter((resource) => resource.kind === "Deployment") .map((resource) => resource.metadata?.name) .filter((name): name is string => Boolean(name)); } export function resourceOperationEventTitle( event: ResourceOperationEvent, ): string { const { resource, phase, state } = event.data; return `${phase === "apply" ? "Applied" : phase === "wait" ? "Waited for" : "Deleted"} ${resource.kind}/${resource.name}: ${state}`; } function isResourceOperationEvent( event: unknown, ): event is ResourceOperationEvent { if (!event || typeof event !== "object") return false; const { sequence, data } = event as { sequence?: unknown; data?: unknown; }; if (!Number.isSafeInteger(sequence) || !data || typeof data !== "object") return false; const { resource, phase, state } = data as { resource?: unknown; phase?: unknown; state?: unknown; }; if (!resource || typeof resource !== "object") return false; const identity = resource as Record; return ( typeof identity.apiVersion === "string" && typeof identity.kind === "string" && typeof identity.name === "string" && (identity.namespace === undefined || typeof identity.namespace === "string") && (phase === "apply" || phase === "wait" || phase === "delete") && (state === "started" || state === "succeeded" || state === "failed" || state === "aborted") ); } function workspaceInput( compose: ComposeSpecification, snapshot: WorkspaceSnapshot, ) { return { source: { uri: `cas://${snapshot.digest}`, digest: snapshot.digest, }, config: { compose }, }; } function workspaceMatchesInput( workspace: Workspace, input: ReturnType, ): boolean { return ( isDeepStrictEqual(workspace.spec?.source, input.source) && isDeepStrictEqual(workspace.spec?.config, input.config) ); } function isWorkspaceUpdateAmbiguous(error: unknown): boolean { if (error instanceof KuberApiError) return error.status === 409; if (error instanceof DOMException && error.name === "AbortError") return false; if (error instanceof Error && error.name === "AbortError") return false; return ( error instanceof TypeError || (error instanceof Error && error.name === "TimeoutError") ); } export async function ensureWorkspace( project: string, compose: ComposeSpecification, snapshot: WorkspaceSnapshot, request: ApiRequester = apiRequest, ): Promise { const path = `/workspaces/${encodeURIComponent(project)}`; const input = workspaceInput(compose, snapshot); const update = (workspace: Workspace) => request(path, { method: "PUT", headers: { "if-match": `"${workspace.metadata.resourceVersion}"` }, json: input, }); const recover = async (originalError: unknown): Promise => { try { const fresh = await request(path); if (workspaceMatchesInput(fresh, input)) return fresh; return await update(fresh); } catch { throw originalError; } }; let current: Workspace; try { current = await request(path); } catch (error) { if (!(error instanceof KuberApiError) || error.status !== 404) throw error; try { return await request("/workspaces", { method: "POST", json: { id: project, ...input }, }); } catch (createError) { if (!isWorkspaceUpdateAmbiguous(createError)) throw createError; return recover(createError); } } try { return await update(current); } catch (updateError) { if (!isWorkspaceUpdateAmbiguous(updateError)) throw updateError; return recover(updateError); } } function operationEnvironment( response: Record, ): Record> { return Object.fromEntries( Object.entries(response).filter( ([key, value]) => key !== "operation" && key !== "operationId" && value !== null && typeof value === "object" && !Array.isArray(value), ), ) as Record>; } function adoptionHint(project: string, error: unknown): Error { const message = error instanceof Error ? error.message : String(error); if (!/namespace .* (?:external|different-workspace)/i.test(message)) return error instanceof Error ? error : new Error(message); return new Error( `${message}. Safe adoption requires ${WORKSPACE_ADOPTION_METHOD} ${workspaceAdoptionRoute(project)} with namespace UID and ownership preconditions.`, { cause: error }, ); } async function managementRequest( project: string, request: ApiRequester, path: string, init: ApiRequestInit, ): Promise { try { return await request(path, init); } catch (error) { throw adoptionHint(project, error); } } function operationIdFromResponse(response: unknown): string | undefined { if (!response || typeof response !== "object") return; const operationId = (response as OperationResponse).operationId; return typeof operationId === "string" && operationId ? operationId : undefined; } function operationStatusFromResponse( response: unknown, ): OperationStatus | undefined { if (!response || typeof response !== "object") return; const status = (response as OperationResponse).operation?.status; return status && typeof status === "object" ? status : undefined; } function operationFailure( operationId: string, status: OperationStatus, ): KuberApiError { const error = status.error; const cancelled = status.state === "cancelled"; const message = typeof error?.message === "string" ? error.message : cancelled ? "The operation was cancelled" : "The operation failed"; const code = typeof error?.code === "string" ? error.code : cancelled ? "OPERATION_CANCELLED" : "OPERATION_FAILED"; return new KuberApiError(message, cancelled ? 409 : 500, { title: cancelled ? "Operation cancelled" : "Operation failed", status: cancelled ? 409 : 500, code, operationId, }); } function isRecoverableConnectionInterruption(error: unknown): boolean { if (error instanceof KuberApiError) return RECOVERABLE_API_ERROR_CODES.has(error.code); if (error instanceof DOMException && error.name === "AbortError") return false; if (error instanceof Error && error.name === "AbortError") return false; return ( error instanceof TypeError || (error instanceof Error && error.name === "TimeoutError") ); } function isInterruptedOperation(status: OperationStatus): boolean { return ( status.state === "failed" && status.error?.code === "OPERATION_INTERRUPTED" ); } function hasOperationEventCursorGap( events: OperationEventsResponse, after: number, ): boolean { return ( events.cursorGap === true || (Number.isSafeInteger(events.retainedFirstSequence) && (events.retainedFirstSequence as number) > after + 1) ); } function operationResumeDeadline( rolloutTimeoutMs: number, now: number, ): number { return now + Math.max(rolloutTimeoutMs, 0) + OPERATION_RESUME_GRACE_MS; } function abortReason(signal: AbortSignal): unknown { return ( signal.reason ?? new DOMException("The operation was aborted", "AbortError") ); } function throwIfAborted(signal: AbortSignal | undefined): void { if (signal?.aborted) throw abortReason(signal); } function abortableDelay( milliseconds: number, signal: AbortSignal | undefined, ): Promise { throwIfAborted(signal); return new Promise((resolve, reject) => { let timeout: ReturnType | undefined; const cleanup = () => { if (timeout !== undefined) clearTimeout(timeout); signal?.removeEventListener("abort", abort); }; const complete = () => { cleanup(); resolve(); }; const abort = () => { cleanup(); reject(abortReason(signal!)); }; timeout = setTimeout(complete, milliseconds); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) abort(); }); } async function resumeManagedOperation( project: string, request: ApiRequester, path: string, init: ApiRequestInit, rolloutTimeoutMs: number, options: OperationResumeOptions, ): Promise { const now = options.now ?? Date.now; const deadline = operationResumeDeadline(rolloutTimeoutMs, now()); const headers = new Headers(init.headers); headers.set("idempotency-key", randomUUID()); headers.set("prefer", "respond-async"); let operationInit = { ...init, headers, signal: options.signal ?? init.signal, }; let operationId: string | undefined; let backoffMs = OPERATION_RESUME_INITIAL_BACKOFF_MS; let restartRetryPending = false; let eventCursor = 0; for (;;) { throwIfAborted(options.signal); if (restartRetryPending && now() >= deadline) throw new Error("Timed out while reconnecting to resume the operation"); try { let status: OperationStatus | undefined; if (operationId) { const operation = await managementRequest<{ status: OperationStatus }>( project, request, `/operations/${encodeURIComponent(operationId)}`, { signal: options.signal }, ); status = operation.status; } else { restartRetryPending = false; const response = await managementRequest( project, request, path, operationInit, ); operationId = operationIdFromResponse(response); status = operationStatusFromResponse(response); } if (operationId) { try { const events = await managementRequest( project, request, `/operations/${encodeURIComponent(operationId)}/events?after=${eventCursor}`, { signal: options.signal }, ); if (hasOperationEventCursorGap(events, eventCursor)) throw new OperationProgressCursorGapError(); for (const event of events.items ?? []) { if ( !Number.isSafeInteger(event.sequence) || event.sequence <= eventCursor ) continue; eventCursor = event.sequence; if (!isResourceOperationEvent(event)) continue; options.onEvent?.(event); } } catch (error) { if (error instanceof OperationProgressCursorGapError) throw error; throwIfAborted(options.signal); // Event polling is additive; never delay resumable operation status polling. } } if (!operationId || !status || status.state === "succeeded") return; if (isInterruptedOperation(status)) { if (now() >= deadline) throw operationFailure(operationId, status); operationId = undefined; eventCursor = 0; restartRetryPending = true; headers.set("idempotency-key", randomUUID()); operationInit = { ...init, headers, signal: options.signal ?? init.signal, }; } else if (status.state === "failed" || status.state === "cancelled") throw operationFailure(operationId, status); } catch (error) { throwIfAborted(options.signal); if ( error instanceof KuberApiError && error.code === "OPERATION_INTERRUPTED" ) { if (now() >= deadline) throw adoptionHint(project, error); operationId = undefined; eventCursor = 0; restartRetryPending = true; headers.set("idempotency-key", randomUUID()); operationInit = { ...init, headers, signal: options.signal ?? init.signal, }; } else { if (!isRecoverableConnectionInterruption(error)) throw adoptionHint(project, error); if (now() >= deadline) throw adoptionHint(project, error); } } const remainingMs = deadline - now(); if (remainingMs <= 0) throw new Error("Timed out while reconnecting to resume the operation"); const delayMs = Math.min(backoffMs, remainingMs); if (options.sleep) { throwIfAborted(options.signal); await options.sleep(delayMs); throwIfAborted(options.signal); } else await abortableDelay(delayMs, options.signal); backoffMs = Math.min(backoffMs * 2, OPERATION_RESUME_MAX_BACKOFF_MS); } } async function planResources( project: string, resources: KubernetesResource[], request: ApiRequester = apiRequest, ): Promise { const workspacePath = `/workspaces/${encodeURIComponent(project)}`; return managementRequest( project, request, `${workspacePath}/resources/plan`, { method: "POST", json: { resources } }, ); } async function applyResourcePlan( project: string, plan: ResourcePlan, rolloutTimeoutMs: number, postApply: | ((resources: KubernetesResource[]) => void | Promise) | undefined, request: ApiRequester, resumeOptions: OperationResumeOptions, ): Promise { const workspacePath = `/workspaces/${encodeURIComponent(project)}`; await resumeManagedOperation( project, request, `${workspacePath}/resources/apply`, { method: "POST", json: { resources: plan.desired }, }, rolloutTimeoutMs, resumeOptions, ); await postApply?.(plan.desired); const deployments = getDeploymentNames(plan.desired); if (deployments.length > 0) { await resumeManagedOperation( project, request, `${workspacePath}/resources/wait`, { method: "POST", json: { deployments, timeoutMs: rolloutTimeoutMs }, }, rolloutTimeoutMs, resumeOptions, ); } if (plan.stale.length > 0) { await resumeManagedOperation( project, request, `${workspacePath}/resources/delete`, { method: "POST", json: { resources: plan.stale }, }, rolloutTimeoutMs, resumeOptions, ); } } export async function reconcileResources( project: string, resources: KubernetesResource[], rolloutTimeoutMs: number, postApply: | ((resources: KubernetesResource[]) => void | Promise) | undefined, request: ApiRequester = apiRequest, resumeOptions: OperationResumeOptions = {}, ): Promise { const plan = await planResources(project, resources, request); await applyResourcePlan( project, plan, rolloutTimeoutMs, postApply, request, resumeOptions, ); return plan; } function resourceOperationTarget(resource: { apiVersion?: string; kind?: string; name?: string; namespace?: string; metadata?: { name?: string; namespace?: string }; }): ResourceOperationTarget | undefined { const name = resource.name ?? resource.metadata?.name; if (!resource.apiVersion || !resource.kind || !name) return; return { apiVersion: resource.apiVersion, kind: resource.kind, name, namespace: resource.namespace ?? resource.metadata?.namespace, }; } function resourceOperationKey(resource: ResourceOperationTarget): string { return `${resource.apiVersion}\0${resource.kind}\0${resource.namespace ?? ""}\0${resource.name}`; } function resourceOperationTaskTitle( phase: ResourceOperationEvent["data"]["phase"], resource: ResourceOperationTarget, ): string { const action = phase === "apply" ? "Apply" : phase === "wait" ? "Wait for" : "Delete"; return `${action} ${resource.kind}/${resource.name}`; } export function runLiveResourceOperation( task: ListrTaskWrapper, phase: ResourceOperationEvent["data"]["phase"], targets: ResourceOperationTarget[], operation: ( onEvent: (event: ResourceOperationEvent) => void, signal: AbortSignal, ) => Promise, options: LiveResourceOperationOptions = {}, ): Listr | Promise { if (targets.length === 0) return operation(() => {}, task.signal); const active = new Map< string, ListrTaskWrapper >(); const complete = new Set<() => void>(); let started = 0; return task.newListr( targets.map((target) => ({ title: resourceOperationTaskTitle(phase, target), task: async (_target, child) => { active.set(resourceOperationKey(target), child); options.onTaskStarted?.(target, child); started += 1; if (started !== targets.length) return new Promise((resolve) => { const completeTask = () => { task.signal.removeEventListener("abort", completeTask); complete.delete(completeTask); resolve(); }; complete.add(completeTask); if (task.signal.aborted) completeTask(); else task.signal.addEventListener("abort", completeTask, { once: true, }); }); try { await operation((event) => { const resource = event.data.resource; const activeTask = active.get(resourceOperationKey(resource)); if (!activeTask) return; activeTask.output = event.data.state; task.output = resourceOperationEventTitle(event); }, task.signal); } finally { for (const resolve of [...complete]) resolve(); } }, })), { concurrent: true }, ); } export async function runUp( build: boolean, request: ApiRequester = apiRequest, options: { trust?: TrustIdentity } = {}, ) { const { project, compose, cwd, config, hookContext: getHookContext } = ctx(); const trusted = options.trust ?? (await requireLocalTrust(await resolveTrustIdentity(project, cwd))); const baseRequest = request; request = async ( path: string, init: ApiRequestInit = {}, options?: ApiRequestOptions, ) => { const headers = new Headers(init.headers); for (const [key, value] of Object.entries(trustHeaders(trusted))) headers.set(key, value); return baseRequest(path, { ...init, headers }, options); }; const workspacePath = `/workspaces/${encodeURIComponent(project)}`; const taskCtx = await new Listr( [ { title: "Read compose", task: async (taskCtx, task) => { taskCtx.compose = await compose(); if (build) await config.preBuild?.(taskCtx.compose, await getHookContext()); task.output = `${Object.keys(taskCtx.compose.services ?? {}).length} services`; }, }, { title: "Snapshot workspace", task: async (taskCtx, task) => { let workspaceRoot: string; try { workspaceRoot = await getRepoRoot(cwd); } catch (error) { workspaceRoot = cwd; } taskCtx.workspaceRoot = workspaceRoot; taskCtx.snapshot = await enumerateWorkspace(workspaceRoot); task.output = `${taskCtx.snapshot.manifest.files.length} files`; }, }, { title: "Build images", rendererOptions: { bottomBar: Infinity, persistentOutput: true }, enabled: async () => build && Object.values((await compose()).services ?? {}).some( (service) => service.build, ), task: async (taskCtx, task) => { const services = Object.entries(taskCtx.compose!.services ?? {}) .filter(([, service]) => service.build) .map(([name]) => name); const children = new Map< string, ListrTaskWrapper >(); const pending = new Map void>(); const completed = new Map>( services.map((name) => [ name, new Promise((resolve) => pending.set(name, resolve)), ]), ); let result: Awaited> | undefined; let operationFailure: Error | undefined; let operation: Promise | undefined; const start = () => { operation ??= buildServices( project, taskCtx.compose!, cwd, { progress: (message) => { if (task.output !== message) task.output = message; }, service: (name) => { let stream: ReturnType["stdout"]> | undefined; return { progress: (message) => { const child = children.get(name); if (child && child.output !== message) child.output = message; }, get stream() { const child = children.get(name); if (child) stream ??= child.stdout(); return stream; }, }; }, settled: (name, error) => { pending.get(name)?.(error); pending.delete(name); }, }, { ...config, request, snapshot: taskCtx.snapshot, workspaceRoot: taskCtx.workspaceRoot, signal: task.signal, }, ).then( (value) => { result = value; }, (error: unknown) => { const failure = error instanceof Error ? error : new Error(String(error)); operationFailure = failure; task.output = failure.stack ?? failure.message; task.report(failure, ListrErrorTypes.HAS_FAILED); for (const resolve of pending.values()) resolve(failure); pending.clear(); }, ); return operation; }; return task.newListr( [ ...services.map((name) => ({ title: `Build ${name}`, rendererOptions: { outputBar: 10, persistentOutput: true }, exitOnError: false, task: async ( _ctx: UpContext, child: ListrTaskWrapper, ) => { children.set(name, child); child.output = "Build queued"; if (children.size === services.length) start(); const error = await completed.get(name); if (error) throw error; child.output = "done"; }, })), { task: async () => { await Promise.all(completed.values()); await operation; if (!result) throw operationFailure ?? new Error("Build images failed"); taskCtx.buildImages = result.images; await config.postBuild?.(result, await getHookContext()); task.title = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}`; }, }, ], { concurrent: true }, ); }, }, { title: "Resolve images", enabled: async () => !build && Object.values((await compose()).services ?? {}).some( (service) => service.build, ), task: async (taskCtx, task) => { taskCtx.buildImages = await resolveBuildImages( project, taskCtx.compose!, { ...config, request, }, ); task.output = `${Object.keys(taskCtx.buildImages).length} images`; }, }, { title: "Update workspace", task: async (taskCtx, task) => { const workspace = await ensureWorkspace( project, taskCtx.compose!, taskCtx.snapshot!, request, ); const adopted = await request<{ resourcesAdopted: number }>( workspaceAdoptionRoute(project), { method: WORKSPACE_ADOPTION_METHOD, json: { workspaceUid: workspace.metadata.uid }, }, ); task.output = adopted.resourcesAdopted ? `Adopted ${adopted.resourcesAdopted} existing resources` : "Workspace ready"; }, }, { title: "Reconcile backing services", task: async (taskCtx, task) => { let databaseEnv: Record> = {}; let storageEnv: Record> = {}; await task.newListr( [ { title: "Reconcile databases", enabled: () => getComposePostgresClaims(taskCtx.compose!).length > 0, task: async (_ctx, child) => { const response = await managementRequest>( project, request, `${workspacePath}/databases`, { method: "POST", json: { compose: taskCtx.compose } }, ); databaseEnv = operationEnvironment(response); child.output = `${Object.keys(databaseEnv).length} services`; }, }, { title: "Reconcile S3 storage", enabled: () => getComposeS3Claims(taskCtx.compose!).length > 0, task: async (_ctx, child) => { const response = await managementRequest>( project, request, `${workspacePath}/storage`, { method: "POST", json: { compose: taskCtx.compose } }, ); storageEnv = operationEnvironment(response); child.output = `${Object.keys(storageEnv).length} services`; }, }, ], { concurrent: true }, ).run(); taskCtx.serviceEnv = mergeServiceEnv( mergeServiceEnv(taskCtx.serviceEnv, databaseEnv), storageEnv, ); }, }, { title: "Render manifests", task: async (taskCtx, task) => { taskCtx.resources = await composeToKubernetes( project, taskCtx.compose!, cwd, taskCtx.serviceEnv, taskCtx.buildImages, ); await config.postRender?.( taskCtx.resources as KuberResource[], await getHookContext(), ); task.output = `${taskCtx.resources.length} resources`; }, }, { title: "Reconcile resources", task: async (taskCtx, task) => { taskCtx.plan = await planResources( project, taskCtx.resources!, request, ); const plan = taskCtx.plan; const desired = plan.desired .map(resourceOperationTarget) .filter((resource): resource is ResourceOperationTarget => Boolean(resource), ); const deployments = plan.desired .filter((resource) => resource.kind === "Deployment") .map(resourceOperationTarget) .filter((resource): resource is ResourceOperationTarget => Boolean(resource), ); const stale = plan.stale .map(resourceOperationTarget) .filter((resource): resource is ResourceOperationTarget => Boolean(resource), ); const resourcePath = `${workspacePath}/resources`; return task.newListr([ { title: "Apply resources", task: async (_ctx, phaseTask) => runLiveResourceOperation( phaseTask, "apply", desired, (onEvent, signal) => resumeManagedOperation( project, request, `${resourcePath}/apply`, { method: "POST", json: { resources: plan.desired } }, config.rolloutTimeoutMs, { onEvent, signal }, ), ), }, { title: "Run post-apply hook", skip: !config.postApply, task: async () => config.postApply?.( plan.desired as KuberResource[], await getHookContext(), ), }, { title: "Wait for deployments", skip: deployments.length === 0, task: async (_ctx, phaseTask) => runLiveResourceOperation( phaseTask, "wait", deployments, (onEvent, signal) => resumeManagedOperation( project, request, `${resourcePath}/wait`, { method: "POST", json: { deployments: deployments.map( (deployment) => deployment.name, ), timeoutMs: config.rolloutTimeoutMs, }, }, config.rolloutTimeoutMs, { onEvent, signal }, ), ), }, { title: "Delete stale resources", skip: stale.length === 0, task: async (_ctx, phaseTask) => runLiveResourceOperation( phaseTask, "delete", stale, (onEvent, signal) => resumeManagedOperation( project, request, `${resourcePath}/delete`, { method: "POST", json: { resources: plan.stale } }, config.rolloutTimeoutMs, { onEvent, signal }, ), ), }, ]); }, }, ], { 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, lazy: true }, }, ).run(); return taskCtx; } export const up = defineCommand({ meta: { name: "up", description: "Create and start deployments" }, args: { build: { type: "boolean", default: true, alias: "b", description: "Build images before starting deployments", negativeDescription: "Don't build an image, even if it's policy", }, }, async run({ args }) { await runUp(args.build); }, });