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/dist/installer.js CHANGED
@@ -1,20 +1,32 @@
1
1
  // The two-phase install engine (shared by `install` and `update`):
2
- // POST install → download to a SIBLING temp dir → verify EVERY sha256 →
3
- // re-validate EVERY path (E8/audit row 10 — the server is untrusted) →
4
- // atomic move into place (same-parent rename; staged replace on update) →
5
- // write the receipt → best-effort /install/complete.
6
- // All-or-nothing: any failure discards the temp dir and leaves the target
7
- // exactly as it was ("integrity check failed — nothing installed").
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, networkError } from "./errors.js";
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
- * Download every manifest file into `<parent>/.promptdock-staging-*` (SIBLING of
128
- * the target so the final rename never crosses filesystems), verifying sha256 +
129
- * byte length against the manifest. Throws (and discards the temp dir) on ANY
130
- * mismatch — the sha in the manifest is the transport-integrity anchor.
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 async function downloadAndStage(ctx, api, resp, targetDir) {
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
- mkdirSync(parent, { recursive: true });
151
+ accessSync(grand, fsConstants.W_OK);
152
+ if (statSync(grand).dev !== statSync(parent).dev)
153
+ return parent;
154
+ return grand;
138
155
  }
139
- catch (err) {
140
- throw mapFsError(err, parent);
156
+ catch {
157
+ return parent;
141
158
  }
142
- const tempDir = join(parent, `.promptdock-staging-${randomBytes(6).toString("hex")}`);
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
- mkdirSync(tempDir, { recursive: true });
158
- for (const entry of resp.manifest) {
159
- const url = urlByPath.get(entry.path);
160
- if (!url) {
161
- throw new CliError(`${INTEGRITY_ABORT} (no download URL for "${entry.path}")`, EXIT.INTEGRITY, {
162
- footer: "integrity",
163
- });
164
- }
165
- let res;
166
- try {
167
- res = await ctx.fetch(url);
168
- }
169
- catch {
170
- throw networkError(api.baseUrl, ctx.env);
171
- }
172
- if (!res.ok) {
173
- // A lapsed signed URL is a free re-request (audit row 13) — say so.
174
- 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" });
175
- }
176
- // ⚠️ CHECK BEFORE BUFFERING, then check again after.
177
- //
178
- // The fuse exists so a compromised server cannot fill your disk, and
179
- // `await res.arrayBuffer()` reads the ENTIRE body into memory first — so a check
180
- // that only runs afterwards has already let the download happen. A hostile server
181
- // could stream gigabytes and the guard would fire, too late, on a machine that had
182
- // already paid for it.
183
- //
184
- // So: refuse on a declared `content-length` over the fuse without reading a byte,
185
- // and refuse again on the real length (content-length is server-supplied and may
186
- // be absent or lie, which is exactly why the second check stays).
187
- const declared = Number(res.headers.get("content-length"));
188
- if (Number.isFinite(declared) && declared > MAX_SKILL_FILE_BYTES) {
189
- throw new CliError(`${INTEGRITY_ABORT} ("${entry.path}" declares ${declared} bytes, over the limit)`, EXIT.INTEGRITY, { footer: "integrity" });
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
- * Atomic-ish commit: fresh target → one rename; existing target → staged
220
- * replace (target → .promptdock-old-*, temp → target, drop old; on failure the
221
- * old dir is restored). Same-parent renames only.
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 commitStaged(tempDir, targetDir) {
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(tempDir, { recursive: true, force: true });
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 dir stays on disk for manual recovery */
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
- rmSync(oldDir, { recursive: true, force: true });
522
+ return oldDir;
258
523
  }
259
524
  /**
260
- * The full phase-2: download → verify → commit → receipt → best-effort complete.
261
- * `refString` is the canonical handle/slug; `prior` carries a matching update's
262
- * receipt so unknown fields survive the rewrite (DX F16).
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
- export async function performInstall(ctx, api, opts) {
265
- const staged = await downloadAndStage(ctx, api, opts.resp, opts.targetDir);
266
- commitStaged(staged.tempDir, opts.targetDir);
267
- const receipt = {
268
- schema: RECEIPT_SCHEMA,
269
- cli_version: ctx.version,
270
- skill_id: opts.resp.skill.id,
271
- ref: opts.refString,
272
- version_id: opts.resp.skill.version_id,
273
- version: opts.resp.skill.version,
274
- installed_at: new Date(ctx.now()).toISOString(),
275
- target: opts.targetId,
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
- catch {
291
- /* best-effort */
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 receipt;
551
+ return null;
294
552
  }
@@ -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;