From 9e79c0ca9454494eb7392e0ce61ef3d7ae93c960 Mon Sep 17 00:00:00 2001 From: Karsten Samaschke Date: Sun, 15 Feb 2026 08:45:22 +0100 Subject: [PATCH 1/2] feat: refresh sources and harden serve replacement (#125) * feat: harden source refresh and dashboard serve workflow Add startup and scheduled source refresh for CLI/API, update notifier wiring, and digest verification for skills/hooks installs. Improve serve behavior with safe dashboard replacement and platform-aware image fallback/build. Include UI refresh notifications and security threat model notes. * fix: limit serve port reclaim to ICA-owned processes Prevent `ica serve` from terminating unrelated local services when reclaiming API/UI ports. Validate process ownership before signal operations and fail fast with actionable guidance when the listener is not ICA-managed. --- .github/workflows/dashboard-ghcr.yml | 1 + intelligent-code-agents-threat-model.md | 130 +++++++++ src/installer-api/server/index.ts | 130 +++++---- src/installer-cli/index.ts | 270 ++++++++++++++---- src/installer-core/catalog.ts | 6 + src/installer-core/catalogMultiSource.ts | 7 + src/installer-core/contentDigest.ts | 67 +++++ src/installer-core/executor.ts | 35 +++ src/installer-core/hookCatalog.ts | 6 + src/installer-core/hookExecutor.ts | 35 +++ src/installer-core/hookState.ts | 1 + src/installer-core/hookSync.ts | 65 ++++- src/installer-core/sourceRefresh.ts | 77 +++++ src/installer-core/sourceSync.ts | 65 ++++- src/installer-core/types.ts | 3 + src/installer-core/updateCheck.ts | 97 +++++++ .../web/src/InstallerDashboard.tsx | 163 ++++++++++- src/installer-dashboard/web/src/styles.css | 53 ++++ tests/installer/api-realtime.test.ts | 38 +++ tests/installer/catalog.test.ts | 2 + tests/installer/cli-launch-runtime.test.ts | 6 + tests/installer/executor.test.ts | 50 +++- tests/installer/hooks.test.ts | 45 +++ tests/installer/serve-image-build.test.ts | 19 +- tests/installer/serve-orchestration.test.ts | 23 ++ tests/installer/sources.test.ts | 54 ++++ 26 files changed, 1305 insertions(+), 143 deletions(-) create mode 100644 intelligent-code-agents-threat-model.md create mode 100644 src/installer-core/contentDigest.ts create mode 100644 src/installer-core/sourceRefresh.ts create mode 100644 src/installer-core/updateCheck.ts diff --git a/.github/workflows/dashboard-ghcr.yml b/.github/workflows/dashboard-ghcr.yml index 83df786..46b00a5 100644 --- a/.github/workflows/dashboard-ghcr.yml +++ b/.github/workflows/dashboard-ghcr.yml @@ -48,6 +48,7 @@ jobs: with: context: . file: src/installer-dashboard/Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/intelligent-code-agents-threat-model.md b/intelligent-code-agents-threat-model.md new file mode 100644 index 0000000..7c33ea4 --- /dev/null +++ b/intelligent-code-agents-threat-model.md @@ -0,0 +1,130 @@ +## Assumption-validation check-in +- The installer API is intended for local host use only and not internet-exposed (`/api/v1/*` loopback + API key guard). +- Skills/hooks repositories are trusted-by-default but still treated as tamperable supply-chain inputs. +- `ica serve` is run by a local developer with Docker privileges. +- The dashboard UI is accessed locally via the BFF proxy and not directly from arbitrary origins. + +Questions to confirm: +1. Is any deployment expected where `/api/v1/*` is reachable beyond loopback (for example remote dev hosts or shared VMs)? +2. Are third-party/community source repos expected to be used by default in production developer environments? +3. Should symlink installs be considered acceptable for high-trust local workflows, or must copy+verify be enforced always? + +## Executive summary +The highest risks are supply-chain integrity and privileged local execution paths: source sync pulls and executes content from git repositories, and dashboard/CLI orchestration performs local process and container control. Current controls are solid for local-only API access and content digest verification at install time, but residual risk remains around trust of upstream source repos and symlink-mode drift after installation. + +## Scope and assumptions +In scope: +- `src/installer-cli/index.ts` +- `src/installer-api/server/index.ts` +- `src/installer-core/sourceSync.ts` +- `src/installer-core/hookSync.ts` +- `src/installer-core/executor.ts` +- `src/installer-core/hookExecutor.ts` +- `src/installer-core/updateCheck.ts` +- `src/installer-core/contentDigest.ts` +- `src/installer-dashboard/web/src/InstallerDashboard.tsx` + +Out of scope: +- External GitHub/GHCR runtime security posture. +- Host OS hardening and Docker daemon configuration. + +Open questions materially affecting risk: +- Whether this API is ever exposed off-loopback. +- Whether untrusted source repositories are expected in regular workflows. +- Whether symlink mode is allowed in high-assurance contexts. + +## System model +### Primary components +- CLI orchestration starts API/BFF and Docker dashboard container (`src/installer-cli/index.ts:1256`). +- API provides management endpoints and enforces local-only + API key checks (`src/installer-api/server/index.ts:928`). +- Source sync pulls skills/hooks repositories and mirrors content locally (`src/installer-core/sourceSync.ts:192`, `src/installer-core/hookSync.ts:199`). +- Install executors verify content digests before/after installation in copy mode (`src/installer-core/executor.ts:86`, `src/installer-core/hookExecutor.ts:62`). +- Dashboard triggers refresh and install operations over local API/BFF (`src/installer-dashboard/web/src/InstallerDashboard.tsx:845`). + +### Data flows and trust boundaries +- User UI -> BFF/API: local HTTP, API key and loopback guard. +- API -> Git remotes: git clone/fetch over HTTPS/SSH with optional credentials. +- API/CLI -> Docker daemon: container lifecycle and image pull/build operations. +- Source mirror -> Install targets: filesystem copy/symlink of skills/hooks. +- API -> GitHub Releases API: version metadata fetch (`src/installer-core/updateCheck.ts:43`). + +#### Diagram +```mermaid +flowchart LR + User["Local User"] --> UI["Dashboard UI"] + UI --> BFF["BFF"] + BFF --> API["Installer API"] + API --> Src["Skills Hooks Mirror"] + API --> Git["Git Repos"] + API --> Docker["Docker Daemon"] + API --> GH["GitHub Releases API"] + Src --> Targets["Agent Homes"] +``` + +## Assets and security objectives +| Asset | Why it matters | Security objective (C/I/A) | +|---|---|---| +| Installed skills/hooks content | Controls agent behavior and command execution | I, A | +| Source credentials/tokens | Access to private repositories | C | +| API key for local control plane | Authorizes install/refresh operations | C, I | +| Local Docker/container state | Hosts dashboard process and user workloads | I, A | +| Source revision/digest metadata | Detects tampering and stale state | I | + +## Attacker model +### Capabilities +- Local adversary/process on developer host attempting loopback access. +- Compromised or malicious upstream source repository content. +- Accidental operator misuse of serve/refresh workflows. + +### Non-capabilities +- Remote internet attacker cannot directly call API when loopback binding is preserved. +- Browser-origin attacker cannot call API without local origin and API key routing controls. + +## Entry points and attack surfaces +| Surface | How reached | Trust boundary | Notes | Evidence (repo path / symbol) | +|---|---|---|---|---| +| `/api/v1/*` endpoints | Local HTTP requests | UI/process -> API | Loopback + `x-ica-api-key` gate | `src/installer-api/server/index.ts:928` | +| Source refresh endpoints | API POST | API -> Git remotes | Triggers git sync and local mirror writes | `src/installer-api/server/index.ts:861` | +| Scheduled auto-refresh | API interval timer | API scheduler -> Git remotes | Repeated network + filesystem sync | `src/installer-api/server/index.ts:1192` | +| CLI serve container/process control | Local CLI run | CLI -> Docker/OS processes | Removes named dashboard container and reclaims ports | `src/installer-cli/index.ts:1263` | +| Install execution | CLI/API install calls | Mirror -> Agent home FS | Digest verification controls integrity in copy mode | `src/installer-core/executor.ts:86` | + +## Top abuse paths +1. Attacker compromises configured source repo -> API refresh pulls malicious skill -> operator installs -> agent executes attacker instructions. +2. Local process obtains API key from local environment/log leak -> issues install/uninstall API calls -> modifies agent home behavior. +3. Malicious local process binds dashboard internal port -> prevents serve startup or induces operational failure. +4. Operator enables symlink mode -> source directory changes post-install -> runtime content drift bypasses post-copy verification. +5. Upstream API (GitHub releases) manipulation/instability -> update notification noise or suppression -> delayed patch adoption. + +## Threat model table +| Threat ID | Threat source | Prerequisites | Threat action | Impact | Impacted assets | Existing controls (evidence) | Gaps | Recommended mitigations | Detection ideas | Likelihood | Impact severity | Priority | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| TM-001 | Compromised source repository | Source repo configured/enabled and refreshed | Serve/API refresh pulls malicious skills/hooks | Agent behavior compromise, possible command execution | Installed skills/hooks, agent homes | Digest verification on install (`src/installer-core/executor.ts:86`, `src/installer-core/hookExecutor.ts:62`) | Integrity assures consistency, not trust provenance | Add signed manifests/commit pinning and allowlist trusted source owners | Alert on source revision jumps and digest deltas | medium | high | high | +| TM-002 | Local unauthorized caller | API key disclosed or guessed; local host access | Call `/api/v1/*` mutating endpoints | Unauthorized install/remove/sync | API key, install state | Loopback restriction + API key check (`src/installer-api/server/index.ts:937`) | No rate limiting/audit policy for failed auth attempts | Add auth-failure throttling and structured security logs | Track repeated 401/403 bursts | low | high | medium | +| TM-003 | Local availability attacker | Port contention on UI/API/internal dashboard ports | Block or disrupt serve orchestration | Installer downtime, failed refresh/install sessions | Docker state, API/BFF availability | Named-container removal + targeted reclaim (`src/installer-cli/index.ts:426`) | Loopback reclaim can still terminate non-ICA PIDs for API/UI ports | Restrict PID reclaim to ICA-owned commands, otherwise fail-fast | Log process commandline before kill; emit warning if non-ICA | medium | medium | medium | +| TM-004 | Credential persistence leak | HTTPS token used for source sync; abnormal interruption | Tokenized remote URL left in local git config | Secret exposure from local state | Source credentials | Remote URL reset logic in `finally` (`src/installer-core/sourceSync.ts:244`) | Crash/kill -9 before cleanup can leave credentialized URL | Prefer per-command auth helper over storing tokenized remote URL | Periodic scan for credentials in `~/.ica/**/.git/config` | low | high | medium | +| TM-005 | Update-check path abuse | External API slowness/outage | Health/update check stalls or fails | Operational noise, delayed UX | Update telemetry | Timeout + cache in update checker (`src/installer-core/updateCheck.ts:41`, `src/installer-core/updateCheck.ts:69`) | No signed metadata verification, relies on API response trust | Optionally verify release origin metadata and add backoff metrics | Monitor update-check errors and latency | low | low | low | + +## Criticality calibration +- critical: direct remote code execution or cross-tenant data compromise without local preconditions. +- high: local-to-privileged compromise of agent behavior or credential theft with realistic prerequisites. +- medium: meaningful availability or integrity degradation requiring local foothold/misconfiguration. +- low: telemetry/noise issues or edge-case misbehavior with limited security impact. + +## Focus paths for security review +| Path | Why it matters | Related Threat IDs | +|---|---|---| +| `src/installer-core/sourceSync.ts` | Source trust boundary and tokenized remote handling | TM-001, TM-004 | +| `src/installer-core/hookSync.ts` | Hook source sync executes same trust pattern as skills | TM-001, TM-004 | +| `src/installer-core/executor.ts` | Skill integrity enforcement and install mode semantics | TM-001, TM-003 | +| `src/installer-core/hookExecutor.ts` | Hook integrity enforcement and install mode semantics | TM-001, TM-003 | +| `src/installer-api/server/index.ts` | Local authn/authz guard and scheduled refresh surface | TM-002, TM-005 | +| `src/installer-cli/index.ts` | Docker/process lifecycle controls and potential local DoS | TM-003 | +| `src/installer-core/updateCheck.ts` | External metadata trust and timeout/cache strategy | TM-005 | + +## Quality check +- Entry points covered: API routes, refresh endpoints, serve orchestration, install paths. +- Trust boundaries represented in threats: UI/API, API/Git, API/Docker, mirror/target FS, API/external release API. +- Runtime vs CI/dev separation: runtime installer paths only; CI workflows treated as out of scope. +- User clarifications: pending (questions listed in assumption check-in). +- Assumptions/open questions: explicitly documented above. diff --git a/src/installer-api/server/index.ts b/src/installer-api/server/index.ts index 308b3e0..4d64008 100644 --- a/src/installer-api/server/index.ts +++ b/src/installer-api/server/index.ts @@ -18,8 +18,10 @@ import { executeHookOperation, HookInstallRequest, HookTargetPlatform } from ".. import { loadHookInstallState } from "../../installer-core/hookState"; import { registerRepository } from "../../installer-core/repositories"; import { redactSensitive, safeErrorMessage } from "../../installer-core/security"; +import { refreshSourcesAndHooks } from "../../installer-core/sourceRefresh"; import { loadInstallState } from "../../installer-core/state"; import { discoverTargets, resolveTargetPaths } from "../../installer-core/targets"; +import { checkForAppUpdate } from "../../installer-core/updateCheck"; import { SUPPORTED_TARGETS } from "../../installer-core/constants"; import { findRepoRoot } from "../../installer-core/repo"; import { InstallRequest, InstallScope, InstallSelection, TargetPlatform } from "../../installer-core/types"; @@ -93,6 +95,7 @@ interface InstallerApiDependencies { loadHookSources: typeof loadHookSources; syncSource: typeof syncSource; syncHookSource: typeof syncHookSource; + checkForAppUpdate: typeof checkForAppUpdate; } export interface InstallerApiServerOptions { @@ -321,6 +324,7 @@ export async function createInstallerApiServer(options: InstallerApiServerOption const apiKey = options.apiKey || process.env.ICA_API_KEY || ""; const wsTicketTtlMs = options.wsTicketTtlMs ?? Number(process.env.ICA_WS_TICKET_TTL_MS || "60000"); const wsHeartbeatMs = options.wsHeartbeatMs ?? Number(process.env.ICA_WS_HEARTBEAT_MS || "15000"); + const autoRefreshMinutes = Math.max(0, Number(process.env.ICA_SOURCE_REFRESH_INTERVAL_MINUTES || "60")); const deps: InstallerApiDependencies = { executeOperation, executeHookOperation, @@ -330,6 +334,7 @@ export async function createInstallerApiServer(options: InstallerApiServerOption loadHookSources, syncSource, syncHookSource, + checkForAppUpdate, ...(options.dependencies || {}), }; @@ -429,11 +434,13 @@ export async function createInstallerApiServer(options: InstallerApiServerOption }); app.get("/api/v1/health", async () => { + const update = await deps.checkForAppUpdate(installerVersion); return { ok: true, service: "ica-installer-api", version: installerVersion, timestamp: new Date().toISOString(), + update, }; }); @@ -853,31 +860,29 @@ export async function createInstallerApiServer(options: InstallerApiServerOption app.post("/api/v1/sources/:id/refresh", async (request, reply) => { const params = request.params as { id: string }; - const skillSource = (await deps.loadSources()).find((item) => item.id === params.id); - const hookSource = (await deps.loadHookSources()).find((item) => item.id === params.id); - if (!skillSource && !hookSource) { - return reply.code(404).send({ error: `Unknown source '${params.id}'.` }); - } const opId = `op_${crypto.randomUUID()}`; realtime.emit("source", "source.refresh.started", { sourceId: params.id }, opId); - const credentialProvider = createCredentialProvider(); try { + const result = await refreshSourcesAndHooks( + { + credentials: createCredentialProvider(), + loadSources: deps.loadSources, + loadHookSources: deps.loadHookSources, + syncSource: deps.syncSource, + syncHookSource: deps.syncHookSource, + }, + { sourceId: params.id, onlyEnabled: false }, + ); + if (!result.matched) { + return reply.code(404).send({ error: `Unknown source '${params.id}'.` }); + } + const item = result.refreshed[0]; const refreshed: Array<{ type: "skills" | "hooks"; revision?: string; localPath?: string; error?: string }> = []; - if (skillSource) { - try { - const result = await deps.syncSource(skillSource, credentialProvider); - refreshed.push({ type: "skills", revision: result.revision, localPath: result.localPath }); - } catch (error) { - refreshed.push({ type: "skills", error: sanitizeError(error) }); - } + if (item.skills) { + refreshed.push({ type: "skills", ...item.skills }); } - if (hookSource) { - try { - const result = await deps.syncHookSource(hookSource, credentialProvider); - refreshed.push({ type: "hooks", revision: result.revision, localPath: result.localPath }); - } catch (error) { - refreshed.push({ type: "hooks", error: sanitizeError(error) }); - } + if (item.hooks) { + refreshed.push({ type: "hooks", ...item.hooks }); } realtime.emit("source", "source.refresh.completed", { sourceId: params.id, refreshed }, opId); return { sourceId: params.id, refreshed, operationId: opId }; @@ -891,47 +896,14 @@ export async function createInstallerApiServer(options: InstallerApiServerOption const opId = `op_${crypto.randomUUID()}`; realtime.emit("source", "source.refresh.started", { sourceId: "all" }, opId); try { - const credentialProvider = createCredentialProvider(); - const skillSources = (await deps.loadSources()).filter((source) => source.enabled); - const hookSources = (await deps.loadHookSources()).filter((source) => source.enabled); - const byId = new Map(); - for (const source of skillSources) { - byId.set(source.id, { ...(byId.get(source.id) || {}), skills: source }); - } - for (const source of hookSources) { - byId.set(source.id, { ...(byId.get(source.id) || {}), hooks: source }); - } - - const refreshed: Array<{ - sourceId: string; - skills?: { revision?: string; localPath?: string; error?: string }; - hooks?: { revision?: string; localPath?: string; error?: string }; - }> = []; - for (const [sourceId, entry] of byId.entries()) { - const item: { - sourceId: string; - skills?: { revision?: string; localPath?: string; error?: string }; - hooks?: { revision?: string; localPath?: string; error?: string }; - } = { sourceId }; - - if (entry.skills) { - try { - const result = await deps.syncSource(entry.skills, credentialProvider); - item.skills = { revision: result.revision, localPath: result.localPath }; - } catch (error) { - item.skills = { error: sanitizeError(error) }; - } - } - if (entry.hooks) { - try { - const result = await deps.syncHookSource(entry.hooks, credentialProvider); - item.hooks = { revision: result.revision, localPath: result.localPath }; - } catch (error) { - item.hooks = { error: sanitizeError(error) }; - } - } - refreshed.push(item); - } + const result = await refreshSourcesAndHooks({ + credentials: createCredentialProvider(), + loadSources: deps.loadSources, + loadHookSources: deps.loadHookSources, + syncSource: deps.syncSource, + syncHookSource: deps.syncHookSource, + }); + const refreshed = result.refreshed; realtime.emit("source", "source.refresh.completed", { sourceId: "all", refreshed }, opId); return { refreshed, operationId: opId }; } catch (error) { @@ -1216,6 +1188,42 @@ export async function createInstallerApiServer(options: InstallerApiServerOption throw error; } }); + + let refreshInFlight = false; + const runAutoRefresh = async (trigger: "startup" | "interval"): Promise => { + if (refreshInFlight) { + return; + } + refreshInFlight = true; + const opId = `op_${crypto.randomUUID()}`; + realtime.emit("source", "source.refresh.started", { sourceId: "all", trigger }, opId); + try { + const result = await refreshSourcesAndHooks({ + credentials: createCredentialProvider(), + loadSources: deps.loadSources, + loadHookSources: deps.loadHookSources, + syncSource: deps.syncSource, + syncHookSource: deps.syncHookSource, + }); + realtime.emit("source", "source.refresh.completed", { sourceId: "all", refreshed: result.refreshed, trigger }, opId); + } catch (error) { + realtime.emit("source", "source.refresh.failed", { sourceId: "all", error: sanitizeError(error), trigger }, opId); + } finally { + refreshInFlight = false; + } + }; + + if (autoRefreshMinutes > 0) { + void runAutoRefresh("startup"); + const intervalMs = autoRefreshMinutes * 60_000; + const timer = setInterval(() => { + void runAutoRefresh("interval"); + }, intervalMs); + timer.unref(); + app.addHook("onClose", async () => { + clearInterval(timer); + }); + } return app; } diff --git a/src/installer-cli/index.ts b/src/installer-cli/index.ts index c8714f2..41a9ac8 100644 --- a/src/installer-cli/index.ts +++ b/src/installer-cli/index.ts @@ -20,8 +20,10 @@ import { loadHookCatalogFromSources, HookInstallSelection } from "../installer-c import { executeHookOperation, HookInstallRequest, HookTargetPlatform } from "../installer-core/hookExecutor"; import { loadHookInstallState } from "../installer-core/hookState"; import { registerRepository } from "../installer-core/repositories"; +import { refreshSourcesAndHooks } from "../installer-core/sourceRefresh"; import { loadInstallState } from "../installer-core/state"; import { parseTargets, resolveTargetPaths } from "../installer-core/targets"; +import { checkForAppUpdate } from "../installer-core/updateCheck"; import { findRepoRoot } from "../installer-core/repo"; import { InstallMode, InstallRequest, InstallScope, InstallSelection, OperationKind, TargetPlatform } from "../installer-core/types"; @@ -230,6 +232,25 @@ function canSignalPid(pid: number): boolean { } } +async function isIcaOwnedServePid(pid: number): Promise { + if (process.platform === "win32") { + // Keep previous behavior on Windows where lightweight commandline checks are less portable. + return true; + } + try { + const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "command="], { maxBuffer: 1024 * 1024 }); + const command = (stdout || "").toLowerCase(); + return ( + command.includes("installer-api/server/index.js") || + command.includes("installer-bff/server/index.js") || + command.includes("dist/src/installer-api/server/index.js") || + command.includes("dist/src/installer-bff/server/index.js") + ); + } catch { + return false; + } +} + async function waitForPortAvailable(port: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -253,6 +274,14 @@ async function reclaimLoopbackPort(port: number, flagName: "ui-port" | "api-port ); } + for (const pid of pids) { + if (!(await isIcaOwnedServePid(pid))) { + throw new Error( + `Requested --${flagName}=${port} is in use by non-ICA process (pid ${pid}). Stop it manually or choose a different port.`, + ); + } + } + output.write(`Notice: ${flagName} ${port} is busy; stopping existing process on that port.\n`); for (const pid of pids) { try { @@ -301,6 +330,15 @@ export function parseServeReusePorts(rawValue: string): ServeReusePortsMode { throw new Error(`Invalid --reuse-ports value '${rawValue}'. Supported: true|false`); } +export function parseServeRefreshMinutes(rawValue: string): number { + const trimmed = rawValue.trim(); + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid --sources-refresh-minutes value '${rawValue}'. Use 0 to disable or a positive number.`); + } + return Math.floor(parsed); +} + export function shouldBuildDashboardImage(input: { mode: ServeImageBuildMode; image: string; @@ -322,6 +360,17 @@ export function shouldBuildDashboardImage(input: { return input.image === input.defaultImage; } +export function shouldFallbackToSourceBuild(pullErrorMessage: string): boolean { + const normalized = pullErrorMessage.toLowerCase(); + return ( + normalized.includes("no matching manifest") || + normalized.includes("no match for platform in manifest") || + normalized.includes("manifest unknown") || + normalized.includes("manifest not found") || + normalized.includes("not found: manifest") + ); +} + function toDockerRunArgs(base: { containerName: string; image: string; @@ -401,6 +450,37 @@ async function dockerInspect(containerName: string): Promise { } } +async function reclaimDockerPublishedPort(port: number, expectedContainerName: string): Promise { + try { + const { stdout } = await execFileAsync( + "docker", + ["ps", "--filter", `publish=${port}`, "--format", "{{.ID}} {{.Names}}"], + { + maxBuffer: 4 * 1024 * 1024, + }, + ); + const ids = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (ids.length === 0) { + return 0; + } + let removed = 0; + for (const row of ids) { + const [id, name] = row.split(/\s+/, 2); + if (!id || !name || name !== expectedContainerName) { + continue; + } + await execFileAsync("docker", ["rm", "-f", id], { maxBuffer: 8 * 1024 * 1024 }); + removed += 1; + } + return removed; + } catch { + return 0; + } +} + async function dockerImageExists(image: string): Promise { try { await execFileAsync("docker", ["image", "inspect", image], { maxBuffer: 8 * 1024 * 1024 }); @@ -416,6 +496,16 @@ async function ensureDashboardImage(options: { mode: ServeImageBuildMode; defaultImage: string; }): Promise { + const dockerfilePath = path.join(options.repoRoot, "src", "installer-dashboard", "Dockerfile"); + const buildFromSource = async (): Promise => { + if (!fs.existsSync(dockerfilePath)) { + throw new Error(`Dashboard Dockerfile not found at ${dockerfilePath}. Provide --image= or run with --build-image=never.`); + } + output.write(`Building dashboard image '${options.image}' from source...\n`); + await execFileAsync("docker", ["build", "-f", dockerfilePath, "-t", options.image, options.repoRoot], { maxBuffer: 16 * 1024 * 1024 }); + output.write(`Built dashboard image '${options.image}'.\n`); + }; + const imageExists = await dockerImageExists(options.image); const shouldBuild = shouldBuildDashboardImage({ mode: options.mode, @@ -426,19 +516,21 @@ async function ensureDashboardImage(options: { if (!shouldBuild) { if (!imageExists && options.image.trim().toLowerCase().startsWith("ghcr.io/")) { output.write(`Pulling dashboard image '${options.image}'...\n`); - await execFileAsync("docker", ["pull", options.image], { maxBuffer: 16 * 1024 * 1024 }); + try { + await execFileAsync("docker", ["pull", options.image], { maxBuffer: 16 * 1024 * 1024 }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const canFallback = options.mode !== "never" && fs.existsSync(dockerfilePath) && shouldFallbackToSourceBuild(message); + if (!canFallback) { + throw error; + } + output.write("Dashboard image pull failed for this platform; falling back to source build.\n"); + await buildFromSource(); + } } return; } - - const dockerfilePath = path.join(options.repoRoot, "src", "installer-dashboard", "Dockerfile"); - if (!fs.existsSync(dockerfilePath)) { - throw new Error(`Dashboard Dockerfile not found at ${dockerfilePath}. Provide --image= or run with --build-image=never.`); - } - - output.write(`Building dashboard image '${options.image}' from source...\n`); - await execFileAsync("docker", ["build", "-f", dockerfilePath, "-t", options.image, options.repoRoot], { maxBuffer: 16 * 1024 * 1024 }); - output.write(`Built dashboard image '${options.image}'.\n`); + await buildFromSource(); } function printHelp(): void { @@ -484,6 +576,52 @@ function printHelp(): void { output.write(` --yes\n`); output.write(` --json\n`); output.write(` --refresh (for catalog: force live source refresh)\n`); + output.write(` --sources-refresh-minutes=60 (serve only; set 0 to disable periodic source refresh)\n`); +} + +function resolveInstallerVersion(repoRoot: string): string { + const versionFile = path.join(repoRoot, "VERSION"); + if (!fs.existsSync(versionFile)) { + return "0.0.0"; + } + try { + const value = fs.readFileSync(versionFile, "utf8").trim(); + return value || "0.0.0"; + } catch { + return "0.0.0"; + } +} + +async function maybePrintUpdateNotifier(repoRoot: string, options: Record): Promise { + if (boolOption(options, "json", false)) { + return; + } + const currentVersion = resolveInstallerVersion(repoRoot); + const update = await checkForAppUpdate(currentVersion); + if (!update.updateAvailable || !update.latestVersion) { + return; + } + const targetVersion = update.latestVersion.replace(/^v/i, ""); + const link = update.latestReleaseUrl || "https://github.com/intelligentcode-ai/intelligent-code-agents/releases/latest"; + output.write(`Update available: ICA ${targetVersion} (current ${currentVersion}). ${link}\n`); +} + +async function refreshSourcesOnCliStart(): Promise { + try { + const result = await refreshSourcesAndHooks({ + credentials: createCredentialProvider(), + loadSources, + loadHookSources, + syncSource, + syncHookSource, + }); + const errors = result.refreshed.flatMap((entry) => [entry.skills?.error, entry.hooks?.error]).filter((item): item is string => Boolean(item)); + if (errors.length > 0) { + output.write(`Warning: startup source refresh completed with ${errors.length} error(s).\n`); + } + } catch (error) { + output.write(`Warning: startup source refresh failed: ${error instanceof Error ? error.message : String(error)}\n`); + } } function openBrowser(url: string): void { @@ -985,44 +1123,24 @@ async function runSources(positionals: string[], options: Record repo.id === sourceId) - : repositories.filter((repo) => repo.skills?.enabled !== false || repo.hooks?.enabled !== false); - if (targets.length === 0) { + const result = await refreshSourcesAndHooks( + { + credentials: credentialProvider, + loadSources, + loadHookSources, + syncSource, + syncHookSource, + }, + { sourceId, onlyEnabled: true }, + ); + if (!result.matched) { throw new Error(sourceId ? `Unknown source '${sourceId}'` : "No enabled sources found."); } - - const refreshed: Array<{ - id: string; - skills?: { revision?: string; localPath?: string; error?: string }; - hooks?: { revision?: string; localPath?: string; error?: string }; - }> = []; - for (const repo of targets) { - const item: { - id: string; - skills?: { revision?: string; localPath?: string; error?: string }; - hooks?: { revision?: string; localPath?: string; error?: string }; - } = { id: repo.id }; - - if (repo.skills) { - try { - const result = await syncSource(repo.skills, credentialProvider); - item.skills = { revision: result.revision, localPath: result.localPath }; - } catch (error) { - item.skills = { error: error instanceof Error ? error.message : String(error) }; - } - } - if (repo.hooks) { - try { - const result = await syncHookSource(repo.hooks, credentialProvider); - item.hooks = { revision: result.revision, localPath: result.localPath }; - } catch (error) { - item.hooks = { error: error instanceof Error ? error.message : String(error) }; - } - } - refreshed.push(item); - } + const refreshed = result.refreshed.map((item) => ({ + id: item.sourceId, + skills: item.skills, + hooks: item.hooks, + })); output.write(json ? `${JSON.stringify(refreshed, null, 2)}\n` : `Refreshed ${refreshed.length} repositories.\n`); return; } @@ -1160,8 +1278,22 @@ async function runServe(options: Record): Promise): Promise): Promise): Promise): Promise): Promise ${staticOrigin} + ${apiBaseUrl}\n`); output.write(`Container: ${containerName} (${(dockerRunResult.stdout || "").trim()})\n`); + output.write( + sourcesRefreshMinutes > 0 + ? `Source auto-refresh: every ${sourcesRefreshMinutes} minute(s)\n` + : "Source auto-refresh: disabled\n", + ); if (open) { openBrowser(dashboardUrl); } @@ -1331,6 +1469,12 @@ async function runLaunch(options: Record): Promise { const { command, options, positionals } = parseArgv(process.argv.slice(2)); const normalized = command.toLowerCase(); + const repoRoot = findRepoRoot(__dirname); + + if (normalized !== "help") { + await refreshSourcesOnCliStart(); + await maybePrintUpdateNotifier(repoRoot, options); + } if (["install", "uninstall", "sync"].includes(normalized)) { await runOperation(normalized as OperationKind, options); diff --git a/src/installer-core/catalog.ts b/src/installer-core/catalog.ts index 7a7b958..26641cd 100644 --- a/src/installer-core/catalog.ts +++ b/src/installer-core/catalog.ts @@ -7,6 +7,7 @@ import { isSkillBlocked } from "./skillBlocklist"; import { DEFAULT_SKILLS_ROOT, OFFICIAL_SOURCE_ID, OFFICIAL_SOURCE_NAME, OFFICIAL_SOURCE_URL, getIcaStateRoot } from "./sources"; import { frontmatterList, frontmatterString, parseFrontmatter } from "./skillMetadata"; import { pathExists, writeText } from "./fs"; +import { computeDirectoryDigest } from "./contentDigest"; interface LocalCatalogEntry { name: string; @@ -22,6 +23,8 @@ interface LocalCatalogEntry { compatibleTargets: TargetPlatform[]; resources: SkillResource[]; sourcePath: string; + contentDigest?: string; + contentFileCount?: number; } interface CacheRecord { @@ -101,6 +104,7 @@ function toCatalogEntry(skillDir: string, repoRoot: string): LocalCatalogEntry | const author = frontmatterString(frontmatter, "author"); const contactEmail = frontmatterString(frontmatter, "contact-email") || frontmatterString(frontmatter, "contactEmail"); const website = frontmatterString(frontmatter, "website"); + const digest = computeDirectoryDigest(skillDir); return { name, @@ -116,6 +120,8 @@ function toCatalogEntry(skillDir: string, repoRoot: string): LocalCatalogEntry | compatibleTargets: ["claude", "codex", "cursor", "gemini", "antigravity"] satisfies TargetPlatform[], resources: collectResources(skillDir), sourcePath: path.relative(repoRoot, skillDir).replace(/\\/g, "/"), + contentDigest: digest.digest, + contentFileCount: digest.fileCount, }; } diff --git a/src/installer-core/catalogMultiSource.ts b/src/installer-core/catalogMultiSource.ts index 7482a38..87c8e42 100644 --- a/src/installer-core/catalogMultiSource.ts +++ b/src/installer-core/catalogMultiSource.ts @@ -7,6 +7,7 @@ import { syncSource } from "./sourceSync"; import { CatalogSkill, InstallSelection, SkillCatalog, SkillResource, SkillSource, TargetPlatform } from "./types"; import { isSkillBlocked } from "./skillBlocklist"; import { frontmatterList, frontmatterString, parseFrontmatter } from "./skillMetadata"; +import { computeDirectoryDigest } from "./contentDigest"; interface CatalogOptions { repoVersion: string; @@ -137,6 +138,7 @@ function toCatalogSkill(source: SkillSource, skillDir: string): CatalogSkill | n const author = frontmatterString(frontmatter, "author"); const contactEmail = frontmatterString(frontmatter, "contact-email") || frontmatterString(frontmatter, "contactEmail"); const website = frontmatterString(frontmatter, "website"); + const digest = computeDirectoryDigest(skillDir); return { skillId, @@ -159,6 +161,8 @@ function toCatalogSkill(source: SkillSource, skillDir: string): CatalogSkill | n sourcePath: skillDir, version: frontmatterString(frontmatter, "version"), updatedAt: stat.mtime.toISOString(), + contentDigest: digest.digest, + contentFileCount: digest.fileCount, }; } @@ -178,6 +182,7 @@ function toCatalogSkillFromIndex(source: SkillSource, root: string, entry: Skill const scope = (entry.scope || "").trim().toLowerCase() || undefined; const subcategory = (entry.subcategory || "").trim().toLowerCase() || undefined; const tags = normalizeTags(entry.tags); + const digest = computeDirectoryDigest(skillDir); return { skillId: `${source.id}/${skillName}`, @@ -200,6 +205,8 @@ function toCatalogSkillFromIndex(source: SkillSource, root: string, entry: Skill sourcePath: skillDir, version: entry.version?.trim() || undefined, updatedAt: stat.mtime.toISOString(), + contentDigest: digest.digest, + contentFileCount: digest.fileCount, }; } diff --git a/src/installer-core/contentDigest.ts b/src/installer-core/contentDigest.ts new file mode 100644 index 0000000..b2a1203 --- /dev/null +++ b/src/installer-core/contentDigest.ts @@ -0,0 +1,67 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +export interface DirectoryDigest { + digest: string; + fileCount: number; +} + +function toPosixRelative(value: string): string { + return value.split(path.sep).join("/"); +} + +function listDirectoryEntries(rootPath: string): Array<{ absolute: string; relative: string; kind: "file" | "symlink" }> { + const stack: string[] = [rootPath]; + const items: Array<{ absolute: string; relative: string; kind: "file" | "symlink" }> = []; + + while (stack.length > 0) { + const current = stack.pop() as string; + const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.name === ".git") { + continue; + } + const absolute = path.join(current, entry.name); + const relative = toPosixRelative(path.relative(rootPath, absolute)); + if (entry.isDirectory()) { + stack.push(absolute); + continue; + } + if (entry.isSymbolicLink()) { + items.push({ absolute, relative, kind: "symlink" }); + continue; + } + if (entry.isFile()) { + items.push({ absolute, relative, kind: "file" }); + } + } + } + + return items.sort((a, b) => a.relative.localeCompare(b.relative)); +} + +export function computeDirectoryDigest(rootPath: string): DirectoryDigest { + const hash = crypto.createHash("sha256"); + const entries = listDirectoryEntries(rootPath); + + for (const item of entries) { + hash.update(`path:${item.relative}\n`); + if (item.kind === "symlink") { + const target = fs.readlinkSync(item.absolute, "utf8"); + hash.update("kind:symlink\n"); + hash.update(`target:${target}\n`); + continue; + } + const content = fs.readFileSync(item.absolute); + hash.update("kind:file\n"); + hash.update(`size:${content.byteLength}\n`); + hash.update(content); + hash.update("\n"); + } + + return { + digest: `sha256:${hash.digest("hex")}`, + fileCount: entries.length, + }; +} diff --git a/src/installer-core/executor.ts b/src/installer-core/executor.ts index 36698a5..a8458bf 100644 --- a/src/installer-core/executor.ts +++ b/src/installer-core/executor.ts @@ -8,6 +8,7 @@ import { mergeMcpConfig } from "./mcp"; import { computePlannerDelta } from "./planner"; import { assertPathWithin, redactSensitive } from "./security"; import { appendHistory, createEmptyState, getStatePath, loadInstallState, reconcileLegacyManagedSkills, saveInstallState } from "./state"; +import { computeDirectoryDigest } from "./contentDigest"; import { InstallRequest, InstallState, @@ -82,6 +83,34 @@ function pushError(report: TargetOperationReport, code: string, message: string) report.errors.push({ code, message: redactSensitive(message) }); } +function verifySkillSourceIntegrity(skill: SkillCatalog["skills"][number], report: TargetOperationReport): string { + const actual = computeDirectoryDigest(skill.sourcePath); + const expected = skill.contentDigest || actual.digest; + + if (!skill.contentDigest) { + pushWarning( + report, + "MISSING_SKILL_DIGEST", + `Skill '${skill.skillId}' did not provide a catalog content digest; verified using runtime source digest only.`, + ); + } + + if (actual.digest !== expected) { + throw new Error( + `Integrity verification failed for '${skill.skillId}'. Expected ${expected}, received ${actual.digest}.`, + ); + } + + return expected; +} + +function verifyInstalledSkillIntegrity(destinationPath: string, expectedDigest: string): void { + const installed = computeDirectoryDigest(destinationPath); + if (installed.digest !== expectedDigest) { + throw new Error(`Installed skill digest mismatch at '${destinationPath}'. Expected ${expectedDigest}, received ${installed.digest}.`); + } +} + async function removeTrackedPath(installPath: string, candidatePath: string): Promise { assertPathWithin(installPath, candidatePath); await removePath(candidatePath); @@ -201,6 +230,7 @@ async function installOrSyncTarget( const destination = path.join(skillsDir, skill.name); await removePath(destination); + const expectedDigest = verifySkillSourceIntegrity(skill, report); let effectiveMode = request.mode; if (request.mode === "symlink") { @@ -215,6 +245,10 @@ async function installOrSyncTarget( await copyPath(skill.sourcePath, destination); } + if (effectiveMode === "copy") { + verifyInstalledSkillIntegrity(destination, expectedDigest); + } + const managed: ManagedSkillState = { name: skill.name, skillName: skill.skillName, @@ -222,6 +256,7 @@ async function installOrSyncTarget( sourceId: skill.sourceId, sourceUrl: skill.sourceUrl, sourceRevision: catalog.sources.find((source) => source.id === skill.sourceId)?.revision, + sourceContentDigest: expectedDigest, orphaned: false, installMode: request.mode, effectiveMode, diff --git a/src/installer-core/hookCatalog.ts b/src/installer-core/hookCatalog.ts index e5e2f18..eaa0dfe 100644 --- a/src/installer-core/hookCatalog.ts +++ b/src/installer-core/hookCatalog.ts @@ -5,6 +5,7 @@ import { ensureHookSourceRegistry, HookSource, setHookSourceSyncStatus } from ". import { safeErrorMessage } from "./security"; import { syncHookSource } from "./hookSync"; import { TargetPlatform } from "./types"; +import { computeDirectoryDigest } from "./contentDigest"; const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/; @@ -19,6 +20,8 @@ export interface CatalogHook { sourcePath: string; version?: string; updatedAt?: string; + contentDigest?: string; + contentFileCount?: number; compatibleTargets: Array>; } @@ -76,6 +79,7 @@ function toCatalogHook(source: HookSource, hookDir: string): CatalogHook | null const hookName = frontmatter.name || path.basename(hookDir); const hookId = `${source.id}/${hookName}`; + const digest = computeDirectoryDigest(hookDir); return { hookId, @@ -88,6 +92,8 @@ function toCatalogHook(source: HookSource, hookDir: string): CatalogHook | null sourcePath: hookDir, version: frontmatter.version, updatedAt: stat.mtime.toISOString(), + contentDigest: digest.digest, + contentFileCount: digest.fileCount, compatibleTargets: ["claude", "gemini"], }; } diff --git a/src/installer-core/hookExecutor.ts b/src/installer-core/hookExecutor.ts index 1a12c46..e3963fc 100644 --- a/src/installer-core/hookExecutor.ts +++ b/src/installer-core/hookExecutor.ts @@ -3,6 +3,7 @@ import { copyPath, ensureDir, removePath, trySymlinkDirectory } from "./fs"; import { loadHookCatalogFromSources, findHookById, resolveHookSelections, HookInstallSelection } from "./hookCatalog"; import { appendHookHistory, createEmptyHookState, loadHookInstallState, ManagedHookState, saveHookInstallState } from "./hookState"; import { parseTargets, resolveTargetPaths } from "./targets"; +import { computeDirectoryDigest } from "./contentDigest"; export type HookTargetPlatform = "claude" | "gemini"; export type HookInstallScope = "user" | "project"; @@ -58,6 +59,34 @@ function pushError(report: HookTargetOperationReport, code: string, message: str report.errors.push({ code, message }); } +function verifyHookSourceIntegrity(hook: NonNullable>, report: HookTargetOperationReport): string { + const actual = computeDirectoryDigest(hook.sourcePath); + const expected = hook.contentDigest || actual.digest; + + if (!hook.contentDigest) { + pushWarning( + report, + "MISSING_HOOK_DIGEST", + `Hook '${hook.hookId}' did not provide a catalog content digest; verified using runtime source digest only.`, + ); + } + + if (actual.digest !== expected) { + throw new Error( + `Integrity verification failed for '${hook.hookId}'. Expected ${expected}, received ${actual.digest}.`, + ); + } + + return expected; +} + +function verifyInstalledHookIntegrity(destinationPath: string, expectedDigest: string): void { + const installed = computeDirectoryDigest(destinationPath); + if (installed.digest !== expectedDigest) { + throw new Error(`Installed hook digest mismatch at '${destinationPath}'. Expected ${expectedDigest}, received ${installed.digest}.`); + } +} + function defaultTargetReport(target: HookTargetPlatform, installPath: string, operation: HookOperation): HookTargetOperationReport { return { target, @@ -171,6 +200,7 @@ async function installOrSyncTarget(repoRoot: string, request: HookInstallRequest const destination = path.join(hooksDir, hook.name); await removePath(destination); + const expectedDigest = verifyHookSourceIntegrity(hook, report); let effectiveMode: HookInstallMode = request.mode; if (request.mode === "symlink") { @@ -185,6 +215,10 @@ async function installOrSyncTarget(repoRoot: string, request: HookInstallRequest await copyPath(hook.sourcePath, destination); } + if (effectiveMode === "copy") { + verifyInstalledHookIntegrity(destination, expectedDigest); + } + const managed: ManagedHookState = { name: hook.name, hookName: hook.hookName, @@ -192,6 +226,7 @@ async function installOrSyncTarget(repoRoot: string, request: HookInstallRequest sourceId: hook.sourceId, sourceUrl: hook.sourceUrl, sourceRevision: catalog.sources.find((source) => source.id === hook.sourceId)?.revision, + sourceContentDigest: expectedDigest, orphaned: false, installMode: request.mode, effectiveMode, diff --git a/src/installer-core/hookState.ts b/src/installer-core/hookState.ts index 593435a..890a0b1 100644 --- a/src/installer-core/hookState.ts +++ b/src/installer-core/hookState.ts @@ -10,6 +10,7 @@ export interface ManagedHookState { sourceId: string; sourceUrl: string; sourceRevision?: string; + sourceContentDigest?: string; orphaned?: boolean; installMode: "symlink" | "copy"; effectiveMode: "symlink" | "copy"; diff --git a/src/installer-core/hookSync.ts b/src/installer-core/hookSync.ts index 15a4dee..2299378 100644 --- a/src/installer-core/hookSync.ts +++ b/src/installer-core/hookSync.ts @@ -50,6 +50,33 @@ async function runGitWithLockRetry(args: string[], cwd?: string, maxAttempts = 5 throw new Error("git command failed unexpectedly"); } +async function hasRemoteBranch(repoPath: string, branch: string): Promise { + try { + await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], repoPath); + return true; + } catch { + return false; + } +} + +async function ensureOriginFetchRefspec(repoPath: string): Promise { + const wildcardRefspec = "+refs/heads/*:refs/remotes/origin/*"; + const configured = await runGit(["config", "--get-all", "remote.origin.fetch"], repoPath).catch(() => ""); + const current = configured + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); + + if (current.length === 1 && current[0] === wildcardRefspec) { + return; + } + + if (current.length > 0) { + await runGitWithLockRetry(["config", "--unset-all", "remote.origin.fetch"], repoPath); + } + await runGitWithLockRetry(["config", "--add", "remote.origin.fetch", wildcardRefspec], repoPath); +} + async function withHookSourceSyncLock(sourceId: string, task: () => Promise): Promise { const previous = hookSourceSyncLocks.get(sourceId) || Promise.resolve(); let releaseLock = () => {}; @@ -71,6 +98,16 @@ async function withHookSourceSyncLock(sourceId: string, task: () => Promise { + try { + const symRef = await runGit(["ls-remote", "--symref", "origin", "HEAD"], repoPath); + const match = symRef.match(/ref:\s+refs\/heads\/([^\s]+)\s+HEAD/i); + if (match?.[1]) { + return match[1]; + } + } catch { + // Fall through to local remote-tracking refs. + } + try { const value = await runGit(["rev-parse", "--abbrev-ref", "origin/HEAD"], repoPath); if (value.startsWith("origin/")) { @@ -80,12 +117,23 @@ async function detectDefaultBranch(repoPath: string): Promise { // Fall through to common defaults. } - try { - await runGit(["show-ref", "--verify", "--quiet", "refs/remotes/origin/main"], repoPath); - return "main"; - } catch { - return "master"; + const preferred = ["main", "master"]; + for (const branch of preferred) { + if (await hasRemoteBranch(repoPath, branch)) { + return branch; + } + } + + const discovered = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"], repoPath).catch(() => ""); + for (const ref of discovered.split(/\r?\n/)) { + const trimmed = ref.trim(); + if (!trimmed || trimmed === "origin/HEAD" || !trimmed.startsWith("origin/")) { + continue; + } + return trimmed.slice("origin/".length); } + + throw new Error("Unable to determine hook source default branch from origin."); } async function setOriginUrl(repoPath: string, repoUrl: string): Promise { @@ -162,15 +210,18 @@ export async function syncHookSource(source: HookSource, credentials: Credential try { if (!hasGitRepo) { await runGit(["clone", "--depth", "1", remoteUrl, repoPath], sourceRoot); + await ensureOriginFetchRefspec(repoPath); + await runGit(["fetch", "origin", "--prune"], repoPath); await setOriginUrl(repoPath, plainRemote); } else { await setOriginUrl(repoPath, remoteUrl); - await runGit(["fetch", "--all", "--prune"], repoPath); + await ensureOriginFetchRefspec(repoPath); + await runGit(["fetch", "origin", "--prune"], repoPath); await setOriginUrl(repoPath, plainRemote); } const branch = await detectDefaultBranch(repoPath); - await runGit(["checkout", "-f", branch], repoPath); + await runGit(["checkout", "-f", "-B", branch, `origin/${branch}`], repoPath); await runGit(["reset", "--hard", `origin/${branch}`], repoPath); const revision = await runGit(["rev-parse", "HEAD"], repoPath); const hooksPath = await mirrorHooksToStateStore(source, repoPath); diff --git a/src/installer-core/sourceRefresh.ts b/src/installer-core/sourceRefresh.ts new file mode 100644 index 0000000..20841d5 --- /dev/null +++ b/src/installer-core/sourceRefresh.ts @@ -0,0 +1,77 @@ +import { CredentialProvider } from "./credentials"; +import { HookSource } from "./hookSources"; +import { HookSourceSyncResult } from "./hookSync"; +import { SkillSource } from "./types"; +import { SourceSyncResult } from "./sourceSync"; +import { safeErrorMessage } from "./security"; + +export interface RefreshEntryResult { + sourceId: string; + skills?: { revision?: string; localPath?: string; error?: string }; + hooks?: { revision?: string; localPath?: string; error?: string }; +} + +interface RefreshDependencies { + credentials: CredentialProvider; + loadSources: () => Promise; + loadHookSources: () => Promise; + syncSource: (source: SkillSource, credentials: CredentialProvider) => Promise; + syncHookSource: (source: HookSource, credentials: CredentialProvider) => Promise; +} + +function createSourceMap(skillSources: SkillSource[], hookSources: HookSource[]): Map { + const byId = new Map(); + for (const source of skillSources) { + byId.set(source.id, { ...(byId.get(source.id) || {}), skills: source }); + } + for (const source of hookSources) { + byId.set(source.id, { ...(byId.get(source.id) || {}), hooks: source }); + } + return byId; +} + +export async function refreshSourcesAndHooks( + deps: RefreshDependencies, + options: { sourceId?: string; onlyEnabled?: boolean } = {}, +): Promise<{ refreshed: RefreshEntryResult[]; matched: boolean }> { + const onlyEnabled = options.onlyEnabled !== false; + const allSkills = await deps.loadSources(); + const allHooks = await deps.loadHookSources(); + const skillSources = onlyEnabled ? allSkills.filter((source) => source.enabled) : allSkills; + const hookSources = onlyEnabled ? allHooks.filter((source) => source.enabled) : allHooks; + const byId = createSourceMap(skillSources, hookSources); + + const sourceId = options.sourceId?.trim(); + const targetEntries: Array<[string, { skills?: SkillSource; hooks?: HookSource } | undefined]> = sourceId + ? [[sourceId, byId.get(sourceId)]] + : Array.from(byId.entries()); + const refreshed: RefreshEntryResult[] = []; + for (const [id, entry] of targetEntries) { + if (!entry) { + continue; + } + const result: RefreshEntryResult = { sourceId: id }; + if (entry.skills) { + try { + const sync = await deps.syncSource(entry.skills, deps.credentials); + result.skills = { revision: sync.revision, localPath: sync.localPath }; + } catch (error) { + result.skills = { error: safeErrorMessage(error, "Skill source refresh failed.") }; + } + } + if (entry.hooks) { + try { + const sync = await deps.syncHookSource(entry.hooks, deps.credentials); + result.hooks = { revision: sync.revision, localPath: sync.localPath }; + } catch (error) { + result.hooks = { error: safeErrorMessage(error, "Hook source refresh failed.") }; + } + } + refreshed.push(result); + } + + return { + refreshed, + matched: sourceId ? refreshed.length > 0 : refreshed.length > 0 || byId.size === 0, + }; +} diff --git a/src/installer-core/sourceSync.ts b/src/installer-core/sourceSync.ts index 6a8ae5c..7a352de 100644 --- a/src/installer-core/sourceSync.ts +++ b/src/installer-core/sourceSync.ts @@ -51,6 +51,33 @@ async function runGitWithLockRetry(args: string[], cwd?: string, maxAttempts = 5 throw new Error("git command failed unexpectedly"); } +async function hasRemoteBranch(repoPath: string, branch: string): Promise { + try { + await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], repoPath); + return true; + } catch { + return false; + } +} + +async function ensureOriginFetchRefspec(repoPath: string): Promise { + const wildcardRefspec = "+refs/heads/*:refs/remotes/origin/*"; + const configured = await runGit(["config", "--get-all", "remote.origin.fetch"], repoPath).catch(() => ""); + const current = configured + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); + + if (current.length === 1 && current[0] === wildcardRefspec) { + return; + } + + if (current.length > 0) { + await runGitWithLockRetry(["config", "--unset-all", "remote.origin.fetch"], repoPath); + } + await runGitWithLockRetry(["config", "--add", "remote.origin.fetch", wildcardRefspec], repoPath); +} + async function withSourceSyncLock(sourceId: string, task: () => Promise): Promise { const previous = sourceSyncLocks.get(sourceId) || Promise.resolve(); let releaseLock = () => {}; @@ -72,6 +99,16 @@ async function withSourceSyncLock(sourceId: string, task: () => Promise): } async function detectDefaultBranch(repoPath: string): Promise { + try { + const symRef = await runGit(["ls-remote", "--symref", "origin", "HEAD"], repoPath); + const match = symRef.match(/ref:\s+refs\/heads\/([^\s]+)\s+HEAD/i); + if (match?.[1]) { + return match[1]; + } + } catch { + // Fall through to local remote-tracking refs. + } + try { const value = await runGit(["rev-parse", "--abbrev-ref", "origin/HEAD"], repoPath); if (value.startsWith("origin/")) { @@ -81,12 +118,23 @@ async function detectDefaultBranch(repoPath: string): Promise { // Fall through to common defaults. } - try { - await runGit(["show-ref", "--verify", "--quiet", "refs/remotes/origin/main"], repoPath); - return "main"; - } catch { - return "master"; + const preferred = ["main", "master"]; + for (const branch of preferred) { + if (await hasRemoteBranch(repoPath, branch)) { + return branch; + } + } + + const discovered = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"], repoPath).catch(() => ""); + for (const ref of discovered.split(/\r?\n/)) { + const trimmed = ref.trim(); + if (!trimmed || trimmed === "origin/HEAD" || !trimmed.startsWith("origin/")) { + continue; + } + return trimmed.slice("origin/".length); } + + throw new Error("Unable to determine source default branch from origin."); } async function setOriginUrl(repoPath: string, repoUrl: string): Promise { @@ -155,15 +203,18 @@ export async function syncSource(source: SkillSource, credentials: CredentialPro try { if (!hasGitRepo) { await runGit(["clone", "--depth", "1", remoteUrl, repoPath], sourceRoot); + await ensureOriginFetchRefspec(repoPath); + await runGit(["fetch", "origin", "--prune"], repoPath); await setOriginUrl(repoPath, plainRemote); } else { await setOriginUrl(repoPath, remoteUrl); - await runGit(["fetch", "--all", "--prune"], repoPath); + await ensureOriginFetchRefspec(repoPath); + await runGit(["fetch", "origin", "--prune"], repoPath); await setOriginUrl(repoPath, plainRemote); } const branch = await detectDefaultBranch(repoPath); - await runGit(["checkout", "-f", branch], repoPath); + await runGit(["checkout", "-f", "-B", branch, `origin/${branch}`], repoPath); await runGit(["reset", "--hard", `origin/${branch}`], repoPath); const revision = await runGit(["rev-parse", "HEAD"], repoPath); const skillsPath = await mirrorSkillsToStateStore(source, repoPath); diff --git a/src/installer-core/types.ts b/src/installer-core/types.ts index 7279753..cf0cc17 100644 --- a/src/installer-core/types.ts +++ b/src/installer-core/types.ts @@ -50,6 +50,8 @@ export interface CatalogSkill { sourcePath: string; version?: string; updatedAt?: string; + contentDigest?: string; + contentFileCount?: number; } export interface SkillCatalog { @@ -99,6 +101,7 @@ export interface ManagedSkillState { sourceId: string; sourceUrl: string; sourceRevision?: string; + sourceContentDigest?: string; orphaned?: boolean; installMode: InstallMode; effectiveMode: InstallMode; diff --git a/src/installer-core/updateCheck.ts b/src/installer-core/updateCheck.ts new file mode 100644 index 0000000..9b0cf41 --- /dev/null +++ b/src/installer-core/updateCheck.ts @@ -0,0 +1,97 @@ +import { safeErrorMessage } from "./security"; + +export interface AppUpdateStatus { + currentVersion: string; + latestVersion?: string; + latestReleaseUrl?: string; + checkedAt: string; + updateAvailable: boolean; + error?: string; +} + +interface CachedStatus { + status: AppUpdateStatus; + expiresAtMs: number; +} + +const updateCache = new Map(); + +function parseSemver(value: string): [number, number, number] | null { + const match = value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + if (!match) return null; + return [Number.parseInt(match[1], 10), Number.parseInt(match[2], 10), Number.parseInt(match[3], 10)]; +} + +export function isVersionNewer(candidate: string, current: string): boolean { + const a = parseSemver(candidate); + const b = parseSemver(current); + if (!a || !b) { + return candidate.trim() !== current.trim(); + } + if (a[0] !== b[0]) return a[0] > b[0]; + if (a[1] !== b[1]) return a[1] > b[1]; + return a[2] > b[2]; +} + +export async function fetchLatestGithubRelease( + repo = process.env.ICA_RELEASE_REPO || "intelligentcode-ai/intelligent-code-agents", + timeoutMs = Number(process.env.ICA_RELEASE_CHECK_TIMEOUT_MS || "1200"), +): Promise<{ version: string; url?: string }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs)); + try { + const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "ica-installer", + }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`GitHub releases API returned ${response.status}.`); + } + const payload = (await response.json()) as { tag_name?: string; html_url?: string; name?: string }; + const rawVersion = String(payload.tag_name || payload.name || "").trim(); + if (!rawVersion) { + throw new Error("GitHub releases API response did not include a tag name."); + } + return { + version: rawVersion.replace(/^v/i, ""), + url: payload.html_url, + }; + } finally { + clearTimeout(timer); + } +} + +export async function checkForAppUpdate(currentVersion: string, force = false): Promise { + const cacheKey = currentVersion.trim() || "unknown"; + const ttlMs = Math.max(60_000, Number(process.env.ICA_RELEASE_CHECK_CACHE_MS || `${10 * 60 * 1000}`)); + const nowMs = Date.now(); + const cached = updateCache.get(cacheKey); + if (!force && cached && cached.expiresAtMs > nowMs) { + return cached.status; + } + + try { + const latest = await fetchLatestGithubRelease(); + const status: AppUpdateStatus = { + currentVersion, + latestVersion: latest.version, + latestReleaseUrl: latest.url, + checkedAt: new Date(nowMs).toISOString(), + updateAvailable: isVersionNewer(latest.version, currentVersion), + }; + updateCache.set(cacheKey, { status, expiresAtMs: nowMs + ttlMs }); + return status; + } catch (error) { + const status: AppUpdateStatus = { + currentVersion, + checkedAt: new Date(nowMs).toISOString(), + updateAvailable: false, + error: safeErrorMessage(error, "Unable to check for updates."), + }; + updateCache.set(cacheKey, { status, expiresAtMs: nowMs + ttlMs }); + return status; + } +} diff --git a/src/installer-dashboard/web/src/InstallerDashboard.tsx b/src/installer-dashboard/web/src/InstallerDashboard.tsx index 87c7540..9c7e74e 100644 --- a/src/installer-dashboard/web/src/InstallerDashboard.tsx +++ b/src/installer-dashboard/web/src/InstallerDashboard.tsx @@ -39,6 +39,8 @@ type Skill = { resources: Array<{ type: string; path: string }>; version?: string; updatedAt?: string; + contentDigest?: string; + contentFileCount?: number; }; type InstallationSkill = { @@ -132,7 +134,24 @@ type DashboardMode = "light" | "dark"; type DashboardAccent = "slate" | "blue" | "red" | "green" | "amber"; type DashboardBackground = "slate" | "ocean" | "sand" | "forest" | "wine"; type LegacyDashboardTheme = "light" | "dark" | "blue" | "red" | "green"; -type HealthPayload = { version?: string; error?: string }; +type HealthPayload = { + version?: string; + update?: { + currentVersion?: string; + latestVersion?: string; + latestReleaseUrl?: string; + checkedAt?: string; + updateAvailable?: boolean; + error?: string; + }; + error?: string; +}; +type SkillChangeNotice = { + added: number; + removed: number; + changed: number; + checkedAt: string; +}; const allTargets: Target[] = ["claude", "codex", "cursor", "gemini", "antigravity"]; const modeStorageKey = "ica.dashboard.mode"; @@ -157,6 +176,9 @@ const backgroundOptions: Array<{ id: DashboardBackground; label: string }> = [ { id: "forest", label: "Forest" }, { id: "wine", label: "Wine" }, ]; +const rawUpdateCheckMinutes = + Number((import.meta as { env?: Record }).env?.VITE_ICA_UPDATE_CHECK_MINUTES || "60"); +const updateCheckMinutes = Number.isFinite(rawUpdateCheckMinutes) && rawUpdateCheckMinutes > 0 ? Math.floor(rawUpdateCheckMinutes) : 60; function isDashboardMode(value: string | null): value is DashboardMode { return value === "light" || value === "dark"; @@ -230,6 +252,15 @@ function titleCase(value: string): string { .replace(/\b[a-z]/g, (match) => match.toUpperCase()); } +function buildSkillSnapshot(items: Skill[]): Map { + const snapshot = new Map(); + for (const skill of items) { + const key = `${skill.version || ""}|${skill.contentDigest || ""}|${skill.updatedAt || ""}`; + snapshot.set(skill.skillId, key); + } + return snapshot; +} + export function InstallerDashboard(): JSX.Element { const [sources, setSources] = useState([]); const [skills, setSkills] = useState([]); @@ -251,6 +282,9 @@ export function InstallerDashboard(): JSX.Element { const [startupWarnings, setStartupWarnings] = useState([]); const [liveStatus, setLiveStatus] = useState("http-only"); const [appVersion, setAppVersion] = useState("unknown"); + const [appUpdate, setAppUpdate] = useState(null); + const [updateChecking, setUpdateChecking] = useState(false); + const [skillChangeNotice, setSkillChangeNotice] = useState(null); const [apiReachable, setApiReachable] = useState(false); const [startupRunId, setStartupRunId] = useState(0); const [catalogLoading, setCatalogLoading] = useState(false); @@ -279,6 +313,8 @@ export function InstallerDashboard(): JSX.Element { const appearanceTriggerRef = useRef(null); const refreshInstallationsRef = useRef<() => Promise>(async () => undefined); const refreshCatalogRef = useRef<() => Promise>(async () => undefined); + const updateCheckRef = useRef<() => Promise>(async () => undefined); + const skillSnapshotRef = useRef | null>(null); const selectedTargetList = useMemo(() => Array.from(targets).sort(), [targets]); const selectedHookTargetList = useMemo( @@ -454,7 +490,40 @@ export function InstallerDashboard(): JSX.Element { throw new Error(asErrorMessage(payload, "Failed to load skills catalog.")); } setCatalogLoadingProgress(88); - setSkills(Array.isArray(payload.skills) ? payload.skills : []); + const nextSkills = Array.isArray(payload.skills) ? payload.skills : []; + setSkills(nextSkills); + const nextSnapshot = buildSkillSnapshot(nextSkills); + const previousSnapshot = skillSnapshotRef.current; + if (previousSnapshot && previousSnapshot.size > 0) { + let added = 0; + let removed = 0; + let changed = 0; + + for (const skillId of nextSnapshot.keys()) { + if (!previousSnapshot.has(skillId)) { + added += 1; + continue; + } + if (previousSnapshot.get(skillId) !== nextSnapshot.get(skillId)) { + changed += 1; + } + } + for (const skillId of previousSnapshot.keys()) { + if (!nextSnapshot.has(skillId)) { + removed += 1; + } + } + + if (added > 0 || removed > 0 || changed > 0) { + setSkillChangeNotice({ + added, + removed, + changed, + checkedAt: new Date().toISOString(), + }); + } + } + skillSnapshotRef.current = nextSnapshot; if (payload.stale) { const source = payload.catalogSource || "fallback"; setCatalogWarning(payload.staleReason || `Catalog is currently served from ${source}.`); @@ -506,6 +575,7 @@ export function InstallerDashboard(): JSX.Element { const version = typeof payload.version === "string" && payload.version.trim() ? payload.version.trim() : "unknown"; setApiReachable(true); setAppVersion(version); + setAppUpdate(payload.update && typeof payload.update === "object" ? payload.update : null); } async function fetchInstallations(): Promise { @@ -562,6 +632,10 @@ export function InstallerDashboard(): JSX.Element { await Promise.all([fetchSkills(), fetchHooks()]); }; + updateCheckRef.current = async () => { + await runUpdateCheck(); + }; + function setSkillsSelection(skillIds: string[], shouldSelect: boolean): void { setSelectionCustomized(true); setSelectedSkills((current) => { @@ -771,11 +845,39 @@ export function InstallerDashboard(): JSX.Element { } } + async function runUpdateCheck(): Promise { + if (updateChecking) { + return; + } + setUpdateChecking(true); + setError(""); + try { + const res = await apiFetch("/api/v1/sources/refresh-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const payload = await res.json(); + if (!res.ok) { + throw new Error(asErrorMessage(payload, "Source refresh failed.")); + } + await Promise.all([fetchApiVersion(), fetchSources(), fetchSkills(), fetchHooks(), fetchInstallations(), fetchHookInstallations()]); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setUpdateChecking(false); + } + } + async function refreshSource(sourceId?: string): Promise { + if (!sourceId) { + await runUpdateCheck(); + return; + } setBusy(true); setError(""); try { - const endpoint = sourceId ? `/api/v1/sources/${sourceId}/refresh` : "/api/v1/sources/refresh-all"; + const endpoint = `/api/v1/sources/${sourceId}/refresh`; const res = await apiFetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -914,6 +1016,20 @@ export function InstallerDashboard(): JSX.Element { }; }, []); + useEffect(() => { + if (!apiReachable || updateCheckMinutes <= 0) { + return; + } + const timer = window.setInterval(() => { + void updateCheckRef.current().catch((err) => + addStartupWarning(`Scheduled update check: ${err instanceof Error ? err.message : String(err)}`), + ); + }, updateCheckMinutes * 60 * 1000); + return () => { + window.clearInterval(timer); + }; + }, [apiReachable]); + useEffect(() => { if (selectionCustomized) return; setSelectedSkills(new Set(installedSkillIds)); @@ -1073,6 +1189,44 @@ export function InstallerDashboard(): JSX.Element { Startup warnings: {startupWarnings.join(" | ")} )} + {!apiUnavailable && appUpdate?.updateAvailable && ( +
+
+ New ICA app version available + + Current v{appVersion} • Latest v{appUpdate.latestVersion} + +
+
+ {appUpdate.latestReleaseUrl && ( + + View release + + )} + +
+
+ )} + {!apiUnavailable && skillChangeNotice && ( +
+
+ Skills repository changed + + +{skillChangeNotice.added} added • -{skillChangeNotice.removed} removed • {skillChangeNotice.changed} updated + +
+
+ + +
+
+ )} {catalogLoading && (
@@ -1126,6 +1280,9 @@ export function InstallerDashboard(): JSX.Element {
+