@danypops/pi-packed 0.19.7 → 0.19.10
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.
- package/dist/client.d.ts +109 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1 -0
- package/dist/protocol.d.ts +221 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +1 -0
- package/extension/src/{permission.ts → approval/permission.ts} +1 -1
- package/extension/src/index.ts +1 -1
- package/extension/src/packed.ts +2 -2
- package/extension/src/{discover.ts → tabs/discover.ts} +4 -4
- package/extension/src/{resource-config.ts → tabs/resource-config.ts} +4 -4
- package/extension/src/{security-tui.ts → tabs/security-tui.ts} +3 -3
- package/extension/src/tool-output.ts +1 -1
- package/extension/src/tools.ts +2 -2
- package/extension/src/tui.ts +4 -4
- package/package.json +31 -8
- package/service/schema/pi-setup-v1.schema.json +70 -0
- package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
- package/service/src/adoption/advisories.ts +268 -0
- package/service/src/adoption/check.ts +872 -0
- package/service/src/adoption/commit-freshness.ts +167 -0
- package/service/src/adoption/doctor.ts +135 -0
- package/service/src/adoption/install-validation.ts +187 -0
- package/service/src/adoption/pack.ts +291 -0
- package/service/src/adoption/score.ts +466 -0
- package/service/src/adoption/smoke-child.ts +113 -0
- package/service/src/adoption/smoke.ts +282 -0
- package/service/src/cli/cli.ts +926 -0
- package/service/src/daemon/cleanup.ts +76 -0
- package/service/src/daemon/client.ts +412 -0
- package/service/src/daemon/daemon-service.ts +249 -0
- package/service/src/daemon/daemon.ts +110 -0
- package/service/src/daemon/service.ts +664 -0
- package/service/src/daemon/watcher.ts +92 -0
- package/service/src/index/build-index.ts +256 -0
- package/service/src/packages/catalog.ts +61 -0
- package/service/src/packages/db.ts +224 -0
- package/service/src/packages/install.ts +60 -0
- package/service/src/packages/installed.ts +123 -0
- package/service/src/packages/package.ts +141 -0
- package/service/src/packages/resources.ts +203 -0
- package/service/src/pi/pi-version.ts +171 -0
- package/service/src/public/atomic-json.ts +32 -0
- package/service/src/public/client.ts +277 -0
- package/service/src/public/protocol.ts +169 -0
- package/service/src/publish/publish.ts +855 -0
- package/service/src/registry/registry.ts +246 -0
- package/service/src/security/security.ts +128 -0
- package/service/src/self-update/self-update.ts +148 -0
- package/service/src/setup/setup.ts +761 -0
- package/service/src/shared/atomic-json.ts +33 -0
- package/service/src/shared/cache.ts +21 -0
- package/service/src/shared/constants.ts +73 -0
- package/service/src/shared/log.ts +21 -0
- package/service/src/shared/paths.ts +88 -0
- package/service/src/shared/state.ts +15 -0
- package/service/src/shared/version.ts +46 -0
- package/service/test/advisories.test.ts +287 -0
- package/service/test/check.test.ts +368 -0
- package/service/test/cleanup.test.ts +220 -0
- package/service/test/cli.test.ts +1303 -0
- package/service/test/core.test.ts +181 -0
- package/service/test/daemon-kit-migration.test.ts +181 -0
- package/service/test/daemon-service.test.ts +238 -0
- package/service/test/db.test.ts +178 -0
- package/service/test/doctor.test.ts +234 -0
- package/service/test/domain.test.ts +291 -0
- package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
- package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
- package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
- package/service/test/index.test.ts +353 -0
- package/service/test/install-validation.test.ts +114 -0
- package/service/test/install.test.ts +113 -0
- package/service/test/log.test.ts +42 -0
- package/service/test/pack-score.test.ts +513 -0
- package/service/test/pi-version.test.ts +318 -0
- package/service/test/public-boundary.test.ts +54 -0
- package/service/test/public-client.test.ts +127 -0
- package/service/test/public-consumer.ts +8 -0
- package/service/test/publish.test.ts +333 -0
- package/service/test/registry-contract.test.ts +148 -0
- package/service/test/resources.test.ts +255 -0
- package/service/test/security.test.ts +89 -0
- package/service/test/self-update.test.ts +257 -0
- package/service/test/service.test.ts +555 -0
- package/service/test/setup.test.ts +375 -0
- package/service/test/smoke.test.ts +118 -0
- package/service/test/version.test.ts +37 -0
- package/service/tsconfig.consumer.json +13 -0
- package/service/tsconfig.public.json +12 -0
- /package/extension/src/{reload.ts → approval/reload.ts} +0 -0
- /package/extension/src/{discover-model.ts → tabs/discover-model.ts} +0 -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
|
+
}
|