@wipcomputer/wip-ldm-os 0.4.86 → 0.4.87-alpha.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/bin/ldm.js +374 -4
- package/docs/backup/README.md +83 -80
- package/docs/backup/TECHNICAL.md +107 -90
- package/package.json +16 -3
- package/scripts/ldm-backup-engine.py +1176 -0
- package/scripts/ldm-backup-policy.json +1420 -0
- package/scripts/ldm-backup.sh +15 -273
- package/scripts/test-backup-engine.py +609 -0
- package/scripts/test-doctor-commit-deployed.mjs +300 -0
- package/scripts/test-ldm-install-preserves-foreign-bin.mjs +5 -0
- package/shared/docs/README.md.tmpl +1 -1
- package/shared/docs/directory-map.md.tmpl +1 -1
- package/shared/docs/how-backup-works.md.tmpl +50 -83
- package/shared/docs/system-directories.md.tmpl +1 -1
- package/shared/docs/what-is-dotldm.md.tmpl +1 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test: `ldm doctor --commit-deployed`.
|
|
3
|
+
//
|
|
4
|
+
// Covers:
|
|
5
|
+
// wip-tracking-private-only/bugs/installer/open-tickets/2026-07-06--cc-mini--ldm-doctor-commit-deployed.md
|
|
6
|
+
//
|
|
7
|
+
// The command records installer-deployed config drift in a tracked home tree so
|
|
8
|
+
// the tree stops blocking `git pull`, gitignores runtime/transient junk, and
|
|
9
|
+
// leaves personal choices. settings.json is split at JSON-key granularity:
|
|
10
|
+
// installer-owned keys (hooks) commit, personal keys (model, permissions) stay.
|
|
11
|
+
//
|
|
12
|
+
// Follows the test-doctor-hook-dedupe.mjs harness: real bin/ldm.js against a
|
|
13
|
+
// temp HOME with a real, disposable local git repo for the home tree. gh / npm
|
|
14
|
+
// / crontab are shimmed on PATH and LDM_SELF_UPDATED=1 so no network, no PR, no
|
|
15
|
+
// operator state is touched. No push, no merge: the command records locally.
|
|
16
|
+
|
|
17
|
+
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const cli = join(repo, 'bin', 'ldm.js');
|
|
25
|
+
|
|
26
|
+
let failed = 0;
|
|
27
|
+
function assert(cond, label, output = '') {
|
|
28
|
+
if (cond) {
|
|
29
|
+
console.log(` [PASS] ${label}`);
|
|
30
|
+
} else {
|
|
31
|
+
console.log(` [FAIL] ${label}`);
|
|
32
|
+
if (output) console.log(` ${String(output).trim().split('\n').slice(-20).join('\n ')}`);
|
|
33
|
+
failed++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const OLD_HOOKS = { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: 'node /old/boot-hook.mjs', timeout: 15 }] }] };
|
|
38
|
+
const NEW_HOOKS = { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: 'node /new/boot-hook.mjs', timeout: 15 }] }] };
|
|
39
|
+
|
|
40
|
+
function git(dir, ...a) {
|
|
41
|
+
return execFileSync('git', ['-C', dir, ...a], { encoding: 'utf8' });
|
|
42
|
+
}
|
|
43
|
+
function writeJSON(p, obj) {
|
|
44
|
+
writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build a fixture HOME with a real local git repo at HOME/.claude. `driftFn`
|
|
48
|
+
// receives the repo dir and mutates it into the post-install dirty state.
|
|
49
|
+
function setupHome(driftFn) {
|
|
50
|
+
const home = mkdtempSync(join(tmpdir(), 'ldm-commitdep-'));
|
|
51
|
+
const claude = join(home, '.claude');
|
|
52
|
+
const fakeBin = join(home, 'fakebin');
|
|
53
|
+
mkdirSync(join(claude, 'rules'), { recursive: true });
|
|
54
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
55
|
+
|
|
56
|
+
// Baseline committed state.
|
|
57
|
+
writeJSON(join(claude, 'settings.json'), { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: OLD_HOOKS });
|
|
58
|
+
writeFileSync(join(claude, 'rules', 'base.md'), 'old rule\n');
|
|
59
|
+
// projects/ and cache/ already ignored; plans/ deliberately NOT, so the
|
|
60
|
+
// command has a new transient path to gitignore.
|
|
61
|
+
writeFileSync(join(claude, '.gitignore'), 'projects/\ncache/\n');
|
|
62
|
+
|
|
63
|
+
git(claude, 'init', '-q');
|
|
64
|
+
git(claude, 'config', 'user.email', 'test@example.com');
|
|
65
|
+
git(claude, 'config', 'user.name', 'Test');
|
|
66
|
+
git(claude, 'config', 'commit.gpgsign', 'false');
|
|
67
|
+
// Isolate the fixture from any global core.hooksPath (the real machine sets a
|
|
68
|
+
// pre-commit hook there that blocks commits on main). Kept OUTSIDE the repo so
|
|
69
|
+
// an added hook file never pollutes the repo's status. Tests that want a hook
|
|
70
|
+
// drop one into this dir explicitly.
|
|
71
|
+
const hooksDir = join(home, 'githooks');
|
|
72
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
73
|
+
git(claude, 'config', 'core.hooksPath', hooksDir);
|
|
74
|
+
git(claude, 'add', '-A');
|
|
75
|
+
git(claude, 'commit', '-q', '-m', 'baseline');
|
|
76
|
+
|
|
77
|
+
driftFn(claude);
|
|
78
|
+
|
|
79
|
+
// Shims: never touched by this command, present for parity with doctor tests.
|
|
80
|
+
for (const b of ['gh', 'npm', 'crontab']) {
|
|
81
|
+
writeFileSync(join(fakeBin, b), '#!/bin/sh\nexit 0\n');
|
|
82
|
+
chmodSync(join(fakeBin, b), 0o755);
|
|
83
|
+
}
|
|
84
|
+
return { home, claude, fakeBin, hooksDir };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function run({ home, claude, fakeBin }, extra = []) {
|
|
88
|
+
const args = ['doctor', '--commit-deployed', '--home', claude, ...extra];
|
|
89
|
+
try {
|
|
90
|
+
return execFileSync('node', [cli, ...args], {
|
|
91
|
+
env: { ...process.env, HOME: home, PATH: `${fakeBin}:${process.env.PATH}`, LDM_SELF_UPDATED: '1' },
|
|
92
|
+
encoding: 'utf-8',
|
|
93
|
+
timeout: 30000,
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return (err.stdout || '') + (err.stderr || '');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function headJSON(claude, path) {
|
|
101
|
+
return JSON.parse(git(claude, 'show', `HEAD:${path}`));
|
|
102
|
+
}
|
|
103
|
+
function statusPorcelain(claude) {
|
|
104
|
+
return git(claude, 'status', '--porcelain').trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log('Test 1: deployed + transient drift, no personal drift -> clean tree');
|
|
108
|
+
{
|
|
109
|
+
const w = setupHome((c) => {
|
|
110
|
+
const s = { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS };
|
|
111
|
+
writeJSON(join(c, 'settings.json'), s); // deployed key (hooks) changed
|
|
112
|
+
writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n'); // deployed file changed
|
|
113
|
+
mkdirSync(join(c, 'plans'), { recursive: true });
|
|
114
|
+
writeFileSync(join(c, 'plans', 'session.json'), '{}\n'); // transient, NOT yet ignored
|
|
115
|
+
mkdirSync(join(c, 'projects'), { recursive: true });
|
|
116
|
+
writeFileSync(join(c, 'projects', 'x.json'), '{}\n'); // transient, already ignored
|
|
117
|
+
});
|
|
118
|
+
const out = run(w);
|
|
119
|
+
assert(/record installer-deployed config drift/.test(git(w.claude, 'log', '-1', '--format=%s')), 'recorded a chore(deploy) commit', out);
|
|
120
|
+
assert(headJSON(w.claude, 'settings.json').hooks.SessionStart[0].hooks[0].command === 'node /new/boot-hook.mjs', 'committed settings.json has the new hooks', out);
|
|
121
|
+
assert(headJSON(w.claude, 'settings.json').model === 'sonnet', 'committed settings.json keeps model (personal untouched)');
|
|
122
|
+
assert(git(w.claude, 'show', 'HEAD:rules/base.md') === 'new rule\n', 'committed the deployed rules/ change');
|
|
123
|
+
assert(readFileSync(join(w.claude, '.gitignore'), 'utf8').includes('plans/'), 'gitignored the new transient path (plans/)', out);
|
|
124
|
+
assert(statusPorcelain(w.claude) === '', 'tree is CLEAN after recording', statusPorcelain(w.claude));
|
|
125
|
+
const backups = existsSync(join(w.home, '.ldm', 'backups')) ? readdirSync(join(w.home, '.ldm', 'backups')) : [];
|
|
126
|
+
assert(backups.length === 1 && existsSync(join(w.home, '.ldm', 'backups', backups[0], 'MANIFEST.txt')), 'timestamped backup with manifest taken before mutation', backups.join(','));
|
|
127
|
+
|
|
128
|
+
// Idempotent second run.
|
|
129
|
+
const headBefore = git(w.claude, 'rev-parse', 'HEAD').trim();
|
|
130
|
+
const out2 = run(w);
|
|
131
|
+
assert(/already clean, nothing to record/.test(out2), 'second run is a no-op (tree already clean)', out2);
|
|
132
|
+
assert(git(w.claude, 'rev-parse', 'HEAD').trim() === headBefore, 'no extra commit on the clean second run');
|
|
133
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log('Test 2: mixed settings.json (hooks + model) -> key granularity, personal left');
|
|
137
|
+
{
|
|
138
|
+
const w = setupHome((c) => {
|
|
139
|
+
writeJSON(join(c, 'settings.json'), { model: 'opus', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
|
|
140
|
+
writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n');
|
|
141
|
+
});
|
|
142
|
+
const out = run(w);
|
|
143
|
+
assert(headJSON(w.claude, 'settings.json').hooks.SessionStart[0].hooks[0].command === 'node /new/boot-hook.mjs', 'committed the hooks key', out);
|
|
144
|
+
assert(headJSON(w.claude, 'settings.json').model === 'sonnet', 'did NOT commit the personal model key (still old on HEAD)', out);
|
|
145
|
+
assert(JSON.parse(readFileSync(join(w.claude, 'settings.json'), 'utf8')).model === 'opus', 'working settings.json keeps the new personal model');
|
|
146
|
+
const dirty = statusPorcelain(w.claude).split('\n').filter(Boolean);
|
|
147
|
+
assert(dirty.length === 1 && /settings\.json$/.test(dirty[0]), 'only settings.json remains dirty (the personal delta)', statusPorcelain(w.claude));
|
|
148
|
+
assert(/Left uncommitted/.test(out) && /settings\.json \(model\)/.test(out), 'reports model left as personal', out);
|
|
149
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log('Test 3: --include-personal folds personal keys in -> clean tree');
|
|
153
|
+
{
|
|
154
|
+
const w = setupHome((c) => {
|
|
155
|
+
writeJSON(join(c, 'settings.json'), { model: 'opus', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
|
|
156
|
+
});
|
|
157
|
+
const out = run(w, ['--include-personal']);
|
|
158
|
+
assert(headJSON(w.claude, 'settings.json').model === 'opus', 'committed the personal model under --include-personal', out);
|
|
159
|
+
assert(headJSON(w.claude, 'settings.json').hooks.SessionStart[0].hooks[0].command === 'node /new/boot-hook.mjs', 'committed the hooks too');
|
|
160
|
+
assert(statusPorcelain(w.claude) === '', 'tree clean with --include-personal', statusPorcelain(w.claude));
|
|
161
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log('Test 4: personal-only drift -> nothing committed, left for the user');
|
|
165
|
+
{
|
|
166
|
+
const w = setupHome((c) => {
|
|
167
|
+
writeJSON(join(c, 'settings.json'), { model: 'opus', permissions: { defaultMode: 'default' }, hooks: OLD_HOOKS });
|
|
168
|
+
});
|
|
169
|
+
const headBefore = git(w.claude, 'rev-parse', 'HEAD').trim();
|
|
170
|
+
const out = run(w);
|
|
171
|
+
assert(/No deployed drift or new runtime paths to record/.test(out), 'reports nothing deployed to record', out);
|
|
172
|
+
assert(git(w.claude, 'rev-parse', 'HEAD').trim() === headBefore, 'no commit created for personal-only drift');
|
|
173
|
+
assert(JSON.parse(readFileSync(join(w.claude, 'settings.json'), 'utf8')).model === 'opus', 'personal model left in the working tree');
|
|
174
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log('Test 5: dry run records nothing');
|
|
178
|
+
{
|
|
179
|
+
const w = setupHome((c) => {
|
|
180
|
+
writeJSON(join(c, 'settings.json'), { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
|
|
181
|
+
});
|
|
182
|
+
const headBefore = git(w.claude, 'rev-parse', 'HEAD').trim();
|
|
183
|
+
const out = run(w, ['--dry-run']);
|
|
184
|
+
assert(/\[DRY RUN\] Would record/.test(out), 'dry run reports intent', out);
|
|
185
|
+
assert(git(w.claude, 'rev-parse', 'HEAD').trim() === headBefore, 'dry run creates no commit');
|
|
186
|
+
assert(/ M settings\.json|settings\.json/.test(statusPorcelain(w.claude)), 'dry run leaves the tree unchanged (still dirty)');
|
|
187
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log('Test 6: non-git home is reported, not crashed');
|
|
191
|
+
{
|
|
192
|
+
const home = mkdtempSync(join(tmpdir(), 'ldm-commitdep-nogit-'));
|
|
193
|
+
const notRepo = join(home, 'plain');
|
|
194
|
+
mkdirSync(notRepo, { recursive: true });
|
|
195
|
+
const fakeBin = join(home, 'fakebin');
|
|
196
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
197
|
+
for (const b of ['gh', 'npm', 'crontab']) { writeFileSync(join(fakeBin, b), '#!/bin/sh\nexit 0\n'); chmodSync(join(fakeBin, b), 0o755); }
|
|
198
|
+
let out = '';
|
|
199
|
+
try {
|
|
200
|
+
out = execFileSync('node', [cli, 'doctor', '--commit-deployed', '--home', notRepo], {
|
|
201
|
+
env: { ...process.env, HOME: home, PATH: `${fakeBin}:${process.env.PATH}`, LDM_SELF_UPDATED: '1' },
|
|
202
|
+
encoding: 'utf-8', timeout: 30000,
|
|
203
|
+
});
|
|
204
|
+
} catch (err) { out = (err.stdout || '') + (err.stderr || ''); }
|
|
205
|
+
assert(/Not a git work tree/.test(out), 'reports a non-git home cleanly', out);
|
|
206
|
+
rmSync(home, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
console.log('Test 7: pre-existing personal .gitignore edit is NOT swept into the deploy commit');
|
|
210
|
+
{
|
|
211
|
+
const w = setupHome((c) => {
|
|
212
|
+
writeJSON(join(c, 'settings.json'), { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
|
|
213
|
+
// User has an uncommitted personal .gitignore edit...
|
|
214
|
+
writeFileSync(join(c, '.gitignore'), 'projects/\ncache/\npersonal-local/\n');
|
|
215
|
+
// ...and there is a new transient path that forces a gitignore addition.
|
|
216
|
+
mkdirSync(join(c, 'plans'), { recursive: true });
|
|
217
|
+
writeFileSync(join(c, 'plans', 'session.json'), '{}\n');
|
|
218
|
+
});
|
|
219
|
+
const out = run(w);
|
|
220
|
+
const committedGitignore = git(w.claude, 'show', 'HEAD:.gitignore');
|
|
221
|
+
assert(committedGitignore.includes('plans/'), 'the new transient pattern was committed', out);
|
|
222
|
+
assert(!committedGitignore.includes('personal-local/'), 'the pre-existing personal .gitignore edit was NOT committed', committedGitignore);
|
|
223
|
+
assert(readFileSync(join(w.claude, '.gitignore'), 'utf8').includes('personal-local/'), 'personal .gitignore edit still present in working tree');
|
|
224
|
+
const dirty = statusPorcelain(w.claude).split('\n').filter(Boolean);
|
|
225
|
+
assert(dirty.length === 1 && /\.gitignore$/.test(dirty[0]), 'only .gitignore (the personal edit) remains dirty', statusPorcelain(w.claude));
|
|
226
|
+
assert(/\.gitignore \(your existing edits/.test(out), 'reports .gitignore personal edits left', out);
|
|
227
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
console.log('Test 8: malformed settings.json is skipped, never deletes committed hooks');
|
|
231
|
+
{
|
|
232
|
+
const w = setupHome((c) => {
|
|
233
|
+
writeFileSync(join(c, 'settings.json'), '{ this is not valid json\n'); // corrupt working file
|
|
234
|
+
writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n'); // a legit deployed change
|
|
235
|
+
});
|
|
236
|
+
const headBefore = git(w.claude, 'rev-parse', 'HEAD').trim();
|
|
237
|
+
const out = run(w);
|
|
238
|
+
assert(/settings\.json skipped/.test(out) && /not valid JSON/.test(out), 'warns and skips malformed settings.json', out);
|
|
239
|
+
// The committed settings.json must still carry the original hooks (not deleted).
|
|
240
|
+
const committed = headJSON(w.claude, 'settings.json');
|
|
241
|
+
assert(committed.hooks && committed.hooks.SessionStart, 'committed settings.json still has its hooks (not clobbered)', JSON.stringify(committed));
|
|
242
|
+
assert(committed.hooks.SessionStart[0].hooks[0].command === 'node /old/boot-hook.mjs', 'hooks unchanged at HEAD (no deletion commit)');
|
|
243
|
+
// The legit deployed rules/ change still got recorded (skip is scoped to settings.json).
|
|
244
|
+
assert(git(w.claude, 'rev-parse', 'HEAD').trim() !== headBefore, 'a commit was still made for the deployed rules/ change');
|
|
245
|
+
assert(git(w.claude, 'show', 'HEAD:rules/base.md') === 'new rule\n', 'rules/ change committed despite settings.json skip');
|
|
246
|
+
assert(/settings\.json/.test(statusPorcelain(w.claude)), 'malformed settings.json left dirty for manual repair');
|
|
247
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log('Test 9: a tracked file under a transient prefix is left, not falsely claimed clean');
|
|
251
|
+
{
|
|
252
|
+
const w = setupHome((c) => {
|
|
253
|
+
// Commit a tracked file under a transient prefix in the baseline drift...
|
|
254
|
+
mkdirSync(join(c, 'plans'), { recursive: true });
|
|
255
|
+
writeFileSync(join(c, 'plans', 'tracked.md'), 'v1\n');
|
|
256
|
+
git(c, 'add', '--', 'plans/tracked.md');
|
|
257
|
+
git(c, 'commit', '-q', '-m', 'add tracked transient');
|
|
258
|
+
// ...then modify it (tracked-transient drift) plus a real deployed change.
|
|
259
|
+
writeFileSync(join(c, 'plans', 'tracked.md'), 'v2\n');
|
|
260
|
+
writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n');
|
|
261
|
+
});
|
|
262
|
+
const out = run(w);
|
|
263
|
+
assert(git(w.claude, 'show', 'HEAD:rules/base.md') === 'new rule\n', 'deployed rules/ change committed', out);
|
|
264
|
+
assert(git(w.claude, 'show', 'HEAD:plans/tracked.md') === 'v1\n', 'tracked-transient file was NOT committed (still v1 at HEAD)');
|
|
265
|
+
assert(/tracked transient/.test(out), 'reports the tracked-transient file as left', out);
|
|
266
|
+
const dirty = statusPorcelain(w.claude).split('\n').filter(Boolean);
|
|
267
|
+
assert(dirty.length === 1 && /plans\/tracked\.md$/.test(dirty[0]), 'tracked-transient file remains dirty (honestly not claimed clean)', statusPorcelain(w.claude));
|
|
268
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
console.log('Test 10: records even when a pre-commit hook blocks commits on main');
|
|
272
|
+
{
|
|
273
|
+
const w = setupHome((c) => {
|
|
274
|
+
writeJSON(join(c, 'settings.json'), { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
|
|
275
|
+
writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n');
|
|
276
|
+
});
|
|
277
|
+
// Simulate the real machine: a pre-commit hook that hard-blocks `git commit`
|
|
278
|
+
// on main. The command lands its commit via plumbing, so it must still work.
|
|
279
|
+
const hook = join(w.hooksDir, 'pre-commit');
|
|
280
|
+
writeFileSync(hook, '#!/bin/sh\nif [ "$(git symbolic-ref --short HEAD)" = "main" ]; then echo "BLOCKED: no commits on main"; exit 1; fi\n');
|
|
281
|
+
chmodSync(hook, 0o755);
|
|
282
|
+
// Ensure the fixture is actually on main and the hook really blocks a plain commit.
|
|
283
|
+
execFileSync('git', ['-C', w.claude, 'branch', '-M', 'main'], { encoding: 'utf8' });
|
|
284
|
+
let blocked = false;
|
|
285
|
+
try { execFileSync('git', ['-C', w.claude, 'commit', '-q', '--allow-empty', '-m', 'probe'], { encoding: 'utf8' }); }
|
|
286
|
+
catch { blocked = true; }
|
|
287
|
+
assert(blocked, 'sanity: a plain git commit on main is blocked by the hook');
|
|
288
|
+
|
|
289
|
+
const out = run(w);
|
|
290
|
+
assert(git(w.claude, 'log', '-1', '--format=%s').includes('record installer-deployed config drift'), 'command still recorded the commit on main via plumbing', out);
|
|
291
|
+
assert(git(w.claude, 'show', 'HEAD:rules/base.md') === 'new rule\n', 'deployed change landed despite the blocking hook');
|
|
292
|
+
assert(statusPorcelain(w.claude) === '', 'tree clean after recording past the hook', statusPorcelain(w.claude));
|
|
293
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (failed > 0) {
|
|
297
|
+
console.log(`\n${failed} assertion(s) failed`);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
console.log('\nAll doctor commit-deployed tests passed');
|
|
@@ -102,6 +102,11 @@ exit 0
|
|
|
102
102
|
assert((readFileSync(shimPath).length > 0), 'foreign shim remains non-empty', output);
|
|
103
103
|
assert((statSync(shimPath).mode & 0o111) !== 0, 'foreign shim remains executable', output);
|
|
104
104
|
assert((existsSync(join(binDir, 'ldm-backup.sh'))), 'ldm-owned scripts still deploy into ~/.ldm/bin', output);
|
|
105
|
+
assert((existsSync(join(binDir, 'ldm-backup-engine.py'))), 'backup engine deploys into ~/.ldm/bin', output);
|
|
106
|
+
assert((existsSync(join(binDir, 'ldm-backup-policy.json'))), 'backup policy deploys into ~/.ldm/bin', output);
|
|
107
|
+
assert((statSync(join(binDir, 'ldm-backup-engine.py')).mode & 0o111) !== 0, 'backup engine is executable', output);
|
|
108
|
+
assert((statSync(join(binDir, 'ldm-backup-policy.json')).mode & 0o111) === 0, 'backup policy is not executable', output);
|
|
109
|
+
assert(readFileSync(join(binDir, 'ldm-backup-policy.json'), 'utf8') === readFileSync(join(repo, 'scripts', 'ldm-backup-policy.json'), 'utf8'), 'backup policy content matches package source', output);
|
|
105
110
|
|
|
106
111
|
const restored = readFileSync(shimPath);
|
|
107
112
|
assert(restored.length === Buffer.byteLength(shimContent), 'foreign shim size is unchanged', output);
|
|
@@ -16,7 +16,7 @@ If something is wrong, update `~/.ldm/config.json` or run `ldm install`. The doc
|
|
|
16
16
|
| `what-is-dotldm.md` | The `~/.ldm/` runtime directory explained |
|
|
17
17
|
| `directory-map.md` | What lives where and why |
|
|
18
18
|
| `how-worktrees-work.md` | Git worktrees, the _worktrees/ convention |
|
|
19
|
-
| `how-backup-works.md` |
|
|
19
|
+
| `how-backup-works.md` | Safe dry run, verified internal snapshots, and later-tier gates |
|
|
20
20
|
| `how-releases-work.md` | The full release pipeline |
|
|
21
21
|
| `how-install-works.md` | ldm install, the install prompt, dogfooding |
|
|
22
22
|
| `how-agents-work.md` | Agents, harnesses, bridge, memory |
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
hooks/ <- Claude Code hooks
|
|
44
44
|
state/ <- runtime state
|
|
45
45
|
tmp/ <- install staging
|
|
46
|
-
backups/
|
|
46
|
+
backups/v1/internal/ <- verified internal snapshots and in-progress staging
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
## Harness Directories (deployment targets)
|
|
@@ -1,111 +1,78 @@
|
|
|
1
1
|
# How Backup Works
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Safe planning first
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The backup engine has a strictly read-only dry run:
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```bash
|
|
8
|
+
~/.ldm/bin/ldm-backup.sh --dry-run
|
|
9
|
+
```
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|--------|--------|-------------|
|
|
11
|
-
| `~/.ldm/memory/crystal.db` | sqlite3 .backup | Irreplaceable memory (all agents) |
|
|
12
|
-
| `~/.ldm/agents/` | cp -a | Identity files, journals, daily logs |
|
|
13
|
-
| `~/.ldm/state/` | cp -a | Config, version, registry |
|
|
14
|
-
| `~/.ldm/config.json` | cp | Workspace pointer, org |
|
|
15
|
-
| `~/.openclaw/memory/main.sqlite` | sqlite3 .backup | OC conversations |
|
|
16
|
-
| `~/.openclaw/memory/context-embeddings.sqlite` | sqlite3 .backup | Embeddings |
|
|
17
|
-
| `~/.openclaw/workspace/` | tar | Shared context, daily logs |
|
|
18
|
-
| `~/.openclaw/agents/main/sessions/` | tar | OC session JSONL |
|
|
19
|
-
| `~/.openclaw/openclaw.json` | cp | OC config |
|
|
20
|
-
| `~/.claude/CLAUDE.md` | cp | CC instructions |
|
|
21
|
-
| `~/.claude/settings.json` | cp | CC settings |
|
|
22
|
-
| `~/.claude/projects/` | tar | CC auto-memory + transcripts |
|
|
23
|
-
| `~/wipcomputerinc/` | tar (excludes node_modules, .git/objects, old backups, _trash) | Entire workspace |
|
|
11
|
+
It shows every policy source, classification, exclusion reason, per-source estimate, total estimated new bytes, available disk capacity, required reserve, planned staging path, planned internal destination, blocked requirements, and the final preflight verdict.
|
|
24
12
|
|
|
25
|
-
|
|
13
|
+
Dry run does not create files or directories. It does not acquire the persistent lock, write state, copy data, create a manifest, create a completion marker, rotate snapshots, write to Vault or iCloud, change the schedule, enable the LaunchAgent, or modify containment state. Sensitive filenames and contents are not printed.
|
|
26
14
|
|
|
27
|
-
|
|
15
|
+
Exit zero means the proposed run passes the same policy and preflight used by the real engine. Exit nonzero means a required source, encryption gate, destination, or disk reserve blocks the run.
|
|
28
16
|
|
|
29
|
-
|
|
30
|
-
~/.ldm/backups/2026-03-24--09-50-22/
|
|
31
|
-
ldm/
|
|
32
|
-
memory/crystal.db
|
|
33
|
-
agents/
|
|
34
|
-
state/
|
|
35
|
-
config.json
|
|
36
|
-
openclaw/
|
|
37
|
-
memory/main.sqlite
|
|
38
|
-
memory/context-embeddings.sqlite
|
|
39
|
-
workspace.tar
|
|
40
|
-
sessions.tar
|
|
41
|
-
openclaw.json
|
|
42
|
-
claude/
|
|
43
|
-
CLAUDE.md
|
|
44
|
-
settings.json
|
|
45
|
-
projects.tar
|
|
46
|
-
wipcomputerinc.tar
|
|
47
|
-
```
|
|
17
|
+
## Canonical source policy
|
|
48
18
|
|
|
49
|
-
|
|
19
|
+
`~/.ldm/bin/ldm-backup-policy.json` defines required, rebuildable, sensitive, blocked, manifest-only, and excluded sources. Estimation, capture, manifest generation, and exclusion records all use this one policy.
|
|
50
20
|
|
|
51
|
-
|
|
21
|
+
Sources that require encryption remain visibly blocked until that later gate ships. The engine never turns a blocked or missing required source into a partial success.
|
|
52
22
|
|
|
53
|
-
|
|
54
|
-
~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/
|
|
55
|
-
wipcomputerinc-lesa-2026-03-24--09-50-22.tar.gz
|
|
56
|
-
```
|
|
23
|
+
The policy has one explicit ID for every approved source-inventory row. The test suite compares the exact ID list so a future omission cannot silently pass.
|
|
57
24
|
|
|
58
|
-
|
|
25
|
+
## Runtime and changing files
|
|
59
26
|
|
|
60
|
-
|
|
27
|
+
The engine uses Python 3 standard library only. No third-party Python packages are required. If `python3` is unavailable, the runner exits with `Backup failed [python3-missing]`. Owner acceptance of Python 3 as a release prerequisite must be recorded separately at the release gate.
|
|
61
28
|
|
|
62
|
-
|
|
63
|
-
~/.ldm/bin/ldm-backup.sh # run backup now
|
|
64
|
-
~/.ldm/bin/ldm-backup.sh --dry-run # preview what would be backed up
|
|
65
|
-
~/.ldm/bin/ldm-backup.sh --keep 14 # keep 14 days instead of 7
|
|
66
|
-
~/.ldm/bin/ldm-backup.sh --include-secrets # include ~/.ldm/secrets/
|
|
67
|
-
```
|
|
29
|
+
A real run observes each source again immediately before capture. Changes since dry-run or initial planning are recorded and captured from the newer observation. Files and trees must then remain stable while that source is copied. Same-size changes are detected by checksum, and any during-capture mutation fails the run. SQLite uses the SQLite backup API for a transactionally consistent snapshot.
|
|
68
30
|
|
|
69
|
-
|
|
31
|
+
The engine recalculates the reserve before copying a changed source. It includes staged bytes, stat-only refreshed remaining sources, a full checksum observation of the source about to be copied, projected metadata, and the required 10 percent reserve. Growth that crosses the floor fails before that source is copied.
|
|
70
32
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
33
|
+
Manifest-only requirements must produce validated machine-readable evidence. An entry without an implemented generator is marked blocked before its source path is walked. When a required evidence generator is not implemented, including repo-aware Git/worktree and system inventories, the source stays blocked instead of being treated as fulfilled.
|
|
34
|
+
|
|
35
|
+
## Successful snapshot
|
|
36
|
+
|
|
37
|
+
A completed internal snapshot lives under:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
~/.ldm/backups/v1/internal/<snapshot-id>/
|
|
78
41
|
```
|
|
79
42
|
|
|
80
|
-
|
|
43
|
+
It is built first as `.in-progress-<snapshot-id>`. Promotion happens only after every required capture succeeds, SQLite copies pass `PRAGMA quick_check`, checksums validate, the manifest validates, and `completion.json` is written last.
|
|
44
|
+
|
|
45
|
+
Any required failure exits nonzero, writes no completion marker, and never prints `Backup complete`.
|
|
46
|
+
|
|
47
|
+
## Retention
|
|
48
|
+
|
|
49
|
+
The engine retains {{backup.keep}} unpinned verified snapshots plus every pinned snapshot. Pinned snapshots do not consume an unpinned retention slot. In-progress and unverified directories are never rotated.
|
|
50
|
+
|
|
51
|
+
## Structured state
|
|
52
|
+
|
|
53
|
+
Diagnostic state lives under `~/.ldm/state/backup/v1/`:
|
|
54
|
+
|
|
55
|
+
- `schedule.json`
|
|
56
|
+
- `attempt.json`
|
|
57
|
+
- `result.json`
|
|
58
|
+
- `success-local.json`
|
|
81
59
|
|
|
82
|
-
|
|
60
|
+
State records are atomic, but they are not completion proof. A snapshot requires a valid manifest and completion marker.
|
|
83
61
|
|
|
84
|
-
|
|
85
|
-
|------|------|-----|
|
|
86
|
-
| Backup | 3:00 AM | LaunchAgent `ai.openclaw.ldm-backup` runs `~/.ldm/bin/ldm-backup.sh` |
|
|
62
|
+
Snapshot identifiers use UTC and end in `Z`. If no valid containment marker is present, schedule state records evidence status as `unknown`.
|
|
87
63
|
|
|
88
|
-
|
|
64
|
+
## Current boundaries
|
|
89
65
|
|
|
90
|
-
|
|
66
|
+
This engine correctness slice covers the internal tier. Vault, iCloud, monitoring, isolated restore, migration, and schedule resumption remain separately gated. Do not run a real backup until those gates are approved.
|
|
91
67
|
|
|
92
|
-
|
|
93
|
-
- `paths.workspace` ... workspace path
|
|
94
|
-
- `paths.icloudBackup` ... iCloud offsite destination
|
|
95
|
-
- `backup.keep` ... retention days (default: 7)
|
|
96
|
-
- `backup.includeSecrets` ... whether to include `~/.ldm/secrets/`
|
|
97
|
-
- `org` ... used for tar filename prefix
|
|
68
|
+
## Your system
|
|
98
69
|
|
|
99
|
-
|
|
70
|
+
**Internal destination:** `~/.ldm/backups/v1/internal/`
|
|
100
71
|
|
|
101
|
-
|
|
72
|
+
**Configured iCloud path:** `~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/` (not written by this engine slice)
|
|
102
73
|
|
|
103
|
-
|
|
74
|
+
**Schedule label:** `ai.openclaw.ldm-backup`
|
|
104
75
|
|
|
105
|
-
|
|
76
|
+
**Retention:** {{backup.keep}} unpinned verified snapshots
|
|
106
77
|
|
|
107
|
-
**
|
|
108
|
-
**iCloud offsite:** `~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/`
|
|
109
|
-
**Schedule:** 3:00 AM via LaunchAgent `ai.openclaw.ldm-backup`
|
|
110
|
-
**Retention:** {{backup.keep}} days local, {{backup.keep}} days iCloud
|
|
111
|
-
**Script:** `~/.ldm/bin/ldm-backup.sh` (deployed by `ldm install`)
|
|
78
|
+
**Runner:** `~/.ldm/bin/ldm-backup.sh`
|
|
@@ -33,7 +33,7 @@ Where LDM OS runs. Rules, commands, and skills are authored here and deployed to
|
|
|
33
33
|
| `~/.ldm/hooks/` | Claude Code hooks | ldm install |
|
|
34
34
|
| `~/.ldm/state/` | Runtime state | Tools (watermarks, markers) |
|
|
35
35
|
| `~/.ldm/tmp/` | Install staging | ldm install (cleaned after) |
|
|
36
|
-
| `~/.ldm/backups/` |
|
|
36
|
+
| `~/.ldm/backups/v1/internal/` | Verified internal snapshots and in-progress staging | ldm-backup.sh |
|
|
37
37
|
|
|
38
38
|
## Harness Directories (deployment targets)
|
|
39
39
|
|
|
@@ -41,7 +41,7 @@ The LDM OS runtime directory. Everything the system needs to run lives here. You
|
|
|
41
41
|
hooks/ Claude Code hooks
|
|
42
42
|
state/ runtime state (last release marker, watermarks)
|
|
43
43
|
tmp/ install staging (cleaned after install)
|
|
44
|
-
backups/
|
|
44
|
+
backups/v1/internal/ verified internal snapshots and in-progress staging
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
## What Gets Backed Up
|