/** * service.ts — the HTTP entry point into the daemon, as a pure Web Standard * handler: (Request) → Response. Bun.serve wraps it for the network; * tests call it in-process. Same port, two adapters — Cockburn's symmetry. */ import { existsSync as fileExistsSync } from "node:fs"; import { VehicleRegistry } from "@danypops/vehicle-server"; import { createVehicleMetricsMiddleware } from "@danypops/vehicle-server/metrics-middleware"; import { registerVehicleMetricsOperations } from "@danypops/vehicle-server/metrics-operations"; import type { VehicleMetricsStore } from "@danypops/vehicle-server/metrics"; import { createVehicleHttpApp } from "@danypops/vehicle-server/http"; import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http"; import type { ServiceSpec } from "@danypops/vehicle-server/service"; import { registerPackedVehicleOperations } from "./vehicle-registration.ts"; import { type AdvisoryReport, resolveInstalledVersions, scanInstalledPackages } from "../adoption/advisories.ts"; import { type CheckReport, type PackageChecker, StaticPackageChecker } from "../adoption/check.ts"; import { type DoctorReport, runDoctor } from "../adoption/doctor.ts"; import type { ModuleFreshnessDiagnostic } from "../adoption/module-freshness.ts"; import { NpmPackVerifier, type PackReport } from "../adoption/pack.ts"; import { type AdoptionReport, scoreTarget } from "../adoption/score.ts"; import { buildIndex, indexPath, type PackageIndex, readIndex, writeIndex } from "../index/build-index.ts"; import { syncCatalog } from "../packages/catalog.ts"; import { updateManyPackages } from "../packages/install.ts"; import { catalogList, dbPath, getSyncMeta, latestVersion, openDb, searchLocal } from "../packages/db.ts"; import { defaultPiHome, npmPackageName, readInstalledPackagesAcrossScopes, splitNpmSource } from "../packages/installed.ts"; import type { InstalledPkg, Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome, UpdatesSnapshot } from "../packages/package.ts"; import { buildSearchQuery, clampLimit } from "../packages/package.ts"; import { listPackageResources, type PackageResources, RESOURCE_FIELDS, type ResourceField, resolveInstalledDir, resolveToggleSettingsPath, toggleResource, } from "../packages/resources.ts"; import { checkPiVersion, type PiVersionReport } from "../pi/pi-version.ts"; import { assertPackagePermission, type MutationApproval, PackageApprovalRequiredError, type PackageOperation, readSecuritySettings, writeSecuritySettings, } from "../security/security.ts"; import { type SetupApplyResult, type SetupExportReport, SetupManager, type SetupPlan, type SetupUpdateReport } from "../setup/setup.ts"; import { TTLCache } from "../shared/cache.ts"; import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "../shared/constants.ts"; import { createLogger } from "../shared/log.ts"; import { VERSION } from "../shared/version.ts"; import { formatCleanupSummary, runCleanup } from "./cleanup.ts"; import { classifyUpdateSource, type DaemonServiceInstaller, listManagedPackages, type ReconcileAllResult, reconcileAllDaemonServices, RealDaemonServiceInstaller, } from "./daemon-service.ts"; import { checkUpdates, loadUpdates } from "./watcher.ts"; const log = createLogger("service"); export interface Deps { reg: Registry; inst: Installer; token: string; stateDir: string; dataDir?: string; piHome?: string; cache?: TTLCache; checker?: PackageChecker; packer?: { verify(path: string): Promise }; scorer?: { score(target: string): Promise }; setup?: { export(projectRoot: string, options?: { force?: boolean; machineLocal?: boolean }): Promise; update(manifestPath: string): Promise; plan(manifestPath: string, options?: { prune?: boolean }): Promise; apply(manifestPath: string, options?: { prune?: boolean }): Promise; }; daemonServiceInstaller?: DaemonServiceInstaller; /** * Re-checks this exact running process's own captured startup snapshot * of its runtime dependencies against their current on-disk state -- * see module-freshness.ts. Sync (a handful of small file stats/reads), * injected by daemon.ts once at process start; undefined for any Deps * built without a real running process behind it (a standalone `packed * doctor` never has a snapshot to compare against at all). */ moduleFreshness?: () => ModuleFreshnessDiagnostic[]; piVersion?: { check(options?: { timeoutMs?: number }): Promise }; advisories?: { scan(installed: Record): Promise }; /** Tool/operation usage metrics store -- see the vehicleRegistry construction below. Opened/closed by daemon.ts. */ vehicleMetrics?: VehicleMetricsStore; } export type OperationName = | "package.search" | "package.info" | "package.installed" | "package.catalog" | "package.catalog.sync" | "package.index" | "package.index.build" | "package.updates" | "package.check" | "package.pack" | "package.score" | "setup.export" | "setup.update" | "setup.plan" | "setup.apply" | "package.security.get" | "package.security.set" | "package.install" | "package.install_service" | "package.restart_service" | "package.reconcile_services" | "package.remove" | "package.update" | "package.update_all" | "resources.list" | "resources.toggle" | "pi.status" | "advisories.scan" | "doctor.run" | "package.updates.project"; export interface OperationInputs { "package.search": { query: string; limit: number; offline?: boolean }; "package.info": { name: string }; "package.installed": Record; "package.catalog": Record; "package.catalog.sync": Record; "package.index": Record; "package.index.build": Record; "package.updates": Record; "package.check": { path: string; smoke?: boolean }; "package.pack": { path: string }; "package.score": { target: string }; "setup.export": { projectRoot: string; force?: boolean; machineLocal?: boolean }; "setup.update": { manifestPath: string }; "setup.plan": { manifestPath: string; prune?: boolean }; "setup.apply": { manifestPath: string; approved?: boolean; prune?: boolean }; "package.security.get": Record; "package.security.set": { mutationApproval: MutationApproval; approved?: boolean }; "package.install": { source: string; approved?: boolean }; "package.install_service": { source: string; approved?: boolean }; "package.restart_service": { source: string; approved?: boolean }; "package.reconcile_services": { approved?: boolean; projectRoot?: string }; "package.remove": { name: string; approved?: boolean }; "package.update": { source: string; approved?: boolean; target?: string }; "package.update_all": { sources?: string[]; approved?: boolean }; "resources.list": { projectRoot?: string }; "resources.toggle": { source: string; field: ResourceField; path: string; enabled: boolean; projectRoot?: string; approved?: boolean }; "pi.status": Record; "advisories.scan": { name?: string }; "doctor.run": { projectRoot?: string }; "package.updates.project": { projectRoot: string }; } interface MutationResponse { ok: boolean; output: string; } interface UpdateMutationResponse extends MutationResponse, Partial> { packageUpdated?: boolean; serviceReconciled?: boolean; } interface UpdateAllMutationResponse extends MutationResponse { results: Array<{ source: string; ok: boolean; output: string; serviceReconciled?: boolean } & Partial>>; reresolveError?: string; } interface InstallServiceResponse { ok: boolean; output: string; spec?: Pick; notADaemon?: boolean; } interface RestartServiceResponse extends InstallServiceResponse { restarted?: boolean; } interface ReconcileServicesResponse extends MutationResponse, ReconcileAllResult {} export interface OperationOutputs { "package.search": { query: string; total: number; results: SearchPage["results"]; offline?: boolean }; "package.info": PkgInfo; "package.installed": InstalledPkg[]; "package.catalog": { fetchedAt?: string; sha256?: string; packages: Pkg[] }; "package.catalog.sync": { synced: number }; "package.index": PackageIndex | undefined; "package.index.build": PackageIndex; "package.updates": UpdatesSnapshot; "package.check": CheckReport; "package.pack": PackReport; "package.score": AdoptionReport; "setup.export": SetupExportReport; "setup.update": SetupUpdateReport; "setup.plan": SetupPlan; "setup.apply": SetupApplyResult; "package.security.get": { mutationApproval: MutationApproval }; "package.security.set": { mutationApproval: MutationApproval }; "package.install": MutationResponse; "package.install_service": InstallServiceResponse; "package.restart_service": RestartServiceResponse; "package.reconcile_services": ReconcileServicesResponse; "package.remove": MutationResponse; "package.update": UpdateMutationResponse; "package.update_all": UpdateAllMutationResponse; "resources.list": { global: PackageResources[]; project: PackageResources[] }; "resources.toggle": MutationResponse; "pi.status": PiVersionReport; "advisories.scan": AdvisoryReport; "doctor.run": DoctorReport; "package.updates.project": UpdatesSnapshot; } export const OPERATION_NAMES: readonly OperationName[] = [ "package.search", "package.info", "package.installed", "package.catalog", "package.catalog.sync", "package.index", "package.index.build", "package.updates", "package.check", "package.pack", "package.score", "setup.export", "setup.update", "setup.plan", "setup.apply", "package.security.get", "package.security.set", "package.install", "package.install_service", "package.restart_service", "package.reconcile_services", "package.remove", "package.update", "package.update_all", "resources.list", "resources.toggle", "pi.status", "advisories.scan", "doctor.run", "package.updates.project", ]; class PackageOperationError extends Error { constructor( message: string, readonly status: number, ) { super(message); } } const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/; const NAME_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/; function json(v: unknown, init?: ResponseInit): Response { return jsonResponse(v, init); } function err(status: number, msg: string, details: Record = {}): Response { return jsonResponse({ error: msg, ...details }, { status }); } function pickSpec(spec: ServiceSpec): Pick { return { name: spec.name, binPath: spec.binPath }; } export function createApp(deps: Deps): { fetch: (req: Request) => Promise } { const cache = deps.cache ?? new TTLCache(); const dataDir = deps.dataDir ?? deps.stateDir; const checker = deps.checker ?? new StaticPackageChecker(); const packer = deps.packer ?? new NpmPackVerifier(); const setup = deps.setup ?? new SetupManager(deps.reg, deps.inst, deps.piHome ?? defaultPiHome()); const daemonServiceInstaller = deps.daemonServiceInstaller ?? new RealDaemonServiceInstaller(); const piHomeForServiceInstall = deps.piHome ?? defaultPiHome(); // Additive, real Vehicle protocol surface for this daemon's full operation set -- served // at /vehicle/* alongside (not replacing) /api/v1/ops below. Every operation delegates to // the exact same executeOperation() this route's own /api/v1/ops handler calls. const vehicleRegistry = new VehicleRegistry({ name: "packed", packageJsonUrl: new URL("../../../package.json", import.meta.url), description: "Pi package lifecycle daemon", }); registerPackedVehicleOperations(vehicleRegistry, executeOperation); // Live from day one: the registry's own gate mirrors whatever mutationApproval already // is on disk at startup, then tracks every /security POST from here on (see below) -- // never a fixed per-deployment constant baked in once and forgotten. vehicleRegistry.configureApprovals({ enabled: readSecuritySettings(deps.stateDir).mutationApproval === "always" }); // Records how often each real operation is invoked (server-side, every caller) plus, via // metrics.recordClientEvent, client-observed Vehicle Shell meta-tool calls -- see // @danypops/vehicle-server's own metrics README section. The store itself is opened/closed by // daemon.ts (same lifecycle as the packages db), not here -- createApp() only wires it onto // this registry when given one, so a caller that omits vehicleMetrics (e.g. a unit test) is // unaffected. if (deps.vehicleMetrics) { vehicleRegistry.useExecutionMiddleware(createVehicleMetricsMiddleware(deps.vehicleMetrics, "packed")); registerVehicleMetricsOperations(vehicleRegistry, deps.vehicleMetrics, "packed"); } const permissions = [...new Set(vehicleRegistry.manifest().operations.flatMap((operation) => operation.permissions))]; const vehicleApp = createVehicleHttpApp({ registry: vehicleRegistry, token: deps.token, invocationAuthority: { mode: "attested", resolve: () => ({ permissions, principal: { id: "packed-authenticated-client" } }) }, }); function authorize(operation: PackageOperation, approved: boolean): Response | undefined { try { assertPackagePermission(readSecuritySettings(deps.stateDir), operation, approved); return undefined; } catch (error) { if (error instanceof PackageApprovalRequiredError) { return err(403, error.message, { code: error.code, operation: error.operation }); } throw error; } } async function route(req: Request): Promise { const url = new URL(req.url); const path = url.pathname; if (path === "/health" && req.method === "GET") return healthResponse(VERSION); if (path === "/ready" && req.method === "GET") return readyResponse(true); if (path === "/security" && req.method === "GET") { return json(readSecuritySettings(deps.stateDir)); } if (path === "/security" && req.method === "POST") { let body: { mutationApproval?: unknown; approved?: unknown }; try { body = (await req.json()) as typeof body; } catch { return err(400, "invalid security settings JSON"); } if (body.mutationApproval !== "always" && body.mutationApproval !== "never") { return err(400, "mutationApproval must be always or never"); } const denied = authorize("security.write", body.approved === true); if (denied) return denied; const mutationApproval = body.mutationApproval as MutationApproval; const written = await writeSecuritySettings(deps.stateDir, { mutationApproval }); // Same live-toggle guarantee the legacy /api/v1/ops transport already had via // readSecuritySettings() being re-read fresh on every request -- the Vehicle // transport's own gate is a stateful in-memory policy, so a change here has to // be pushed to it explicitly instead of being implicitly always-fresh. vehicleRegistry.updateApprovalPolicy({ enabled: mutationApproval === "always" }); return json(written); } if (path === "/search" && req.method === "GET") { const q = url.searchParams.get("q") ?? ""; const limit = clampLimit(Number(url.searchParams.get("limit")), SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT); // offline=1: serve from the SQLite mirror (apt-cache search analog) if (url.searchParams.get("offline") === "1") { const db = openDb(dbPath(dataDir)); try { const results = searchLocal(db, q, limit); return json({ query: q, total: results.length, results, offline: true }); } finally { db.close(); } } try { const { results, total } = await deps.reg.search(buildSearchQuery(q), limit); return json({ query: q, total, results }); } catch (e) { return err(502, e instanceof Error ? e.message : String(e)); } } if (path === "/info" && req.method === "GET") { const name = url.searchParams.get("name") ?? ""; if (!name) return err(400, "missing name"); try { const info = await deps.reg.info(name); // The one single-package, user-facing call site (pkg_info / the Find tab's "i" // inspector) -- deliberately NOT inside Registry.info() itself, which every bulk // caller (build-index.ts, score.ts, setup.ts, cli.ts, publish.ts) also shares. // build-index.ts's own doc comment documents a real incident: calling downloads() // per package at catalog scale (thousands of entries) hits npm's downloads API 429s // within minutes -- exactly what baking this into info() itself would reintroduce // for every bulk consumer. One package, on demand, interactively, is a fundamentally // different volume of call than a background catalog scan. Both enrichments are // individually tolerant of their own failure (same rule info()'s own README fetch // already follows) -- a slow or unavailable api.npmjs.org must never fail the whole // inspect. const [modified, downloads] = await Promise.all([ deps.reg.modifiedAt?.(name).catch(() => undefined), deps.reg.downloads?.(name).catch(() => undefined), ]); return json({ ...info, modified: modified ?? info.modified, downloads: downloads ?? info.downloads }); } catch (e) { return err(502, e instanceof Error ? e.message : String(e)); } } if (path === "/installed" && req.method === "GET") { return json(listManagedPackages(deps.piHome ?? defaultPiHome())); } if (path === "/remove" && req.method === "POST") { let name = ""; let approved = false; try { const body = (await req.json()) as { name?: unknown; approved?: unknown }; name = String(body.name ?? ""); approved = body.approved === true; } catch { /* fall through to validation */ } if (!NAME_RE.test(name)) { return err(400, "invalid name; want a bare npm package name"); } const denied = authorize("remove", approved); if (denied) return denied; // pi.cleanup is read and applied before delegating to pi remove -- // once pi remove finishes, an npm-sourced package's own directory // (and its manifest) may already be gone. const piHome = deps.piHome ?? defaultPiHome(); const installedDir = resolveInstalledDir(piHome, `npm:${name}`); let serviceRemoved = false; if (installedDir) { const service = await daemonServiceInstaller.remove(piHome, `npm:${name}`); if (!service.ok && !service.notADaemon) return json({ ok: false, name, output: service.reason }); if (service.ok) { if (!service.result.installed) return json({ ok: false, name, output: service.result.reason }); serviceRemoved = true; } } const cleanup = installedDir ? runCleanup(installedDir) : []; try { const output = await deps.inst.remove(`npm:${name}`, { approved }); return json({ ok: true, name, output: output + formatCleanupSummary(cleanup), serviceRemoved }); } catch (e) { const message = e instanceof Error ? e.message : String(e); return json({ ok: false, name, output: message + formatCleanupSummary(cleanup) }); } } if (path === "/install" && req.method === "POST") { let source = ""; let approved = false; try { const body = (await req.json()) as { source?: unknown; approved?: unknown }; source = String(body.source ?? ""); approved = body.approved === true; } catch { /* fall through to validation */ } if (!SOURCE_RE.test(source)) { return err(400, "invalid source; want npm:[@ver], git://[@ref], or https://…"); } const denied = authorize("install", approved); if (denied) return denied; try { const output = await deps.inst.install(source, { approved }); if (!source.startsWith("npm:")) return json({ ok: true, source, output }); const service = await daemonServiceInstaller.install(piHomeForServiceInstall, source); if (!service.ok) { if (service.notADaemon) return json({ ok: true, source, output }); return json({ ok: false, source, output: `${output}\n${service.reason}` }); } if (!service.result.installed) return json({ ok: false, source, output: `${output}\n${service.result.reason}` }); return json({ ok: true, source, output: `${output}\ninstalled persistent Vehicle ${service.spec.name}`, service: pickSpec(service.spec), }); } catch (e) { return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) }); } } if (path === "/install-service" && req.method === "POST") { let source = ""; let approved = false; try { const body = (await req.json()) as { source?: unknown; approved?: unknown }; source = String(body.source ?? ""); approved = body.approved === true; } catch { /* fall through to validation */ } if (!SOURCE_RE.test(source)) { return err(400, "invalid source; want npm:[@ver] -- daemon-service installation only supports npm sources today"); } const denied = authorize("install_service", approved); if (denied) return denied; const resolved = await daemonServiceInstaller.install(piHomeForServiceInstall, source); if (!resolved.ok) return json({ ok: false, output: resolved.reason, notADaemon: resolved.notADaemon }); if (!resolved.result.installed) return json({ ok: false, output: resolved.result.reason, spec: pickSpec(resolved.spec) }); return json({ ok: true, output: `installed a persistent service for ${resolved.spec.name}`, spec: pickSpec(resolved.spec) }); } if (path === "/restart-service" && req.method === "POST") { let source = ""; let approved = false; try { const body = (await req.json()) as { source?: unknown; approved?: unknown }; source = String(body.source ?? ""); approved = body.approved === true; } catch { /* fall through to validation */ } if (!SOURCE_RE.test(source)) { return err(400, "invalid source; want npm:[@ver] -- daemon-service restart only supports npm sources today"); } const denied = authorize("restart_service", approved); if (denied) return denied; const resolved = await daemonServiceInstaller.restart(piHomeForServiceInstall, source); if (!resolved.ok) return json({ ok: false, output: resolved.reason, notADaemon: resolved.notADaemon }); const output = resolved.restarted ? `restarted the persistent service for ${resolved.spec.name}` : (resolved.reason ?? `no restart needed for ${resolved.spec.name}`); return json({ ok: true, output, restarted: resolved.restarted, spec: pickSpec(resolved.spec) }); } if (path === "/reconcile-services" && req.method === "POST") { let approved = false; let projectRoot: string | undefined; try { const body = (await req.json()) as { approved?: unknown; projectRoot?: unknown }; approved = body.approved === true; projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : undefined; } catch { /* fall through -- approved stays false, projectRoot stays undefined */ } const denied = authorize("reconcile_services", approved); if (denied) return denied; const result = await reconcileAllDaemonServices(piHomeForServiceInstall, projectRoot, daemonServiceInstaller); const output = `reconciled ${result.reconciled.length} Vehicle(s), skipped ${result.skipped} non-daemon package(s)${result.failed.length > 0 ? `, ${result.failed.length} failure(s)` : ""}`; return json({ ok: result.failed.length === 0, output, ...result }); } if (path === "/update" && req.method === "POST") { let source = ""; let approved = false; let target: string | undefined; try { const body = (await req.json()) as { source?: unknown; approved?: unknown; target?: unknown }; source = String(body.source ?? ""); approved = body.approved === true; target = typeof body.target === "string" && body.target.length > 0 ? body.target : undefined; } catch { /* fall through to validation */ } if (!SOURCE_RE.test(source)) { return err(400, "invalid source; want a configured npm:, git:, or https package source"); } if (target !== undefined && !SOURCE_RE.test(target)) { return err(400, "invalid target; want a configured npm:, git:, or https package source"); } const denied = authorize("update", approved); if (denied) return denied; // A successful replace re-identifies the package under `target`, not // `source` -- a daemon-backed package's own service restart (below) // must reconcile against the NEW installed source, never the one that // was just removed. const restartSource = target ?? source; // A real Vehicle-shaped daemon dependency with no pi: manifest of its // own (e.g. @danypops/lector, pinned independently of its pi-lector // wrapper) can never be found by `pi update --extension` -- pi-core's // own "configured packages" notion IS settings.json's packages[]. // Route it through updateDaemonDependency() instead of ever shelling // to pi at all, rather than surfacing its raw, unhelpful "No matching // package found". See packed-package-update-restart-service-cant-manage. const kind = classifyUpdateSource(piHomeForServiceInstall, source); try { let outcome: UpdateOutcome; if (kind === "daemon-dependency") { if (!deps.inst.updateDaemonDependency) { return json({ ok: false, source, output: `${source} is a real Vehicle-shaped daemon dependency with no pi: manifest of its own -- this installer cannot update it directly`, reloadRequired: false, }); } const packageName = npmPackageName(source); if (!packageName) return err(400, "invalid source; want npm:[@ver]"); if (target !== undefined && npmPackageName(target) !== packageName) { return err(400, "target must be the same package as source for a daemon-dependency update"); } const [, targetVersion] = target ? splitNpmSource(target.slice(4)) : [undefined, undefined]; outcome = await deps.inst.updateDaemonDependency(packageName, { approved, version: targetVersion || undefined }); } else { outcome = await deps.inst.update(source, { approved, target }); } if (!restartSource.startsWith("npm:") || outcome.alreadyUpToDate) return json({ ok: true, source, ...outcome }); const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, restartSource); if (!service.ok) { if (service.notADaemon) return json({ ok: true, source, ...outcome }); return json({ ok: false, source, ...outcome, packageUpdated: !outcome.alreadyUpToDate, serviceReconciled: false, output: `${outcome.output}\nDaemon adoption required: ${service.reason}. Session reload alone is insufficient.` }); } if (!service.restarted) return json({ ok: false, source, ...outcome, packageUpdated: !outcome.alreadyUpToDate, serviceReconciled: false, output: `${outcome.output}\nDaemon adoption required for ${service.spec.name}: ${service.reason ?? "reconciliation incomplete"}. Use Packed's targeted install-service operation; session reload alone is insufficient.`, }); return json({ ok: true, source, ...outcome, serviceReconciled: true }); } catch (error) { return json({ ok: false, source, output: error instanceof Error ? error.message : String(error), reloadRequired: false }); } } if (path === "/update-all" && req.method === "POST") { let approved = false; let sources: string[] | undefined; try { const body = (await req.json()) as { approved?: unknown; sources?: unknown }; approved = body.approved === true; if (Array.isArray(body.sources)) sources = body.sources.filter((item): item is string => typeof item === "string"); } catch { /* fall through -- approved stays false, sources stays undefined (defaults to every stale package) */ } if (sources && sources.some((source) => !SOURCE_RE.test(source))) { return err(400, "invalid source; want a configured npm:, git:, or https package source"); } const denied = authorize("update_all", approved); if (denied) return denied; // Default: every currently-stale GLOBAL package the mirror already knows about -- same set // `packed updates` (no --project) reports. A caller wanting project-scoped sources too // passes them explicitly via body.sources; this endpoint never guesses project scope. if (!sources) { const snap = await loadUpdates(deps.stateDir); sources = (snap?.updates ?? []).map((entry) => `npm:${entry.name}`); } // Daemon-dependency sources (see classifyUpdateSource's own doc comment) still route through // updateOnly()/update() here, exactly like a bare `pi update --extension` would today -- a // known, non-regressive scope limit for this batch endpoint's first version, not a silent // misclassification: each such source simply reports its already-existing, honest // "No matching package found" failure as its own independent per-source outcome. const batchResult = await updateManyPackages(deps.inst, sources, { approved }); const results: UpdateAllMutationResponse["results"] = batchResult.outcomes.map((item) => item.status === "succeeded" && item.outcome ? { source: item.source, ok: true, ...item.outcome } : { source: item.source, ok: false, output: item.error ?? "update failed" }, ); // Best-effort per-source service-restart reconciliation, same as /update's own -- one // package's restart failing never fails the batch or blocks a sibling's own reconciliation. for (const result of results) { if (!result.ok || result.alreadyUpToDate || !result.source.startsWith("npm:")) continue; try { const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, result.source); if (service.ok) result.serviceReconciled = service.restarted; } catch { /* best-effort -- a restart failure never fails the batch */ } } const ok = results.every((result) => result.ok) && !batchResult.reresolveError; const output = `updated ${results.filter((result) => result.ok && !result.alreadyUpToDate).length}/${results.length} package(s)`; return json({ ok, output, results, ...(batchResult.reresolveError ? { reresolveError: batchResult.reresolveError } : {}) }); } if (path === "/updates" && req.method === "GET") { const snap = await loadUpdates(deps.stateDir); return json(snap ?? { updates: [] }); } if (path === "/catalog" && req.method === "GET") { const db = openDb(dbPath(dataDir)); try { const meta = getSyncMeta(db); return json({ fetchedAt: meta?.fetchedAt, sha256: meta?.sha256, packages: catalogList(db) }); } finally { db.close(); } } return err(404, "not found"); } async function executeOperation(op: Name, input: OperationInputs[Name]): Promise { if (op === "package.catalog.sync") return { synced: await syncCatalog(deps.reg, dataDir) } as OperationOutputs[Name]; if (op === "package.index") return readIndex(indexPath(dataDir)) as OperationOutputs[Name]; if (op === "package.index.build") { const index = await buildIndex(deps.reg, dataDir); await writeIndex(indexPath(dataDir), index); return index as OperationOutputs[Name]; } if (op === "package.check" || op === "package.pack") { const packagePath = (input as OperationInputs["package.check"] | OperationInputs["package.pack"]).path; if (typeof packagePath !== "string" || packagePath.length === 0 || packagePath.length > 4_096) throw new PackageOperationError("path must be a non-empty string up to 4096 characters", 400); if (op === "package.pack") return (await packer.verify(packagePath)) as OperationOutputs[Name]; return (await checker.check(packagePath, { smoke: (input as OperationInputs["package.check"]).smoke === true, })) as OperationOutputs[Name]; } if (op === "package.score") { const target = (input as OperationInputs["package.score"]).target; if (typeof target !== "string" || target.length === 0 || target.length > 4_096) throw new PackageOperationError("target must be a non-empty string up to 4096 characters", 400); return (deps.scorer ? await deps.scorer.score(target) : await scoreTarget(target, deps.reg, packer)) as OperationOutputs[Name]; } if (op === "setup.export") { const value = input as OperationInputs["setup.export"]; if (typeof value.projectRoot !== "string" || value.projectRoot.length === 0 || value.projectRoot.length > 4_096) throw new PackageOperationError("projectRoot must be a non-empty string up to 4096 characters", 400); return (await setup.export(value.projectRoot, { force: value.force === true, machineLocal: value.machineLocal === true, })) as OperationOutputs[Name]; } if (op === "setup.update" || op === "setup.plan" || op === "setup.apply") { const value = input as OperationInputs["setup.update"] | OperationInputs["setup.plan"] | OperationInputs["setup.apply"]; if (typeof value.manifestPath !== "string" || value.manifestPath.length === 0 || value.manifestPath.length > 4_096) throw new PackageOperationError("manifestPath must be a non-empty string up to 4096 characters", 400); if (op === "setup.update") return (await setup.update(value.manifestPath)) as OperationOutputs[Name]; if (op === "setup.apply") { const applyInput = input as OperationInputs["setup.apply"]; const denied = authorize("setup.apply", applyInput.approved === true); if (denied) throw new PackageOperationError(((await denied.json()) as { error: string }).error, denied.status); return (await setup.apply(value.manifestPath, { prune: applyInput.prune === true })) as OperationOutputs[Name]; } return (await setup.plan(value.manifestPath, { prune: (input as OperationInputs["setup.plan"]).prune === true, })) as OperationOutputs[Name]; } if (op === "pi.status") { return (deps.piVersion ? await deps.piVersion.check() : await checkPiVersion()) as OperationOutputs[Name]; } if (op === "advisories.scan") { const value = input as OperationInputs["advisories.scan"]; if (value.name !== undefined && (typeof value.name !== "string" || value.name.length === 0 || value.name.length > 214)) throw new PackageOperationError("name must be a non-empty string up to 214 characters", 400); const installed = resolveInstalledVersions(deps.piHome ?? defaultPiHome(), value.name); const scan = deps.advisories?.scan ?? scanInstalledPackages; return (await scan(installed)) as OperationOutputs[Name]; } if (op === "resources.list") { const value = input as OperationInputs["resources.list"]; if (value.projectRoot !== undefined && (typeof value.projectRoot !== "string" || value.projectRoot.length > 4_096)) throw new PackageOperationError("projectRoot must be a string up to 4096 characters", 400); return listPackageResources(deps.piHome ?? defaultPiHome(), value.projectRoot) as OperationOutputs[Name]; } if (op === "doctor.run") { const value = input as OperationInputs["doctor.run"]; if (value.projectRoot !== undefined && (typeof value.projectRoot !== "string" || value.projectRoot.length > 4_096)) throw new PackageOperationError("projectRoot must be a string up to 4096 characters", 400); const report = await runDoctor(deps.piHome ?? defaultPiHome(), value.projectRoot); const moduleFreshness = deps.moduleFreshness?.(); if (moduleFreshness === undefined) return report as OperationOutputs[Name]; const anyStale = moduleFreshness.some((diagnostic) => diagnostic.stale); return { ...report, moduleFreshness, ok: report.ok && !anyStale } as OperationOutputs[Name]; } if (op === "package.updates.project") { const value = input as OperationInputs["package.updates.project"]; if (typeof value.projectRoot !== "string" || value.projectRoot.length === 0 || value.projectRoot.length > 4_096) throw new PackageOperationError("projectRoot must be a non-empty string up to 4096 characters", 400); // Live, on-demand, cross-scope -- distinct from package.updates' own persisted, // global-only background snapshot (startWatcher), which has no project context. const db = openDb(dbPath(dataDir)); try { const installed = readInstalledPackagesAcrossScopes(deps.piHome ?? defaultPiHome(), value.projectRoot); const updates = checkUpdates((name) => latestVersion(db, name), installed); return { checkedAt: new Date().toISOString(), updates } as OperationOutputs[Name]; } finally { db.close(); } } if (op === "resources.toggle") { const value = input as OperationInputs["resources.toggle"]; if (typeof value.source !== "string" || value.source.length === 0 || value.source.length > 4_096) throw new PackageOperationError("source must be a non-empty string up to 4096 characters", 400); if (!RESOURCE_FIELDS.includes(value.field)) throw new PackageOperationError("field must be one of extensions, skills, prompts, themes", 400); if (typeof value.path !== "string" || value.path.length === 0 || value.path.length > 4_096 || value.path.includes("..")) throw new PackageOperationError("path must be a non-empty, non-escaping relative path up to 4096 characters", 400); const denied = authorize("resources.toggle", value.approved === true); if (denied) throw new PackageOperationError(((await denied.json()) as { error: string }).error, denied.status); const piHome = deps.piHome ?? defaultPiHome(); const settingsPath = resolveToggleSettingsPath(piHome, value.projectRoot); if (value.projectRoot && !fileExistsSync(settingsPath)) throw new PackageOperationError("no project settings file to toggle", 404); const result = await toggleResource({ settingsPath, source: value.source, field: value.field, path: value.path, enabled: value.enabled, }); return { ok: result.ok, output: result.ok ? `${value.enabled ? "enabled" : "disabled"} ${value.path}` : (result.error ?? "toggle failed"), } as OperationOutputs[Name]; } let path: string; let init: RequestInit = {}; switch (op) { case "package.search": { const value = input as OperationInputs["package.search"]; const params = new URLSearchParams({ q: value.query, limit: String(value.limit) }); if (value.offline) params.set("offline", "1"); path = `/search?${params}`; break; } case "package.info": path = `/info?name=${encodeURIComponent((input as OperationInputs["package.info"]).name)}`; break; case "package.installed": path = "/installed"; break; case "package.catalog": path = "/catalog"; break; case "package.updates": path = "/updates"; break; case "package.security.get": path = "/security"; break; case "package.security.set": path = "/security"; init = { method: "POST", body: JSON.stringify(input) }; break; case "package.install": path = "/install"; init = { method: "POST", body: JSON.stringify(input) }; break; case "package.install_service": path = "/install-service"; init = { method: "POST", body: JSON.stringify(input) }; break; case "package.restart_service": path = "/restart-service"; init = { method: "POST", body: JSON.stringify(input) }; break; case "package.reconcile_services": path = "/reconcile-services"; init = { method: "POST", body: JSON.stringify(input) }; break; case "package.remove": path = "/remove"; init = { method: "POST", body: JSON.stringify(input) }; break; case "package.update": path = "/update"; init = { method: "POST", body: JSON.stringify(input) }; break; case "package.update_all": path = "/update-all"; init = { method: "POST", body: JSON.stringify(input) }; break; default: throw new PackageOperationError(`unknown operation: ${String(op)}`, 404); } const response = await route(new Request(`http://packed.internal${path}`, init)); const body = (await response.json()) as { error?: unknown }; if (!response.ok) { throw new PackageOperationError( typeof body.error === "string" ? body.error : `operation failed with HTTP ${response.status}`, response.status, ); } return body as OperationOutputs[Name]; } return { async fetch(req: Request): Promise { const t0 = Date.now(); const requestUrl = new URL(req.url); if (requestUrl.pathname.startsWith("/vehicle/")) return vehicleApp.fetch(req); if (!requireBearerToken(req, deps.token)) return errorResponse("missing or invalid bearer token", 401); if (req.method === "GET" && requestUrl.pathname === "/api/v1/ops") return jsonResponse({ operations: OPERATION_NAMES }); if (req.method === "POST" && requestUrl.pathname === "/api/v1/ops") { try { const body = (await req.json()) as { op?: unknown; input?: unknown }; if (typeof body.op !== "string") return errorResponse("op is required", 400); const input = body.input ?? {}; if (typeof input !== "object" || input === null || Array.isArray(input)) return errorResponse("input must be an object", 400); const result = await executeOperation(body.op as OperationName, input as OperationInputs[OperationName]); return jsonResponse({ result }); } catch (error) { return errorResponse( error instanceof Error ? error.message : String(error), error instanceof PackageOperationError ? error.status : 400, ); } } // Cache successful GETs by URI (smart-proxy concern). if (req.method === "GET" && !["/health", "/updates", "/catalog", "/security"].includes(new URL(req.url).pathname)) { const hit = cache.get(req.url); if (hit) { log.debug("request", { path: new URL(req.url).pathname, cache: "hit", ms: Date.now() - t0 }); return new Response(hit, { headers: { "content-type": "application/json", "x-cache": "hit" } }); } const res = await route(req); if (res.status === 200) cache.set(req.url, await res.clone().text()); log.debug("request", { path: new URL(req.url).pathname, status: res.status, cache: "miss", ms: Date.now() - t0 }); return res; } const res = await route(req); log.debug("request", { path: new URL(req.url).pathname, status: res.status, ms: Date.now() - t0 }); return res; }, }; }