promptdock 1.2.1 → 1.2.2
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/README.md +67 -21
- package/dist/auth.js +8 -2
- package/dist/commands/install.js +114 -41
- package/dist/commands/lifecycle.js +117 -19
- package/dist/context.d.ts +9 -0
- package/dist/context.js +56 -0
- package/dist/errors.d.ts +21 -1
- package/dist/errors.js +12 -1
- package/dist/help.d.ts +2 -0
- package/dist/help.js +25 -6
- package/dist/index.js +4 -1
- package/dist/installer.d.ts +88 -15
- package/dist/installer.js +356 -98
- package/dist/integrity.d.ts +13 -0
- package/dist/integrity.js +27 -0
- package/dist/leftovers.d.ts +32 -0
- package/dist/leftovers.js +175 -0
- package/dist/local-changes.d.ts +108 -0
- package/dist/local-changes.js +334 -0
- package/dist/receipts.d.ts +2 -1
- package/dist/receipts.js +10 -3
- package/dist/semver.d.ts +19 -0
- package/dist/semver.js +54 -0
- package/dist/terminal-text.d.ts +4 -3
- package/dist/terminal-text.js +4 -3
- package/dist/verdicts.d.ts +2 -0
- package/dist/verdicts.js +24 -9
- package/package.json +2 -2
package/dist/installer.js
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
// The two-phase install engine (shared by `install` and `update`):
|
|
2
|
-
// POST install →
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// write the receipt →
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// POST install → refuse on local changes the manifest reveals → download into a
|
|
3
|
+
// temporary folder ABOVE the skills folder → verify EVERY sha256 → re-validate EVERY
|
|
4
|
+
// path (E8/audit row 10 — the server is untrusted) → check local changes AGAIN →
|
|
5
|
+
// carry the member's own files into the new copy → write the receipt into it → swap
|
|
6
|
+
// (same-filesystem renames, the old copy kept until the new one verifies) → best-effort
|
|
7
|
+
// /install/complete.
|
|
8
|
+
// All-or-nothing: any failure before the swap discards the temporary folder and leaves
|
|
9
|
+
// the target exactly as it was ("Nothing was installed" / "Nothing was changed").
|
|
8
10
|
import { randomBytes } from "node:crypto";
|
|
9
|
-
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { accessSync, constants as fsConstants, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
10
12
|
import { dirname, join } from "node:path";
|
|
11
|
-
import { CliError, EXIT, mapFsError
|
|
13
|
+
import { CliError, EXIT, mapFsError } from "./errors.js";
|
|
12
14
|
import { SERVER_PATH_MAX, terminalLine } from "./terminal-text.js";
|
|
13
15
|
import { CLI_DOS_MAX_SKILL_FILES as MAX_SKILL_FILES, CLI_DOS_MAX_SKILL_FILE_BYTES as MAX_SKILL_FILE_BYTES, CLI_DOS_MAX_SKILL_TOTAL_BYTES as MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
|
|
14
16
|
import { sha256Hex, sha256Matches } from "./integrity.js";
|
|
17
|
+
import { carryEntry, conflictChanges, isCarried, localChangesRefusal, mergeChanges, pathKeyFor, probeCaseInsensitive, receiptSideChanges, specialChanges, unlistedEntries, } from "./local-changes.js";
|
|
15
18
|
import { validateSkillPaths } from "./paths.js";
|
|
16
|
-
import { writeReceipt, RECEIPT_SCHEMA } from "./receipts.js";
|
|
19
|
+
import { RECEIPT_NAME, readReceipt, writeReceipt, RECEIPT_SCHEMA } from "./receipts.js";
|
|
17
20
|
const INTEGRITY_ABORT = "integrity check failed — nothing installed";
|
|
21
|
+
/** An install's temporary folders, by kind. The number in the name is the operation's start. */
|
|
22
|
+
export const STAGING_PREFIX = ".promptdock-staging-";
|
|
23
|
+
export const OLD_PREFIX = ".promptdock-old-";
|
|
24
|
+
/** A previous copy kept because the new one did not verify: never removed automatically. */
|
|
25
|
+
export const KEPT_PREFIX = ".promptdock-kept-";
|
|
26
|
+
/** `.promptdock-<kind>-<start ms>-<random>`: the age a later command reads (ENG-A4). */
|
|
27
|
+
export function tempName(kind, startedAt) {
|
|
28
|
+
return `.promptdock-${kind}-${Math.floor(startedAt)}-${randomBytes(6).toString("hex")}`;
|
|
29
|
+
}
|
|
18
30
|
/** Narrow the untrusted install response; throws INTEGRITY on a bad shape. */
|
|
19
31
|
export function checkInstallResponse(resp) {
|
|
20
32
|
const bad = () => new CliError(`${INTEGRITY_ABORT} (malformed server response)`, EXIT.INTEGRITY, {
|
|
@@ -124,22 +136,65 @@ export function assertSafeManifest(manifest) {
|
|
|
124
136
|
}
|
|
125
137
|
}
|
|
126
138
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
139
|
+
* Where an install's temporary folders live (DX-A2, I5): the folder ABOVE the skills
|
|
140
|
+
* folder, so a killed install never leaves a folder holding a SKILL.md where the agent
|
|
141
|
+
* scans for skills. Falls back to the skills folder itself when the folder above is
|
|
142
|
+
* missing, not writable, or on another filesystem (the swap must stay a rename, and a
|
|
143
|
+
* rename across filesystems fails with EXDEV). The skills folder must exist.
|
|
131
144
|
*/
|
|
132
|
-
export
|
|
133
|
-
assertSafeManifest(resp.manifest);
|
|
134
|
-
const urlByPath = new Map(resp.files.map((f) => [f.path, f.url]));
|
|
145
|
+
export function tempRootFor(targetDir) {
|
|
135
146
|
const parent = dirname(targetDir);
|
|
147
|
+
const grand = dirname(parent);
|
|
148
|
+
if (grand === parent)
|
|
149
|
+
return parent;
|
|
136
150
|
try {
|
|
137
|
-
|
|
151
|
+
accessSync(grand, fsConstants.W_OK);
|
|
152
|
+
if (statSync(grand).dev !== statSync(parent).dev)
|
|
153
|
+
return parent;
|
|
154
|
+
return grand;
|
|
138
155
|
}
|
|
139
|
-
catch
|
|
140
|
-
|
|
156
|
+
catch {
|
|
157
|
+
return parent;
|
|
141
158
|
}
|
|
142
|
-
|
|
159
|
+
}
|
|
160
|
+
/** Can this process really create a folder in `dir`? (A mode-bit check can pass and lie.) */
|
|
161
|
+
function canCreateIn(dir) {
|
|
162
|
+
const probe = join(dir, `.promptdock-probe-${randomBytes(4).toString("hex")}`);
|
|
163
|
+
try {
|
|
164
|
+
mkdirSync(probe);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
rmSync(probe, { recursive: true, force: true });
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
const DROPPED = (path, i, n) => `The connection dropped while downloading ${terminalLine(path, SERVER_PATH_MAX)} (${i} of ${n}). Nothing was installed. Run the same command again.`;
|
|
173
|
+
const LINKS_EXPIRED = "The download links expired before the files finished downloading. Nothing was installed. Run the same command again.";
|
|
174
|
+
const CHANGED_WHILE_INSTALLING = "This skill changed while it was installing. Nothing was installed. Run the same command again.";
|
|
175
|
+
/** A re-signed response must describe the same files, or it is not a refresh (I9). */
|
|
176
|
+
function sameManifest(a, b) {
|
|
177
|
+
if (a.skill.version_id !== b.skill.version_id || a.manifest.length !== b.manifest.length)
|
|
178
|
+
return false;
|
|
179
|
+
const key = (e) => `${e.path}\u0000${e.sha256.toLowerCase()}`;
|
|
180
|
+
const want = new Set(a.manifest.map(key));
|
|
181
|
+
return b.manifest.every((e) => want.has(key(e)));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Download every manifest file into `tempDir` (which must not exist yet), verifying
|
|
185
|
+
* sha256 + byte length against the manifest. Throws (and removes `tempDir`) on ANY
|
|
186
|
+
* mismatch — the sha in the manifest is the transport-integrity anchor.
|
|
187
|
+
*
|
|
188
|
+
* I6: a dropped connection is exit 6 (`download`), never an "unexpected error".
|
|
189
|
+
* I9: signed URLs expire after a few minutes while files download one at a time, so a
|
|
190
|
+
* 400 or 403 from a signed URL re-signs ONCE through `resign` (the install endpoint:
|
|
191
|
+
* `record_skill_install` is idempotent per member and skill, so no second install is
|
|
192
|
+
* recorded) and retries; a second expiry is the plain "links expired" refusal.
|
|
193
|
+
*/
|
|
194
|
+
export async function downloadAndStage(ctx, resp0, tempDir, opts = {}) {
|
|
195
|
+
let resp = resp0;
|
|
196
|
+
assertSafeManifest(resp.manifest);
|
|
197
|
+
let urlByPath = new Map(resp.files.map((f) => [f.path, f.url]));
|
|
143
198
|
const files = [];
|
|
144
199
|
/**
|
|
145
200
|
* Bytes actually RECEIVED, not bytes the server said it would send.
|
|
@@ -153,42 +208,84 @@ export async function downloadAndStage(ctx, api, resp, targetDir) {
|
|
|
153
208
|
* The manifest check is a fast pre-flight; THIS is the enforcement.
|
|
154
209
|
*/
|
|
155
210
|
let received = 0;
|
|
211
|
+
let resigned = false;
|
|
212
|
+
const n = resp.manifest.length;
|
|
156
213
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
let
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
214
|
+
try {
|
|
215
|
+
mkdirSync(tempDir);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
throw mapFsError(err, tempDir);
|
|
219
|
+
}
|
|
220
|
+
for (let i = 0; i < resp.manifest.length; i++) {
|
|
221
|
+
const entry = resp.manifest[i];
|
|
222
|
+
let buf = null;
|
|
223
|
+
while (buf === null) {
|
|
224
|
+
const url = urlByPath.get(entry.path);
|
|
225
|
+
if (!url) {
|
|
226
|
+
throw new CliError(`${INTEGRITY_ABORT} (no download URL for "${entry.path}")`, EXIT.INTEGRITY, {
|
|
227
|
+
footer: "integrity",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
let res;
|
|
231
|
+
try {
|
|
232
|
+
res = await ctx.fetch(url);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
throw new CliError(DROPPED(entry.path, i + 1, n), EXIT.NETWORK, { footer: "download" });
|
|
236
|
+
}
|
|
237
|
+
if ((res.status === 400 || res.status === 403) && opts.resign) {
|
|
238
|
+
await res.body?.cancel().catch(() => { });
|
|
239
|
+
if (resigned)
|
|
240
|
+
throw new CliError(LINKS_EXPIRED, EXIT.NETWORK, { footer: "download" });
|
|
241
|
+
resigned = true;
|
|
242
|
+
let fresh;
|
|
243
|
+
try {
|
|
244
|
+
fresh = checkInstallResponse(await opts.resign());
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
if (err instanceof CliError && err.code === "version_conflict") {
|
|
248
|
+
throw new CliError(CHANGED_WHILE_INSTALLING, err.exitCode, { footer: "version_conflict" });
|
|
249
|
+
}
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
if (!sameManifest(resp, fresh)) {
|
|
253
|
+
throw new CliError(CHANGED_WHILE_INSTALLING, EXIT.DENIED, { footer: "version_conflict" });
|
|
254
|
+
}
|
|
255
|
+
resp = fresh;
|
|
256
|
+
urlByPath = new Map(resp.files.map((f) => [f.path, f.url]));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!res.ok) {
|
|
260
|
+
// A lapsed signed URL is a free re-request (audit row 13) — say so.
|
|
261
|
+
throw new CliError(`download failed for "${entry.path}" (HTTP ${res.status}) — re-run the install (the download link may have expired; retrying is free)`, EXIT.NETWORK, { footer: "download" });
|
|
262
|
+
}
|
|
263
|
+
// ⚠️ CHECK BEFORE BUFFERING, then check again after.
|
|
264
|
+
//
|
|
265
|
+
// The fuse exists so a compromised server cannot fill your disk, and
|
|
266
|
+
// `await res.arrayBuffer()` reads the ENTIRE body into memory first — so a check
|
|
267
|
+
// that only runs afterwards has already let the download happen. A hostile server
|
|
268
|
+
// could stream gigabytes and the guard would fire, too late, on a machine that had
|
|
269
|
+
// already paid for it.
|
|
270
|
+
//
|
|
271
|
+
// So: refuse on a declared `content-length` over the fuse without reading a byte,
|
|
272
|
+
// and refuse again on the real length (content-length is server-supplied and may
|
|
273
|
+
// be absent or lie, which is exactly why the second check stays).
|
|
274
|
+
const declared = Number(res.headers.get("content-length"));
|
|
275
|
+
if (Number.isFinite(declared) && declared > MAX_SKILL_FILE_BYTES) {
|
|
276
|
+
throw new CliError(`${INTEGRITY_ABORT} ("${entry.path}" declares ${declared} bytes, over the limit)`, EXIT.INTEGRITY, { footer: "integrity" });
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
buf = await readBounded(res, MAX_SKILL_FILE_BYTES, entry.path);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
// I6: a body that stops mid-stream rejects with a bare TypeError ("terminated"),
|
|
283
|
+
// which used to surface as "unexpected error" and exit 1.
|
|
284
|
+
if (err instanceof CliError)
|
|
285
|
+
throw err;
|
|
286
|
+
throw new CliError(DROPPED(entry.path, i + 1, n), EXIT.NETWORK, { footer: "download" });
|
|
287
|
+
}
|
|
190
288
|
}
|
|
191
|
-
const buf = await readBounded(res, MAX_SKILL_FILE_BYTES, entry.path);
|
|
192
289
|
received += buf.byteLength;
|
|
193
290
|
if (received > MAX_SKILL_TOTAL_BYTES) {
|
|
194
291
|
throw new CliError(`${INTEGRITY_ABORT} (the package sent more than the ${MAX_SKILL_TOTAL_BYTES}-byte install limit)`, EXIT.INTEGRITY, { footer: "integrity" });
|
|
@@ -208,7 +305,7 @@ export async function downloadAndStage(ctx, api, resp, targetDir) {
|
|
|
208
305
|
}
|
|
209
306
|
files.push({ path: entry.path, sha256: sha256Hex(buf) });
|
|
210
307
|
}
|
|
211
|
-
return { tempDir, files };
|
|
308
|
+
return { tempDir, files, resp };
|
|
212
309
|
}
|
|
213
310
|
catch (err) {
|
|
214
311
|
rmSync(tempDir, { recursive: true, force: true });
|
|
@@ -216,79 +313,240 @@ export async function downloadAndStage(ctx, api, resp, targetDir) {
|
|
|
216
313
|
}
|
|
217
314
|
}
|
|
218
315
|
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
316
|
+
* The full phase 2: refuse on manifest conflicts → download → verify → check again →
|
|
317
|
+
* carry → receipt into the new copy → swap → verify → drop the old copy → best-effort
|
|
318
|
+
* complete. `refString` is the canonical handle/slug.
|
|
222
319
|
*/
|
|
223
|
-
export function
|
|
320
|
+
export async function performInstall(ctx, api, opts) {
|
|
321
|
+
// ENG-A4: one start time for both temporary folders, taken before the receipt is
|
|
322
|
+
// written, so the receipt is never older than the folders' names.
|
|
323
|
+
const startedAt = ctx.now();
|
|
324
|
+
const { targetDir, existing, force } = opts;
|
|
224
325
|
const parent = dirname(targetDir);
|
|
326
|
+
try {
|
|
327
|
+
mkdirSync(parent, { recursive: true });
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
throw mapFsError(err, parent);
|
|
331
|
+
}
|
|
332
|
+
// The folder above can pass a mode-bit check and still refuse a write (a sandbox, SIP);
|
|
333
|
+
// then the skills folder it is, as DX-A2 allows.
|
|
334
|
+
let root = tempRootFor(targetDir);
|
|
335
|
+
if (root !== parent && !canCreateIn(root))
|
|
336
|
+
root = parent;
|
|
337
|
+
const key = pathKeyFor(probeCaseInsensitive(root, ctx.platform));
|
|
338
|
+
const prior = existing.kind === "update" ? existing.receipt : null;
|
|
339
|
+
const carries = existing.kind === "update" || existing.kind === "unmanaged";
|
|
340
|
+
const shipped = opts.resp.manifest.map((e) => e.path);
|
|
341
|
+
const refuse = (changes) => localChangesRefusal({
|
|
342
|
+
verb: opts.verb,
|
|
343
|
+
title: opts.title,
|
|
344
|
+
from: prior ? String(prior.version) : null,
|
|
345
|
+
to: String(opts.resp.skill.version),
|
|
346
|
+
dir: targetDir,
|
|
347
|
+
changes,
|
|
348
|
+
});
|
|
349
|
+
/** what blocks right now: the receipt's view, then collisions with what ships */
|
|
350
|
+
const scan = () => {
|
|
351
|
+
const unlisted = unlistedEntries(targetDir, prior, key);
|
|
352
|
+
const conflicts = conflictChanges(unlisted, shipped, key);
|
|
353
|
+
const receiptSide = prior ? receiptSideChanges(targetDir, prior, unlisted) : specialChanges(unlisted);
|
|
354
|
+
return { unlisted, conflicts, blocking: mergeChanges(receiptSide, conflicts) };
|
|
355
|
+
};
|
|
356
|
+
// ── refuse before any download on what the manifest reveals (DX-A1's order) ─────────
|
|
357
|
+
if (carries && !force && existsSync(targetDir)) {
|
|
358
|
+
const { blocking } = scan();
|
|
359
|
+
if (blocking.length > 0)
|
|
360
|
+
throw refuse(blocking);
|
|
361
|
+
}
|
|
362
|
+
opts.onDownload?.(opts.resp);
|
|
363
|
+
// ── the temporary folder, removed by Ctrl-C until the swap (I5) ─────────────────────
|
|
364
|
+
const tempDir = join(root, tempName("staging", startedAt));
|
|
365
|
+
const releaseTrap = ctx.trap?.(() => rmSync(tempDir, { recursive: true, force: true })) ?? (() => { });
|
|
366
|
+
let staged;
|
|
367
|
+
try {
|
|
368
|
+
staged = await downloadAndStage(ctx, opts.resp, tempDir, { resign: opts.resign });
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
releaseTrap();
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
const resp = staged.resp;
|
|
375
|
+
let oldDir;
|
|
376
|
+
const kept = [];
|
|
377
|
+
const replaced = [];
|
|
378
|
+
const removed = [];
|
|
379
|
+
let receipt;
|
|
380
|
+
try {
|
|
381
|
+
// ── check again, right before the swap (ENG-A36) ──────────────────────────────────
|
|
382
|
+
if (carries && existsSync(targetDir)) {
|
|
383
|
+
const { unlisted, conflicts, blocking } = scan();
|
|
384
|
+
if (blocking.length > 0 && !force)
|
|
385
|
+
throw refuse(blocking);
|
|
386
|
+
const ships = new Set(shipped.map(key));
|
|
387
|
+
for (const c of blocking) {
|
|
388
|
+
replaced.push({
|
|
389
|
+
path: c.path,
|
|
390
|
+
reason: c.reason,
|
|
391
|
+
shipped: c.reason === "path_conflict" || ships.has(key(c.path)),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// ── carry the member's own files into the new copy (ENG-A6) ─────────────────────
|
|
395
|
+
for (const e of unlisted) {
|
|
396
|
+
if (!isCarried(e, conflicts, key))
|
|
397
|
+
continue;
|
|
398
|
+
try {
|
|
399
|
+
carryEntry(targetDir, staged.tempDir, e);
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
throw mapFsError(err, join(targetDir, e.path));
|
|
403
|
+
}
|
|
404
|
+
if (e.kind === "file" || e.kind === "link")
|
|
405
|
+
kept.push(e.path);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else if (existing.kind === "foreign" && existsSync(targetDir)) {
|
|
409
|
+
for (const e of unlistedEntries(targetDir, null, key)) {
|
|
410
|
+
if (e.kind !== "dir")
|
|
411
|
+
removed.push(e.path);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// ── the receipt goes in BEFORE the swap (I5) ──────────────────────────────────────
|
|
415
|
+
receipt = {
|
|
416
|
+
schema: RECEIPT_SCHEMA,
|
|
417
|
+
cli_version: ctx.version,
|
|
418
|
+
skill_id: resp.skill.id,
|
|
419
|
+
ref: opts.refString,
|
|
420
|
+
version_id: resp.skill.version_id,
|
|
421
|
+
version: resp.skill.version,
|
|
422
|
+
installed_at: new Date(Math.max(ctx.now(), startedAt)).toISOString(),
|
|
423
|
+
target: opts.targetId,
|
|
424
|
+
files: staged.files,
|
|
425
|
+
};
|
|
426
|
+
try {
|
|
427
|
+
writeReceipt(staged.tempDir, receipt, prior ?? undefined);
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
throw mapFsError(err, join(staged.tempDir, RECEIPT_NAME));
|
|
431
|
+
}
|
|
432
|
+
oldDir = commitStaged(staged.tempDir, targetDir, root, startedAt);
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
rmSync(staged.tempDir, { recursive: true, force: true });
|
|
436
|
+
releaseTrap();
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
releaseTrap();
|
|
440
|
+
const warnings = [];
|
|
441
|
+
if (oldDir) {
|
|
442
|
+
const problem = verifyInstalled(targetDir, resp.manifest, kept);
|
|
443
|
+
if (problem) {
|
|
444
|
+
// ⚠️ Never leave this copy under the `old` name: the leftover sweep deletes an old
|
|
445
|
+
// copy whose target holds a newer receipt, which this target does.
|
|
446
|
+
const keptDir = join(root, tempName("kept", startedAt));
|
|
447
|
+
try {
|
|
448
|
+
renameSync(oldDir, keptDir);
|
|
449
|
+
rmSync(`${oldDir}.json`, { force: true });
|
|
450
|
+
warnings.push(`Kept the previous copy at ${keptDir} because ${problem}. Check ${targetDir}, then delete the previous copy.`);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
warnings.push(`Kept the previous copy at ${oldDir} because ${problem}. Check ${targetDir}, then delete the previous copy.`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
try {
|
|
458
|
+
rmSync(oldDir, { recursive: true, force: true });
|
|
459
|
+
rmSync(`${oldDir}.json`, { force: true });
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
warnings.push(`Couldn't remove the previous copy at ${oldDir} (${err.message}); the next promptdock command removes it.`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// E5 phase 2 — best-effort: the files are on disk either way; the server's
|
|
467
|
+
// grace sweep expires an uncompleted ticket, and a retryable complete is
|
|
468
|
+
// idempotent. Never fail a finished install over this call.
|
|
469
|
+
try {
|
|
470
|
+
await api.request("POST", `/api/v1/cli/skills/${resp.skill.id}/install/complete`);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
/* best-effort */
|
|
474
|
+
}
|
|
475
|
+
return { receipt, kept, replaced, removed, warnings };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* The swap. A fresh target is one rename. An existing target goes aside as
|
|
479
|
+
* `.promptdock-old-*` under `root`, beside a sidecar naming the target so a later
|
|
480
|
+
* command can put it back after a crash (ENG-A4); the staged copy is renamed in, and
|
|
481
|
+
* the old copy is returned for the caller to verify against and then delete (ENG-A36).
|
|
482
|
+
* A failed second rename renames the old copy back: the machine must never end up with
|
|
483
|
+
* NOTHING.
|
|
484
|
+
*/
|
|
485
|
+
export function commitStaged(tempDir, targetDir, root, startedAt) {
|
|
225
486
|
if (!existsSync(targetDir)) {
|
|
226
487
|
try {
|
|
227
488
|
renameSync(tempDir, targetDir);
|
|
228
489
|
}
|
|
229
490
|
catch (err) {
|
|
230
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
231
491
|
throw mapFsError(err, targetDir);
|
|
232
492
|
}
|
|
233
|
-
return;
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
const oldDir = join(root, tempName("old", startedAt));
|
|
496
|
+
try {
|
|
497
|
+
writeFileSync(`${oldDir}.json`, JSON.stringify({ target: targetDir }) + "\n");
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
throw mapFsError(err, `${oldDir}.json`);
|
|
234
501
|
}
|
|
235
|
-
const oldDir = join(parent, `.promptdock-old-${randomBytes(6).toString("hex")}`);
|
|
236
502
|
try {
|
|
237
503
|
renameSync(targetDir, oldDir);
|
|
238
504
|
}
|
|
239
505
|
catch (err) {
|
|
240
|
-
rmSync(
|
|
506
|
+
rmSync(`${oldDir}.json`, { force: true });
|
|
241
507
|
throw mapFsError(err, targetDir);
|
|
242
508
|
}
|
|
243
509
|
try {
|
|
244
510
|
renameSync(tempDir, targetDir);
|
|
245
511
|
}
|
|
246
512
|
catch (err) {
|
|
247
|
-
// Roll the old contents back — the machine must never end up with NOTHING.
|
|
248
513
|
try {
|
|
249
514
|
renameSync(oldDir, targetDir);
|
|
515
|
+
rmSync(`${oldDir}.json`, { force: true });
|
|
250
516
|
}
|
|
251
517
|
catch {
|
|
252
|
-
/* the old
|
|
518
|
+
/* the old copy and its sidecar stay for the next command to restore */
|
|
253
519
|
}
|
|
254
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
255
520
|
throw mapFsError(err, targetDir);
|
|
256
521
|
}
|
|
257
|
-
|
|
522
|
+
return oldDir;
|
|
258
523
|
}
|
|
259
524
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
525
|
+
* After the swap: does the new copy hold its receipt, every shipped file with the right
|
|
526
|
+
* bytes, and every member file that was carried? Returns why not, or null. Only then is
|
|
527
|
+
* the old copy deleted (ENG-A36), so a defect in the carry can never cost a member a
|
|
528
|
+
* file.
|
|
263
529
|
*/
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
files: staged.files,
|
|
277
|
-
};
|
|
278
|
-
try {
|
|
279
|
-
writeReceipt(opts.targetDir, receipt, opts.prior);
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
throw mapFsError(err, join(opts.targetDir, ".promptdock.json"));
|
|
283
|
-
}
|
|
284
|
-
// E5 phase 2 — best-effort: the files are on disk either way; the server's
|
|
285
|
-
// grace sweep expires an uncompleted ticket, and a retryable complete is
|
|
286
|
-
// idempotent. Never fail a finished install over this call.
|
|
287
|
-
try {
|
|
288
|
-
await api.request("POST", `/api/v1/cli/skills/${opts.resp.skill.id}/install/complete`);
|
|
530
|
+
function verifyInstalled(targetDir, manifest, kept) {
|
|
531
|
+
if (readReceipt(targetDir).kind !== "ok")
|
|
532
|
+
return "the new copy has no readable receipt";
|
|
533
|
+
for (const e of manifest) {
|
|
534
|
+
try {
|
|
535
|
+
if (!sha256Matches(readFileSync(join(targetDir, e.path)), e.sha256)) {
|
|
536
|
+
return `${e.path} did not verify after the swap`;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return `${e.path} is missing after the swap`;
|
|
541
|
+
}
|
|
289
542
|
}
|
|
290
|
-
|
|
291
|
-
|
|
543
|
+
for (const p of kept) {
|
|
544
|
+
try {
|
|
545
|
+
lstatSync(join(targetDir, p));
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return `your file ${p} did not carry over`;
|
|
549
|
+
}
|
|
292
550
|
}
|
|
293
|
-
return
|
|
551
|
+
return null;
|
|
294
552
|
}
|
package/dist/integrity.d.ts
CHANGED
|
@@ -2,3 +2,16 @@
|
|
|
2
2
|
export declare function sha256Hex(data: Uint8Array | Buffer): string;
|
|
3
3
|
/** Constant-shape compare (case-forgiving hex; empty expected = never matches). */
|
|
4
4
|
export declare function sha256Matches(data: Uint8Array | Buffer, expectedHex: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Does `buf` hold the bytes a receipt recorded, line endings aside? (I8, 1.2.2)
|
|
7
|
+
*
|
|
8
|
+
* A skill folder checked out with git's `autocrlf` gets CRLF endings, and a byte-exact
|
|
9
|
+
* hash then calls every text file edited, which refuses every update and uninstall for a
|
|
10
|
+
* change nobody made. So a mismatch is retried with the content normalized to LF and to
|
|
11
|
+
* CRLF. A match after normalization means the file differs ONLY in line endings, which
|
|
12
|
+
* no update or uninstall can destroy anything by replacing.
|
|
13
|
+
*
|
|
14
|
+
* A NUL byte means binary, and binary never gets the retry: rewriting `\r\n` inside a
|
|
15
|
+
* binary file is not a line-ending difference.
|
|
16
|
+
*/
|
|
17
|
+
export declare function matchesReceiptHash(buf: Buffer, sha256: string): boolean;
|
package/dist/integrity.js
CHANGED
|
@@ -9,3 +9,30 @@ export function sha256Matches(data, expectedHex) {
|
|
|
9
9
|
return false;
|
|
10
10
|
return sha256Hex(data) === expectedHex.toLowerCase();
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Does `buf` hold the bytes a receipt recorded, line endings aside? (I8, 1.2.2)
|
|
14
|
+
*
|
|
15
|
+
* A skill folder checked out with git's `autocrlf` gets CRLF endings, and a byte-exact
|
|
16
|
+
* hash then calls every text file edited, which refuses every update and uninstall for a
|
|
17
|
+
* change nobody made. So a mismatch is retried with the content normalized to LF and to
|
|
18
|
+
* CRLF. A match after normalization means the file differs ONLY in line endings, which
|
|
19
|
+
* no update or uninstall can destroy anything by replacing.
|
|
20
|
+
*
|
|
21
|
+
* A NUL byte means binary, and binary never gets the retry: rewriting `\r\n` inside a
|
|
22
|
+
* binary file is not a line-ending difference.
|
|
23
|
+
*/
|
|
24
|
+
export function matchesReceiptHash(buf, sha256) {
|
|
25
|
+
const want = sha256.toLowerCase();
|
|
26
|
+
if (sha256Hex(buf) === want)
|
|
27
|
+
return true;
|
|
28
|
+
if (buf.includes(0))
|
|
29
|
+
return false;
|
|
30
|
+
const text = buf.toString("latin1");
|
|
31
|
+
if (!text.includes("\n"))
|
|
32
|
+
return false;
|
|
33
|
+
const lf = text.replace(/\r\n/g, "\n");
|
|
34
|
+
if (sha256Hex(Buffer.from(lf, "latin1")) === want)
|
|
35
|
+
return true;
|
|
36
|
+
const crlf = lf.replace(/\n/g, "\r\n");
|
|
37
|
+
return sha256Hex(Buffer.from(crlf, "latin1")) === want;
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CliContext } from "./context.js";
|
|
2
|
+
/** A staging folder younger than this may belong to an install still running. */
|
|
3
|
+
export declare const STAGING_MAX_AGE_MS: number;
|
|
4
|
+
export type LeftoverNote = {
|
|
5
|
+
kind: "removed";
|
|
6
|
+
path: string;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "restored";
|
|
9
|
+
path: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "undated";
|
|
12
|
+
path: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "unclaimed";
|
|
15
|
+
path: string;
|
|
16
|
+
};
|
|
17
|
+
/** Both folders an install's temporary folders for `targetDir` can be in. */
|
|
18
|
+
export declare function tempRootsFor(targetDir: string): string[];
|
|
19
|
+
/** The temporary-folder roots for every skills folder a lifecycle command can see. */
|
|
20
|
+
export declare function sweepRoots(opts: {
|
|
21
|
+
cwd: string;
|
|
22
|
+
home: string;
|
|
23
|
+
scope: "local" | "global" | "both";
|
|
24
|
+
targetId?: string;
|
|
25
|
+
dir?: string;
|
|
26
|
+
}): string[];
|
|
27
|
+
/** Sweep `roots` (deduplicated). Never throws: a leftover must not block the command. */
|
|
28
|
+
export declare function sweepLeftovers(roots: string[], now: number): LeftoverNote[];
|
|
29
|
+
/** The line each note prints on stderr (stdout stays the command's own, `--json` or not). */
|
|
30
|
+
export declare function leftoverLine(note: LeftoverNote): string;
|
|
31
|
+
/** Sweep and report: what every skill-touching command runs before it reads a receipt. */
|
|
32
|
+
export declare function sweepAndReport(ctx: CliContext, roots: string[]): void;
|