Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions src/installer-cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -734,7 +730,8 @@ function isLoopbackHost(host: string): boolean {

async function promptInteractive(command: OperationKind, options: Record<string, string | boolean>): Promise<InstallRequest> {
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 {
Expand Down Expand Up @@ -797,7 +794,8 @@ async function promptInteractive(command: OperationKind, options: Record<string,

async function buildRequestFromFlags(command: OperationKind, options: Record<string, string | boolean>): Promise<InstallRequest> {
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 =
Expand Down Expand Up @@ -870,15 +868,16 @@ async function runList(options: Record<string, string | boolean>): Promise<void>

async function runDoctor(options: Record<string, string | boolean>): Promise<void> {
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)) {
Expand All @@ -895,7 +894,8 @@ async function runDoctor(options: Record<string, string | boolean>): Promise<voi
async function runCatalog(options: Record<string, string | boolean>): Promise<void> {
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;
Expand Down
37 changes: 37 additions & 0 deletions src/installer-core/applicationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof loadCatalogFromSources>>["skills"];
}>;
executeInstallOperation(request: InstallRequest): Promise<OperationReport>;
executeUninstallOperation(request: InstallRequest): Promise<OperationReport>;
executeSyncOperation(request: InstallRequest): Promise<OperationReport>;
Expand Down Expand Up @@ -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 });
},
Expand Down
2 changes: 1 addition & 1 deletion tests/installer/api-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { spawn } from "node:child_process";

const repoRoot = process.cwd();

async function waitForApiReady(port: number, apiKey: string, retries = 40): Promise<void> {
async function waitForApiReady(port: number, apiKey: string, retries = 120): Promise<void> {
for (let i = 0; i < retries; i += 1) {
try {
const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, {
Expand Down
45 changes: 45 additions & 0 deletions tests/installer/cli-shared-service-red-phase.test.ts
Original file line number Diff line number Diff line change
@@ -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\);/);
});
Loading