@danypops/pi-packed 0.19.9 → 0.19.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/client.d.ts +109 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +1 -0
  4. package/dist/protocol.d.ts +221 -0
  5. package/dist/protocol.d.ts.map +1 -0
  6. package/dist/protocol.js +1 -0
  7. package/extension/src/approval/permission.ts +1 -1
  8. package/extension/src/packed.ts +2 -2
  9. package/extension/src/tabs/security-tui.ts +1 -1
  10. package/extension/src/tool-output.ts +1 -1
  11. package/package.json +31 -8
  12. package/service/schema/pi-setup-v1.schema.json +70 -0
  13. package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
  14. package/service/src/adoption/advisories.ts +268 -0
  15. package/service/src/adoption/check.ts +872 -0
  16. package/service/src/adoption/commit-freshness.ts +167 -0
  17. package/service/src/adoption/doctor.ts +135 -0
  18. package/service/src/adoption/install-validation.ts +187 -0
  19. package/service/src/adoption/pack.ts +291 -0
  20. package/service/src/adoption/score.ts +466 -0
  21. package/service/src/adoption/smoke-child.ts +113 -0
  22. package/service/src/adoption/smoke.ts +282 -0
  23. package/service/src/cli/cli.ts +926 -0
  24. package/service/src/daemon/cleanup.ts +76 -0
  25. package/service/src/daemon/client.ts +412 -0
  26. package/service/src/daemon/daemon-service.ts +249 -0
  27. package/service/src/daemon/daemon.ts +110 -0
  28. package/service/src/daemon/service.ts +664 -0
  29. package/service/src/daemon/watcher.ts +92 -0
  30. package/service/src/index/build-index.ts +256 -0
  31. package/service/src/packages/catalog.ts +61 -0
  32. package/service/src/packages/db.ts +224 -0
  33. package/service/src/packages/install.ts +60 -0
  34. package/service/src/packages/installed.ts +123 -0
  35. package/service/src/packages/package.ts +141 -0
  36. package/service/src/packages/resources.ts +203 -0
  37. package/service/src/pi/pi-version.ts +171 -0
  38. package/service/src/public/atomic-json.ts +32 -0
  39. package/service/src/public/client.ts +277 -0
  40. package/service/src/public/protocol.ts +169 -0
  41. package/service/src/publish/publish.ts +855 -0
  42. package/service/src/registry/registry.ts +246 -0
  43. package/service/src/security/security.ts +128 -0
  44. package/service/src/self-update/self-update.ts +148 -0
  45. package/service/src/setup/setup.ts +761 -0
  46. package/service/src/shared/atomic-json.ts +33 -0
  47. package/service/src/shared/cache.ts +21 -0
  48. package/service/src/shared/constants.ts +73 -0
  49. package/service/src/shared/log.ts +21 -0
  50. package/service/src/shared/paths.ts +88 -0
  51. package/service/src/shared/state.ts +15 -0
  52. package/service/src/shared/version.ts +46 -0
  53. package/service/test/advisories.test.ts +287 -0
  54. package/service/test/check.test.ts +368 -0
  55. package/service/test/cleanup.test.ts +220 -0
  56. package/service/test/cli.test.ts +1303 -0
  57. package/service/test/core.test.ts +181 -0
  58. package/service/test/daemon-kit-migration.test.ts +181 -0
  59. package/service/test/daemon-service.test.ts +238 -0
  60. package/service/test/db.test.ts +178 -0
  61. package/service/test/doctor.test.ts +234 -0
  62. package/service/test/domain.test.ts +291 -0
  63. package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
  64. package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
  65. package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
  66. package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
  67. package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
  68. package/service/test/index.test.ts +353 -0
  69. package/service/test/install-validation.test.ts +114 -0
  70. package/service/test/install.test.ts +113 -0
  71. package/service/test/log.test.ts +42 -0
  72. package/service/test/pack-score.test.ts +513 -0
  73. package/service/test/pi-version.test.ts +318 -0
  74. package/service/test/public-boundary.test.ts +54 -0
  75. package/service/test/public-client.test.ts +127 -0
  76. package/service/test/public-consumer.ts +8 -0
  77. package/service/test/publish.test.ts +333 -0
  78. package/service/test/registry-contract.test.ts +148 -0
  79. package/service/test/resources.test.ts +255 -0
  80. package/service/test/security.test.ts +89 -0
  81. package/service/test/self-update.test.ts +257 -0
  82. package/service/test/service.test.ts +555 -0
  83. package/service/test/setup.test.ts +375 -0
  84. package/service/test/smoke.test.ts +118 -0
  85. package/service/test/version.test.ts +37 -0
  86. package/service/tsconfig.consumer.json +13 -0
  87. package/service/tsconfig.public.json +12 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Tracks the running Pi coding agent version against the same first-party
3
+ * source Pi's own update-check uses -- never the npm registry, which could
4
+ * disagree with pi.dev/api/latest-version during a package-rename
5
+ * migration (the API's own packageName field exists for exactly that).
6
+ */
7
+
8
+ import { defaultPiBin } from "../packages/install.ts";
9
+ import {
10
+ type InteractiveRunResult,
11
+ runBounded,
12
+ runInherited,
13
+ type VersionCommand,
14
+ type VersionCommandResult,
15
+ versionAtLeast,
16
+ } from "../publish/publish.ts";
17
+
18
+ const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
19
+ const DEFAULT_TIMEOUT_MS = 10_000;
20
+ const MAX_NOTE_BYTES = 2_000;
21
+ /** The API response includes this field on every response, not only during
22
+ * an actual rename (confirmed live) -- a genuine migration signal is
23
+ * `packageName !== CURRENT_PI_PACKAGE_NAME`, matching Pi's own
24
+ * getSelfUpdatePlan comparison, never mere field presence. */
25
+ export const CURRENT_PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
26
+
27
+ /** Resolves the real pi binary the same way install.ts's ExecInstaller
28
+ * does (PI_PACKED_PI_BIN / PI_BIN / bare "pi" fallback) -- a bare "pi"
29
+ * argv fails under systemd's minimal PATH, which doesn't include wherever
30
+ * a user's own pi install actually lives. */
31
+ export const readPiVersion: VersionCommand = () => runBounded([defaultPiBin(), "--version"]);
32
+
33
+ export interface PiReleaseInfo {
34
+ version: string;
35
+ /** Exists for a future package-rename migration -- absent under normal operation. */
36
+ packageName?: string;
37
+ note?: string;
38
+ }
39
+
40
+ function packedUserAgent(): string {
41
+ const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`;
42
+ return `packed (${process.platform}; ${runtime}; ${process.arch})`;
43
+ }
44
+
45
+ function parseVersionOutput(result: VersionCommandResult): string | undefined {
46
+ if (result.code !== 0) return undefined;
47
+ const trimmed = result.stdout.trim();
48
+ return /^\d+\.\d+\.\d+/.test(trimmed) ? trimmed : undefined;
49
+ }
50
+
51
+ /** Resolves only the locally running Pi version -- no network call, unlike
52
+ * checkPiVersion, which also fetches the latest release. Compatibility
53
+ * scoring needs the current version only, so it never pays pi.dev latency
54
+ * or fails closed on a network blip that has nothing to do with it. */
55
+ export async function resolveCurrentPiVersion(versionCommand: VersionCommand = readPiVersion): Promise<string | undefined> {
56
+ return parseVersionOutput(await versionCommand().catch((): VersionCommandResult => ({ code: 1, stdout: "", stderr: "" })));
57
+ }
58
+
59
+ export type FetchLatestPiRelease = (options?: { timeoutMs?: number }) => Promise<PiReleaseInfo | undefined>;
60
+
61
+ /**
62
+ * Fetches the exact endpoint Pi's own self-update check uses. Respects the
63
+ * same opt-outs Pi's own checker does (PI_SKIP_VERSION_CHECK, PI_OFFLINE)
64
+ * so Packed never makes a network call the user already disabled. Never
65
+ * throws -- an unreachable endpoint, timeout, or malformed response all
66
+ * resolve to undefined ("unknown"), never a crash. `baseUrl` is test-only
67
+ * (points at a real local Bun.serve() fixture instead of the live endpoint,
68
+ * matching this codebase's existing HttpRegistry test convention).
69
+ */
70
+ export function createFetchLatestPiRelease(baseUrl: string = LATEST_VERSION_URL): FetchLatestPiRelease {
71
+ return async (options = {}) => {
72
+ if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) return undefined;
73
+ try {
74
+ const response = await fetch(baseUrl, {
75
+ headers: { "User-Agent": packedUserAgent(), accept: "application/json" },
76
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
77
+ });
78
+ if (!response.ok) return undefined;
79
+ const data = (await response.json()) as { packageName?: unknown; version?: unknown; note?: unknown };
80
+ if (typeof data.version !== "string" || !data.version.trim()) return undefined;
81
+ const packageName = typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : undefined;
82
+ const note = typeof data.note === "string" && data.note.trim() ? data.note.trim().slice(0, MAX_NOTE_BYTES) : undefined;
83
+ return { version: data.version.trim(), ...(packageName ? { packageName } : {}), ...(note ? { note } : {}) };
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ };
88
+ }
89
+
90
+ export const fetchLatestPiRelease: FetchLatestPiRelease = createFetchLatestPiRelease();
91
+
92
+ export interface PiVersionReport {
93
+ /** undefined when `pi` isn't on PATH or its output couldn't be parsed. */
94
+ current?: string;
95
+ /** undefined when the latest-version check failed, timed out, or was opted out of. */
96
+ latest?: string;
97
+ /** undefined whenever either current or latest is unknown -- never a guess. */
98
+ upToDate?: boolean;
99
+ packageName?: string;
100
+ note?: string;
101
+ }
102
+
103
+ /** Pure argv construction, kept separate from the actual spawn so tests
104
+ * never risk invoking a real `pi update --self`. */
105
+ export function piUpdateSelfArgs(): string[] {
106
+ return [defaultPiBin(), "update", "--self"];
107
+ }
108
+
109
+ /**
110
+ * Runs Pi's own already-correct self-update command directly -- npm/pnpm/
111
+ * yarn/bun install-method detection, Windows quirks, its own release note,
112
+ * all already solved there. Packed orchestrates it, never reimplements it,
113
+ * matching the exact boundary already established for npm login/trust.
114
+ * Inherited stdio so any interactive prompt Pi itself shows still works.
115
+ */
116
+ export function runPiUpdateSelf(): Promise<InteractiveRunResult> {
117
+ return runInherited(piUpdateSelfArgs());
118
+ }
119
+
120
+ export interface PiVersionCheckOptions {
121
+ versionCommand?: VersionCommand;
122
+ fetchLatest?: FetchLatestPiRelease;
123
+ timeoutMs?: number;
124
+ }
125
+
126
+ export async function checkPiVersion(options: PiVersionCheckOptions = {}): Promise<PiVersionReport> {
127
+ const fetchLatest = options.fetchLatest ?? fetchLatestPiRelease;
128
+ const current = await resolveCurrentPiVersion(options.versionCommand);
129
+ const release = await fetchLatest({ timeoutMs: options.timeoutMs });
130
+ if (!release) return current ? { current } : {};
131
+ const upToDate = current ? versionAtLeast(current, release.version) : undefined;
132
+ const renamed = release.packageName && release.packageName !== CURRENT_PI_PACKAGE_NAME ? release.packageName : undefined;
133
+ return {
134
+ ...(current ? { current } : {}),
135
+ latest: release.version,
136
+ ...(upToDate !== undefined ? { upToDate } : {}),
137
+ ...(renamed ? { packageName: renamed } : {}),
138
+ ...(release.note ? { note: release.note } : {}),
139
+ };
140
+ }
141
+
142
+ export interface PiStatusInteractiveDeps {
143
+ /** Reads current status; called once up front, and again after a
144
+ * successful update to report the refreshed state. */
145
+ check(): Promise<PiVersionReport>;
146
+ /** Asks the human a yes/no question. Real callers gate this on a TTY
147
+ * themselves before ever invoking this function at all. */
148
+ confirm(question: string): Promise<boolean>;
149
+ /** Runs the real update subprocess. Only ever called after confirm()
150
+ * resolves true. */
151
+ runUpdate(): Promise<{ ok: boolean }>;
152
+ }
153
+
154
+ /**
155
+ * Pure orchestration decision logic for "packed pi status", fully
156
+ * dependency-injected and unit-testable without a real terminal, a real
157
+ * subprocess, or a real network call: confirm only when genuinely behind,
158
+ * update only after a yes, re-check only after a successful update, never
159
+ * silently swallow a failed update by reporting stale success. The real
160
+ * CLI entrypoint supplies real confirm/runUpdate/check implementations and
161
+ * is itself not unit tested, matching this codebase's established split
162
+ * between tested decision logic and untested real-I/O glue.
163
+ */
164
+ export async function runPiStatusInteractive(deps: PiStatusInteractiveDeps): Promise<PiVersionReport> {
165
+ const report = await deps.check();
166
+ if (report.upToDate !== false || !report.current || !report.latest) return report;
167
+ if (!(await deps.confirm(`pi ${report.current} is behind the latest ${report.latest}. Run pi update --self now?`))) return report;
168
+ const result = await deps.runUpdate();
169
+ if (!result.ok) return report;
170
+ return deps.check();
171
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A Pi extension package (pi-packed) can't safely import ../shared/*.ts
3
+ * raw at extension runtime -- pi's jiti-based extension loader has a real,
4
+ * demonstrated failure class transpiling a dependency's raw, unbuilt
5
+ * TypeScript. Duplicated (not re-exported) from ../shared/atomic-json.ts
6
+ * for that reason, same rationale as client.ts/protocol.ts living under
7
+ * src/public/ as their own self-contained compiled entry points rather
8
+ * than thin re-exports of internal daemon-side modules.
9
+ */
10
+ import { existsSync, lstatSync, mkdirSync } from "node:fs";
11
+ import { dirname } from "node:path";
12
+ import { createAtomicJsonWriter } from "@danypops/vehicle-core";
13
+ import { createNodeAtomicJsonFsAdapter } from "@danypops/vehicle-server/atomic-json";
14
+
15
+ const writer = createAtomicJsonWriter({ fs: createNodeAtomicJsonFsAdapter() });
16
+
17
+ export interface WriteJsonAtomicOptions {
18
+ /** POSIX mode for the written file. Omitted means the OS/adapter's own default. */
19
+ mode?: number;
20
+ /** Pretty-prints with 2-space indentation. Defaults to false (compact). */
21
+ pretty?: boolean;
22
+ /** Appends a trailing newline. Defaults to true. */
23
+ trailingNewline?: boolean;
24
+ /** Parent directory mode, created if missing. Defaults to 0o700 (private). */
25
+ dirMode?: number;
26
+ }
27
+
28
+ export async function writeJsonAtomic(path: string, value: unknown, options: WriteJsonAtomicOptions = {}): Promise<void> {
29
+ mkdirSync(dirname(path), { recursive: true, mode: options.dirMode ?? 0o700 });
30
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error(`ATOMIC_JSON_PATH_UNSAFE: refusing to replace symlink ${path}`);
31
+ await writer.write(path, value, { mode: options.mode, pretty: options.pretty, trailingNewline: options.trailingNewline ?? true });
32
+ }
@@ -0,0 +1,277 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
6
+ import { readDaemonHandle, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
7
+ import { isServiceInstalled as vehicleIsServiceInstalled } from "@danypops/vehicle-server/service";
8
+ import type {
9
+ ExtensionOperationInputs,
10
+ ExtensionOperationName,
11
+ ExtensionOperationOutputs,
12
+ PackageInfo,
13
+ PackageResources,
14
+ PackageSummary,
15
+ PiStatus,
16
+ ResourceField,
17
+ SecuritySettings,
18
+ ServiceSpecSummary,
19
+ SetupApplyResult,
20
+ SetupPlan,
21
+ UpdateEntry,
22
+ UpdateOutcome,
23
+ } from "./protocol.js";
24
+
25
+ /** 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). */
26
+ export class InstallServiceError extends Error {
27
+ constructor(
28
+ message: string,
29
+ readonly notADaemon?: boolean,
30
+ ) {
31
+ super(message);
32
+ this.name = "InstallServiceError";
33
+ }
34
+ }
35
+
36
+ export interface PackedClientPaths {
37
+ token: string;
38
+ handle: string;
39
+ serviceDescriptor: string;
40
+ }
41
+ const SERVICE_NAME = "pi-packed";
42
+ const SERVICE_UNIT_NAME = "pi-packed.service";
43
+ export interface PackedPathOptions {
44
+ env?: Record<string, string | undefined>;
45
+ home?: string;
46
+ platform?: NodeJS.Platform;
47
+ }
48
+ export interface PackedExtensionClient {
49
+ search(query: string, limit: number, offline?: boolean): Promise<{ query: string; total: number; results: PackageSummary[] }>;
50
+ info(name: string): Promise<PackageInfo>;
51
+ installed(): Promise<ExtensionOperationOutputs["package.installed"]>;
52
+ updates(): Promise<UpdateEntry[]>;
53
+ security(): Promise<SecuritySettings>;
54
+ setMutationApproval(value: SecuritySettings["mutationApproval"], approved?: boolean): Promise<SecuritySettings>;
55
+ install(source: string, approved?: boolean): Promise<string>;
56
+ installService(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSpecSummary }>;
57
+ remove(name: string, approved?: boolean): Promise<string>;
58
+ update(source: string, approved?: boolean): Promise<UpdateOutcome>;
59
+ setupPlan(manifestPath: string, prune?: boolean): Promise<SetupPlan>;
60
+ setupApply(manifestPath: string, approved?: boolean, prune?: boolean): Promise<SetupApplyResult>;
61
+ listResources(projectRoot?: string): Promise<{ global: PackageResources[]; project: PackageResources[] }>;
62
+ toggleResource(
63
+ source: string,
64
+ field: ResourceField,
65
+ path: string,
66
+ enabled: boolean,
67
+ projectRoot?: string,
68
+ approved?: boolean,
69
+ ): Promise<string>;
70
+ piStatus(): Promise<PiStatus>;
71
+ }
72
+
73
+ export function resolvePackedClientPaths(options: PackedPathOptions = {}): PackedClientPaths {
74
+ const env = options.env ?? process.env;
75
+ if (env.PI_PACKED_HOME) {
76
+ const directory = resolve(env.PI_PACKED_HOME);
77
+ return { token: `${directory}/token`, handle: `${directory}/handle.json`, serviceDescriptor: `${directory}/${SERVICE_UNIT_NAME}` };
78
+ }
79
+ const paths = resolveDaemonPaths(
80
+ {
81
+ stateDirectoryName: "pi-packed",
82
+ databaseFilename: "packages.db",
83
+ tokenFilename: "token",
84
+ handleFilename: "handle.json",
85
+ systemdUnitName: SERVICE_UNIT_NAME,
86
+ },
87
+ options,
88
+ );
89
+ return { token: paths.token, handle: paths.handle, serviceDescriptor: paths.serviceDescriptor };
90
+ }
91
+
92
+ /** Real, file-existence-only check on Linux/macOS (Windows checks a
93
+ * registry Run key instead) -- cheap, synchronous, no subprocess. */
94
+ function isPackedServiceInstalled(serviceDescriptor: string): boolean {
95
+ return vehicleIsServiceInstalled(
96
+ { name: SERVICE_NAME, binPath: "", descriptorPath: serviceDescriptor },
97
+ {
98
+ fileExists: existsSync,
99
+ writeFile: () => {},
100
+ readFile: () => null,
101
+ removeFile: () => {},
102
+ mkdirp: () => {},
103
+ runCommand: () => ({ ok: false, output: "" }),
104
+ which: () => false,
105
+ },
106
+ );
107
+ }
108
+
109
+ export type FetchTransport = (request: Request) => Promise<Response>;
110
+
111
+ function boundedTransport(transport: FetchTransport, timeoutMs = 30_000): FetchTransport {
112
+ return (request) => transport(new Request(request, { signal: AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)]) }));
113
+ }
114
+
115
+ export class PackedClient implements PackedExtensionClient {
116
+ private readonly rpc: AuthenticatedRpcClient<ExtensionOperationName, ExtensionOperationInputs, ExtensionOperationOutputs>;
117
+ constructor(base: string, token: string, transport: FetchTransport = fetch) {
118
+ this.rpc = new AuthenticatedRpcClient(base, token, { label: "Packed", transport: boundedTransport(transport) });
119
+ }
120
+ private call<Name extends ExtensionOperationName>(
121
+ name: Name,
122
+ input: ExtensionOperationInputs[Name],
123
+ ): Promise<ExtensionOperationOutputs[Name]> {
124
+ return this.rpc.call(name, input);
125
+ }
126
+ health(): Promise<unknown> {
127
+ return this.rpc.health();
128
+ }
129
+ search(query: string, limit: number, offline = false) {
130
+ return this.call("package.search", { query, limit, offline });
131
+ }
132
+ info(name: string) {
133
+ return this.call("package.info", { name });
134
+ }
135
+ installed() {
136
+ return this.call("package.installed", {});
137
+ }
138
+ async updates() {
139
+ return (await this.call("package.updates", {})).updates;
140
+ }
141
+ security() {
142
+ return this.call("package.security.get", {});
143
+ }
144
+ setMutationApproval(mutationApproval: SecuritySettings["mutationApproval"], approved = false) {
145
+ return this.call("package.security.set", { mutationApproval, approved });
146
+ }
147
+ async install(source: string, approved = false) {
148
+ const result = await this.call("package.install", { source, approved });
149
+ if (!result.ok) throw new Error(result.output);
150
+ return result.output;
151
+ }
152
+ async installService(source: string, approved = false) {
153
+ const result = await this.call("package.install_service", { source, approved });
154
+ if (!result.ok) throw new InstallServiceError(result.output, result.notADaemon);
155
+ return { output: result.output, spec: result.spec };
156
+ }
157
+ async remove(name: string, approved = false) {
158
+ const result = await this.call("package.remove", { name, approved });
159
+ if (!result.ok) throw new Error(result.output);
160
+ return result.output;
161
+ }
162
+ async update(source: string, approved = false): Promise<UpdateOutcome> {
163
+ const result = await this.call("package.update", { source, approved });
164
+ if (!result.ok) throw new Error(result.output);
165
+ return {
166
+ output: result.output,
167
+ reloadRequired: result.reloadRequired ?? true,
168
+ alreadyUpToDate: result.alreadyUpToDate ?? false,
169
+ pinned: result.pinned ?? false,
170
+ previousVersion: result.previousVersion,
171
+ currentVersion: result.currentVersion,
172
+ };
173
+ }
174
+ setupPlan(manifestPath: string, prune = false) {
175
+ return this.call("setup.plan", { manifestPath, prune });
176
+ }
177
+ setupApply(manifestPath: string, approved = false, prune = false) {
178
+ return this.call("setup.apply", { manifestPath, approved, prune });
179
+ }
180
+ listResources(projectRoot?: string) {
181
+ return this.call("resources.list", { projectRoot });
182
+ }
183
+ async toggleResource(source: string, field: ResourceField, path: string, enabled: boolean, projectRoot?: string, approved = false) {
184
+ const result = await this.call("resources.toggle", { source, field, path, enabled, projectRoot, approved });
185
+ if (!result.ok) throw new Error(result.output);
186
+ return result.output;
187
+ }
188
+ piStatus() {
189
+ return this.call("pi.status", {});
190
+ }
191
+ }
192
+
193
+ export async function connectPackedClient(paths = resolvePackedClientPaths(), transport: FetchTransport = fetch): Promise<PackedClient> {
194
+ const handle = readDaemonHandle(paths.handle);
195
+ if (!handle) throw new Error("Packed daemon is not running");
196
+ let token: string;
197
+ try {
198
+ token = readFileSync(paths.token, "utf8").trim();
199
+ } catch {
200
+ throw new Error("Packed daemon token is unavailable");
201
+ }
202
+ if (!/^[a-f0-9]{64}$/.test(token)) throw new Error("Packed daemon token is invalid");
203
+ const client = new PackedClient(`http://${handle.host}:${handle.port}`, token, transport);
204
+ await client.health();
205
+ return client;
206
+ }
207
+
208
+ export interface EnsureClientDeps {
209
+ connect(): Promise<PackedClient>;
210
+ /** Real OS check, not a guess -- does this machine have a supervised
211
+ * service registered for this daemon (systemd --user unit / launchd
212
+ * plist / Windows Run key)? */
213
+ isServiceInstalled(): boolean;
214
+ /** Spawns a detached, self-contained daemon for this session only.
215
+ * Never called when isServiceInstalled() is true. */
216
+ spawn(): void;
217
+ sleep(ms: number): Promise<void>;
218
+ retryAttempts?: number;
219
+ retryDelayMs?: number;
220
+ }
221
+
222
+ /**
223
+ * Pure connect-or-provision decision, fully dependency-injected and
224
+ * unit-testable without a real filesystem, subprocess, or network call.
225
+ *
226
+ * A real, confirmed hazard motivates the isServiceInstalled() branch: an
227
+ * auto-spawned orphan gets vehicle-server's 30-minute idle budget (vs. a
228
+ * supervised service's own, typically much shorter, policy), so it can
229
+ * keep winning vehicle-server's single-instance lock race against every
230
+ * subsequent supervised restart, invisibly, for up to half an hour. When a
231
+ * service is installed, this never spawns a second, differently-supervised
232
+ * process -- it retries the connection instead, on the assumption the
233
+ * supervisor is responsible for the daemon actually being reachable, and
234
+ * fails with a message pointing at the service rather than silently
235
+ * creating a competing one.
236
+ */
237
+ export async function ensureClient(deps: EnsureClientDeps): Promise<PackedClient> {
238
+ try {
239
+ return await deps.connect();
240
+ } catch {}
241
+ const attempts = deps.retryAttempts ?? 30;
242
+ const delayMs = deps.retryDelayMs ?? 100;
243
+ const serviceInstalled = deps.isServiceInstalled();
244
+ if (!serviceInstalled) deps.spawn();
245
+ for (let attempt = 0; attempt < attempts; attempt++) {
246
+ await deps.sleep(delayMs);
247
+ try {
248
+ return await deps.connect();
249
+ } catch {}
250
+ }
251
+ const waitedSeconds = (attempts * delayMs) / 1000;
252
+ throw new Error(
253
+ serviceInstalled
254
+ ? `Packed daemon did not become ready within ${waitedSeconds} seconds, but a supervised service is installed -- check it directly (e.g. systemctl --user status pi-packed.service) rather than auto-spawning a second one`
255
+ : `Packed daemon did not become ready within ${waitedSeconds} seconds`,
256
+ );
257
+ }
258
+
259
+ export async function ensurePackedClient(paths = resolvePackedClientPaths(), transport: FetchTransport = fetch): Promise<PackedClient> {
260
+ return ensureClient({
261
+ connect: () => connectPackedClient(paths, transport),
262
+ isServiceInstalled: () => isPackedServiceInstalled(paths.serviceDescriptor),
263
+ spawn: () => {
264
+ const override = process.env.PI_PACKED_BIN;
265
+ const command = override ?? process.env.PI_PACKED_BUN ?? "bun";
266
+ const args = override ? ["serve"] : [join(dirname(fileURLToPath(import.meta.url)), "../service/src/cli/cli.ts"), "serve"];
267
+ const child = spawn(command, args, {
268
+ detached: true,
269
+ stdio: "ignore",
270
+ env: { ...process.env, DAEMON_KIT_LAUNCH_PROVENANCE: "auto-spawn" },
271
+ });
272
+ child.once("error", () => {});
273
+ child.unref();
274
+ },
275
+ sleep: (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)),
276
+ });
277
+ }
@@ -0,0 +1,169 @@
1
+ export interface PackageEvidence {
2
+ shape: "keyword-only" | "manifest" | "conventional";
3
+ verified: boolean;
4
+ evidence: string[];
5
+ }
6
+ export interface PublicationEvidence {
7
+ integrity?: string;
8
+ provenanceUrl?: string;
9
+ trustedPublisher: "verified" | "not-verified" | "unknown";
10
+ }
11
+ export interface PackageSummary {
12
+ name: string;
13
+ version: string;
14
+ description?: string;
15
+ date?: string;
16
+ packageEvidence?: PackageEvidence;
17
+ publication?: PublicationEvidence;
18
+ }
19
+ export interface PackageInfo extends PackageSummary {
20
+ homepage?: string;
21
+ repository?: string;
22
+ license?: string;
23
+ keywords?: string[];
24
+ pi?: Record<string, unknown>;
25
+ modified?: string;
26
+ unpackedSize?: number;
27
+ readme?: string;
28
+ readmeAvailable?: boolean;
29
+ bugs?: string;
30
+ peerDependencies?: Record<string, string>;
31
+ }
32
+ export interface InstalledPackage {
33
+ name: string;
34
+ pinned?: string;
35
+ installed?: string;
36
+ }
37
+ export interface UpdateEntry {
38
+ name: string;
39
+ installed: string;
40
+ latest: string;
41
+ detectedAt?: string;
42
+ }
43
+ export interface UpdateOutcome {
44
+ output: string;
45
+ reloadRequired: boolean;
46
+ alreadyUpToDate: boolean;
47
+ pinned: boolean;
48
+ previousVersion?: string;
49
+ currentVersion?: string;
50
+ }
51
+ export interface Diagnostic {
52
+ code: string;
53
+ severity: "error" | "warning" | "info";
54
+ path: string;
55
+ message: string;
56
+ fix?: string;
57
+ }
58
+ export interface SetupOperation {
59
+ kind: "install-package" | "update-package" | "remove-package" | "write-profile" | "remove-profile";
60
+ packageName?: string;
61
+ name?: string;
62
+ scope: "global" | "project";
63
+ source?: string;
64
+ resolved?: string;
65
+ fields?: string[];
66
+ }
67
+ export interface SetupPlan {
68
+ ok: boolean;
69
+ manifestPath: string;
70
+ operations: SetupOperation[];
71
+ manifestSha256?: string;
72
+ prune?: boolean;
73
+ diagnostics: Diagnostic[];
74
+ }
75
+ export interface SetupApplyResult {
76
+ ok: boolean;
77
+ manifestPath: string;
78
+ operations: Array<{ kind: SetupOperation["kind"]; target: string; status: "succeeded" | "failed"; output?: string }>;
79
+ reloadRequired: boolean;
80
+ diagnostics: Diagnostic[];
81
+ }
82
+ export interface SecuritySettings {
83
+ mutationApproval: "always" | "never";
84
+ }
85
+ export type ResourceField = "extensions" | "skills" | "prompts" | "themes";
86
+ export interface ResourceItem {
87
+ path: string;
88
+ enabled: boolean;
89
+ }
90
+ export type PackageResources = { source: string; name: string; scope: "global" | "project" } & Record<ResourceField, ResourceItem[]>;
91
+
92
+ export interface ServiceSpecSummary {
93
+ name: string;
94
+ binPath: string;
95
+ descriptorPath: string;
96
+ }
97
+
98
+ /** Mirrors service/src/pi/pi-version.ts's CURRENT_PI_PACKAGE_NAME
99
+ * literal -- not a shared import, since the public boundary deliberately
100
+ * stays free of internal runtime-specific modules. Keep the two in sync by hand. */
101
+ export const PI_COMMAND_NAME = "@earendil-works/pi-coding-agent";
102
+
103
+ /** Mirrors pi/pi-version.ts's PiVersionReport. */
104
+ export interface PiStatus {
105
+ current?: string;
106
+ latest?: string;
107
+ upToDate?: boolean;
108
+ packageName?: string;
109
+ note?: string;
110
+ }
111
+
112
+ export type ExtensionOperationName =
113
+ | "package.search"
114
+ | "package.info"
115
+ | "package.installed"
116
+ | "package.updates"
117
+ | "package.security.get"
118
+ | "package.security.set"
119
+ | "package.install"
120
+ | "package.install_service"
121
+ | "package.remove"
122
+ | "package.update"
123
+ | "setup.plan"
124
+ | "setup.apply"
125
+ | "resources.list"
126
+ | "resources.toggle"
127
+ | "pi.status";
128
+ export interface ExtensionOperationInputs {
129
+ "package.search": { query: string; limit: number; offline?: boolean };
130
+ "package.info": { name: string };
131
+ "package.installed": Record<string, never>;
132
+ "package.updates": Record<string, never>;
133
+ "pi.status": Record<string, never>;
134
+ "package.security.get": Record<string, never>;
135
+ "package.security.set": { mutationApproval: SecuritySettings["mutationApproval"]; approved?: boolean };
136
+ "package.install": { source: string; approved?: boolean };
137
+ "package.install_service": { source: string; approved?: boolean };
138
+ "package.remove": { name: string; approved?: boolean };
139
+ "package.update": { source: string; approved?: boolean };
140
+ "setup.plan": { manifestPath: string; prune?: boolean };
141
+ "setup.apply": { manifestPath: string; approved?: boolean; prune?: boolean };
142
+ "resources.list": { projectRoot?: string };
143
+ "resources.toggle": { source: string; field: ResourceField; path: string; enabled: boolean; projectRoot?: string; approved?: boolean };
144
+ }
145
+ export interface ExtensionOperationOutputs {
146
+ "package.search": { query: string; total: number; results: PackageSummary[]; offline?: boolean };
147
+ "package.info": PackageInfo;
148
+ "package.installed": InstalledPackage[];
149
+ "package.updates": { updates: UpdateEntry[] };
150
+ "package.security.get": SecuritySettings;
151
+ "package.security.set": SecuritySettings;
152
+ "package.install": { ok: boolean; output: string };
153
+ "package.install_service": { ok: boolean; output: string; spec?: ServiceSpecSummary; notADaemon?: boolean };
154
+ "package.remove": { ok: boolean; output: string };
155
+ "package.update": {
156
+ ok: boolean;
157
+ output: string;
158
+ reloadRequired?: boolean;
159
+ alreadyUpToDate?: boolean;
160
+ pinned?: boolean;
161
+ previousVersion?: string;
162
+ currentVersion?: string;
163
+ };
164
+ "setup.plan": SetupPlan;
165
+ "setup.apply": SetupApplyResult;
166
+ "resources.list": { global: PackageResources[]; project: PackageResources[] };
167
+ "resources.toggle": { ok: boolean; output: string };
168
+ "pi.status": PiStatus;
169
+ }