import { spawn } from "node:child_process"; import { accessSync, constants as fsConstants, readFileSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client"; import { readDaemonHandle, resolveDaemonPaths } from "@danypops/vehicle-server/paths"; import { createNodeServiceInstallDeps, isServiceInstalled as vehicleIsServiceInstalled } from "@danypops/vehicle-server/service"; import type { ExtensionOperationInputs, ExtensionOperationName, ExtensionOperationOutputs, PackageInfo, PackageResources, PackageSummary, PiStatus, ResourceField, SecuritySettings, ServiceSpecSummary, SetupApplyResult, SetupPlan, UpdateEntry, UpdateOutcome, } from "./protocol.js"; /** notADaemon: the overwhelmingly common case for install() -- most Pi packages aren't daemons at all. Distinct from a real installService failure (systemctl unavailable, spec resolved but installation itself failed). */ export class InstallServiceError extends Error { constructor( message: string, readonly notADaemon?: boolean, ) { super(message); this.name = "InstallServiceError"; } } export interface PackedClientPaths { token: string; handle: string; serviceDescriptor: string; } const SERVICE_NAME = "pi-packed"; const SERVICE_UNIT_NAME = "pi-packed.service"; export interface PackedPathOptions { env?: Record; home?: string; platform?: NodeJS.Platform; } export interface PackedExtensionClient { search(query: string, limit: number, offline?: boolean): Promise<{ query: string; total: number; results: PackageSummary[] }>; info(name: string): Promise; installed(): Promise; updates(): Promise; security(): Promise; setMutationApproval(value: SecuritySettings["mutationApproval"], approved?: boolean): Promise; install(source: string, approved?: boolean): Promise; installService(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSpecSummary }>; remove(name: string, approved?: boolean): Promise; update(source: string, approved?: boolean, target?: string): Promise; setupPlan(manifestPath: string, prune?: boolean): Promise; setupApply(manifestPath: string, approved?: boolean, prune?: boolean): Promise; listResources(projectRoot?: string): Promise<{ global: PackageResources[]; project: PackageResources[] }>; toggleResource( source: string, field: ResourceField, path: string, enabled: boolean, projectRoot?: string, approved?: boolean, ): Promise; piStatus(): Promise; } export function resolvePackedClientPaths(options: PackedPathOptions = {}): PackedClientPaths { const env = options.env ?? process.env; if (env.PI_PACKED_HOME) { const directory = resolve(env.PI_PACKED_HOME); return { token: `${directory}/token`, handle: `${directory}/handle.json`, serviceDescriptor: `${directory}/${SERVICE_UNIT_NAME}` }; } const paths = resolveDaemonPaths( { stateDirectoryName: "pi-packed", databaseFilename: "packages.db", tokenFilename: "token", handleFilename: "handle.json", systemdUnitName: SERVICE_UNIT_NAME, }, options, ); return { token: paths.token, handle: paths.handle, serviceDescriptor: paths.serviceDescriptor }; } export interface PackedVehicleClientTarget { /** Base URL for the daemon's Vehicle-projected surface (see service.ts's createApp, which mounts * createVehicleHttpApp() at /vehicle/* on this same port, alongside /api/v1/ops) -- @danypops/ * vehicle-client's RemoteVehicleClient mounts its own /vehicle/manifest, /vehicle/invoke, * /vehicle/cancel routes under this. */ baseUrl: string; token: string; } /** * Narrow surface for a Vehicle-projected operation consumer -- same daemon, same handle file, same * Bearer token every other Packed RPC call already uses (connectPackedClient reads both the same * way). Returns undefined rather than throwing when the daemon has never started -- no handle/token * on disk yet -- matching resolvePushChannelTarget/resolveVehicleClientTarget's own tolerance in * @danypops/papyrus for the identical condition. */ export function resolveVehicleClientTarget(paths = resolvePackedClientPaths()): PackedVehicleClientTarget | undefined { const handle = readDaemonHandle(paths.handle); if (!handle) return undefined; let token: string; try { token = readFileSync(paths.token, "utf8").trim(); } catch { return undefined; } if (!/^[a-f0-9]{64}$/.test(token)) return undefined; return { baseUrl: `http://${handle.host}:${handle.port}`, token }; } /** Checks Armada's authoritative desired fleet rather than native descriptor files. */ function isPackedServiceInstalled(): boolean { return vehicleIsServiceInstalled(SERVICE_NAME, createNodeServiceInstallDeps()); } export type FetchTransport = (request: Request) => Promise; function boundedTransport(transport: FetchTransport, timeoutMs = 30_000): FetchTransport { return (request) => transport(new Request(request, { signal: AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)]) })); } export class PackedClient implements PackedExtensionClient { private readonly rpc: AuthenticatedRpcClient; constructor(base: string, token: string, transport: FetchTransport = fetch) { this.rpc = new AuthenticatedRpcClient(base, token, { label: "Packed", transport: boundedTransport(transport) }); } private call( name: Name, input: ExtensionOperationInputs[Name], ): Promise { return this.rpc.call(name, input); } health(): Promise { return this.rpc.health(); } search(query: string, limit: number, offline = false) { return this.call("package.search", { query, limit, offline }); } info(name: string) { return this.call("package.info", { name }); } installed() { return this.call("package.installed", {}); } async updates() { return (await this.call("package.updates", {})).updates; } security() { return this.call("package.security.get", {}); } setMutationApproval(mutationApproval: SecuritySettings["mutationApproval"], approved = false) { return this.call("package.security.set", { mutationApproval, approved }); } async install(source: string, approved = false) { const result = await this.call("package.install", { source, approved }); if (!result.ok) throw new Error(result.output); return result.output; } async installService(source: string, approved = false) { const result = await this.call("package.install_service", { source, approved }); if (!result.ok) throw new InstallServiceError(result.output, result.notADaemon); return { output: result.output, spec: result.spec }; } async remove(name: string, approved = false) { const result = await this.call("package.remove", { name, approved }); if (!result.ok) throw new Error(result.output); return result.output; } async update(source: string, approved = false, target?: string): Promise { const result = await this.call("package.update", { source, approved, target }); if (!result.ok) throw new Error(result.output); return { output: result.output, reloadRequired: result.reloadRequired ?? true, alreadyUpToDate: result.alreadyUpToDate ?? false, pinned: result.pinned ?? false, previousVersion: result.previousVersion, currentVersion: result.currentVersion, outOfRangeUpdateAvailable: result.outOfRangeUpdateAvailable, pinnedSourceRequiresTarget: result.pinnedSourceRequiresTarget, replaced: result.replaced, before: result.before, after: result.after, rollback: result.rollback, }; } setupPlan(manifestPath: string, prune = false) { return this.call("setup.plan", { manifestPath, prune }); } setupApply(manifestPath: string, approved = false, prune = false) { return this.call("setup.apply", { manifestPath, approved, prune }); } listResources(projectRoot?: string) { return this.call("resources.list", { projectRoot }); } async toggleResource(source: string, field: ResourceField, path: string, enabled: boolean, projectRoot?: string, approved = false) { const result = await this.call("resources.toggle", { source, field, path, enabled, projectRoot, approved }); if (!result.ok) throw new Error(result.output); return result.output; } piStatus() { return this.call("pi.status", {}); } } export async function connectPackedClient(paths = resolvePackedClientPaths(), transport: FetchTransport = fetch): Promise { const handle = readDaemonHandle(paths.handle); if (!handle) throw new Error("Packed daemon is not running"); let token: string; try { token = readFileSync(paths.token, "utf8").trim(); } catch { throw new Error("Packed daemon token is unavailable"); } if (!/^[a-f0-9]{64}$/.test(token)) throw new Error("Packed daemon token is invalid"); const client = new PackedClient(`http://${handle.host}:${handle.port}`, token, transport); await client.health(); return client; } export interface EnsureClientDeps { connect(): Promise; /** Real OS check, not a guess -- does this machine have a supervised * service registered for this daemon (systemd --user unit / launchd * plist / Windows Run key)? */ isServiceInstalled(): boolean; /** Spawns a detached, self-contained daemon for this session only. * Never called when isServiceInstalled() is true. */ spawn(): void; sleep(ms: number): Promise; retryAttempts?: number; retryDelayMs?: number; } /** * Pure connect-or-provision decision, fully dependency-injected and * unit-testable without a real filesystem, subprocess, or network call. * * A real, confirmed hazard motivates the isServiceInstalled() branch: an * auto-spawned orphan gets vehicle-server's 30-minute idle budget (vs. a * supervised service's own, typically much shorter, policy), so it can * keep winning vehicle-server's single-instance lock race against every * subsequent supervised restart, invisibly, for up to half an hour. When a * service is installed, this never spawns a second, differently-supervised * process -- it retries the connection instead, on the assumption the * supervisor is responsible for the daemon actually being reachable, and * fails with a message pointing at the service rather than silently * creating a competing one. */ export async function ensureClient(deps: EnsureClientDeps): Promise { try { return await deps.connect(); } catch {} const attempts = deps.retryAttempts ?? 30; const delayMs = deps.retryDelayMs ?? 100; const serviceInstalled = deps.isServiceInstalled(); if (!serviceInstalled) deps.spawn(); for (let attempt = 0; attempt < attempts; attempt++) { await deps.sleep(delayMs); try { return await deps.connect(); } catch {} } const waitedSeconds = (attempts * delayMs) / 1000; throw new Error( serviceInstalled ? `The managed Packed service is registered but unreachable after ${waitedSeconds} seconds. Its supervisor may have stopped it or it may be failing; restart the managed service and inspect its status and logs. Packed will not auto-spawn a competing process.` : `Packed daemon did not become ready within ${waitedSeconds} seconds`, ); } /** * An auto-spawned daemon is detached and long-lived, so it keeps whatever * PATH its host process had at spawn time forever. Resolve `pi`'s absolute * path now and pin it, rather than trust a bare "pi" to keep resolving in * that frozen PATH. Leaves an explicit PI_PACKED_PI_BIN/PI_BIN untouched. */ export function resolvePiBinForSpawn(env: NodeJS.ProcessEnv = process.env): string | undefined { if (env.PI_PACKED_PI_BIN || env.PI_BIN) return undefined; // explicit override already present; nothing to pin const path = env.PATH; if (!path) return undefined; const names = process.platform === "win32" ? ["pi.exe", "pi.cmd", "pi.bat", "pi"] : ["pi"]; for (const dir of path.split(delimiter)) { if (!dir) continue; for (const name of names) { const candidate = join(dir, name); try { accessSync(candidate, fsConstants.X_OK); return candidate; } catch {} } } return undefined; } export async function ensurePackedClient(paths = resolvePackedClientPaths(), transport: FetchTransport = fetch): Promise { return ensureClient({ connect: () => connectPackedClient(paths, transport), isServiceInstalled: () => isPackedServiceInstalled(), spawn: () => { const override = process.env.PI_PACKED_BIN; const command = override ?? process.env.PI_PACKED_BUN ?? "bun"; const args = override ? ["serve"] : [join(dirname(fileURLToPath(import.meta.url)), "../service/src/cli/cli.ts"), "serve"]; const resolvedPiBin = resolvePiBinForSpawn(); const child = spawn(command, args, { detached: true, stdio: "ignore", env: { ...process.env, DAEMON_KIT_LAUNCH_PROVENANCE: "auto-spawn", ...(resolvedPiBin ? { PI_BIN: resolvedPiBin } : {}), }, }); child.once("error", () => {}); child.unref(); }, sleep: (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)), }); }