diff --git a/cli/package.json b/cli/package.json index 266c1d38d07..9b9643695a0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -8,6 +8,10 @@ "./package.json": "./package.json", "./src/components/DefaultMainScreen": "./src/components/DefaultMainScreen.tsx", "./src/lib/constants": "./src/lib/constants.ts", + "./src/lib/hatch-local": "./src/lib/hatch-local.ts", + "./src/lib/retire-local": "./src/lib/retire-local.ts", + "./src/lib/guardian-token": "./src/lib/guardian-token.ts", + "./src/lib/lifecycle-reporter": "./src/lib/lifecycle-reporter.ts", "./src/commands/*": "./src/commands/*.ts" }, "bin": { diff --git a/cli/src/lib/__tests__/lifecycle-reporter.test.ts b/cli/src/lib/__tests__/lifecycle-reporter.test.ts new file mode 100644 index 00000000000..8213793ad56 --- /dev/null +++ b/cli/src/lib/__tests__/lifecycle-reporter.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import { consoleLifecycleReporter } from "../lifecycle-reporter.js"; + +describe("consoleLifecycleReporter", () => { + const originalDesktopApp = process.env.VELLUM_DESKTOP_APP; + let stdoutWriteSpy: ReturnType; + + beforeEach(() => { + stdoutWriteSpy = spyOn(process.stdout, "write").mockImplementation( + () => true, + ); + }); + + afterEach(() => { + stdoutWriteSpy.mockRestore(); + if (originalDesktopApp === undefined) { + delete process.env.VELLUM_DESKTOP_APP; + } else { + process.env.VELLUM_DESKTOP_APP = originalDesktopApp; + } + }); + + test("routes log/warn/error to the matching console methods", () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + consoleLifecycleReporter.log("hello"); + consoleLifecycleReporter.warn("careful"); + consoleLifecycleReporter.error("boom"); + + expect(logSpy).toHaveBeenCalledWith("hello"); + expect(warnSpy).toHaveBeenCalledWith("careful"); + expect(errorSpy).toHaveBeenCalledWith("boom"); + + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + test("emits the HATCH_PROGRESS stdout contract under VELLUM_DESKTOP_APP", () => { + process.env.VELLUM_DESKTOP_APP = "1"; + + consoleLifecycleReporter.progress(3, 6, "Starting assistant..."); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + `HATCH_PROGRESS:${JSON.stringify({ step: 3, total: 6, label: "Starting assistant..." })}\n`, + ); + }); + + test("suppresses progress output when not running under the desktop app", () => { + delete process.env.VELLUM_DESKTOP_APP; + + consoleLifecycleReporter.progress(1, 6, "Allocating resources..."); + + expect(stdoutWriteSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/lib/hatch-local.ts b/cli/src/lib/hatch-local.ts index 0b1101fa1f4..dec7607772b 100644 --- a/cli/src/lib/hatch-local.ts +++ b/cli/src/lib/hatch-local.ts @@ -33,7 +33,10 @@ import { import { generateInstanceName } from "./random-name.js"; import { leaseGuardianToken } from "./guardian-token.js"; import { archiveLogFile, resetLogFile } from "./xdg-log.js"; -import { emitProgress } from "./desktop-progress.js"; +import { + consoleLifecycleReporter, + type LifecycleReporter, +} from "./lifecycle-reporter.js"; import { configureHatchProviderApiKey, formatProviderName, @@ -134,6 +137,25 @@ function installCLISymlink(): void { export interface HatchLocalOptions { setupProviderCredentials?: boolean; + /** + * Sink for progress and log output. Defaults to the console reporter so CLI + * callers keep their existing terminal output; in-process callers can inject + * their own reporter to consume progress without writing to stdout. + */ + reporter?: LifecycleReporter; +} + +export interface HatchLocalResult { + assistantId: string; + runtimeUrl: string; + localUrl: string; + species: Species; + /** + * Guardian access token leased during hatch, when the lease succeeded. The + * full token pair is persisted to disk regardless; this is surfaced so an + * in-process caller can prime a connection without re-reading the file. + */ + guardianAccessToken?: string; } export async function hatchLocal( @@ -143,7 +165,8 @@ export async function hatchLocal( keepAlive: boolean = false, configValues: Record = {}, options: HatchLocalOptions = {}, -): Promise { +): Promise { + const reporter = options.reporter ?? consoleLifecycleReporter; const provider = options.setupProviderCredentials === false ? undefined @@ -153,7 +176,7 @@ export async function hatchLocal( name ?? process.env.VELLUM_ASSISTANT_NAME, ); - emitProgress(1, 6, "Allocating resources..."); + reporter.progress(1, 6, "Allocating resources..."); const existing = findAssistantByName(instanceName); if (existing && (!existing.cloud || existing.cloud === "local")) { @@ -175,29 +198,29 @@ export async function hatchLocal( archiveLogFile("hatch.log", logsDir); resetLogFile("hatch.log"); - console.log(`🄚 Hatching local assistant: ${instanceName}`); - console.log(` Species: ${species}`); - console.log(""); + reporter.log(`🄚 Hatching local assistant: ${instanceName}`); + reporter.log(` Species: ${species}`); + reporter.log(""); const apiKeyCheck = checkProviderApiKey(); if (!apiKeyCheck.hasKey) { - console.warn( + reporter.warn( "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.", ); - console.warn(" To fix, export your key before running vellum hatch:"); - console.warn(" export ANTHROPIC_API_KEY="); - console.warn(""); + reporter.warn(" To fix, export your key before running vellum hatch:"); + reporter.warn(" export ANTHROPIC_API_KEY="); + reporter.warn(""); } if (!process.env.APP_VERSION) { process.env.APP_VERSION = cliPkg.version; } - emitProgress(2, 6, "Writing configuration..."); + reporter.progress(2, 6, "Writing configuration..."); const hatchConfigValues = buildHatchConfigValues(configValues, provider); const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues); - emitProgress(3, 6, "Starting assistant..."); + reporter.progress(3, 6, "Starting assistant..."); const signingKey = generateLocalSigningKey(); const bootstrapSecret = generateLocalSigningKey(); await startLocalDaemon(watch, resources, { @@ -205,14 +228,17 @@ export async function hatchLocal( signingKey, }); - emitProgress(4, 6, "Starting gateway..."); + reporter.progress(4, 6, "Starting gateway..."); let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`; try { - runtimeUrl = await startGateway(watch, resources, { signingKey, bootstrapSecret }); + runtimeUrl = await startGateway(watch, resources, { + signingKey, + bootstrapSecret, + }); } catch (error) { // Gateway failed — stop the daemon we just started so we don't leave // orphaned processes with no lock file entry. - console.error( + reporter.error( `\nāŒ Gateway startup failed — stopping assistant to avoid orphaned processes.`, ); await stopLocalProcesses(resources); @@ -223,24 +249,28 @@ export async function hatchLocal( // instead of hitting /v1/guardian/init itself. Use loopback to satisfy // the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN // IP which the daemon rejects as non-loopback. - emitProgress(5, 6, "Securing connection..."); + reporter.progress(5, 6, "Securing connection..."); const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`; const maxLeaseAttempts = 3; let guardianAccessToken: string | undefined; for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) { try { - const tokenData = await leaseGuardianToken(loopbackUrl, instanceName, bootstrapSecret); + const tokenData = await leaseGuardianToken( + loopbackUrl, + instanceName, + bootstrapSecret, + ); guardianAccessToken = tokenData.accessToken; break; } catch (err) { if (attempt < maxLeaseAttempts) { const delayMs = 2000 * 2 ** (attempt - 1); - console.error( + reporter.error( `āš ļø Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`, ); await new Promise((r) => setTimeout(r, delayMs)); } else { - console.error( + reporter.error( `āš ļø Guardian token lease failed after ${maxLeaseAttempts} attempts: ${err}\n` + ` The assistant is running but guardian-token.json was not written.\n` + ` If the desktop app loses its stored credentials, re-hatch to recover.`, @@ -261,7 +291,7 @@ export async function hatchLocal( guardianBootstrapSecret: bootstrapSecret, }; - emitProgress(6, 6, "Saving configuration..."); + reporter.progress(6, 6, "Saving configuration..."); saveAssistantEntry(localEntry); setActiveAssistant(instanceName); @@ -270,13 +300,13 @@ export async function hatchLocal( } if (provider !== undefined && provider !== null && !guardianAccessToken) { - console.error( + reporter.error( `āš ļø Provider credential setup skipped because the guardian token was not leased.\n` + ` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the connection.`, ); } else if (provider !== undefined) { - console.log(""); - console.log( + reporter.log(""); + reporter.log( provider === null ? "Checking provider credentials..." : `Checking ${formatProviderName(provider)} credentials...`, @@ -289,14 +319,22 @@ export async function hatchLocal( }); } - console.log(""); - console.log(`āœ… Local assistant hatched!`); - console.log(""); - console.log("Instance details:"); - console.log(` Name: ${instanceName}`); - console.log(` Runtime: ${runtimeUrl}`); - console.log(""); - logHatchNextSteps(console.log, instanceName); + reporter.log(""); + reporter.log(`āœ… Local assistant hatched!`); + reporter.log(""); + reporter.log("Instance details:"); + reporter.log(` Name: ${instanceName}`); + reporter.log(` Runtime: ${runtimeUrl}`); + reporter.log(""); + logHatchNextSteps((message) => reporter.log(message), instanceName); + + const result: HatchLocalResult = { + assistantId: instanceName, + runtimeUrl, + localUrl: `http://127.0.0.1:${resources.gatewayPort}`, + species, + guardianAccessToken, + }; if (keepAlive) { const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`; @@ -306,7 +344,7 @@ export async function hatchLocal( let consecutiveFailures = 0; const shutdown = async (): Promise => { - console.log("\nShutting down local processes..."); + reporter.log("\nShutting down local processes..."); await stopLocalProcesses(resources); process.exit(0); }; @@ -330,7 +368,7 @@ export async function hatchLocal( consecutiveFailures++; } if (consecutiveFailures >= MAX_FAILURES) { - console.log( + reporter.log( `\nāš ļø ${healthTarget} stopped responding — shutting down.`, ); await stopLocalProcesses(resources); @@ -338,4 +376,6 @@ export async function hatchLocal( } } } + + return result; } diff --git a/cli/src/lib/lifecycle-reporter.ts b/cli/src/lib/lifecycle-reporter.ts new file mode 100644 index 00000000000..f2823aad133 --- /dev/null +++ b/cli/src/lib/lifecycle-reporter.ts @@ -0,0 +1,31 @@ +import { emitProgress } from "./desktop-progress.js"; + +/** + * Sink for the human-facing and structured output of long-running lifecycle + * operations (hatch, retire). Injecting it lets an in-process caller (e.g. a + * desktop main process embedding these functions) observe progress without the + * operation writing to the terminal, while the CLI keeps its existing stdout. + */ +export interface LifecycleReporter { + /** + * Coarse step progress. The CLI reporter mirrors this to the desktop + * `HATCH_PROGRESS:` stdout channel. + */ + progress(step: number, total: number, label: string): void; + log(message: string): void; + warn(message: string): void; + error(message: string): void; +} + +/** + * Reporter used by the CLI commands: human-readable lines to the console plus + * structured step events on the desktop progress channel. Reproduces the exact + * terminal output — and the `HATCH_PROGRESS:` lines under `VELLUM_DESKTOP_APP` — + * that existing subprocess consumers parse. + */ +export const consoleLifecycleReporter: LifecycleReporter = { + progress: (step, total, label) => emitProgress(step, total, label), + log: (message) => console.log(message), + warn: (message) => console.warn(message), + error: (message) => console.error(message), +}; diff --git a/cli/src/lib/retire-local.ts b/cli/src/lib/retire-local.ts index 3dccb003514..3a3cb95440d 100644 --- a/cli/src/lib/retire-local.ts +++ b/cli/src/lib/retire-local.ts @@ -3,22 +3,34 @@ import { homedir } from "os"; import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs"; import { basename, dirname, join } from "path"; -import { - getDaemonPidPath, - loadAllAssistants, -} from "./assistant-config.js"; +import { getDaemonPidPath, loadAllAssistants } from "./assistant-config.js"; import type { AssistantEntry } from "./assistant-config.js"; import { stopOrphanedDaemonProcesses, stopProcessByPidFile, } from "./process.js"; import { getArchivePath, getMetadataPath } from "./retire-archive.js"; +import { + consoleLifecycleReporter, + type LifecycleReporter, +} from "./lifecycle-reporter.js"; + +export interface RetireLocalResult { + assistantId: string; + /** Whether the instance data directory was archived (false when skipped). */ + archived: boolean; + /** Path to the background tar archive, when archiving was started. */ + archivePath?: string; + /** True when another local assistant shared the data dir, so it was kept. */ + sharedDataDir?: boolean; +} export async function retireLocal( name: string, entry: AssistantEntry, -): Promise { - console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n"); + reporter: LifecycleReporter = consoleLifecycleReporter, +): Promise { + reporter.log("\u{1F5D1}\ufe0f Stopping local assistant...\n"); if (!entry.resources) { throw new Error( @@ -38,11 +50,11 @@ export async function retireLocal( }); if (otherSharesDir) { - console.log( + reporter.log( ` Skipping process stop and archive — another local assistant shares ${vellumDir}.`, ); - console.log("\u2705 Local instance retired (config entry removed only)."); - return; + reporter.log("\u2705 Local instance retired (config entry removed only)."); + return { assistantId: name, archived: false, sharedDataDir: true }; } const daemonPidFile = getDaemonPidPath(resources); @@ -87,11 +99,11 @@ export async function retireLocal( const stagingDir = `${archivePath}.staging`; if (!existsSync(dirToArchive)) { - console.log( + reporter.log( ` No data directory at ${dirToArchive} — nothing to archive.`, ); - console.log("\u2705 Local instance retired."); - return; + reporter.log("\u2705 Local instance retired."); + return { assistantId: name, archived: false }; } // Ensure the retired archive directory exists before attempting the rename @@ -123,6 +135,8 @@ export async function retireLocal( }); child.unref(); - console.log(`šŸ“¦ Archiving to ${archivePath} in the background.`); - console.log("\u2705 Local instance retired."); + reporter.log(`šŸ“¦ Archiving to ${archivePath} in the background.`); + reporter.log("\u2705 Local instance retired."); + + return { assistantId: name, archived: true, archivePath }; }