@wipcomputer/wip-ldm-os 0.4.85-alpha.9 → 0.4.87-alpha.1
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 +22 -2
- package/SKILL.md +137 -15
- package/bin/ldm.js +976 -75
- package/lib/deploy.mjs +36 -1
- package/lib/registry-migrations.mjs +296 -0
- package/package.json +21 -3
- package/scripts/test-boot-dir-truth.mjs +158 -0
- package/scripts/test-boot-hook-registration.mjs +136 -0
- package/scripts/test-boot-payload-trim.mjs +200 -0
- package/scripts/test-crc-agentid-tenant-boundary.mjs +2 -2
- package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
- package/scripts/test-crc-e2ee-session-route.mjs +9 -2
- package/scripts/test-crc-pair-login-flow.mjs +76 -1
- package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
- package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
- package/scripts/test-deploy-hook-ownership.mjs +160 -0
- package/scripts/test-doctor-commit-deployed.mjs +300 -0
- package/scripts/test-doctor-hook-dedupe.mjs +188 -0
- package/scripts/test-install-prompt-policy.mjs +84 -0
- package/scripts/test-installer-target-self-update.mjs +131 -0
- package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
- package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
- package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
- package/scripts/test-ldm-status-concurrency.mjs +118 -0
- package/scripts/test-ldm-status-timeout.mjs +96 -0
- package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
- package/scripts/test-readme-install-prompt.mjs +66 -0
- package/shared/boot/boot-config.json +18 -8
- package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
- package/shared/rules/security.md +4 -0
- package/shared/templates/install-prompt.md +20 -2
- package/src/boot/README.md +24 -1
- package/src/boot/boot-config.json +18 -8
- package/src/boot/boot-hook.mjs +118 -28
- package/src/boot/installer.mjs +33 -10
- package/src/hosted-mcp/.env.example +4 -0
- package/src/hosted-mcp/README.md +37 -0
- package/src/hosted-mcp/app/footer.js +2 -2
- package/src/hosted-mcp/app/kaleidoscope-login.html +489 -42
- package/src/hosted-mcp/app/pair.html +21 -3
- package/src/hosted-mcp/app/wip-logo.png +0 -0
- package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
- package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
- package/src/hosted-mcp/demo/footer.js +2 -2
- package/src/hosted-mcp/demo/index.html +166 -44
- package/src/hosted-mcp/demo/login.html +87 -23
- package/src/hosted-mcp/demo/privacy.html +4 -214
- package/src/hosted-mcp/demo/tos.html +4 -189
- package/src/hosted-mcp/deploy.sh +2 -0
- package/src/hosted-mcp/docs/self-host.md +268 -0
- package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
- package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
- package/src/hosted-mcp/legal/legal-footer.js +75 -0
- package/src/hosted-mcp/legal/legal.css +166 -0
- package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
- package/src/hosted-mcp/legal/privacy/index.html +253 -0
- package/src/hosted-mcp/server.mjs +920 -74
|
@@ -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');
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test: `ldm doctor` duplicate-hook + invalid-model checks.
|
|
3
|
+
//
|
|
4
|
+
// Covers the 2026-07-04 tickets:
|
|
5
|
+
// ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--installer-sessionstart-hook-duplicate-registration.md
|
|
6
|
+
// ai/product/bugs/guard/2026-07-04--cc-mini--no-blessed-recipe-for-live-settings-remediation.md
|
|
7
|
+
//
|
|
8
|
+
// Cases:
|
|
9
|
+
// 1. Duplicate-laden settings: doctor reports, does NOT write without --fix.
|
|
10
|
+
// 2. --fix collapses duplicates to 1, writes a timestamped backup first,
|
|
11
|
+
// preserves unrelated hooks and unknown keys.
|
|
12
|
+
// 3. Invalid model value ("opus" + real ESC 0x1B + "[1m"): reported, removed under --fix.
|
|
13
|
+
// 4. Malformed settings.json: warns and skips, file untouched, no crash.
|
|
14
|
+
//
|
|
15
|
+
// Follows the test-doctor-cron-target.mjs pattern: real bin/ldm.js doctor
|
|
16
|
+
// against a temp HOME, crontab/npm shimmed on PATH so the operator's real
|
|
17
|
+
// state is never read.
|
|
18
|
+
|
|
19
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const cli = join(repo, 'bin', 'ldm.js');
|
|
27
|
+
const pkg = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8'));
|
|
28
|
+
|
|
29
|
+
let failed = 0;
|
|
30
|
+
function assert(cond, label, output = '') {
|
|
31
|
+
if (cond) {
|
|
32
|
+
console.log(` [PASS] ${label}`);
|
|
33
|
+
} else {
|
|
34
|
+
console.log(` [FAIL] ${label}`);
|
|
35
|
+
if (output) console.log(` --- output (last lines) ---\n ${output.trim().split('\n').slice(-25).join('\n ')}`);
|
|
36
|
+
failed++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setupHome(settingsFactory) {
|
|
41
|
+
const home = mkdtempSync(join(tmpdir(), 'ldm-doctor-dedupe-'));
|
|
42
|
+
const ldmRoot = join(home, '.ldm');
|
|
43
|
+
const fakeBin = join(home, 'fakebin');
|
|
44
|
+
mkdirSync(join(ldmRoot, 'extensions'), { recursive: true });
|
|
45
|
+
mkdirSync(join(home, '.claude'), { recursive: true });
|
|
46
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
47
|
+
|
|
48
|
+
writeFileSync(join(ldmRoot, 'version.json'), JSON.stringify({ version: pkg.version }, null, 2) + '\n');
|
|
49
|
+
writeFileSync(join(ldmRoot, 'extensions', 'registry.json'), JSON.stringify({ _format: 'v2', extensions: {} }, null, 2) + '\n');
|
|
50
|
+
|
|
51
|
+
// Hook scripts must exist on disk: doctor --fix runs the stale-path
|
|
52
|
+
// cleanup (#30) before the dedupe pass, and missing targets would be
|
|
53
|
+
// removed as stale instead of collapsed as duplicates.
|
|
54
|
+
const bootScript = join(ldmRoot, 'shared', 'boot', 'boot-hook.mjs');
|
|
55
|
+
const guardScript = join(ldmRoot, 'extensions', 'wip-branch-guard', 'guard.mjs');
|
|
56
|
+
const stopScript = join(ldmRoot, 'extensions', 'stop-hook.mjs');
|
|
57
|
+
for (const f of [bootScript, guardScript, stopScript]) {
|
|
58
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
59
|
+
writeFileSync(f, 'process.exit(0);\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const paths = { bootScript, guardScript, stopScript };
|
|
63
|
+
const settingsContent = settingsFactory(paths);
|
|
64
|
+
const settingsPath = join(home, '.claude', 'settings.json');
|
|
65
|
+
writeFileSync(settingsPath, typeof settingsContent === 'string'
|
|
66
|
+
? settingsContent
|
|
67
|
+
: JSON.stringify(settingsContent, null, 2) + '\n');
|
|
68
|
+
|
|
69
|
+
// Empty crontab + no-op npm on PATH (never touch operator state/network).
|
|
70
|
+
writeFileSync(join(fakeBin, 'crontab'), '#!/bin/sh\nexit 0\n');
|
|
71
|
+
chmodSync(join(fakeBin, 'crontab'), 0o755);
|
|
72
|
+
writeFileSync(join(fakeBin, 'npm'), '#!/bin/sh\nexit 0\n');
|
|
73
|
+
chmodSync(join(fakeBin, 'npm'), 0o755);
|
|
74
|
+
|
|
75
|
+
return { home, fakeBin, settingsPath, paths };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function runDoctor({ home, fakeBin }, fix = false) {
|
|
79
|
+
const args = ['doctor'];
|
|
80
|
+
if (fix) args.push('--fix');
|
|
81
|
+
try {
|
|
82
|
+
return execFileSync('node', [cli, ...args], {
|
|
83
|
+
env: { ...process.env, HOME: home, PATH: `${fakeBin}:${process.env.PATH}`, LDM_SELF_UPDATED: '1' },
|
|
84
|
+
encoding: 'utf-8',
|
|
85
|
+
timeout: 30000,
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return (err.stdout || '') + (err.stderr || '');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const bootCmd = (paths) => `node ${paths.bootScript}`;
|
|
93
|
+
const bootEntry = (paths) => ({ matcher: '*', hooks: [{ type: 'command', command: bootCmd(paths), timeout: 15 }] });
|
|
94
|
+
const guardEntry = (paths) => ({ hooks: [{ type: 'command', command: `node ${paths.guardScript}`, timeout: 10 }] });
|
|
95
|
+
|
|
96
|
+
function dupeLadenSettings(paths) {
|
|
97
|
+
return {
|
|
98
|
+
unknownTopLevelKey: { keep: 'me' },
|
|
99
|
+
permissions: { defaultMode: 'default' },
|
|
100
|
+
hooks: {
|
|
101
|
+
SessionStart: [
|
|
102
|
+
...Array.from({ length: 5 }, () => bootEntry(paths)),
|
|
103
|
+
guardEntry(paths),
|
|
104
|
+
...Array.from({ length: 5 }, () => bootEntry(paths)),
|
|
105
|
+
],
|
|
106
|
+
Stop: [{ hooks: [{ type: 'command', command: `node ${paths.stopScript}` }] }],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log('Test 1: duplicates reported, no write without --fix');
|
|
112
|
+
{
|
|
113
|
+
const w = setupHome(dupeLadenSettings);
|
|
114
|
+
const before = readFileSync(w.settingsPath, 'utf-8');
|
|
115
|
+
const out = runDoctor(w);
|
|
116
|
+
const after = readFileSync(w.settingsPath, 'utf-8');
|
|
117
|
+
assert(/SessionStart: 10 identical hook entries/.test(out), 'reports 10 identical SessionStart entries', out);
|
|
118
|
+
assert(/ldm doctor --fix to collapse 9 duplicate hook entries/.test(out), 'suggests --fix with count', out);
|
|
119
|
+
assert(before === after, 'settings.json untouched without --fix');
|
|
120
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log('Test 2: --fix collapses, backs up first, preserves everything else');
|
|
124
|
+
{
|
|
125
|
+
const w = setupHome(dupeLadenSettings);
|
|
126
|
+
const out = runDoctor(w, true);
|
|
127
|
+
const s = JSON.parse(readFileSync(w.settingsPath, 'utf-8'));
|
|
128
|
+
const bootCount = s.hooks.SessionStart.filter((e) => e.hooks?.[0]?.command === bootCmd(w.paths)).length;
|
|
129
|
+
const backups = readdirSync(join(w.home, '.claude')).filter((f) => f.startsWith('settings.json.bak-'));
|
|
130
|
+
assert(/Collapsed 9 duplicate hook entries/.test(out), 'reports 9 collapsed', out);
|
|
131
|
+
assert(bootCount === 1, `boot-hook entries collapsed to 1 (got ${bootCount})`);
|
|
132
|
+
assert(s.hooks.SessionStart.length === 2, 'boot hook + branch guard remain');
|
|
133
|
+
assert(s.hooks.Stop.length === 1, 'unrelated Stop hook preserved');
|
|
134
|
+
assert(s.unknownTopLevelKey?.keep === 'me', 'unknown top-level key preserved');
|
|
135
|
+
assert(backups.length === 1, `timestamped backup written (found ${backups.length})`);
|
|
136
|
+
if (backups.length === 1) {
|
|
137
|
+
const bak = JSON.parse(readFileSync(join(w.home, '.claude', backups[0]), 'utf-8'));
|
|
138
|
+
assert(bak.hooks.SessionStart.length === 11, 'backup holds the pre-fix state');
|
|
139
|
+
}
|
|
140
|
+
const again = runDoctor(w, true);
|
|
141
|
+
assert(!/Collapsed/.test(again), 'second --fix run finds nothing to collapse', again);
|
|
142
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log('Test 3: invalid model value reported and removed under --fix');
|
|
146
|
+
{
|
|
147
|
+
// The corruption fixture carries a REAL ESC control char (0x1B): "opus"
|
|
148
|
+
// plus an ANSI bold fragment, which is what a terminal paste persists.
|
|
149
|
+
// Printable brackets alone are NOT corruption (see Test 4).
|
|
150
|
+
const w = setupHome((paths) => ({ model: 'opus\x1b[1m', hooks: { SessionStart: [bootEntry(paths)] } }));
|
|
151
|
+
const out = runDoctor(w);
|
|
152
|
+
assert(/model value is invalid/.test(out), 'reports invalid model', out);
|
|
153
|
+
const before = JSON.parse(readFileSync(w.settingsPath, 'utf-8'));
|
|
154
|
+
assert(typeof before.model === 'string', 'model untouched without --fix');
|
|
155
|
+
const fixOut = runDoctor(w, true);
|
|
156
|
+
const s = JSON.parse(readFileSync(w.settingsPath, 'utf-8'));
|
|
157
|
+
assert(/Removed invalid model value/.test(fixOut), 'reports removal under --fix', fixOut);
|
|
158
|
+
assert(!('model' in s), 'model key removed from disk');
|
|
159
|
+
assert(s.hooks.SessionStart.length === 1, 'hooks untouched by model fix');
|
|
160
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log('Test 4: valid model values are left alone (including bracketed 1M-context IDs)');
|
|
164
|
+
{
|
|
165
|
+
const w = setupHome(() => ({ model: 'claude-fable-5[1m]', hooks: {} }));
|
|
166
|
+
const out = runDoctor(w, true);
|
|
167
|
+
const s = JSON.parse(readFileSync(w.settingsPath, 'utf-8'));
|
|
168
|
+
assert(!/model value is invalid/.test(out), 'no invalid-model report for bracketed 1M id', out);
|
|
169
|
+
assert(s.model === 'claude-fable-5[1m]', 'legitimate 1M-context model preserved');
|
|
170
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log('Test 5: malformed settings.json warns, skips, file untouched');
|
|
174
|
+
{
|
|
175
|
+
const w = setupHome(() => '{ this is not json\n');
|
|
176
|
+
const before = readFileSync(w.settingsPath, 'utf-8');
|
|
177
|
+
const out = runDoctor(w, true);
|
|
178
|
+
const after = readFileSync(w.settingsPath, 'utf-8');
|
|
179
|
+
assert(/not valid JSON; skipping hook\/model checks/.test(out), 'warns about malformed JSON', out);
|
|
180
|
+
assert(before === after, 'malformed file untouched');
|
|
181
|
+
rmSync(w.home, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (failed > 0) {
|
|
185
|
+
console.log(`\n${failed} assertion(s) failed`);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
console.log('\nAll doctor hook-dedupe tests passed');
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
6
|
+
|
|
7
|
+
const files = [
|
|
8
|
+
"README.md",
|
|
9
|
+
"SKILL.md",
|
|
10
|
+
"shared/templates/install-prompt.md",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const contents = Object.fromEntries(
|
|
14
|
+
files.map((file) => [file, readFileSync(join(repoRoot, file), "utf8")]),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const failures = [];
|
|
18
|
+
|
|
19
|
+
for (const file of ["README.md", "shared/templates/install-prompt.md"]) {
|
|
20
|
+
const text = contents[file];
|
|
21
|
+
for (const phrase of [
|
|
22
|
+
"Use the install document and live local checks as the source of truth.",
|
|
23
|
+
"Do not search memory or prior notes for this install. Do not pre-load context from `MEMORY.md`, `crystal_search`, local skill dev guides, or other local memory before fetching the install document.",
|
|
24
|
+
"Do not run GitHub commands during this install flow. Do not run or request approval for `gh release`, `gh api`, or `gh search`.",
|
|
25
|
+
"If release notes are not available from local or npm metadata, say that and do not fetch them from GitHub.",
|
|
26
|
+
"If installed: run `ldm status`",
|
|
27
|
+
"check available npm tracks from the install document",
|
|
28
|
+
"If yes to dry run, use the selected track's dry-run path from the install document.",
|
|
29
|
+
"If I say install, use the selected track's install path from the install document, then run `ldm doctor`.",
|
|
30
|
+
"install the CLI first using the selected track's bootstrap command from the install document",
|
|
31
|
+
"Then run:\n`ldm init --dry-run`",
|
|
32
|
+
"If I say install, run:\n`ldm init`",
|
|
33
|
+
]) {
|
|
34
|
+
if (!text.includes(phrase)) {
|
|
35
|
+
failures.push(`${file} missing install prompt phrase: ${phrase}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (text.includes("If it is, run ldm install --dry-run")) {
|
|
39
|
+
failures.push(`${file} still tells installed users to start with ldm install --dry-run`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const skill = contents["SKILL.md"];
|
|
44
|
+
for (const phrase of [
|
|
45
|
+
"Memory policy for install flows: do not consult `MEMORY.md`, do not run `crystal_search`, and do not search prior notes when this skill is invoked, including in any parallel or batched exploration step.",
|
|
46
|
+
"The only context sources for this install flow are `https://wip.computer/install/wip-ldm-os.txt` and the live local commands that document prescribes.",
|
|
47
|
+
"Read that document and run those commands. Do not pre-load other context.",
|
|
48
|
+
"Do not run GitHub commands during the install-state flow.",
|
|
49
|
+
"Do not run or request approval for `gh release list`, `gh release view`, `gh api repos/*`, `gh search`, or any other GitHub query unless the user explicitly asks for release notes.",
|
|
50
|
+
"npm view @wipcomputer/wip-ldm-os dist-tags --json",
|
|
51
|
+
"The README prompt should stay short. This install document owns the detailed track rules.",
|
|
52
|
+
"stable/current/latest: `ldm install --dry-run`",
|
|
53
|
+
"beta/latest beta: `ldm install --beta --dry-run`",
|
|
54
|
+
"alpha/latest alpha: `ldm install --alpha --dry-run`",
|
|
55
|
+
"beta/latest beta: `npm install -g @wipcomputer/wip-ldm-os@beta`",
|
|
56
|
+
"alpha/latest alpha: `npm install -g @wipcomputer/wip-ldm-os@alpha`",
|
|
57
|
+
"Use the output of `ldm status`, installed package metadata, and npm metadata.",
|
|
58
|
+
"Do not use GitHub commands here.",
|
|
59
|
+
"If npm metadata for a package does not include release notes:",
|
|
60
|
+
"Say \"release notes not available from local metadata.\"",
|
|
61
|
+
"Do not infer release-note content from package descriptions, commit messages, or repo READMEs.",
|
|
62
|
+
"An approval dialog is not a user request.",
|
|
63
|
+
]) {
|
|
64
|
+
if (!skill.includes(phrase)) {
|
|
65
|
+
failures.push(`SKILL.md missing install policy phrase: ${phrase}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (/gh release (list|view) --repo/.test(skill)) {
|
|
70
|
+
failures.push("SKILL.md still includes concrete gh release commands in the install flow");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const temporalMemoryPolicyPhrase = "your first action is " + "to fetch";
|
|
74
|
+
if (skill.includes(temporalMemoryPolicyPhrase)) {
|
|
75
|
+
failures.push("SKILL.md still uses temporal first-action memory-policy phrasing");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (failures.length > 0) {
|
|
79
|
+
console.error("install prompt policy checks failed:");
|
|
80
|
+
for (const failure of failures) console.error(` - ${failure}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log("install-prompt-policy: LDM OS prompt and install doc agree");
|