@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.32

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.
Files changed (67) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +136 -14
  3. package/bin/ldm.js +525 -75
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/lib/registry-migrations.mjs +296 -0
  9. package/package.json +24 -3
  10. package/scripts/test-boot-hook-registration.mjs +136 -0
  11. package/scripts/test-boot-payload-trim.mjs +200 -0
  12. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  13. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  14. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  15. package/scripts/test-crc-pair-login-flow.mjs +115 -0
  16. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  17. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  18. package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
  19. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  20. package/scripts/test-install-prompt-policy.mjs +84 -0
  21. package/scripts/test-installer-skill-directory.mjs +55 -0
  22. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  23. package/scripts/test-installer-target-self-update.mjs +131 -0
  24. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  25. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  26. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  27. package/scripts/test-ldm-status-concurrency.mjs +118 -0
  28. package/scripts/test-ldm-status-timeout.mjs +96 -0
  29. package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
  30. package/scripts/test-readme-install-prompt.mjs +66 -0
  31. package/shared/boot/boot-config.json +18 -8
  32. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  33. package/shared/rules/security.md +4 -0
  34. package/shared/templates/install-prompt.md +20 -2
  35. package/src/boot/README.md +24 -1
  36. package/src/boot/boot-config.json +18 -8
  37. package/src/boot/boot-hook.mjs +118 -28
  38. package/src/boot/installer.mjs +33 -10
  39. package/src/hosted-mcp/.env.example +4 -0
  40. package/src/hosted-mcp/README.md +37 -0
  41. package/src/hosted-mcp/app/footer.js +74 -0
  42. package/src/hosted-mcp/app/kaleidoscope-login.html +1290 -0
  43. package/src/hosted-mcp/app/pair.html +165 -57
  44. package/src/hosted-mcp/app/sprites.png +0 -0
  45. package/src/hosted-mcp/app/wip-logo.png +0 -0
  46. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  47. package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
  48. package/src/hosted-mcp/demo/footer.js +2 -2
  49. package/src/hosted-mcp/demo/index.html +169 -51
  50. package/src/hosted-mcp/demo/login.html +390 -28
  51. package/src/hosted-mcp/demo/privacy.html +4 -214
  52. package/src/hosted-mcp/demo/tos.html +4 -189
  53. package/src/hosted-mcp/deploy.sh +308 -56
  54. package/src/hosted-mcp/docs/self-host.md +268 -0
  55. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  56. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  57. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  58. package/src/hosted-mcp/legal/legal.css +166 -0
  59. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  60. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  61. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  62. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  63. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  64. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  65. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  66. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  67. package/src/hosted-mcp/server.mjs +1681 -166
@@ -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");
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ const home = mkdtempSync(join(tmpdir(), 'ldm-skill-dir-home-'));
7
+ const source = mkdtempSync(join(tmpdir(), 'ldm-skill-dir-source-'));
8
+
9
+ function assert(condition, message) {
10
+ if (!condition) throw new Error(message);
11
+ }
12
+
13
+ try {
14
+ process.env.HOME = home;
15
+
16
+ for (const dir of ['.claude', '.openclaw', '.codex', '.agents']) {
17
+ mkdirSync(join(home, dir), { recursive: true });
18
+ }
19
+
20
+ const skillDir = join(source, 'skills', 'wip-ai-chat-ui');
21
+ mkdirSync(join(skillDir, 'references'), { recursive: true });
22
+ mkdirSync(join(skillDir, 'agents'), { recursive: true });
23
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: wip-ai-chat-ui\ndescription: "test skill"\n---\n\n# Test Skill\n');
24
+ writeFileSync(join(skillDir, 'references', 'stack.md'), '# Stack\n');
25
+ writeFileSync(join(skillDir, 'agents', 'openai.yaml'), 'display_name: "WIP AI Chat UI"\n');
26
+
27
+ const { detectInterfacesJSON } = await import('../lib/detect.mjs');
28
+ const detected = detectInterfacesJSON(source);
29
+ assert(detected.interfaceCount === 1, 'skill directory repo should expose one interface');
30
+ assert(detected.interfaces.skill?.skills?.[0]?.name === 'wip-ai-chat-ui', 'skill directory name should be detected');
31
+
32
+ const { installFromPath } = await import('../lib/deploy.mjs');
33
+ const result = await installFromPath(source);
34
+ assert(result.interfaces === 1, 'skill directory install should process one interface');
35
+
36
+ for (const target of [
37
+ join(home, '.claude', 'skills', 'wip-ai-chat-ui'),
38
+ join(home, '.openclaw', 'skills', 'wip-ai-chat-ui'),
39
+ join(home, '.codex', 'skills', 'wip-ai-chat-ui'),
40
+ join(home, '.agents', 'skills', 'wip-ai-chat-ui'),
41
+ ]) {
42
+ assert(existsSync(join(target, 'SKILL.md')), `${target} should include SKILL.md`);
43
+ assert(existsSync(join(target, 'references', 'stack.md')), `${target} should include references`);
44
+ assert(existsSync(join(target, 'agents', 'openai.yaml')), `${target} should include agents metadata`);
45
+ assert(!lstatSync(target).isSymbolicLink(), `${target} should be a deployed directory, not a symlink`);
46
+ }
47
+
48
+ const codexSkill = readFileSync(join(home, '.codex', 'skills', 'wip-ai-chat-ui', 'SKILL.md'), 'utf8');
49
+ assert(codexSkill.includes('name: wip-ai-chat-ui'), 'Codex target should contain the expected skill');
50
+
51
+ console.log('installer skill directory regression passed');
52
+ } finally {
53
+ rmSync(home, { recursive: true, force: true });
54
+ rmSync(source, { recursive: true, force: true });
55
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ const home = mkdtempSync(join(tmpdir(), 'ldm-skill-dry-run-home-'));
7
+ const source = mkdtempSync(join(tmpdir(), 'ldm-skill-dry-run-source-'));
8
+
9
+ function assert(condition, message) {
10
+ if (!condition) throw new Error(message);
11
+ }
12
+
13
+ try {
14
+ process.env.HOME = home;
15
+
16
+ for (const dir of ['.claude', '.openclaw', '.codex', '.agents']) {
17
+ mkdirSync(join(home, dir), { recursive: true });
18
+ }
19
+
20
+ const workspace = join(home, 'workspace');
21
+ mkdirSync(workspace, { recursive: true });
22
+ mkdirSync(join(home, '.ldm'), { recursive: true });
23
+ writeFileSync(join(home, '.ldm', 'config.json'), JSON.stringify({
24
+ workspace,
25
+ harnesses: {
26
+ 'claude-code': {
27
+ detected: true,
28
+ home: join(home, '.claude'),
29
+ skills: join(home, '.claude', 'skills'),
30
+ },
31
+ openclaw: {
32
+ detected: true,
33
+ home: join(home, '.openclaw'),
34
+ skills: join(home, '.openclaw', 'skills'),
35
+ },
36
+ codex: {
37
+ detected: true,
38
+ home: join(home, '.codex'),
39
+ skills: join(home, '.codex', 'skills'),
40
+ },
41
+ 'wip-agents': {
42
+ detected: true,
43
+ home: join(home, '.agents'),
44
+ skills: join(home, '.agents', 'skills'),
45
+ },
46
+ },
47
+ }, null, 2));
48
+
49
+ mkdirSync(join(source, 'references'), { recursive: true });
50
+ mkdirSync(join(source, 'agents'), { recursive: true });
51
+ writeFileSync(join(source, 'package.json'), JSON.stringify({
52
+ name: '@wipcomputer/wip-ai-chat-ui',
53
+ version: '0.1.1',
54
+ }, null, 2));
55
+ writeFileSync(join(source, 'SKILL.md'), '---\nname: wip-ai-chat-ui\ndescription: "test skill"\n---\n\n# Test Skill\n');
56
+ writeFileSync(join(source, 'references', 'stack.md'), '# Stack\n');
57
+ writeFileSync(join(source, 'agents', 'openai.yaml'), 'display_name: "WIP AI Chat UI"\n');
58
+
59
+ const { setFlags, installFromPath } = await import('../lib/deploy.mjs');
60
+
61
+ const lines = [];
62
+ const originalLog = console.log;
63
+ console.log = (...args) => lines.push(args.join(' '));
64
+
65
+ try {
66
+ setFlags({ dryRun: true, jsonOutput: false });
67
+ const result = await installFromPath(source);
68
+ assert(result.interfaces === 1, 'dry run should process one skill interface');
69
+ } finally {
70
+ console.log = originalLog;
71
+ }
72
+
73
+ const output = lines.join('\n');
74
+
75
+ for (const expected of [
76
+ 'Would copy:',
77
+ '- SKILL.md',
78
+ '- references/',
79
+ '- agents/',
80
+ 'Permanent copy:',
81
+ join(home, '.ldm', 'extensions', 'wip-ai-chat-ui', 'SKILL.md'),
82
+ join(home, '.ldm', 'extensions', 'wip-ai-chat-ui', 'references/'),
83
+ 'Agent skill targets:',
84
+ `claude-code: ${join(home, '.claude', 'skills', 'wip-ai-chat-ui')}`,
85
+ join(home, '.claude', 'skills', 'wip-ai-chat-ui', 'SKILL.md'),
86
+ `openclaw: ${join(home, '.openclaw', 'skills', 'wip-ai-chat-ui')}`,
87
+ join(home, '.openclaw', 'skills', 'wip-ai-chat-ui', 'references/'),
88
+ `codex: ${join(home, '.codex', 'skills', 'wip-ai-chat-ui')}`,
89
+ `wip-agents: ${join(home, '.agents', 'skills', 'wip-ai-chat-ui')}`,
90
+ 'Workspace docs target:',
91
+ `${join(workspace, 'settings', 'docs', 'skills', 'wip-ai-chat-ui')} (references/ only)`,
92
+ ]) {
93
+ assert(output.includes(expected), `dry-run output should include ${expected}\n\n${output}`);
94
+ }
95
+
96
+ console.log('installer skill dry-run destinations regression passed');
97
+ } finally {
98
+ rmSync(home, { recursive: true, force: true });
99
+ rmSync(source, { recursive: true, force: true });
100
+ }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const cli = readFileSync(join(root, 'bin', 'ldm.js'), 'utf8');
10
+
11
+ const helperName = 'function maybeSelfUpdateLdmCliBeforeInstall()';
12
+ const helperIdx = cli.indexOf(helperName);
13
+ if (helperIdx === -1) {
14
+ throw new Error('Missing shared LDM OS self-update preflight helper');
15
+ }
16
+
17
+ const helperBlock = cli.slice(helperIdx, cli.indexOf('// ── Dead backup trigger cleanup', helperIdx));
18
+ if (!helperBlock.includes('Dry run only: continuing with v${PKG_VERSION}.')) {
19
+ throw new Error('Self-update helper must warn on dry run without updating');
20
+ }
21
+
22
+ if (!helperBlock.includes("execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`")) {
23
+ throw new Error('Self-update helper must update LDM OS before real installs');
24
+ }
25
+
26
+ if (!helperBlock.includes("spawnSync('ldm'")) {
27
+ throw new Error('Self-update helper must re-run the original install command without shell joining args');
28
+ }
29
+
30
+ if (helperBlock.includes('process.argv.slice(2).join')) {
31
+ throw new Error('Self-update helper must preserve argv boundaries when re-running install');
32
+ }
33
+
34
+ const cmdInstallIdx = cli.indexOf('async function cmdInstall()');
35
+ const lockIdx = cli.indexOf('acquireInstallLock()', cmdInstallIdx);
36
+ const initIdx = cli.indexOf('LDM OS not initialized. Running init first', cmdInstallIdx);
37
+ const targetIdx = cli.indexOf('// Find the target (skip flags)', cmdInstallIdx);
38
+ const preflightCallIdx = cli.indexOf('maybeSelfUpdateLdmCliBeforeInstall();', cmdInstallIdx);
39
+ if (cmdInstallIdx === -1 || targetIdx === -1 || preflightCallIdx === -1) {
40
+ throw new Error('Could not find cmdInstall self-update placement');
41
+ }
42
+
43
+ if (preflightCallIdx > lockIdx || preflightCallIdx > initIdx) {
44
+ throw new Error('Self-update preflight must run before lock acquisition and init work');
45
+ }
46
+
47
+ if (preflightCallIdx > targetIdx) {
48
+ throw new Error('Self-update preflight must run before target resolution so app installs are covered');
49
+ }
50
+
51
+ const catalogIdx = cli.indexOf('async function cmdInstallCatalog()');
52
+ const oldCatalogBlock = cli.indexOf('Self-update: check if CLI itself is outdated', catalogIdx);
53
+ const autoDetectIdx = cli.indexOf('autoDetectExtensions();', catalogIdx);
54
+ if (oldCatalogBlock !== -1 && oldCatalogBlock < autoDetectIdx) {
55
+ throw new Error('Catalog install should not own the only self-update block');
56
+ }
57
+
58
+ const tempRoot = mkdtempSync(join(tmpdir(), 'ldm-target-self-update-'));
59
+ try {
60
+ const home = join(tempRoot, 'home');
61
+ const fakeBin = join(tempRoot, 'bin');
62
+ const target = join(tempRoot, 'target skill with spaces');
63
+
64
+ mkdirSync(join(home, '.ldm'), { recursive: true });
65
+ writeFileSync(join(home, '.ldm', 'version.json'), JSON.stringify({
66
+ version: '0.0.0',
67
+ installed: new Date().toISOString(),
68
+ updated: new Date().toISOString(),
69
+ }, null, 2) + '\n');
70
+
71
+ mkdirSync(fakeBin, { recursive: true });
72
+ const fakeNpm = join(fakeBin, 'npm');
73
+ writeFileSync(fakeNpm, `#!/usr/bin/env bash
74
+ if [ "$1" = "view" ] && [ "$2" = "@wipcomputer/wip-ldm-os" ] && [ "$3" = "dist-tags.alpha" ]; then
75
+ echo "99.0.0-alpha.1"
76
+ exit 0
77
+ fi
78
+ echo "unexpected npm command: $*" >&2
79
+ exit 64
80
+ `);
81
+ chmodSync(fakeNpm, 0o755);
82
+
83
+ mkdirSync(target, { recursive: true });
84
+ writeFileSync(join(target, 'SKILL.md'), `---
85
+ name: test-target-skill
86
+ description: Test target skill for installer self-update dry-run checks.
87
+ ---
88
+
89
+ # Test Target Skill
90
+ `);
91
+
92
+ const result = spawnSync(process.execPath, [
93
+ join(root, 'bin', 'ldm.js'),
94
+ 'install',
95
+ '--alpha',
96
+ '--dry-run',
97
+ target,
98
+ ], {
99
+ cwd: root,
100
+ encoding: 'utf8',
101
+ env: {
102
+ ...process.env,
103
+ HOME: home,
104
+ PATH: `${fakeBin}:${process.env.PATH || ''}`,
105
+ },
106
+ });
107
+
108
+ if (result.status !== 0) {
109
+ throw new Error(`Runtime dry-run exited ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
110
+ }
111
+
112
+ if (!result.stdout.includes('LDM OS CLI v')) {
113
+ throw new Error(`Runtime dry-run did not print the LDM OS skew warning\nstdout:\n${result.stdout}`);
114
+ }
115
+
116
+ if (!result.stdout.includes('-> v99.0.0-alpha.1 (alpha track) is available.')) {
117
+ throw new Error(`Runtime dry-run did not include the selected alpha track version\nstdout:\n${result.stdout}`);
118
+ }
119
+
120
+ if (!result.stdout.includes('Dry run only: continuing with v')) {
121
+ throw new Error(`Runtime dry-run did not say it would continue without updating\nstdout:\n${result.stdout}`);
122
+ }
123
+
124
+ if (!result.stdout.includes('Installing: target skill with spaces (dry run)')) {
125
+ throw new Error(`Runtime dry-run did not continue to the targeted install preview\nstdout:\n${result.stdout}`);
126
+ }
127
+ } finally {
128
+ rmSync(tempRoot, { recursive: true, force: true });
129
+ }
130
+
131
+ console.log('targeted install self-update regression checks passed');