diff --git a/src/installer-cli/index.ts b/src/installer-cli/index.ts index 6b51ccc..3f94c13 100644 --- a/src/installer-cli/index.ts +++ b/src/installer-cli/index.ts @@ -8,14 +8,10 @@ import { spawn, execFile } from "node:child_process"; import { promisify } from "node:util"; import readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; -import { executeOperation } from "../installer-core/executor"; -import { loadCatalogFromSources } from "../installer-core/catalog"; import { loadHookCatalogFromSources, HookInstallSelection } from "../installer-core/hookCatalog"; import { executeHookOperation, HookInstallRequest, HookTargetPlatform } from "../installer-core/hookExecutor"; import { loadHookInstallState } from "../installer-core/hookState"; -import { loadInstallState } from "../installer-core/state"; import { parseTargets, resolveTargetPaths } from "../installer-core/targets"; -import { checkForAppUpdate } from "../installer-core/updateCheck"; import { createInstallerApplicationService } from "../installer-core/applicationService"; import { findRepoRoot } from "../installer-core/repo"; import { @@ -616,7 +612,7 @@ async function ensureDashboardImage(options: { function printHelp(): void { output.write(`ICA Installer CLI\n\n`); - output.write(`Commands:\n`); + output.write(`Headless automation commands:\n`); output.write(` ica install\n`); output.write(` ica uninstall\n`); output.write(` ica sync\n`); @@ -734,7 +730,8 @@ function isLoopbackHost(host: string): boolean { async function promptInteractive(command: OperationKind, options: Record): Promise { const repoRoot = findRepoRoot(__dirname); - const catalog = await loadCatalogFromSources(repoRoot, false); + const service = createInstallerApplicationService({ repoRoot }); + const catalog = await service.getCatalogSnapshot(); const rl = readline.createInterface({ input, output }); try { @@ -797,7 +794,8 @@ async function promptInteractive(command: OperationKind, options: Record): Promise { const repoRoot = findRepoRoot(__dirname); - const catalog = await loadCatalogFromSources(repoRoot, false); + const service = createInstallerApplicationService({ repoRoot }); + const catalog = await service.getCatalogSnapshot(); const targets = parseTargetsStrict(stringOption(options, "targets", "")); const scope = (stringOption(options, "scope", "user") === "project" ? "project" : "user") as InstallScope; const projectPath = @@ -870,15 +868,16 @@ async function runList(options: Record): Promise async function runDoctor(options: Record): Promise { const repoRoot = findRepoRoot(__dirname); - const catalog = await loadCatalogFromSources(repoRoot, false); + const service = createInstallerApplicationService({ repoRoot }); + const diagnostics = await service.getDiagnosticSnapshot(); const discovered = parseTargetsStrict(stringOption(options, "targets", "")); const payload = { - node: process.version, - platform: `${os.platform()} ${os.arch()}`, + node: diagnostics.node, + platform: diagnostics.platform, discoveredTargets: discovered, - catalogVersion: catalog.version, - skills: catalog.skills.length, + catalogVersion: diagnostics.catalogVersion, + skills: diagnostics.skills, }; if (boolOption(options, "json", false)) { @@ -895,7 +894,8 @@ async function runDoctor(options: Record): Promise): Promise { const repoRoot = findRepoRoot(__dirname); const refresh = boolOption(options, "refresh", false); - const catalog = await loadCatalogFromSources(repoRoot, refresh); + const service = createInstallerApplicationService({ repoRoot }); + const catalog = await service.getCatalogSnapshot({ refresh }); if (boolOption(options, "json", false)) { output.write(`${JSON.stringify(catalog, null, 2)}\n`); return; diff --git a/src/installer-core/applicationService.ts b/src/installer-core/applicationService.ts index 1c69a4c..90e0ec3 100644 --- a/src/installer-core/applicationService.ts +++ b/src/installer-core/applicationService.ts @@ -187,10 +187,33 @@ export interface RegisterSourceResult { auth: SourceAuthCheckResult; } +export interface DiagnosticSnapshot { + node: string; + platform: string; + catalogVersion: string; + skills: number; +} + export interface InstallerApplicationService { listInstallations(input: InstallationInspectionQuery): Promise<{ installations: InstallationRow[] }>; listHookInstallations(input: InstallationInspectionQuery): Promise<{ installations: HookInstallationRow[] }>; listSources(): Promise<{ sources: PublicSourceView[] }>; + getDiagnosticSnapshot(): Promise<{ + node: string; + platform: string; + catalogVersion: string; + skills: number; + }>; + getCatalogSnapshot(input?: { refresh?: boolean }): Promise<{ + version: string; + generatedAt: string; + catalogSource?: string; + stale?: boolean; + staleReason?: string; + cacheAgeSeconds?: number; + nextRefreshAt?: string; + skills: Awaited>["skills"]; + }>; executeInstallOperation(request: InstallRequest): Promise; executeUninstallOperation(request: InstallRequest): Promise; executeSyncOperation(request: InstallRequest): Promise; @@ -468,6 +491,20 @@ export function createInstallerApplicationService( }; }, + async getDiagnosticSnapshot() { + const catalog = await deps.loadCatalogFromSources(options.repoRoot, false); + return { + node: process.version, + platform: `${process.platform} ${process.arch}`, + catalogVersion: catalog.version, + skills: catalog.skills.length, + }; + }, + + getCatalogSnapshot(input = {}) { + return deps.loadCatalogFromSources(options.repoRoot, input.refresh === true); + }, + executeInstallOperation(request) { return deps.executeOperation(options.repoRoot, request, { hooks: options.installHooks }); }, diff --git a/tests/installer/api-security.test.ts b/tests/installer/api-security.test.ts index 77e696a..6ffe72d 100644 --- a/tests/installer/api-security.test.ts +++ b/tests/installer/api-security.test.ts @@ -5,7 +5,7 @@ import { spawn } from "node:child_process"; const repoRoot = process.cwd(); -async function waitForApiReady(port: number, apiKey: string, retries = 40): Promise { +async function waitForApiReady(port: number, apiKey: string, retries = 120): Promise { for (let i = 0; i < retries; i += 1) { try { const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { diff --git a/tests/installer/cli-shared-service-red-phase.test.ts b/tests/installer/cli-shared-service-red-phase.test.ts new file mode 100644 index 0000000..86da0ed --- /dev/null +++ b/tests/installer/cli-shared-service-red-phase.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readWorkspaceFile(relativePath: string): string { + return fs.readFileSync(path.resolve(process.cwd(), relativePath), "utf8"); +} + +test("CLI help keeps headless automation primary while documenting the desktop-first replacement for removed browser commands", () => { + const cli = readWorkspaceFile("src/installer-cli/index.ts"); + + assert.match(cli, /Desktop workflow:/, "CLI help should explicitly call out the desktop-first local workflow."); + assert.match(cli, /npm run start:desktop/, "CLI help should point local users at the desktop entrypoint."); + assert.match(cli, /ica doctor/, "Diagnostics should remain part of the supported headless CLI surface."); + assert.match(cli, /Legacy browser commands: `ica serve` and `ica launch` have been removed\./, "Removed browser commands should be called out explicitly."); + assert.doesNotMatch(cli, /ica serve \(deprecated browser-era runtime\)/, "Removed commands should not be reintroduced as supported CLI help text."); + assert.doesNotMatch(cli, /ica launch \(deprecated alias for serve\)/, "Removed commands should not be reintroduced as supported CLI help text."); +}); + +test("shared application service contract includes headless CLI diagnostics and catalog access", () => { + const serviceSource = readWorkspaceFile("src/installer-core/applicationService.ts"); + + assert.match(serviceSource, /getDiagnosticSnapshot\(\): Promise<\{/); + assert.match(serviceSource, /getCatalogSnapshot\(input\?: \{ refresh\?: boolean \}\): Promise<\{/); + assert.match(serviceSource, /listHookInstallations\(input: InstallationInspectionQuery\)/); +}); + +test("CLI headless automation entrypoints rely on the shared application service instead of direct runtime modules", () => { + const cli = readWorkspaceFile("src/installer-cli/index.ts"); + + assert.match(cli, /const service = createInstallerApplicationService\(\{ repoRoot \}\);/); + assert.doesNotMatch(cli, /import \{ executeOperation \} from "\.\.\/installer-core\/executor";/); + assert.doesNotMatch(cli, /import \{ loadCatalogFromSources \} from "\.\.\/installer-core\/catalog";/); + assert.doesNotMatch(cli, /import \{ loadInstallState \} from "\.\.\/installer-core\/state";/); + assert.doesNotMatch(cli, /import \{ checkForAppUpdate \} from "\.\.\/installer-core\/updateCheck";/); +}); + +test("CLI doctor and catalog commands use service-backed snapshots rather than direct catalog reads", () => { + const cli = readWorkspaceFile("src/installer-cli/index.ts"); + + assert.match(cli, /await service\.getDiagnosticSnapshot\(\)/); + assert.match(cli, /await service\.getCatalogSnapshot\(\{ refresh \}\)/); + assert.doesNotMatch(cli, /const catalog = await loadCatalogFromSources\(repoRoot,\s*false\);/); +});