@danypops/pi-packed 0.19.9 → 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/approval/permission.ts +1 -1
- package/extension/src/packed.ts +2 -2
- package/extension/src/tabs/security-tui.ts +1 -1
- package/extension/src/tool-output.ts +1 -1
- 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
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry.ts — driven adapter: npm registry over HTTP (web-standard fetch).
|
|
3
|
+
* Lean mapping = Facade over npm's verbose package documents.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { DownloadObservations, Pkg, PkgInfo, Registry, SearchPage } from "../packages/package.ts";
|
|
7
|
+
import {
|
|
8
|
+
NPM_REGISTRY_BASE,
|
|
9
|
+
PAGE_DELAY_MS,
|
|
10
|
+
REGISTRY_FETCH_TIMEOUT_MS,
|
|
11
|
+
RETRY_BASE_DELAY_MS,
|
|
12
|
+
RETRY_MAX_ATTEMPTS,
|
|
13
|
+
SEARCH_PAGE_SIZE,
|
|
14
|
+
} from "../shared/constants.ts";
|
|
15
|
+
import { createLogger } from "../shared/log.ts";
|
|
16
|
+
|
|
17
|
+
const log = createLogger("registry");
|
|
18
|
+
|
|
19
|
+
/** Upstream etiquette: honor Retry-After on 429, exponential backoff
|
|
20
|
+
* otherwise, give up after RETRY_MAX_ATTEMPTS. */
|
|
21
|
+
async function fetchWithRetry(url: string, init: RequestInit | undefined, baseDelayMs: number): Promise<Response> {
|
|
22
|
+
let lastErr: Error | undefined;
|
|
23
|
+
for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
|
|
24
|
+
const t0 = Date.now();
|
|
25
|
+
try {
|
|
26
|
+
const signal = init?.signal
|
|
27
|
+
? AbortSignal.any([init.signal, AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS)])
|
|
28
|
+
: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS);
|
|
29
|
+
const res = await fetch(url, { ...init, signal });
|
|
30
|
+
const ms = Date.now() - t0;
|
|
31
|
+
if (res.status === 429 && attempt < RETRY_MAX_ATTEMPTS) {
|
|
32
|
+
const ra = res.headers.get("retry-after");
|
|
33
|
+
const delayMs =
|
|
34
|
+
ra !== null && Number(ra) > 0
|
|
35
|
+
? Number(ra) * 1000 // authoritative only when positive
|
|
36
|
+
: baseDelayMs * 2 ** (attempt - 1); // npm sends 0: use exponential
|
|
37
|
+
log.warn("429 rate-limited, backing off", { attempt, delayMs, ms, url: url.slice(0, 120) });
|
|
38
|
+
await Bun.sleep(delayMs);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
log.debug("fetch", { status: res.status, ms, attempt, url: url.slice(0, 120) });
|
|
42
|
+
return res;
|
|
43
|
+
} catch (e) {
|
|
44
|
+
lastErr = e instanceof Error ? e : new Error(String(e));
|
|
45
|
+
log.warn("fetch error, retrying", { attempt, error: lastErr.message, url: url.slice(0, 120) });
|
|
46
|
+
if (attempt < RETRY_MAX_ATTEMPTS) await Bun.sleep(baseDelayMs * 2 ** (attempt - 1));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
log.error("retry budget exhausted", { attempts: RETRY_MAX_ATTEMPTS, url: url.slice(0, 120) });
|
|
50
|
+
throw lastErr ?? new Error("retry budget exhausted");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class HttpRegistry implements Registry {
|
|
54
|
+
constructor(
|
|
55
|
+
private base = NPM_REGISTRY_BASE,
|
|
56
|
+
private pageSize = SEARCH_PAGE_SIZE,
|
|
57
|
+
private pageDelayMs = PAGE_DELAY_MS,
|
|
58
|
+
private retryBaseDelayMs = RETRY_BASE_DELAY_MS,
|
|
59
|
+
private downloadsBase = "https://api.npmjs.org",
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
async search(query: string, limit: number): Promise<SearchPage> {
|
|
63
|
+
return this.searchPage(query, 0, limit);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async searchPage(query: string, from: number, size: number): Promise<SearchPage> {
|
|
67
|
+
const params = new URLSearchParams({ text: query, size: String(size), from: String(from) });
|
|
68
|
+
const res = await fetchWithRetry(`${this.base}/-/v1/search?${params}`, undefined, this.retryBaseDelayMs);
|
|
69
|
+
if (!res.ok) throw new Error(`npm search: HTTP ${res.status}`);
|
|
70
|
+
const doc = (await res.json()) as {
|
|
71
|
+
total?: number;
|
|
72
|
+
objects?: { package?: { name?: string; version?: string; description?: string; date?: string } }[];
|
|
73
|
+
};
|
|
74
|
+
const results: Pkg[] = (doc.objects ?? []).flatMap((o) => {
|
|
75
|
+
const p = o.package;
|
|
76
|
+
return p?.name
|
|
77
|
+
? [
|
|
78
|
+
{
|
|
79
|
+
name: boundedString(p.name, 214)!,
|
|
80
|
+
version: boundedString(p.version, 128) ?? "",
|
|
81
|
+
description: boundedString(p.description, 512),
|
|
82
|
+
date: boundedString(p.date, 64),
|
|
83
|
+
packageEvidence: {
|
|
84
|
+
shape: "keyword-only" as const,
|
|
85
|
+
verified: false,
|
|
86
|
+
evidence: ["npm keyword search candidate; tarball not inspected"],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
: [];
|
|
91
|
+
});
|
|
92
|
+
return { results, total: doc.total ?? results.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async searchAll(query: string): Promise<Pkg[]> {
|
|
96
|
+
// Map by name: npm's ranking shifts mid-pagination and a package can
|
|
97
|
+
// appear on two pages — first occurrence wins.
|
|
98
|
+
const byName = new Map<string, Pkg>();
|
|
99
|
+
let from = 0;
|
|
100
|
+
for (;;) {
|
|
101
|
+
if (from > 0 && this.pageDelayMs > 0) await Bun.sleep(this.pageDelayMs);
|
|
102
|
+
const { results, total } = await this.searchPage(query, from, this.pageSize);
|
|
103
|
+
if (results.length === 0 || from >= total) break;
|
|
104
|
+
for (const p of results) {
|
|
105
|
+
if (!byName.has(p.name)) byName.set(p.name, p);
|
|
106
|
+
}
|
|
107
|
+
from += results.length;
|
|
108
|
+
}
|
|
109
|
+
return [...byName.values()];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async info(name: string): Promise<PkgInfo> {
|
|
113
|
+
const encoded = encodeURIComponent(name).replace("%2F", "/");
|
|
114
|
+
const res = await fetchWithRetry(`${this.base}/${encoded}/latest`, { headers: { accept: "application/json" } }, this.retryBaseDelayMs);
|
|
115
|
+
if (!res.ok) throw new Error(`npm info ${name}: HTTP ${res.status}`);
|
|
116
|
+
const v = (await boundedJson(res, 1024 * 1024)) as {
|
|
117
|
+
name?: string;
|
|
118
|
+
version?: string;
|
|
119
|
+
description?: string;
|
|
120
|
+
homepage?: string;
|
|
121
|
+
license?: unknown;
|
|
122
|
+
repository?: unknown;
|
|
123
|
+
bugs?: unknown;
|
|
124
|
+
keywords?: string[];
|
|
125
|
+
pi?: Record<string, unknown>;
|
|
126
|
+
peerDependencies?: Record<string, string>;
|
|
127
|
+
scripts?: Record<string, string>;
|
|
128
|
+
dist?: { unpackedSize?: number; integrity?: string; attestations?: { url?: string; provenance?: unknown } };
|
|
129
|
+
};
|
|
130
|
+
const manifestFields = v.pi ? Object.keys(v.pi).filter((key) => ["extensions", "skills", "prompts", "themes"].includes(key)) : [];
|
|
131
|
+
return {
|
|
132
|
+
name: boundedString(v.name, 214) ?? boundedString(name, 214)!,
|
|
133
|
+
version: boundedString(v.version, 128) ?? "",
|
|
134
|
+
description: boundedString(v.description, 512),
|
|
135
|
+
homepage: boundedString(v.homepage, 2_048),
|
|
136
|
+
repository: boundedString(rawToString(v.repository, "url"), 2_048),
|
|
137
|
+
repositoryDirectory: boundedString(rawToString(v.repository, "directory"), 256),
|
|
138
|
+
bugs: boundedString(rawToString(v.bugs, "url"), 2_048),
|
|
139
|
+
license: boundedString(rawToString(v.license, "type"), 128),
|
|
140
|
+
keywords: v.keywords
|
|
141
|
+
?.filter((value): value is string => typeof value === "string")
|
|
142
|
+
.slice(0, 50)
|
|
143
|
+
.map((value) => value.slice(0, 64)),
|
|
144
|
+
pi: boundedPiManifest(v.pi),
|
|
145
|
+
peerDependencies: boundedDependencies(v.peerDependencies),
|
|
146
|
+
scripts: boundedDependencies(v.scripts),
|
|
147
|
+
readmeAvailable: false,
|
|
148
|
+
unpackedSize: v.dist?.unpackedSize,
|
|
149
|
+
packageEvidence:
|
|
150
|
+
manifestFields.length > 0
|
|
151
|
+
? {
|
|
152
|
+
shape: "manifest",
|
|
153
|
+
verified: false,
|
|
154
|
+
evidence: manifestFields.map((field) => `registry package.json pi.${field}; tarball not inspected`),
|
|
155
|
+
}
|
|
156
|
+
: { shape: "keyword-only", verified: false, evidence: ["registry metadata has no Pi manifest resources; tarball not inspected"] },
|
|
157
|
+
publication: {
|
|
158
|
+
integrity: v.dist?.integrity,
|
|
159
|
+
provenanceUrl: v.dist?.attestations?.provenance ? v.dist.attestations.url : undefined,
|
|
160
|
+
trustedPublisher: "unknown",
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Uses npm's abbreviated multi-version doc, not `/latest` (which carries
|
|
166
|
+
* no `time`/`modified` field at all -- confirmed against the live
|
|
167
|
+
* registry). Still bounded well under the full unabbreviated document's
|
|
168
|
+
* size, since the abbreviated shape drops readme/changelog content per
|
|
169
|
+
* version. */
|
|
170
|
+
async modifiedAt(name: string): Promise<string | undefined> {
|
|
171
|
+
const encoded = encodeURIComponent(name).replace("%2F", "/");
|
|
172
|
+
const res = await fetchWithRetry(
|
|
173
|
+
`${this.base}/${encoded}`,
|
|
174
|
+
{ headers: { accept: "application/vnd.npm.install-v1+json" } },
|
|
175
|
+
this.retryBaseDelayMs,
|
|
176
|
+
);
|
|
177
|
+
if (!res.ok) throw new Error(`npm modifiedAt ${name}: HTTP ${res.status}`);
|
|
178
|
+
const doc = (await boundedJson(res, 512 * 1024)) as { modified?: unknown };
|
|
179
|
+
return boundedString(doc.modified, 64);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async downloads(name: string): Promise<DownloadObservations> {
|
|
183
|
+
const encoded = encodeURIComponent(name).replace("%2F", "/");
|
|
184
|
+
const [weekly, monthly] = await Promise.all([
|
|
185
|
+
fetchWithRetry(`${this.downloadsBase}/downloads/point/last-week/${encoded}`, undefined, this.retryBaseDelayMs),
|
|
186
|
+
fetchWithRetry(`${this.downloadsBase}/downloads/point/last-month/${encoded}`, undefined, this.retryBaseDelayMs),
|
|
187
|
+
]);
|
|
188
|
+
if (!weekly.ok || !monthly.ok) throw new Error(`npm downloads ${name}: HTTP ${weekly.ok ? monthly.status : weekly.status}`);
|
|
189
|
+
const [weekDoc, monthDoc] = (await Promise.all([boundedJson(weekly, 64 * 1024), boundedJson(monthly, 64 * 1024)])) as [
|
|
190
|
+
{ downloads?: number },
|
|
191
|
+
{ downloads?: number },
|
|
192
|
+
];
|
|
193
|
+
return { weekly: boundedCount(weekDoc.downloads), monthly: boundedCount(monthDoc.downloads), observedAt: new Date().toISOString() };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function boundedJson(response: Response, maxBytes: number): Promise<unknown> {
|
|
198
|
+
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
199
|
+
if (declared > maxBytes) throw new Error(`npm response exceeds ${maxBytes} bytes`);
|
|
200
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
201
|
+
if (bytes.byteLength > maxBytes) throw new Error(`npm response exceeds ${maxBytes} bytes`);
|
|
202
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function boundedString(value: unknown, max: number): string | undefined {
|
|
206
|
+
return typeof value === "string" ? value.slice(0, max) : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function boundedPiManifest(value: unknown): Record<string, unknown> | undefined {
|
|
210
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
211
|
+
const source = value as Record<string, unknown>;
|
|
212
|
+
const result: Record<string, unknown> = {};
|
|
213
|
+
for (const field of ["extensions", "skills", "prompts", "themes"] as const) {
|
|
214
|
+
if (Array.isArray(source[field]))
|
|
215
|
+
result[field] = source[field]
|
|
216
|
+
.filter((item): item is string => typeof item === "string")
|
|
217
|
+
.slice(0, 100)
|
|
218
|
+
.map((item) => item.slice(0, 256));
|
|
219
|
+
}
|
|
220
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function boundedDependencies(value: unknown): Record<string, string> | undefined {
|
|
224
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
225
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
226
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
|
227
|
+
.slice(0, 100)
|
|
228
|
+
.map(([name, range]) => [name.slice(0, 214), range.slice(0, 128)]);
|
|
229
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function boundedCount(value: unknown): number | undefined {
|
|
233
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
234
|
+
? Math.min(Math.floor(value), Number.MAX_SAFE_INTEGER)
|
|
235
|
+
: undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** npm fields appear as plain string OR object: license: "MIT" | {type:"MIT"}. */
|
|
239
|
+
function rawToString(raw: unknown, objKey: string): string | undefined {
|
|
240
|
+
if (typeof raw === "string") return raw;
|
|
241
|
+
if (raw && typeof raw === "object") {
|
|
242
|
+
const v = (raw as Record<string, unknown>)[objKey];
|
|
243
|
+
if (typeof v === "string") return v;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeJsonAtomic } from "../shared/atomic-json.ts";
|
|
4
|
+
import { SECURITY_FILE } from "../shared/constants.ts";
|
|
5
|
+
|
|
6
|
+
export const MUTATION_APPROVAL_VALUES = ["always", "never"] as const;
|
|
7
|
+
export type MutationApproval = (typeof MUTATION_APPROVAL_VALUES)[number];
|
|
8
|
+
|
|
9
|
+
export const PACKAGE_OPERATIONS = [
|
|
10
|
+
"search",
|
|
11
|
+
"info",
|
|
12
|
+
"installed",
|
|
13
|
+
"catalog",
|
|
14
|
+
"index.status",
|
|
15
|
+
"index.build",
|
|
16
|
+
"updates",
|
|
17
|
+
"check",
|
|
18
|
+
"setup.plan",
|
|
19
|
+
"security.read",
|
|
20
|
+
"mirror",
|
|
21
|
+
"setup.export",
|
|
22
|
+
"setup.update",
|
|
23
|
+
"install",
|
|
24
|
+
"install_service",
|
|
25
|
+
"restart_service",
|
|
26
|
+
"setup.apply",
|
|
27
|
+
"update",
|
|
28
|
+
"update.self",
|
|
29
|
+
"remove",
|
|
30
|
+
"resources.list",
|
|
31
|
+
"resources.toggle",
|
|
32
|
+
"security.write",
|
|
33
|
+
"pi.status",
|
|
34
|
+
"advisories.scan",
|
|
35
|
+
"doctor",
|
|
36
|
+
] as const;
|
|
37
|
+
export type PackageOperation = (typeof PACKAGE_OPERATIONS)[number];
|
|
38
|
+
export type PackageOperationClassification = "read" | "maintenance" | "code-execution" | "settings-mutation" | "security-mutation";
|
|
39
|
+
|
|
40
|
+
export interface SecuritySettings {
|
|
41
|
+
mutationApproval: MutationApproval;
|
|
42
|
+
}
|
|
43
|
+
export interface SecuritySettingsPort {
|
|
44
|
+
security(): Promise<SecuritySettings>;
|
|
45
|
+
setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise<SecuritySettings>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PackagePermissionDecision {
|
|
49
|
+
operation: PackageOperation;
|
|
50
|
+
classification: PackageOperationClassification;
|
|
51
|
+
approvalRequired: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { mutationApproval: "always" };
|
|
55
|
+
|
|
56
|
+
const CLASSIFICATIONS: Record<PackageOperation, PackageOperationClassification> = {
|
|
57
|
+
search: "read",
|
|
58
|
+
info: "read",
|
|
59
|
+
installed: "read",
|
|
60
|
+
catalog: "read",
|
|
61
|
+
"index.status": "read",
|
|
62
|
+
"index.build": "maintenance",
|
|
63
|
+
updates: "read",
|
|
64
|
+
check: "read",
|
|
65
|
+
"setup.plan": "read",
|
|
66
|
+
"security.read": "read",
|
|
67
|
+
mirror: "maintenance",
|
|
68
|
+
"setup.export": "maintenance",
|
|
69
|
+
"setup.update": "maintenance",
|
|
70
|
+
install: "code-execution",
|
|
71
|
+
install_service: "code-execution",
|
|
72
|
+
restart_service: "code-execution",
|
|
73
|
+
"setup.apply": "code-execution",
|
|
74
|
+
update: "code-execution",
|
|
75
|
+
"update.self": "code-execution",
|
|
76
|
+
remove: "settings-mutation",
|
|
77
|
+
"resources.list": "read",
|
|
78
|
+
"resources.toggle": "settings-mutation",
|
|
79
|
+
"security.write": "security-mutation",
|
|
80
|
+
"pi.status": "read",
|
|
81
|
+
"advisories.scan": "read",
|
|
82
|
+
doctor: "read",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export class PackageApprovalRequiredError extends Error {
|
|
86
|
+
readonly code = "approval_required";
|
|
87
|
+
constructor(readonly operation: PackageOperation) {
|
|
88
|
+
super(`approval required for package operation ${operation}`);
|
|
89
|
+
this.name = "PackageApprovalRequiredError";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function packageOperationClassification(operation: PackageOperation): PackageOperationClassification {
|
|
94
|
+
return CLASSIFICATIONS[operation];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function packagePermissionDecision(settings: SecuritySettings, operation: PackageOperation): PackagePermissionDecision {
|
|
98
|
+
const classification = packageOperationClassification(operation);
|
|
99
|
+
const guarded = classification === "code-execution" || classification === "settings-mutation" || classification === "security-mutation";
|
|
100
|
+
return { operation, classification, approvalRequired: guarded && settings.mutationApproval === "always" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function assertPackagePermission(settings: SecuritySettings, operation: PackageOperation, approved = false): void {
|
|
104
|
+
if (packagePermissionDecision(settings, operation).approvalRequired && !approved) {
|
|
105
|
+
throw new PackageApprovalRequiredError(operation);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function readSecuritySettings(stateDir: string): SecuritySettings {
|
|
110
|
+
try {
|
|
111
|
+
const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as {
|
|
112
|
+
mutationApproval?: unknown;
|
|
113
|
+
installApproval?: unknown;
|
|
114
|
+
};
|
|
115
|
+
const stored = value.mutationApproval ?? value.installApproval;
|
|
116
|
+
return MUTATION_APPROVAL_VALUES.includes(stored as MutationApproval)
|
|
117
|
+
? { mutationApproval: stored as MutationApproval }
|
|
118
|
+
: { ...DEFAULT_SECURITY_SETTINGS };
|
|
119
|
+
} catch {
|
|
120
|
+
return { ...DEFAULT_SECURITY_SETTINGS };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function writeSecuritySettings(stateDir: string, settings: SecuritySettings): Promise<SecuritySettings> {
|
|
125
|
+
if (!MUTATION_APPROVAL_VALUES.includes(settings.mutationApproval)) throw new Error("mutationApproval must be always or never");
|
|
126
|
+
await writeJsonAtomic(join(stateDir, SECURITY_FILE), settings, { mode: 0o600 });
|
|
127
|
+
return { ...settings };
|
|
128
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* self-update.ts — updates Packed's own installation and restarts its
|
|
3
|
+
* supervised systemd service, mirroring pi-version.ts's established
|
|
4
|
+
* pattern: orchestrate the real package manager's own update command,
|
|
5
|
+
* never reimplement version resolution.
|
|
6
|
+
*
|
|
7
|
+
* Detects how this CLI is actually running first. An npm-global install
|
|
8
|
+
* (the real end-user case) gets a real `npm install --global` update. A
|
|
9
|
+
* local git checkout (this repo's own dev setup: the shell wrapper execs
|
|
10
|
+
* `bun .../packed/packages/packed/src/cli/cli.ts` directly, no npm layer at
|
|
11
|
+
* all) skips the update step entirely rather than guessing at a git
|
|
12
|
+
* workflow -- but still restarts the service, which is the actual
|
|
13
|
+
* point for that case: pick up already-committed local source changes
|
|
14
|
+
* without a manual `systemctl restart`.
|
|
15
|
+
*/
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import type { Registry } from "../packages/package.ts";
|
|
18
|
+
import { type InteractiveRunResult, runInherited } from "../publish/publish.ts";
|
|
19
|
+
import { VERSION } from "../shared/version.ts";
|
|
20
|
+
|
|
21
|
+
export const PACKED_PACKAGE_NAME = "@danypops/pi-packed";
|
|
22
|
+
|
|
23
|
+
export type SelfInstallMethod = { kind: "npm-global" } | { kind: "bun-global" } | { kind: "local-checkout"; path: string };
|
|
24
|
+
|
|
25
|
+
/** cli.ts's own resolved real path tells us how it's running: Bun's global
|
|
26
|
+
* install layout is `$BUN_INSTALL/install/global/node_modules/<pkg>/...`
|
|
27
|
+
* (confirmed live against a real `bun add -g` install -- distinct from a
|
|
28
|
+
* plain npm-global tree, which has no `install/global` segment at all), an
|
|
29
|
+
* ordinary node_modules/@danypops/pi-packed tree (npm-managed), or anywhere
|
|
30
|
+
* else (a plain checkout). Bun-global is checked first since its path also
|
|
31
|
+
* contains the broader npm-global substring. */
|
|
32
|
+
export function detectSelfInstallMethod(cliUrl: string = import.meta.url): SelfInstallMethod {
|
|
33
|
+
const path = fileURLToPath(cliUrl);
|
|
34
|
+
if (path.includes(`/install/global/node_modules/${PACKED_PACKAGE_NAME}/`)) return { kind: "bun-global" };
|
|
35
|
+
if (path.includes(`node_modules/${PACKED_PACKAGE_NAME}/`)) return { kind: "npm-global" };
|
|
36
|
+
return { kind: "local-checkout", path };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SelfUpdateReport {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
previousVersion: string;
|
|
42
|
+
latestVersion?: string;
|
|
43
|
+
updated: boolean;
|
|
44
|
+
restarted: boolean;
|
|
45
|
+
message: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SelfUpdateDeps {
|
|
49
|
+
registry: Pick<Registry, "info">;
|
|
50
|
+
installMethod?: SelfInstallMethod;
|
|
51
|
+
/** Defaults to `npm install --global @danypops/pi-packed@latest` via
|
|
52
|
+
* runInherited -- real npm output, no timeout, matching
|
|
53
|
+
* runPiUpdateSelf's exact pattern for pi's own self-update. */
|
|
54
|
+
runNpmInstall?: (args: string[]) => Promise<InteractiveRunResult>;
|
|
55
|
+
/** Defaults to `bun add --global @danypops/pi-packed@latest` via
|
|
56
|
+
* runInherited -- Bun's own documented way to force-update a global
|
|
57
|
+
* install (re-adding with an explicit version); `bun update` has no
|
|
58
|
+
* --global flag at all. */
|
|
59
|
+
runBunInstall?: (args: string[]) => Promise<InteractiveRunResult>;
|
|
60
|
+
/** Undefined when the current platform has no supported restart
|
|
61
|
+
* mechanism (only Linux/systemd today, matching `packed service`'s
|
|
62
|
+
* own Linux-only scope) -- never guessed at. */
|
|
63
|
+
restartService?: () => Promise<InteractiveRunResult>;
|
|
64
|
+
isServiceInstalled?: () => boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runSelfUpdate(deps: SelfUpdateDeps): Promise<SelfUpdateReport> {
|
|
68
|
+
const previousVersion = VERSION;
|
|
69
|
+
const method = deps.installMethod ?? detectSelfInstallMethod();
|
|
70
|
+
let latestVersion: string | undefined;
|
|
71
|
+
let updated = false;
|
|
72
|
+
let updateNote: string;
|
|
73
|
+
|
|
74
|
+
if (method.kind === "local-checkout") {
|
|
75
|
+
updateNote = `skipped update: running from a local checkout at ${method.path} (update it with your own git workflow, e.g. git pull)`;
|
|
76
|
+
} else {
|
|
77
|
+
try {
|
|
78
|
+
latestVersion = (await deps.registry.info(PACKED_PACKAGE_NAME)).version;
|
|
79
|
+
} catch {
|
|
80
|
+
/* best-effort; the package manager's own install resolves latest either way */
|
|
81
|
+
}
|
|
82
|
+
if (method.kind === "bun-global") {
|
|
83
|
+
const runBunInstall = deps.runBunInstall ?? ((args) => runInherited(["bun", ...args]));
|
|
84
|
+
const install = await runBunInstall(["add", "--global", `${PACKED_PACKAGE_NAME}@latest`]);
|
|
85
|
+
if (!install.ok) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
previousVersion,
|
|
89
|
+
latestVersion,
|
|
90
|
+
updated: false,
|
|
91
|
+
restarted: false,
|
|
92
|
+
message: `bun add --global ${PACKED_PACKAGE_NAME}@latest failed (exit ${install.code})`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
updated = true;
|
|
96
|
+
updateNote = "updated via bun";
|
|
97
|
+
} else {
|
|
98
|
+
const runNpmInstall = deps.runNpmInstall ?? ((args) => runInherited(["npm", ...args]));
|
|
99
|
+
const install = await runNpmInstall(["install", "--global", `${PACKED_PACKAGE_NAME}@latest`]);
|
|
100
|
+
if (!install.ok) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
previousVersion,
|
|
104
|
+
latestVersion,
|
|
105
|
+
updated: false,
|
|
106
|
+
restarted: false,
|
|
107
|
+
message: `npm install --global ${PACKED_PACKAGE_NAME}@latest failed (exit ${install.code})`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
updated = true;
|
|
111
|
+
updateNote = "updated via npm";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const isServiceInstalled = deps.isServiceInstalled ?? (() => false);
|
|
116
|
+
if (!isServiceInstalled()) {
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
previousVersion,
|
|
120
|
+
latestVersion,
|
|
121
|
+
updated,
|
|
122
|
+
restarted: false,
|
|
123
|
+
message: `${updateNote}; no supervised pi-packed service was found -- restart any running daemon manually`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (!deps.restartService) {
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
previousVersion,
|
|
130
|
+
latestVersion,
|
|
131
|
+
updated,
|
|
132
|
+
restarted: false,
|
|
133
|
+
message: `${updateNote}; restarting the service isn't supported on this platform yet -- restart it manually: systemctl --user restart pi-packed.service`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const restart = await deps.restartService();
|
|
137
|
+
if (!restart.ok) {
|
|
138
|
+
return {
|
|
139
|
+
ok: updated,
|
|
140
|
+
previousVersion,
|
|
141
|
+
latestVersion,
|
|
142
|
+
updated,
|
|
143
|
+
restarted: false,
|
|
144
|
+
message: `${updateNote}; restarting pi-packed.service failed (exit ${restart.code}) -- restart it manually: systemctl --user restart pi-packed.service`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, previousVersion, latestVersion, updated, restarted: true, message: `${updateNote}; restarted the pi-packed service` };
|
|
148
|
+
}
|