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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Leftover temporary folders from an interrupted install or update (I5, DX-A2, ENG-A4).
|
|
2
|
+
//
|
|
3
|
+
// An install downloads into `.promptdock-staging-<start ms>-<random>` and, when it
|
|
4
|
+
// replaces an existing copy, first renames that copy to `.promptdock-old-<start ms>-
|
|
5
|
+
// <random>` beside a `.json` sidecar naming where it came from. A process killed at the
|
|
6
|
+
// wrong moment leaves one of them behind, and this module is what every later command
|
|
7
|
+
// runs first:
|
|
8
|
+
// - a staging folder older than an hour is removed (younger ones may be another
|
|
9
|
+
// command's install in progress);
|
|
10
|
+
// - an old folder whose skill folder is MISSING is renamed back, and only that: it may
|
|
11
|
+
// be the member's only copy of their files;
|
|
12
|
+
// - an old folder is removed only when its skill folder exists with a receipt written
|
|
13
|
+
// at or after the old folder's start time, i.e. the update that set it aside
|
|
14
|
+
// finished;
|
|
15
|
+
// - a folder whose name carries no start time (1.2.1 and older named them with a
|
|
16
|
+
// random suffix only) is reported and left alone.
|
|
17
|
+
//
|
|
18
|
+
// ⚠️ Ages come from the NAME, never from mtime: a rename keeps the folder's old mtime on
|
|
19
|
+
// APFS and ext4, so a skill last edited days ago would look days old.
|
|
20
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
21
|
+
import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
22
|
+
import { readReceipt } from "./receipts.js";
|
|
23
|
+
import { TARGETS, targetBaseDir } from "./registry.js";
|
|
24
|
+
import { SERVER_PATH_MAX, terminalLine } from "./terminal-text.js";
|
|
25
|
+
/** A staging folder younger than this may belong to an install still running. */
|
|
26
|
+
export const STAGING_MAX_AGE_MS = 60 * 60 * 1000;
|
|
27
|
+
const ANY = /^\.promptdock-(staging|old)-/;
|
|
28
|
+
const DATED = /^\.promptdock-(staging|old)-(\d{10,16})-[0-9a-f]+$/;
|
|
29
|
+
/** Both folders an install's temporary folders for `targetDir` can be in. */
|
|
30
|
+
export function tempRootsFor(targetDir) {
|
|
31
|
+
const parent = dirname(targetDir);
|
|
32
|
+
const grand = dirname(parent);
|
|
33
|
+
return grand === parent ? [parent] : [grand, parent];
|
|
34
|
+
}
|
|
35
|
+
/** The temporary-folder roots for every skills folder a lifecycle command can see. */
|
|
36
|
+
export function sweepRoots(opts) {
|
|
37
|
+
if (opts.dir)
|
|
38
|
+
return tempRootsFor(join(opts.dir));
|
|
39
|
+
const scopes = opts.scope === "both" ? [false, true] : [opts.scope === "global"];
|
|
40
|
+
const targets = opts.targetId ? TARGETS.filter((t) => t.id === opts.targetId) : TARGETS;
|
|
41
|
+
const roots = new Set();
|
|
42
|
+
for (const global of scopes) {
|
|
43
|
+
for (const t of targets) {
|
|
44
|
+
const base = targetBaseDir(t, { cwd: opts.cwd, home: opts.home, global });
|
|
45
|
+
roots.add(dirname(base));
|
|
46
|
+
roots.add(base);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...roots];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Is `target` a skill folder this root could have held the old copy of? The sidecar is
|
|
53
|
+
* a file on disk, so it is untrusted: a crafted one must never turn a restore into a
|
|
54
|
+
* rename to anywhere. A skill folder sits one level under the root (the root is the
|
|
55
|
+
* skills folder) or two (the root is the folder above it).
|
|
56
|
+
*/
|
|
57
|
+
function plausibleTarget(root, target) {
|
|
58
|
+
if (typeof target !== "string" || !isAbsolute(target))
|
|
59
|
+
return false;
|
|
60
|
+
if (resolvePath(target) !== target)
|
|
61
|
+
return false;
|
|
62
|
+
const rel = relative(root, target);
|
|
63
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
|
|
64
|
+
return false;
|
|
65
|
+
const parts = rel.split(sep);
|
|
66
|
+
if (parts.length > 2)
|
|
67
|
+
return false;
|
|
68
|
+
return parts.every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".promptdock-"));
|
|
69
|
+
}
|
|
70
|
+
function exists(path) {
|
|
71
|
+
try {
|
|
72
|
+
lstatSync(path);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Sweep `roots` (deduplicated). Never throws: a leftover must not block the command. */
|
|
80
|
+
export function sweepLeftovers(roots, now) {
|
|
81
|
+
const notes = [];
|
|
82
|
+
for (const root of new Set(roots)) {
|
|
83
|
+
let names;
|
|
84
|
+
try {
|
|
85
|
+
names = readdirSync(root);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const name of names.sort()) {
|
|
91
|
+
if (!ANY.test(name))
|
|
92
|
+
continue;
|
|
93
|
+
const path = join(root, name);
|
|
94
|
+
try {
|
|
95
|
+
if (name.endsWith(".json")) {
|
|
96
|
+
// A sidecar whose folder is gone (the update finished, or failed before the
|
|
97
|
+
// rename): harmless, removed once it is clearly not mid-operation.
|
|
98
|
+
const m = DATED.exec(name.slice(0, -".json".length));
|
|
99
|
+
if (m && now - Number(m[2]) > STAGING_MAX_AGE_MS && !exists(path.slice(0, -".json".length))) {
|
|
100
|
+
rmSync(path, { force: true });
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!lstatSync(path).isDirectory())
|
|
105
|
+
continue;
|
|
106
|
+
const m = DATED.exec(name);
|
|
107
|
+
if (!m) {
|
|
108
|
+
notes.push({ kind: "undated", path });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const startedAt = Number(m[2]);
|
|
112
|
+
if (m[1] === "staging") {
|
|
113
|
+
if (now - startedAt > STAGING_MAX_AGE_MS) {
|
|
114
|
+
rmSync(path, { recursive: true, force: true });
|
|
115
|
+
notes.push({ kind: "removed", path });
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// An old copy: where does it belong?
|
|
120
|
+
let target = null;
|
|
121
|
+
try {
|
|
122
|
+
target = JSON.parse(readFileSync(`${path}.json`, "utf8")).target;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* no readable sidecar */
|
|
126
|
+
}
|
|
127
|
+
if (!plausibleTarget(root, target)) {
|
|
128
|
+
if (now - startedAt > STAGING_MAX_AGE_MS)
|
|
129
|
+
notes.push({ kind: "unclaimed", path });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!exists(target)) {
|
|
133
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
134
|
+
renameSync(path, target);
|
|
135
|
+
rmSync(`${path}.json`, { force: true });
|
|
136
|
+
notes.push({ kind: "restored", path: target });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const read = readReceipt(target);
|
|
140
|
+
const installedAt = read.kind === "ok" ? Date.parse(String(read.receipt.installed_at)) : NaN;
|
|
141
|
+
if (Number.isFinite(installedAt) && installedAt >= startedAt) {
|
|
142
|
+
rmSync(path, { recursive: true, force: true });
|
|
143
|
+
rmSync(`${path}.json`, { force: true });
|
|
144
|
+
notes.push({ kind: "removed", path });
|
|
145
|
+
}
|
|
146
|
+
else if (now - startedAt > STAGING_MAX_AGE_MS) {
|
|
147
|
+
notes.push({ kind: "unclaimed", path });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* one unreadable leftover never stops the sweep or the command */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return notes;
|
|
156
|
+
}
|
|
157
|
+
/** The line each note prints on stderr (stdout stays the command's own, `--json` or not). */
|
|
158
|
+
export function leftoverLine(note) {
|
|
159
|
+
const p = terminalLine(note.path, SERVER_PATH_MAX);
|
|
160
|
+
switch (note.kind) {
|
|
161
|
+
case "removed":
|
|
162
|
+
return `Removed a leftover temporary folder from an interrupted install: ${p}`;
|
|
163
|
+
case "restored":
|
|
164
|
+
return `Restored ${p} from an interrupted update.`;
|
|
165
|
+
case "undated":
|
|
166
|
+
return `Found a temporary folder from an interrupted install that this CLI can't date: ${p}. It may hold your files; check it, then delete it.`;
|
|
167
|
+
case "unclaimed":
|
|
168
|
+
return `Found the previous copy of a skill from an interrupted update: ${p}. Check it, then move it back or delete it.`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Sweep and report: what every skill-touching command runs before it reads a receipt. */
|
|
172
|
+
export function sweepAndReport(ctx, roots) {
|
|
173
|
+
for (const note of sweepLeftovers(roots, ctx.now()))
|
|
174
|
+
ctx.io.err(leftoverLine(note));
|
|
175
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { type Receipt } from "./receipts.js";
|
|
3
|
+
/** Why a path blocks an install. Stable: `--json` consumers switch on it. */
|
|
4
|
+
export type ChangeReason = "modified" | "path_conflict" | "not_regular_file";
|
|
5
|
+
export type LocalChange = {
|
|
6
|
+
/** relative, `/`-separated, as the receipt and the manifest spell paths */
|
|
7
|
+
path: string;
|
|
8
|
+
reason: ChangeReason;
|
|
9
|
+
/** `path_conflict` only: whether the new version ships a file or a folder there */
|
|
10
|
+
ships?: "file" | "folder";
|
|
11
|
+
};
|
|
12
|
+
/** One thing on disk the receipt does not list. Directories are listed only when empty. */
|
|
13
|
+
export type UnlistedEntry = {
|
|
14
|
+
path: string;
|
|
15
|
+
kind: "file" | "link" | "dir" | "special";
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The path comparison a volume actually uses (ENG-A5).
|
|
19
|
+
*
|
|
20
|
+
* APFS and NTFS are case-insensitive by default, so a member's `Notes.md` and a shipped
|
|
21
|
+
* `notes.md` are ONE file there, and treating them as two would let the new version
|
|
22
|
+
* overwrite the member's file silently. NFD and NFC spellings of one name are always the
|
|
23
|
+
* same path here (macOS stores NFD, most editors type NFC), on every volume.
|
|
24
|
+
*/
|
|
25
|
+
export type PathKey = (p: string) => string;
|
|
26
|
+
export declare function pathKeyFor(caseInsensitive: boolean): PathKey;
|
|
27
|
+
/** The platform's default volume rule, for the checks that must not write (dry run). */
|
|
28
|
+
export declare function defaultPathKey(platform: NodeJS.Platform): PathKey;
|
|
29
|
+
/**
|
|
30
|
+
* Is the volume holding `dir` case-insensitive? Probed once per install by creating a
|
|
31
|
+
* mixed-case file and looking for its lowercase spelling. `dir` must exist and be
|
|
32
|
+
* writable; when the probe cannot run, the platform default is assumed (darwin and
|
|
33
|
+
* win32 ship case-insensitive by default), which errs toward reporting a conflict.
|
|
34
|
+
*/
|
|
35
|
+
export declare function probeCaseInsensitive(dir: string, platform: NodeJS.Platform): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Everything in `dir` the receipt does not list, as leaves: files, symlinks (never
|
|
38
|
+
* followed), FIFO/socket/device files, and EMPTY directories (so an empty folder the
|
|
39
|
+
* member made survives too). A directory holding anything is walked, not listed.
|
|
40
|
+
*
|
|
41
|
+
* `receipt` is null for a folder this CLI did not install (no receipt, or another
|
|
42
|
+
* skill's): then every entry is unlisted. The receipt file itself is never an entry.
|
|
43
|
+
*/
|
|
44
|
+
export declare function unlistedEntries(dir: string, receipt: Receipt | null, key: PathKey): UnlistedEntry[];
|
|
45
|
+
/**
|
|
46
|
+
* The changes the RECEIPT can see, checked before the confirm prompt and before the
|
|
47
|
+
* metered install request (DX-A1's order), and again right before the swap (ENG-A36):
|
|
48
|
+
* a receipt-listed file whose bytes differ (line endings aside), or a symlink, folder
|
|
49
|
+
* or special file where it should be; and an unlisted FIFO, socket or device file.
|
|
50
|
+
*
|
|
51
|
+
* A receipt-listed file that is MISSING is not a change: an update that brings it back
|
|
52
|
+
* destroys nothing, so it never blocks.
|
|
53
|
+
*/
|
|
54
|
+
export declare function receiptSideChanges(dir: string, receipt: Receipt, unlisted: UnlistedEntry[]): LocalChange[];
|
|
55
|
+
/** The unlisted FIFO, socket and device files: what blocks when there is no receipt. */
|
|
56
|
+
export declare function specialChanges(unlisted: UnlistedEntry[]): LocalChange[];
|
|
57
|
+
/**
|
|
58
|
+
* Unlisted entries that collide with what the new version ships (DX-A1, ENG-A5):
|
|
59
|
+
* - an entry at a path the new version ships as a file;
|
|
60
|
+
* - an entry where the new version ships a folder (`notes` vs `notes/a.md`);
|
|
61
|
+
* - an entry inside a folder the member made where the new version ships a FILE
|
|
62
|
+
* (`notes/mine.md` vs a shipped `notes`), reported once, as the folder.
|
|
63
|
+
* Paths compare through `key`, so case and Unicode normalization follow the volume.
|
|
64
|
+
* Special files are left to `specialChanges`: they never carry, so they never collide.
|
|
65
|
+
*/
|
|
66
|
+
export declare function conflictChanges(unlisted: UnlistedEntry[], shippedPaths: string[], key: PathKey): LocalChange[];
|
|
67
|
+
/** One row per path, in argument order; a path two checks both report keeps its first reason. */
|
|
68
|
+
export declare function mergeChanges(...lists: LocalChange[][]): LocalChange[];
|
|
69
|
+
/** Should `entry` be carried, given the conflicts `--force` is about to replace? */
|
|
70
|
+
export declare function isCarried(entry: UnlistedEntry, conflicts: LocalChange[], key: PathKey): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Carry one unlisted entry from the current folder into the staging folder (ENG-A6):
|
|
73
|
+
* a regular file by HARD LINK when both sit on one filesystem (no doubled disk use, and
|
|
74
|
+
* the original stays in the old folder until the swap is verified), else a copy with its
|
|
75
|
+
* mode bits and times; a symlink verbatim (readlink + symlink, never followed); an empty
|
|
76
|
+
* folder recreated. Throws on failure, so the caller can abort with nothing changed.
|
|
77
|
+
*/
|
|
78
|
+
export declare function carryEntry(fromDir: string, toDir: string, entry: UnlistedEntry): void;
|
|
79
|
+
/** The refusal's human rows (at most `limit`, then a "+N more" line), one per change. */
|
|
80
|
+
export declare function changeRows(changes: LocalChange[], toVersion: string | null, limit?: number): string[];
|
|
81
|
+
/** `{path, reason}` pairs for `--json` (the `ships` hint stays human-only). */
|
|
82
|
+
export declare function changesJson(changes: LocalChange[]): {
|
|
83
|
+
path: string;
|
|
84
|
+
reason: ChangeReason;
|
|
85
|
+
}[];
|
|
86
|
+
/**
|
|
87
|
+
* The `local_changes` refusal (DX-A1): what blocked, that nothing changed, both ways
|
|
88
|
+
* out, and (from the error's footer) the docs link. Exit 4. The message is printed in
|
|
89
|
+
* red and the two ways out follow it as the hint, so the order on screen is the
|
|
90
|
+
* contract's: rows, "Nothing was changed.", keep, discard, docs.
|
|
91
|
+
*/
|
|
92
|
+
export declare function localChangesRefusal(o: {
|
|
93
|
+
verb: "install" | "update" | "uninstall";
|
|
94
|
+
/** terminal-safe already */
|
|
95
|
+
title: string;
|
|
96
|
+
from: string | null;
|
|
97
|
+
to: string | null;
|
|
98
|
+
dir: string;
|
|
99
|
+
changes: LocalChange[];
|
|
100
|
+
}): CliError;
|
|
101
|
+
/** "Kept {n} of your files: a, b, c +2 more." — or nothing when nothing was carried. */
|
|
102
|
+
export declare function keptLine(kept: string[]): string | null;
|
|
103
|
+
/** What `--force` replaced or removed, one line each, at most `limit` then "+N more". */
|
|
104
|
+
export declare function forceLines(replaced: {
|
|
105
|
+
path: string;
|
|
106
|
+
reason: ChangeReason;
|
|
107
|
+
shipped: boolean;
|
|
108
|
+
}[], removed: string[], limit?: number): string[];
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// The local-changes contract (1.2.2, I1/I8/ENG-A5/ENG-A6): what a member has done to an
|
|
2
|
+
// installed skill folder, and what an install or update may and may not touch.
|
|
3
|
+
//
|
|
4
|
+
// ⚠️ THE DEFECT THIS EXISTS FOR. 1.2.1 swapped the whole directory on every install and
|
|
5
|
+
// update, so a re-run of the install command over an edited skill overwrote the edits
|
|
6
|
+
// and deleted every file the member had added, with exit 0. Two rules close it:
|
|
7
|
+
// 1. A change the receipt can see (an edited receipt-listed file, or an unlisted
|
|
8
|
+
// FIFO, socket or device) refuses the operation unless --force, BEFORE the confirm
|
|
9
|
+
// prompt and the metered install request.
|
|
10
|
+
// 2. Everything else the member put in the folder (regular files, symlinks, folders)
|
|
11
|
+
// is CARRIED into the new version, never left behind in the swapped-out folder.
|
|
12
|
+
// A file the new version also ships at the same path cannot be both, so it refuses too
|
|
13
|
+
// (`path_conflict`), after the manifest is known and before any download.
|
|
14
|
+
import { chmodSync, copyFileSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync, } from "node:fs";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { CliError, EXIT } from "./errors.js";
|
|
18
|
+
import { matchesReceiptHash } from "./integrity.js";
|
|
19
|
+
import { RECEIPT_NAME } from "./receipts.js";
|
|
20
|
+
import { SERVER_PATH_MAX, terminalLine } from "./terminal-text.js";
|
|
21
|
+
export function pathKeyFor(caseInsensitive) {
|
|
22
|
+
return caseInsensitive
|
|
23
|
+
? (p) => p.normalize("NFC").toLowerCase()
|
|
24
|
+
: (p) => p.normalize("NFC");
|
|
25
|
+
}
|
|
26
|
+
/** The platform's default volume rule, for the checks that must not write (dry run). */
|
|
27
|
+
export function defaultPathKey(platform) {
|
|
28
|
+
return pathKeyFor(platform === "darwin" || platform === "win32");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Is the volume holding `dir` case-insensitive? Probed once per install by creating a
|
|
32
|
+
* mixed-case file and looking for its lowercase spelling. `dir` must exist and be
|
|
33
|
+
* writable; when the probe cannot run, the platform default is assumed (darwin and
|
|
34
|
+
* win32 ship case-insensitive by default), which errs toward reporting a conflict.
|
|
35
|
+
*/
|
|
36
|
+
export function probeCaseInsensitive(dir, platform) {
|
|
37
|
+
const name = `.promptdock-case-Pr0bE-${randomBytes(4).toString("hex")}`;
|
|
38
|
+
const probe = join(dir, name);
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(probe, "");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return platform === "darwin" || platform === "win32";
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
lstatSync(join(dir, name.toLowerCase()));
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
rmSync(probe, { force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Everything in `dir` the receipt does not list, as leaves: files, symlinks (never
|
|
58
|
+
* followed), FIFO/socket/device files, and EMPTY directories (so an empty folder the
|
|
59
|
+
* member made survives too). A directory holding anything is walked, not listed.
|
|
60
|
+
*
|
|
61
|
+
* `receipt` is null for a folder this CLI did not install (no receipt, or another
|
|
62
|
+
* skill's): then every entry is unlisted. The receipt file itself is never an entry.
|
|
63
|
+
*/
|
|
64
|
+
export function unlistedEntries(dir, receipt, key) {
|
|
65
|
+
const listed = new Set((receipt?.files ?? []).map((f) => key(f.path)));
|
|
66
|
+
const out = [];
|
|
67
|
+
const walk = (rel) => {
|
|
68
|
+
const abs = rel === "" ? dir : join(dir, rel);
|
|
69
|
+
let names;
|
|
70
|
+
try {
|
|
71
|
+
names = readdirSync(abs);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (rel !== "" && names.length === 0) {
|
|
77
|
+
out.push({ path: rel, kind: "dir" });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const name of names.sort()) {
|
|
81
|
+
if (rel === "" && name === RECEIPT_NAME)
|
|
82
|
+
continue;
|
|
83
|
+
const childRel = rel === "" ? name : `${rel}/${name}`;
|
|
84
|
+
let st;
|
|
85
|
+
try {
|
|
86
|
+
st = lstatSync(join(dir, childRel));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (st.isDirectory()) {
|
|
92
|
+
walk(childRel);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (listed.has(key(childRel)))
|
|
96
|
+
continue;
|
|
97
|
+
out.push({
|
|
98
|
+
path: childRel,
|
|
99
|
+
kind: st.isSymbolicLink() ? "link" : st.isFile() ? "file" : "special",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
walk("");
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The changes the RECEIPT can see, checked before the confirm prompt and before the
|
|
108
|
+
* metered install request (DX-A1's order), and again right before the swap (ENG-A36):
|
|
109
|
+
* a receipt-listed file whose bytes differ (line endings aside), or a symlink, folder
|
|
110
|
+
* or special file where it should be; and an unlisted FIFO, socket or device file.
|
|
111
|
+
*
|
|
112
|
+
* A receipt-listed file that is MISSING is not a change: an update that brings it back
|
|
113
|
+
* destroys nothing, so it never blocks.
|
|
114
|
+
*/
|
|
115
|
+
export function receiptSideChanges(dir, receipt, unlisted) {
|
|
116
|
+
const changes = [];
|
|
117
|
+
for (const f of receipt.files) {
|
|
118
|
+
const abs = join(dir, f.path);
|
|
119
|
+
let st;
|
|
120
|
+
try {
|
|
121
|
+
st = lstatSync(abs);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!st.isFile()) {
|
|
127
|
+
changes.push({ path: f.path, reason: "modified" });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
let buf;
|
|
131
|
+
try {
|
|
132
|
+
buf = readFileSync(abs);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
changes.push({ path: f.path, reason: "modified" });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!matchesReceiptHash(buf, f.sha256))
|
|
139
|
+
changes.push({ path: f.path, reason: "modified" });
|
|
140
|
+
}
|
|
141
|
+
changes.push(...specialChanges(unlisted));
|
|
142
|
+
return changes;
|
|
143
|
+
}
|
|
144
|
+
/** The unlisted FIFO, socket and device files: what blocks when there is no receipt. */
|
|
145
|
+
export function specialChanges(unlisted) {
|
|
146
|
+
return unlisted
|
|
147
|
+
.filter((e) => e.kind === "special")
|
|
148
|
+
.map((e) => ({ path: e.path, reason: "not_regular_file" }));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Unlisted entries that collide with what the new version ships (DX-A1, ENG-A5):
|
|
152
|
+
* - an entry at a path the new version ships as a file;
|
|
153
|
+
* - an entry where the new version ships a folder (`notes` vs `notes/a.md`);
|
|
154
|
+
* - an entry inside a folder the member made where the new version ships a FILE
|
|
155
|
+
* (`notes/mine.md` vs a shipped `notes`), reported once, as the folder.
|
|
156
|
+
* Paths compare through `key`, so case and Unicode normalization follow the volume.
|
|
157
|
+
* Special files are left to `specialChanges`: they never carry, so they never collide.
|
|
158
|
+
*/
|
|
159
|
+
export function conflictChanges(unlisted, shippedPaths, key) {
|
|
160
|
+
const shippedFiles = new Set(shippedPaths.map(key));
|
|
161
|
+
const shippedFolders = new Set();
|
|
162
|
+
for (const p of shippedPaths) {
|
|
163
|
+
let d = dirname(p);
|
|
164
|
+
while (d !== "." && d !== "/") {
|
|
165
|
+
shippedFolders.add(key(d));
|
|
166
|
+
d = dirname(d);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const changes = [];
|
|
170
|
+
const reportedFolders = new Set();
|
|
171
|
+
for (const e of unlisted) {
|
|
172
|
+
if (e.kind === "special")
|
|
173
|
+
continue;
|
|
174
|
+
const k = key(e.path);
|
|
175
|
+
if (shippedFiles.has(k)) {
|
|
176
|
+
changes.push({ path: e.path, reason: "path_conflict", ships: "file" });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (shippedFolders.has(k)) {
|
|
180
|
+
// An empty folder of the member's where the new version ships a folder simply
|
|
181
|
+
// merges with it; anything else at that path cannot share it.
|
|
182
|
+
if (e.kind !== "dir")
|
|
183
|
+
changes.push({ path: e.path, reason: "path_conflict", ships: "folder" });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// A member folder at a shipped FILE path: the shortest ancestor that is one.
|
|
187
|
+
let d = dirname(e.path);
|
|
188
|
+
const ancestors = [];
|
|
189
|
+
while (d !== "." && d !== "/") {
|
|
190
|
+
ancestors.unshift(d);
|
|
191
|
+
d = dirname(d);
|
|
192
|
+
}
|
|
193
|
+
const hit = ancestors.find((a) => shippedFiles.has(key(a)));
|
|
194
|
+
if (hit && !reportedFolders.has(key(hit))) {
|
|
195
|
+
reportedFolders.add(key(hit));
|
|
196
|
+
changes.push({ path: hit, reason: "path_conflict", ships: "file" });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return changes;
|
|
200
|
+
}
|
|
201
|
+
/** One row per path, in argument order; a path two checks both report keeps its first reason. */
|
|
202
|
+
export function mergeChanges(...lists) {
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const list of lists) {
|
|
206
|
+
for (const c of list) {
|
|
207
|
+
if (seen.has(c.path))
|
|
208
|
+
continue;
|
|
209
|
+
seen.add(c.path);
|
|
210
|
+
out.push(c);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
/** Should `entry` be carried, given the conflicts `--force` is about to replace? */
|
|
216
|
+
export function isCarried(entry, conflicts, key) {
|
|
217
|
+
if (entry.kind === "special")
|
|
218
|
+
return false;
|
|
219
|
+
const k = key(entry.path);
|
|
220
|
+
for (const c of conflicts) {
|
|
221
|
+
if (c.reason !== "path_conflict")
|
|
222
|
+
continue;
|
|
223
|
+
const ck = key(c.path);
|
|
224
|
+
if (k === ck || k.startsWith(`${ck}/`))
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Carry one unlisted entry from the current folder into the staging folder (ENG-A6):
|
|
231
|
+
* a regular file by HARD LINK when both sit on one filesystem (no doubled disk use, and
|
|
232
|
+
* the original stays in the old folder until the swap is verified), else a copy with its
|
|
233
|
+
* mode bits and times; a symlink verbatim (readlink + symlink, never followed); an empty
|
|
234
|
+
* folder recreated. Throws on failure, so the caller can abort with nothing changed.
|
|
235
|
+
*/
|
|
236
|
+
export function carryEntry(fromDir, toDir, entry) {
|
|
237
|
+
const src = join(fromDir, entry.path);
|
|
238
|
+
const dst = join(toDir, entry.path);
|
|
239
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
240
|
+
if (entry.kind === "dir") {
|
|
241
|
+
mkdirSync(dst, { recursive: true });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (entry.kind === "link") {
|
|
245
|
+
symlinkSync(readlinkSync(src), dst);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (entry.kind !== "file")
|
|
249
|
+
return;
|
|
250
|
+
try {
|
|
251
|
+
linkSync(src, dst);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
/* another filesystem, or links unsupported: copy below */
|
|
256
|
+
}
|
|
257
|
+
copyFileSync(src, dst);
|
|
258
|
+
const st = statSync(src);
|
|
259
|
+
chmodSync(dst, st.mode & 0o7777);
|
|
260
|
+
utimesSync(dst, st.atime, st.mtime);
|
|
261
|
+
}
|
|
262
|
+
/** A path from the member's own disk, made safe for a terminal row (names may hold anything). */
|
|
263
|
+
const shown = (p) => terminalLine(p, SERVER_PATH_MAX);
|
|
264
|
+
/** The refusal's human rows (at most `limit`, then a "+N more" line), one per change. */
|
|
265
|
+
export function changeRows(changes, toVersion, limit = 10) {
|
|
266
|
+
const rows = changes.slice(0, limit).map((c) => {
|
|
267
|
+
if (c.reason === "modified")
|
|
268
|
+
return ` modified ${shown(c.path)}`;
|
|
269
|
+
if (c.reason === "not_regular_file") {
|
|
270
|
+
return ` special ${shown(c.path)} a special file this install didn't create`;
|
|
271
|
+
}
|
|
272
|
+
const v = toVersion ? `v${toVersion}` : "the new version";
|
|
273
|
+
return ` conflicts ${shown(c.path)} ${v} adds a ${c.ships === "folder" ? "folder" : "file"} at this path`;
|
|
274
|
+
});
|
|
275
|
+
if (changes.length > limit)
|
|
276
|
+
rows.push(` +${changes.length - limit} more; --json lists all`);
|
|
277
|
+
return rows;
|
|
278
|
+
}
|
|
279
|
+
/** `{path, reason}` pairs for `--json` (the `ships` hint stays human-only). */
|
|
280
|
+
export function changesJson(changes) {
|
|
281
|
+
return changes.map((c) => ({ path: c.path, reason: c.reason }));
|
|
282
|
+
}
|
|
283
|
+
const plural = (n, one, many) => (n === 1 ? one : many);
|
|
284
|
+
/**
|
|
285
|
+
* The `local_changes` refusal (DX-A1): what blocked, that nothing changed, both ways
|
|
286
|
+
* out, and (from the error's footer) the docs link. Exit 4. The message is printed in
|
|
287
|
+
* red and the two ways out follow it as the hint, so the order on screen is the
|
|
288
|
+
* contract's: rows, "Nothing was changed.", keep, discard, docs.
|
|
289
|
+
*/
|
|
290
|
+
export function localChangesRefusal(o) {
|
|
291
|
+
const versions = o.from !== null && o.to !== null && o.from !== o.to
|
|
292
|
+
? ` (v${o.from} → v${o.to})`
|
|
293
|
+
: o.to !== null
|
|
294
|
+
? ` (v${o.to})`
|
|
295
|
+
: o.from !== null
|
|
296
|
+
? ` (v${o.from})`
|
|
297
|
+
: "";
|
|
298
|
+
const n = o.changes.length;
|
|
299
|
+
const head = `✖ Didn't ${o.verb} "${o.title}"${versions}: ${n} local ${plural(n, "change", "changes")} in ${shown(o.dir)}`;
|
|
300
|
+
const discard = o.verb === "uninstall"
|
|
301
|
+
? "Discard these changes: add --force. It deletes the files listed above and keeps every other file you added."
|
|
302
|
+
: "Discard these changes: add --force. It replaces the files listed above and keeps every other file you added.";
|
|
303
|
+
return new CliError([head, ...changeRows(o.changes, o.to), "Nothing was changed."].join("\n"), EXIT.INTEGRITY, {
|
|
304
|
+
footer: "local_changes",
|
|
305
|
+
hint: `Keep your work: move these out of the folder, then run the same command again.\n${discard}`,
|
|
306
|
+
changes: changesJson(o.changes),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/** "Kept {n} of your files: a, b, c +2 more." — or nothing when nothing was carried. */
|
|
310
|
+
export function keptLine(kept) {
|
|
311
|
+
if (kept.length === 0)
|
|
312
|
+
return null;
|
|
313
|
+
const names = kept.slice(0, 3).map(shown).join(", ");
|
|
314
|
+
const more = kept.length > 3 ? ` +${kept.length - 3} more` : "";
|
|
315
|
+
// "1 of your files" is the English for one: the noun counts the member's files.
|
|
316
|
+
return `Kept ${kept.length} of your files: ${names}${more}.`;
|
|
317
|
+
}
|
|
318
|
+
/** What `--force` replaced or removed, one line each, at most `limit` then "+N more". */
|
|
319
|
+
export function forceLines(replaced, removed, limit = 10) {
|
|
320
|
+
const lines = replaced.map((r) => {
|
|
321
|
+
if (r.reason === "not_regular_file")
|
|
322
|
+
return `Removed ${shown(r.path)} (a special file)`;
|
|
323
|
+
if (!r.shipped)
|
|
324
|
+
return `Removed ${shown(r.path)} (you had edited it; the new version no longer ships it)`;
|
|
325
|
+
return r.reason === "modified"
|
|
326
|
+
? `Replaced ${shown(r.path)} (you had edited it)`
|
|
327
|
+
: `Replaced ${shown(r.path)} (the new version ships this path)`;
|
|
328
|
+
});
|
|
329
|
+
for (const p of removed)
|
|
330
|
+
lines.push(`Removed ${shown(p)}`);
|
|
331
|
+
if (lines.length <= limit)
|
|
332
|
+
return lines;
|
|
333
|
+
return [...lines.slice(0, limit), `+${lines.length - limit} more; --json lists all`];
|
|
334
|
+
}
|
package/dist/receipts.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export declare function scanReceipts(opts: {
|
|
|
53
53
|
/**
|
|
54
54
|
* DX5 local-edit guard: diff on-disk hashes vs the receipt manifest. A missing
|
|
55
55
|
* file or a symlink where a regular file should be counts as modified (the
|
|
56
|
-
* guard's job is "would uninstall/update destroy human work?").
|
|
56
|
+
* guard's job is "would uninstall/update destroy human work?"). Line endings
|
|
57
|
+
* alone never count (I8: a CRLF checkout is not an edit).
|
|
57
58
|
*/
|
|
58
59
|
export declare function modifiedFiles(dir: string, receipt: Receipt): string[];
|
package/dist/receipts.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// is a "run npx promptdock@latest" refusal, never a guessy uninstall (DX F16).
|
|
5
5
|
import { lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { matchesReceiptHash } from "./integrity.js";
|
|
8
8
|
import { TARGETS, targetBaseDir } from "./registry.js";
|
|
9
9
|
export const RECEIPT_NAME = ".promptdock.json";
|
|
10
10
|
export const RECEIPT_SCHEMA = 1;
|
|
@@ -75,6 +75,12 @@ export function scanReceipts(opts) {
|
|
|
75
75
|
continue;
|
|
76
76
|
}
|
|
77
77
|
for (const name of entries) {
|
|
78
|
+
// ⚠️ An install's temporary folders (`.promptdock-staging-*`, `.promptdock-old-*`)
|
|
79
|
+
// hold a receipt by design (1.2.2 writes it before the swap), and when the folder
|
|
80
|
+
// above the skills folder is not writable they sit HERE. Read as skills they would
|
|
81
|
+
// be listed, updated and uninstalled as if a member had installed them.
|
|
82
|
+
if (name.startsWith(".promptdock-"))
|
|
83
|
+
continue;
|
|
78
84
|
const dir = join(base, name);
|
|
79
85
|
if (seenDirs.has(dir))
|
|
80
86
|
continue; // claude local==global base when cwd==home
|
|
@@ -98,7 +104,8 @@ export function scanReceipts(opts) {
|
|
|
98
104
|
/**
|
|
99
105
|
* DX5 local-edit guard: diff on-disk hashes vs the receipt manifest. A missing
|
|
100
106
|
* file or a symlink where a regular file should be counts as modified (the
|
|
101
|
-
* guard's job is "would uninstall/update destroy human work?").
|
|
107
|
+
* guard's job is "would uninstall/update destroy human work?"). Line endings
|
|
108
|
+
* alone never count (I8: a CRLF checkout is not an edit).
|
|
102
109
|
*/
|
|
103
110
|
export function modifiedFiles(dir, receipt) {
|
|
104
111
|
const modified = [];
|
|
@@ -110,7 +117,7 @@ export function modifiedFiles(dir, receipt) {
|
|
|
110
117
|
modified.push(f.path);
|
|
111
118
|
continue;
|
|
112
119
|
}
|
|
113
|
-
if (
|
|
120
|
+
if (!matchesReceiptHash(readFileSync(abs), f.sha256))
|
|
114
121
|
modified.push(f.path);
|
|
115
122
|
}
|
|
116
123
|
catch {
|