packlight-cli 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/claude-code/inventory.d.ts +48 -0
- package/dist/adapters/claude-code/inventory.js +449 -0
- package/dist/adapters/claude-code/logs.d.ts +54 -0
- package/dist/adapters/claude-code/logs.js +359 -0
- package/dist/adapters/codex/inventory.d.ts +17 -0
- package/dist/adapters/codex/inventory.js +130 -0
- package/dist/adapters/codex/logs.d.ts +22 -0
- package/dist/adapters/codex/logs.js +151 -0
- package/dist/adapters/codex/toml.d.ts +10 -0
- package/dist/adapters/codex/toml.js +76 -0
- package/dist/archive/apply.d.ts +62 -0
- package/dist/archive/apply.js +284 -0
- package/dist/archive/fsops.d.ts +24 -0
- package/dist/archive/fsops.js +95 -0
- package/dist/archive/journal.d.ts +41 -0
- package/dist/archive/journal.js +95 -0
- package/dist/archive/lock.d.ts +2 -0
- package/dist/archive/lock.js +32 -0
- package/dist/archive/manifest.d.ts +37 -0
- package/dist/archive/manifest.js +9 -0
- package/dist/archive/methods.d.ts +46 -0
- package/dist/archive/methods.js +239 -0
- package/dist/archive/paths.d.ts +18 -0
- package/dist/archive/paths.js +61 -0
- package/dist/archive/restore.d.ts +30 -0
- package/dist/archive/restore.js +84 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +329 -0
- package/dist/core/buckets.d.ts +3 -0
- package/dist/core/buckets.js +6 -0
- package/dist/core/frontmatter.d.ts +6 -0
- package/dist/core/frontmatter.js +31 -0
- package/dist/core/hash.d.ts +19 -0
- package/dist/core/hash.js +42 -0
- package/dist/core/messages.d.ts +16 -0
- package/dist/core/messages.js +17 -0
- package/dist/core/scan.d.ts +16 -0
- package/dist/core/scan.js +345 -0
- package/dist/core/summary.d.ts +4 -0
- package/dist/core/summary.js +63 -0
- package/dist/core/types.d.ts +185 -0
- package/dist/core/types.js +3 -0
- package/dist/report/client.d.ts +1 -0
- package/dist/report/client.js +676 -0
- package/dist/report/fix.d.ts +46 -0
- package/dist/report/fix.js +116 -0
- package/dist/report/logo.d.ts +7 -0
- package/dist/report/logo.js +23 -0
- package/dist/report/model.d.ts +103 -0
- package/dist/report/model.js +194 -0
- package/dist/report/render.d.ts +9 -0
- package/dist/report/render.js +45 -0
- package/dist/report/styles.d.ts +1 -0
- package/dist/report/styles.js +202 -0
- package/dist/report/suggest.d.ts +39 -0
- package/dist/report/suggest.js +88 -0
- package/package.json +55 -4
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { cpSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import writeFileAtomic from 'write-file-atomic';
|
|
4
|
+
import { pathFingerprint, sha256 } from '../core/hash.js';
|
|
5
|
+
const sleep = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
6
|
+
/** rename with a bounded retry: Windows reports EPERM/EBUSY while another process briefly holds a file (eng SC1). */
|
|
7
|
+
export function renameWithRetry(from, to, rename = renameSync) {
|
|
8
|
+
for (let attempt = 0;; attempt++) {
|
|
9
|
+
try {
|
|
10
|
+
rename(from, to);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (!['EPERM', 'EBUSY', 'EACCES'].includes(err?.code) || attempt >= 9)
|
|
15
|
+
throw err;
|
|
16
|
+
sleep(50 * (attempt + 1));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Settings writes go through write-file-atomic, which keeps the file's mode and owner (eng SC2). */
|
|
21
|
+
export function atomicWrite(path, data) {
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
|
+
writeFileAtomic.sync(path, data);
|
|
24
|
+
}
|
|
25
|
+
export const fileHash = (path) => {
|
|
26
|
+
try {
|
|
27
|
+
return sha256(readFileSync(path));
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err?.code === 'ENOENT')
|
|
31
|
+
return null;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
export const exists = (path) => lstatSync(path, { throwIfNoEntry: false }) !== undefined;
|
|
36
|
+
/** Fingerprint of a path, or "missing" (journal states compare these). */
|
|
37
|
+
export const stateOf = (path) => (exists(path) ? pathFingerprint(path) : 'missing');
|
|
38
|
+
export class ChangedWhileMoving extends Error {
|
|
39
|
+
}
|
|
40
|
+
/** The verified copy is in place but the source could not be fully removed: both must be kept. */
|
|
41
|
+
export class SourceNotRemoved extends Error {
|
|
42
|
+
}
|
|
43
|
+
/** Where a cross-volume move stages its copy: hidden, beside the destination, and recorded in the journal as scratch. */
|
|
44
|
+
export const stagingPathFor = (to) => join(dirname(to), `.${basename(to)}.packlight-staging`);
|
|
45
|
+
/**
|
|
46
|
+
* Moves a file, folder or link. Same volume: one rename. Across volumes (EXDEV): copy into a staging path
|
|
47
|
+
* beside the destination, check the copy against the source's fingerprint, re-check the source, then swap
|
|
48
|
+
* the staging copy in and remove the source (eng X4). A link is moved as a link, never followed.
|
|
49
|
+
*/
|
|
50
|
+
export function moveTree(from, to, opts = {}) {
|
|
51
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
52
|
+
try {
|
|
53
|
+
renameWithRetry(from, to, opts.rename);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err?.code !== 'EXDEV')
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
const before = pathFingerprint(from);
|
|
61
|
+
const staging = stagingPathFor(to);
|
|
62
|
+
rmSync(staging, { recursive: true, force: true });
|
|
63
|
+
const st = lstatSync(from);
|
|
64
|
+
try {
|
|
65
|
+
if (st.isSymbolicLink())
|
|
66
|
+
symlinkSync(readlinkSync(from), staging, isDirTarget(from) ? 'junction' : 'file');
|
|
67
|
+
else
|
|
68
|
+
cpSync(from, staging, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
|
|
69
|
+
if (pathFingerprint(staging) !== before || pathFingerprint(from) !== before) {
|
|
70
|
+
throw new ChangedWhileMoving(`${from} changed while it was being copied; nothing was moved`);
|
|
71
|
+
}
|
|
72
|
+
renameWithRetry(staging, to);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
rmSync(staging, { recursive: true, force: true });
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
if (st.isSymbolicLink())
|
|
80
|
+
unlinkSync(from);
|
|
81
|
+
else
|
|
82
|
+
rmSync(from, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new SourceNotRemoved(`A verified copy is at ${to}, but ${from} could not be fully removed (${err.message}). Both were kept.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function isDirTarget(link) {
|
|
89
|
+
try {
|
|
90
|
+
return statSync(link).isDirectory();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PacklightPaths } from './paths.js';
|
|
2
|
+
import type { Manifest } from './manifest.js';
|
|
3
|
+
/**
|
|
4
|
+
* One journal for archive and restore (CEO F1, eng X3). Every change is recorded as pending with the
|
|
5
|
+
* expected state of each path before and after it, then performed, then marked done. On every start,
|
|
6
|
+
* a pending entry is finished or rolled back only when the disk matches one of those states exactly;
|
|
7
|
+
* anything else is refused and named, and nothing is touched.
|
|
8
|
+
*/
|
|
9
|
+
export interface PathState {
|
|
10
|
+
path: string;
|
|
11
|
+
/** "tree": fingerprint of a file, folder or link (or "missing"); "file": sha256 of the bytes (or null). */
|
|
12
|
+
kind: 'tree' | 'file';
|
|
13
|
+
before: string | null;
|
|
14
|
+
after: string | null;
|
|
15
|
+
/** A temporary path packlight created (cross-volume staging): removed before recovery decides. */
|
|
16
|
+
scratch?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface JournalEntry {
|
|
19
|
+
opId: string;
|
|
20
|
+
direction: 'archive' | 'restore';
|
|
21
|
+
state: 'pending' | 'done' | 'aborted';
|
|
22
|
+
at: string;
|
|
23
|
+
paths: PathState[];
|
|
24
|
+
}
|
|
25
|
+
export declare function journalBegin(paths: PacklightPaths, entry: Omit<JournalEntry, 'state' | 'at'>): void;
|
|
26
|
+
export declare function journalEnd(paths: PacklightPaths, opId: string, direction: JournalEntry['direction'], state: 'done' | 'aborted'): void;
|
|
27
|
+
export interface RecoveryResult {
|
|
28
|
+
completed: string[];
|
|
29
|
+
rolledBack: string[];
|
|
30
|
+
refused: {
|
|
31
|
+
opId: string;
|
|
32
|
+
direction: string;
|
|
33
|
+
files: string[];
|
|
34
|
+
}[];
|
|
35
|
+
}
|
|
36
|
+
/** Finishes or rolls back whatever a crash left pending. Runs before every command that changes the setup. */
|
|
37
|
+
export declare function recover(paths: PacklightPaths): RecoveryResult;
|
|
38
|
+
export declare function readManifest(paths: PacklightPaths, opId: string): Manifest | undefined;
|
|
39
|
+
export declare function writeManifest(paths: PacklightPaths, m: Manifest): void;
|
|
40
|
+
export declare function setManifestStatus(paths: PacklightPaths, opId: string, status: Manifest['status']): void;
|
|
41
|
+
export declare function listManifests(paths: PacklightPaths): Manifest[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { atomicWrite, fileHash, stateOf } from './fsops.js';
|
|
4
|
+
const read = (paths) => {
|
|
5
|
+
if (!existsSync(paths.journal))
|
|
6
|
+
return { version: 1, entries: [] };
|
|
7
|
+
return JSON.parse(readFileSync(paths.journal, 'utf8'));
|
|
8
|
+
};
|
|
9
|
+
const write = (paths, j) => atomicWrite(paths.journal, JSON.stringify(j, null, 1) + '\n');
|
|
10
|
+
export function journalBegin(paths, entry) {
|
|
11
|
+
const j = read(paths);
|
|
12
|
+
j.entries.push({ ...entry, state: 'pending', at: new Date().toISOString() });
|
|
13
|
+
write(paths, j);
|
|
14
|
+
}
|
|
15
|
+
export function journalEnd(paths, opId, direction, state) {
|
|
16
|
+
const j = read(paths);
|
|
17
|
+
const e = [...j.entries].reverse().find(x => x.opId === opId && x.direction === direction && x.state === 'pending');
|
|
18
|
+
if (e)
|
|
19
|
+
e.state = state;
|
|
20
|
+
write(paths, j);
|
|
21
|
+
}
|
|
22
|
+
const current = (p) => (p.kind === 'tree' ? stateOf(p.path) : fileHash(p.path));
|
|
23
|
+
/** Finishes or rolls back whatever a crash left pending. Runs before every command that changes the setup. */
|
|
24
|
+
export function recover(paths) {
|
|
25
|
+
const result = { completed: [], rolledBack: [], refused: [] };
|
|
26
|
+
const j = read(paths);
|
|
27
|
+
let changed = false;
|
|
28
|
+
for (const e of j.entries.filter(x => x.state === 'pending')) {
|
|
29
|
+
for (const p of e.paths.filter(p => p.scratch))
|
|
30
|
+
rmSync(p.path, { recursive: true, force: true });
|
|
31
|
+
const real = e.paths.filter(p => !p.scratch);
|
|
32
|
+
e.paths = real;
|
|
33
|
+
let now = e.paths.map(current);
|
|
34
|
+
// A cross-volume move that stopped after its verified copy but before removing the source leaves both
|
|
35
|
+
// copies, each matching a known state (eng X4). An archive drops the copy; a restore drops the archive.
|
|
36
|
+
const allBefore = e.paths.every((p, i) => now[i] === p.before);
|
|
37
|
+
const allAfter = e.paths.every((p, i) => now[i] === p.after);
|
|
38
|
+
const mixed = !allBefore && !allAfter && e.paths.every(p => p.kind === 'tree') && e.paths.every((p, i) => now[i] === p.before || now[i] === p.after);
|
|
39
|
+
if (mixed) {
|
|
40
|
+
for (const [i, p] of e.paths.entries()) {
|
|
41
|
+
const extraCopy = e.direction === 'archive' ? now[i] === p.after && p.before === 'missing' : now[i] === p.before && p.after === 'missing';
|
|
42
|
+
if (extraCopy && now[i] !== 'missing')
|
|
43
|
+
rmSync(p.path, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
now = e.paths.map(current);
|
|
46
|
+
}
|
|
47
|
+
const isAfter = e.paths.every((p, i) => now[i] === p.after);
|
|
48
|
+
const isBefore = e.paths.every((p, i) => now[i] === p.before);
|
|
49
|
+
if (isAfter) {
|
|
50
|
+
e.state = 'done';
|
|
51
|
+
if (e.direction === 'restore')
|
|
52
|
+
setManifestStatus(paths, e.opId, 'restored');
|
|
53
|
+
result.completed.push(`${e.direction} ${e.opId}`);
|
|
54
|
+
}
|
|
55
|
+
else if (isBefore) {
|
|
56
|
+
e.state = 'aborted';
|
|
57
|
+
// An archive that never happened leaves no record behind; staging copies from cross-volume moves go too.
|
|
58
|
+
if (e.direction === 'archive')
|
|
59
|
+
rmSync(join(paths.archive, e.opId), { recursive: true, force: true });
|
|
60
|
+
result.rolledBack.push(`${e.direction} ${e.opId}`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
result.refused.push({ opId: e.opId, direction: e.direction, files: e.paths.filter((p, i) => now[i] !== p.before && now[i] !== p.after).map(p => p.path) });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
changed = true;
|
|
67
|
+
}
|
|
68
|
+
if (changed)
|
|
69
|
+
write(paths, j);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
export function readManifest(paths, opId) {
|
|
73
|
+
const file = join(paths.archive, opId, 'manifest.json');
|
|
74
|
+
if (!existsSync(file))
|
|
75
|
+
return undefined;
|
|
76
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
77
|
+
}
|
|
78
|
+
export function writeManifest(paths, m) {
|
|
79
|
+
atomicWrite(join(paths.archive, m.opId, 'manifest.json'), JSON.stringify(m, null, 1) + '\n');
|
|
80
|
+
}
|
|
81
|
+
export function setManifestStatus(paths, opId, status) {
|
|
82
|
+
const m = readManifest(paths, opId);
|
|
83
|
+
if (m && m.status !== status)
|
|
84
|
+
writeManifest(paths, { ...m, status, ...(status === 'restored' ? { restoredAt: new Date().toISOString() } : {}) });
|
|
85
|
+
}
|
|
86
|
+
export function listManifests(paths) {
|
|
87
|
+
let ids;
|
|
88
|
+
try {
|
|
89
|
+
ids = readdirSync(paths.archive);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
return ids.map(id => readManifest(paths, id)).filter((m) => !!m).sort((a, b) => a.opId.localeCompare(b.opId));
|
|
95
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
const alive = (pid) => {
|
|
4
|
+
try {
|
|
5
|
+
process.kill(pid, 0);
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
return err?.code === 'EPERM';
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
/** One packlight process changes the setup at a time (CEO F2). A lock left by a dead process is taken over. */
|
|
13
|
+
export function acquireLock(file) {
|
|
14
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
15
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
16
|
+
try {
|
|
17
|
+
const fd = openSync(file, 'wx');
|
|
18
|
+
writeSync(fd, String(process.pid));
|
|
19
|
+
closeSync(fd);
|
|
20
|
+
return () => rmSync(file, { force: true });
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err?.code !== 'EEXIST')
|
|
24
|
+
throw err;
|
|
25
|
+
const pid = Number(readFileSync(file, 'utf8'));
|
|
26
|
+
if (pid && alive(pid) && pid !== process.pid)
|
|
27
|
+
throw new Error(`Another packlight (process ${pid}) is changing your setup. Wait for it to finish.`);
|
|
28
|
+
rmSync(file, { force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Could not take the lock at ${file}.`);
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Kind, RemovalMethod } from '../core/types.js';
|
|
2
|
+
/** One archive operation: one declaration of one item (eng X2, X5). Readers must accept older versions. */
|
|
3
|
+
export interface Manifest {
|
|
4
|
+
manifestVersion: 1;
|
|
5
|
+
opId: string;
|
|
6
|
+
itemId: string;
|
|
7
|
+
itemName: string;
|
|
8
|
+
kind: Kind;
|
|
9
|
+
agent: string;
|
|
10
|
+
method: RemovalMethod;
|
|
11
|
+
scanId: string;
|
|
12
|
+
archivedAt: string;
|
|
13
|
+
/** archived: packlight holds it; restored: put back; waiting: removable only by hand. */
|
|
14
|
+
status: 'archived' | 'restored' | 'waiting';
|
|
15
|
+
restoredAt?: string;
|
|
16
|
+
note?: string;
|
|
17
|
+
originalPath?: string;
|
|
18
|
+
payloadPath?: string;
|
|
19
|
+
isSymlink?: boolean;
|
|
20
|
+
symlinkTarget?: string;
|
|
21
|
+
/** Fingerprint of what was moved, checked again before restore. */
|
|
22
|
+
fingerprint?: string;
|
|
23
|
+
settingsFile?: string;
|
|
24
|
+
jsonPointer?: string;
|
|
25
|
+
/** The removed or changed JSON value, printed when a restore has to be done by hand (D12). */
|
|
26
|
+
fragment?: unknown;
|
|
27
|
+
/** The whole settings file as it was, base64; null when the file did not exist. */
|
|
28
|
+
originalFileBase64?: string | null;
|
|
29
|
+
fileHashBefore?: string | null;
|
|
30
|
+
fileHashAfter?: string | null;
|
|
31
|
+
plugin?: {
|
|
32
|
+
key: string;
|
|
33
|
+
wasEnabled: boolean | null;
|
|
34
|
+
};
|
|
35
|
+
manualSteps?: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function newOpId(now?: Date): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
/** Operation ids never repeat: UTC time plus 6 random hex (eng X5). They sort by time. */
|
|
3
|
+
let lastMs = 0;
|
|
4
|
+
export function newOpId(now = new Date()) {
|
|
5
|
+
// Strictly increasing even within one millisecond, so "newest first" is a total order (several edits of one file).
|
|
6
|
+
const ms = Math.max(now.getTime(), lastMs + 1);
|
|
7
|
+
lastMs = ms;
|
|
8
|
+
return `${new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.(\d{3})Z$/, '$1Z')}-${randomBytes(3).toString('hex')}`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Item } from '../core/types.js';
|
|
2
|
+
import { type Manifest } from './manifest.js';
|
|
3
|
+
import type { PacklightPaths } from './paths.js';
|
|
4
|
+
export type Step = {
|
|
5
|
+
method: 'move';
|
|
6
|
+
item: Item;
|
|
7
|
+
path: string;
|
|
8
|
+
} | {
|
|
9
|
+
method: 'hook-extract' | 'mcp-extract';
|
|
10
|
+
item: Item;
|
|
11
|
+
file: string;
|
|
12
|
+
pointer: string;
|
|
13
|
+
} | {
|
|
14
|
+
method: 'plugin-disable';
|
|
15
|
+
item: Item;
|
|
16
|
+
file: string;
|
|
17
|
+
pointer: string;
|
|
18
|
+
key: string;
|
|
19
|
+
} | {
|
|
20
|
+
method: 'manual';
|
|
21
|
+
item: Item;
|
|
22
|
+
where: string;
|
|
23
|
+
};
|
|
24
|
+
export interface StepContext {
|
|
25
|
+
paths: PacklightPaths;
|
|
26
|
+
scanId: string;
|
|
27
|
+
now: () => Date;
|
|
28
|
+
/** Hash each settings file is expected to have before packlight's next write to it (CEO F2). */
|
|
29
|
+
expectedHash: Map<string, string | null>;
|
|
30
|
+
/** Test hook that stops the process at a named point, as a kill -9 would (eng X3 fault injection). */
|
|
31
|
+
crash: (point: string) => void;
|
|
32
|
+
}
|
|
33
|
+
export type Outcome = {
|
|
34
|
+
ok: true;
|
|
35
|
+
opId: string;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
reason: string;
|
|
39
|
+
fragment?: unknown;
|
|
40
|
+
file?: string;
|
|
41
|
+
};
|
|
42
|
+
export declare const parsePointer: (p: string) => string[];
|
|
43
|
+
/** Sorts pointers so later array indices come first: removing them never shifts the ones still to do. */
|
|
44
|
+
export declare function comparePointersDesc(a: string, b: string): number;
|
|
45
|
+
export declare function archiveStep(ctx: StepContext, step: Step): Outcome;
|
|
46
|
+
export declare function restoreManifest(ctx: StepContext, m: Manifest): Outcome;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, readlinkSync, rmSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { sha256, valueFingerprint } from '../core/hash.js';
|
|
4
|
+
import { atomicWrite, ChangedWhileMoving, exists, fileHash, moveTree, SourceNotRemoved, stagingPathFor, stateOf } from './fsops.js';
|
|
5
|
+
import { journalBegin, journalEnd, setManifestStatus, writeManifest } from './journal.js';
|
|
6
|
+
import { newOpId } from './manifest.js';
|
|
7
|
+
// ---------------------------------------------------------------- JSON pointers
|
|
8
|
+
export const parsePointer = (p) => p.split('/').slice(1).map(s => s.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
9
|
+
/** Sorts pointers so later array indices come first: removing them never shifts the ones still to do. */
|
|
10
|
+
export function comparePointersDesc(a, b) {
|
|
11
|
+
const pa = parsePointer(a);
|
|
12
|
+
const pb = parsePointer(b);
|
|
13
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
14
|
+
const x = pa[i];
|
|
15
|
+
const y = pb[i];
|
|
16
|
+
if (x === y)
|
|
17
|
+
continue;
|
|
18
|
+
if (x === undefined)
|
|
19
|
+
return 1;
|
|
20
|
+
if (y === undefined)
|
|
21
|
+
return -1;
|
|
22
|
+
const nx = Number(x);
|
|
23
|
+
const ny = Number(y);
|
|
24
|
+
if (Number.isInteger(nx) && Number.isInteger(ny))
|
|
25
|
+
return ny - nx;
|
|
26
|
+
return y.localeCompare(x);
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
/** Re-serialises JSON in the file's own indentation and line ending, keeping a trailing newline if it had one. */
|
|
31
|
+
function serialize(value, original) {
|
|
32
|
+
const indent = original ? (/\n([ \t]+)"/.exec(original)?.[1] ?? ' ') : ' ';
|
|
33
|
+
const eol = original?.includes('\r\n') ? '\r\n' : '\n';
|
|
34
|
+
const text = JSON.stringify(value, null, indent).replace(/\n/g, eol);
|
|
35
|
+
return original === null || /\r?\n$/.test(original) ? text + eol : text;
|
|
36
|
+
}
|
|
37
|
+
function edit(step, json) {
|
|
38
|
+
const seg = parsePointer(step.pointer);
|
|
39
|
+
if (step.method === 'plugin-disable') {
|
|
40
|
+
const next = json && typeof json === 'object' ? json : {};
|
|
41
|
+
next.enabledPlugins ??= {};
|
|
42
|
+
const was = step.key in next.enabledPlugins ? next.enabledPlugins[step.key] : null;
|
|
43
|
+
next.enabledPlugins[step.key] = false;
|
|
44
|
+
return { next, fragment: { [step.key]: was } };
|
|
45
|
+
}
|
|
46
|
+
if (step.method === 'mcp-extract') {
|
|
47
|
+
let parent = json;
|
|
48
|
+
for (const s of seg.slice(0, -1))
|
|
49
|
+
parent = parent?.[s];
|
|
50
|
+
const name = seg[seg.length - 1];
|
|
51
|
+
if (!parent || typeof parent !== 'object' || !(name in parent))
|
|
52
|
+
throw new Error(`${step.pointer} is no longer in ${step.file}`);
|
|
53
|
+
if (valueFingerprint(parent[name]) !== step.item.targetFingerprint)
|
|
54
|
+
throw new Error(NOT_THE_PICKED(step));
|
|
55
|
+
const fragment = { [name]: parent[name] };
|
|
56
|
+
delete parent[name];
|
|
57
|
+
return { next: json, fragment };
|
|
58
|
+
}
|
|
59
|
+
// hook-extract: /hooks/<event>/<group>/hooks/<index>; empty groups and events are removed with it.
|
|
60
|
+
const [, event, gi, , hi] = seg;
|
|
61
|
+
const groups = json?.hooks?.[event];
|
|
62
|
+
const group = Array.isArray(groups) ? groups[Number(gi)] : undefined;
|
|
63
|
+
const hooks = group?.hooks;
|
|
64
|
+
if (!Array.isArray(hooks) || hooks[Number(hi)] === undefined)
|
|
65
|
+
throw new Error(`${step.pointer} is no longer in ${step.file}`);
|
|
66
|
+
// The entry at the pointer must still be the one that was picked: settings files get reordered by other programs.
|
|
67
|
+
if (valueFingerprint({ event, matcher: group.matcher ?? null, command: hooks[Number(hi)]?.command }) !== step.item.targetFingerprint)
|
|
68
|
+
throw new Error(NOT_THE_PICKED(step));
|
|
69
|
+
const [removed] = hooks.splice(Number(hi), 1);
|
|
70
|
+
if (!hooks.length)
|
|
71
|
+
groups.splice(Number(gi), 1);
|
|
72
|
+
if (!groups.length)
|
|
73
|
+
delete json.hooks[event];
|
|
74
|
+
return { next: json, fragment: { event, matcher: group.matcher ?? null, hook: removed } };
|
|
75
|
+
}
|
|
76
|
+
const NOT_THE_PICKED = (step) => `The entry at ${step.pointer} in ${step.file} is no longer the one you picked (the file was reordered). Nothing was changed; scan and apply again.`;
|
|
77
|
+
const base = (ctx, item, method, opId) => ({
|
|
78
|
+
manifestVersion: 1, opId, itemId: item.id, itemName: item.name, kind: item.kind, agent: item.agent, method,
|
|
79
|
+
scanId: ctx.scanId, archivedAt: ctx.now().toISOString(), status: 'archived',
|
|
80
|
+
});
|
|
81
|
+
// ---------------------------------------------------------------- archive
|
|
82
|
+
export function archiveStep(ctx, step) {
|
|
83
|
+
if (step.method === 'manual') {
|
|
84
|
+
const opId = newOpId(ctx.now());
|
|
85
|
+
writeManifest(ctx.paths, { ...base(ctx, step.item, 'manual', opId), status: 'waiting', manualSteps: `Remove it in ${step.where}.` });
|
|
86
|
+
return { ok: true, opId };
|
|
87
|
+
}
|
|
88
|
+
return step.method === 'move' ? archiveMove(ctx, step) : archiveSettings(ctx, step);
|
|
89
|
+
}
|
|
90
|
+
function archiveMove(ctx, step) {
|
|
91
|
+
if (!exists(step.path))
|
|
92
|
+
return { ok: false, reason: `It is no longer at ${step.path}.` };
|
|
93
|
+
const opId = newOpId(ctx.now());
|
|
94
|
+
const payloadPath = join(ctx.paths.archive, opId, 'payload', basename(step.path));
|
|
95
|
+
const st = lstatSync(step.path);
|
|
96
|
+
const fp = stateOf(step.path);
|
|
97
|
+
journalBegin(ctx.paths, {
|
|
98
|
+
opId, direction: 'archive',
|
|
99
|
+
paths: [
|
|
100
|
+
{ path: step.path, kind: 'tree', before: fp, after: 'missing' },
|
|
101
|
+
{ path: payloadPath, kind: 'tree', before: 'missing', after: fp },
|
|
102
|
+
{ path: stagingPathFor(payloadPath), kind: 'tree', before: 'missing', after: 'missing', scratch: true },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
ctx.crash('archive:after-journal');
|
|
106
|
+
writeManifest(ctx.paths, {
|
|
107
|
+
...base(ctx, step.item, 'move', opId), originalPath: step.path, payloadPath, isSymlink: st.isSymbolicLink(),
|
|
108
|
+
...(st.isSymbolicLink() ? { symlinkTarget: readlinkSync(step.path) } : {}), fingerprint: fp,
|
|
109
|
+
});
|
|
110
|
+
ctx.crash('archive:after-manifest');
|
|
111
|
+
try {
|
|
112
|
+
moveTree(step.path, payloadPath);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err instanceof SourceNotRemoved) {
|
|
116
|
+
// The archive holds a complete, verified copy; part of the original is still in place. Keep both.
|
|
117
|
+
journalEnd(ctx.paths, opId, 'archive', 'done');
|
|
118
|
+
return { ok: false, reason: `${err.message} Delete what is left at ${step.path} by hand, or restore with \`packlight restore ${opId}\` once it is gone.` };
|
|
119
|
+
}
|
|
120
|
+
journalEnd(ctx.paths, opId, 'archive', 'aborted');
|
|
121
|
+
rmSync(join(ctx.paths.archive, opId), { recursive: true, force: true });
|
|
122
|
+
return { ok: false, reason: err instanceof ChangedWhileMoving ? err.message : `Could not move it: ${err.message}` };
|
|
123
|
+
}
|
|
124
|
+
ctx.crash('archive:after-change');
|
|
125
|
+
// A same-volume rename does not re-check content: record what actually arrived so restore can verify it.
|
|
126
|
+
const arrived = stateOf(payloadPath);
|
|
127
|
+
if (arrived !== fp)
|
|
128
|
+
writeManifest(ctx.paths, { ...base(ctx, step.item, 'move', opId), originalPath: step.path, payloadPath, isSymlink: st.isSymbolicLink(), ...(st.isSymbolicLink() ? { symlinkTarget: readlinkSync(payloadPath) } : {}), fingerprint: arrived, note: 'It changed between the plan and the move; the archived copy is the changed version.' });
|
|
129
|
+
journalEnd(ctx.paths, opId, 'archive', 'done');
|
|
130
|
+
return { ok: true, opId };
|
|
131
|
+
}
|
|
132
|
+
function archiveSettings(ctx, step) {
|
|
133
|
+
const original = readOrNull(step.file);
|
|
134
|
+
const h = original === null ? null : sha256(original);
|
|
135
|
+
if (ctx.expectedHash.has(step.file) && ctx.expectedHash.get(step.file) !== h) {
|
|
136
|
+
return { ok: false, reason: `${step.file} changed after packlight read it (another program wrote to it). Nothing was changed; scan and apply again.`, file: step.file };
|
|
137
|
+
}
|
|
138
|
+
let next;
|
|
139
|
+
let fragment;
|
|
140
|
+
try {
|
|
141
|
+
({ next, fragment } = edit(step, original === null ? {} : JSON.parse(original.toString('utf8').replace(/^/, ''))));
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
return { ok: false, reason: err.message, file: step.file };
|
|
145
|
+
}
|
|
146
|
+
const text = serialize(next, original?.toString('utf8') ?? null);
|
|
147
|
+
const h2 = sha256(text);
|
|
148
|
+
const opId = newOpId(ctx.now());
|
|
149
|
+
journalBegin(ctx.paths, { opId, direction: 'archive', paths: [{ path: step.file, kind: 'file', before: h, after: h2 }] });
|
|
150
|
+
ctx.crash('archive:after-journal');
|
|
151
|
+
writeManifest(ctx.paths, {
|
|
152
|
+
...base(ctx, step.item, step.method, opId), settingsFile: step.file, jsonPointer: step.pointer, fragment,
|
|
153
|
+
originalFileBase64: original === null ? null : original.toString('base64'), fileHashBefore: h, fileHashAfter: h2,
|
|
154
|
+
...(step.method === 'plugin-disable' ? { plugin: { key: step.key, wasEnabled: fragment[step.key] ?? null } } : {}),
|
|
155
|
+
});
|
|
156
|
+
ctx.crash('archive:after-manifest');
|
|
157
|
+
// Re-hash right before writing: this narrows, but cannot close, the window in which Claude Code itself writes (CEO O4).
|
|
158
|
+
if (fileHash(step.file) !== h) {
|
|
159
|
+
journalEnd(ctx.paths, opId, 'archive', 'aborted');
|
|
160
|
+
rmSync(join(ctx.paths.archive, opId), { recursive: true, force: true });
|
|
161
|
+
return { ok: false, reason: `${step.file} changed while packlight was preparing to edit it. Nothing was changed; apply again.`, file: step.file };
|
|
162
|
+
}
|
|
163
|
+
atomicWrite(step.file, text);
|
|
164
|
+
ctx.crash('archive:after-change');
|
|
165
|
+
journalEnd(ctx.paths, opId, 'archive', 'done');
|
|
166
|
+
ctx.expectedHash.set(step.file, h2);
|
|
167
|
+
return { ok: true, opId };
|
|
168
|
+
}
|
|
169
|
+
const readOrNull = (p) => {
|
|
170
|
+
try {
|
|
171
|
+
return readFileSync(p);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
if (err?.code === 'ENOENT')
|
|
175
|
+
return null;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
// ---------------------------------------------------------------- restore
|
|
180
|
+
export function restoreManifest(ctx, m) {
|
|
181
|
+
if (m.status === 'restored')
|
|
182
|
+
return { ok: false, reason: 'It was already restored.' };
|
|
183
|
+
if (m.method === 'manual' || m.status === 'waiting')
|
|
184
|
+
return { ok: false, reason: `It was removed by hand. Add it back where it was removed: ${m.manualSteps ?? 'see the manifest'}` };
|
|
185
|
+
return m.method === 'move' ? restoreMove(ctx, m) : restoreSettings(ctx, m);
|
|
186
|
+
}
|
|
187
|
+
function restoreMove(ctx, m) {
|
|
188
|
+
const orig = m.originalPath;
|
|
189
|
+
const payload = m.payloadPath;
|
|
190
|
+
if (exists(orig))
|
|
191
|
+
return { ok: false, reason: `Something is already at ${orig}. packlight will not overwrite it; move it aside and restore again.` };
|
|
192
|
+
const fp = stateOf(payload);
|
|
193
|
+
if (fp !== m.fingerprint)
|
|
194
|
+
return { ok: false, reason: `The archived copy at ${payload} no longer matches its record, so it was not restored.` };
|
|
195
|
+
journalBegin(ctx.paths, {
|
|
196
|
+
opId: m.opId, direction: 'restore',
|
|
197
|
+
paths: [
|
|
198
|
+
{ path: orig, kind: 'tree', before: 'missing', after: fp },
|
|
199
|
+
{ path: payload, kind: 'tree', before: fp, after: 'missing' },
|
|
200
|
+
{ path: stagingPathFor(orig), kind: 'tree', before: 'missing', after: 'missing', scratch: true },
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
ctx.crash('restore:after-journal');
|
|
204
|
+
try {
|
|
205
|
+
moveTree(payload, orig);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (err instanceof SourceNotRemoved) {
|
|
209
|
+
// The item is back and verified; only the archived copy could not be fully deleted.
|
|
210
|
+
setManifestStatus(ctx.paths, m.opId, 'restored');
|
|
211
|
+
journalEnd(ctx.paths, m.opId, 'restore', 'done');
|
|
212
|
+
return { ok: true, opId: m.opId };
|
|
213
|
+
}
|
|
214
|
+
journalEnd(ctx.paths, m.opId, 'restore', 'aborted');
|
|
215
|
+
return { ok: false, reason: `Could not move it back: ${err.message}` };
|
|
216
|
+
}
|
|
217
|
+
ctx.crash('restore:after-change');
|
|
218
|
+
setManifestStatus(ctx.paths, m.opId, 'restored');
|
|
219
|
+
journalEnd(ctx.paths, m.opId, 'restore', 'done');
|
|
220
|
+
return { ok: true, opId: m.opId };
|
|
221
|
+
}
|
|
222
|
+
function restoreSettings(ctx, m) {
|
|
223
|
+
const file = m.settingsFile;
|
|
224
|
+
const cur = fileHash(file);
|
|
225
|
+
// Byte-exact restore only (D12): a file edited since the archive is left alone, and the fragment is printed.
|
|
226
|
+
if (cur !== m.fileHashAfter) {
|
|
227
|
+
return { ok: false, file, fragment: m.fragment, reason: `${file} has changed since packlight archived this, so packlight did not touch it. Put this back at ${m.jsonPointer} by hand:` };
|
|
228
|
+
}
|
|
229
|
+
journalBegin(ctx.paths, { opId: m.opId, direction: 'restore', paths: [{ path: file, kind: 'file', before: cur, after: m.fileHashBefore ?? null }] });
|
|
230
|
+
ctx.crash('restore:after-journal');
|
|
231
|
+
if (m.originalFileBase64 === null || m.originalFileBase64 === undefined)
|
|
232
|
+
rmSync(file, { force: true });
|
|
233
|
+
else
|
|
234
|
+
atomicWrite(file, Buffer.from(m.originalFileBase64, 'base64'));
|
|
235
|
+
ctx.crash('restore:after-change');
|
|
236
|
+
setManifestStatus(ctx.paths, m.opId, 'restored');
|
|
237
|
+
journalEnd(ctx.paths, m.opId, 'restore', 'done');
|
|
238
|
+
return { ok: true, opId: m.opId };
|
|
239
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Inventory } from '../core/types.js';
|
|
2
|
+
/** Everything packlight writes lives under <home>/.packlight (acceptance criterion 8). */
|
|
3
|
+
export interface PacklightPaths {
|
|
4
|
+
home: string;
|
|
5
|
+
root: string;
|
|
6
|
+
scans: string;
|
|
7
|
+
archive: string;
|
|
8
|
+
journal: string;
|
|
9
|
+
lock: string;
|
|
10
|
+
keeps: string;
|
|
11
|
+
log: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function packlightPaths(home: string, root?: string): PacklightPaths;
|
|
14
|
+
export declare function readScan(paths: PacklightPaths, scanId: string): Inventory | undefined;
|
|
15
|
+
/** Newest scan for one agent and project scope (eng X6 correction to F3). */
|
|
16
|
+
export declare function newestScan(paths: PacklightPaths, agent: string, projectScope: string): Inventory | undefined;
|
|
17
|
+
/** The browser download folder per OS (eng SC1); undefined when it cannot be found. */
|
|
18
|
+
export declare function downloadsDir(home?: string, platform?: NodeJS.Platform): string | undefined;
|