@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.30
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 +136 -14
- package/bin/ldm.js +422 -75
- package/docs/universal-installer/SPEC.md +16 -3
- package/docs/universal-installer/TECHNICAL.md +4 -4
- package/lib/deploy.mjs +104 -20
- package/lib/detect.mjs +35 -4
- package/lib/registry-migrations.mjs +296 -0
- package/package.json +17 -2
- package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
- package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
- package/scripts/test-crc-e2ee-session-route.mjs +129 -0
- package/scripts/test-crc-pair-login-flow.mjs +40 -0
- package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
- package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
- package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
- package/scripts/test-install-prompt-policy.mjs +84 -0
- package/scripts/test-installer-skill-directory.mjs +55 -0
- package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
- package/scripts/test-installer-target-self-update.mjs +131 -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/templates/install-prompt.md +20 -2
- package/src/hosted-mcp/README.md +37 -0
- package/src/hosted-mcp/app/footer.js +74 -0
- package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
- package/src/hosted-mcp/app/pair.html +165 -57
- package/src/hosted-mcp/app/sprites.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/index.html +3 -7
- package/src/hosted-mcp/demo/login.html +318 -20
- package/src/hosted-mcp/deploy.sh +308 -56
- package/src/hosted-mcp/docs/self-host.md +268 -0
- package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
- package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
- package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
- package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
- package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
- package/src/hosted-mcp/server.mjs +1034 -146
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
|
+
const tempRoot = mkdtempSync(join(tmpdir(), 'ldm-status-timeout-'));
|
|
11
|
+
const sourceVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
|
|
12
|
+
|
|
13
|
+
function assert(condition, message) {
|
|
14
|
+
if (!condition) throw new Error(message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function runStatus({ home, registryUrl }) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const child = spawn(process.execPath, [join(root, 'bin', 'ldm.js'), 'status'], {
|
|
20
|
+
cwd: root,
|
|
21
|
+
env: {
|
|
22
|
+
...process.env,
|
|
23
|
+
HOME: home,
|
|
24
|
+
LDM_STATUS_NPM_REGISTRY_URL: registryUrl,
|
|
25
|
+
LDM_STATUS_NPM_TIMEOUT_MS: '75',
|
|
26
|
+
LDM_STATUS_TOTAL_BUDGET_MS: '250',
|
|
27
|
+
},
|
|
28
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let stdout = '';
|
|
32
|
+
let stderr = '';
|
|
33
|
+
child.stdout.setEncoding('utf8');
|
|
34
|
+
child.stderr.setEncoding('utf8');
|
|
35
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
36
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
37
|
+
child.on('close', status => resolve({ status, stdout, stderr }));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function listen(server) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
server.listen(0, '127.0.0.1', () => resolve(server.address()));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const home = join(tempRoot, 'home');
|
|
49
|
+
const extensions = join(home, '.ldm', 'extensions');
|
|
50
|
+
|
|
51
|
+
mkdirSync(extensions, { recursive: true });
|
|
52
|
+
writeFileSync(join(home, '.ldm', 'version.json'), JSON.stringify({
|
|
53
|
+
version: '0.0.0-test',
|
|
54
|
+
installed: '2026-05-12T00:00:00.000Z',
|
|
55
|
+
updated: '2026-05-12T00:00:00.000Z',
|
|
56
|
+
}, null, 2) + '\n');
|
|
57
|
+
writeFileSync(join(extensions, 'registry.json'), JSON.stringify({
|
|
58
|
+
extensions: {
|
|
59
|
+
'hung-extension': {
|
|
60
|
+
source: { npm: 'hung-extension' },
|
|
61
|
+
installed: { version: '1.0.0' },
|
|
62
|
+
},
|
|
63
|
+
'second-extension': {
|
|
64
|
+
source: { npm: 'second-extension' },
|
|
65
|
+
installed: { version: '1.0.0' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}, null, 2) + '\n');
|
|
69
|
+
|
|
70
|
+
const server = createServer((_req, res) => {
|
|
71
|
+
setTimeout(() => {
|
|
72
|
+
res.setHeader('content-type', 'application/json');
|
|
73
|
+
res.end(JSON.stringify({ 'dist-tags': { latest: '9.9.9' } }));
|
|
74
|
+
}, 2000);
|
|
75
|
+
});
|
|
76
|
+
const address = await listen(server);
|
|
77
|
+
|
|
78
|
+
const startedAt = Date.now();
|
|
79
|
+
const result = await runStatus({ home, registryUrl: `http://${address.address}:${address.port}` });
|
|
80
|
+
const elapsedMs = Date.now() - startedAt;
|
|
81
|
+
server.closeAllConnections();
|
|
82
|
+
server.close();
|
|
83
|
+
|
|
84
|
+
assert(result.status === 0, `ldm status exited ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
|
85
|
+
assert(elapsedMs < 2500, `ldm status should return before the process timeout; elapsed ${elapsedMs}ms`);
|
|
86
|
+
assert(result.stdout.includes(`LDM OS v${sourceVersion}`), `status should print installed LDM OS version\n${result.stdout}`);
|
|
87
|
+
assert(result.stdout.includes('Extensions: 2'), `status should print extension count\n${result.stdout}`);
|
|
88
|
+
assert(result.stdout.includes('Checking updates:'), `status should show progress before update checks\n${result.stdout}`);
|
|
89
|
+
assert(result.stdout.includes('hung-extension: checking npm'), `status should print the extension name before probing it\n${result.stdout}`);
|
|
90
|
+
assert(result.stdout.includes('Update checks skipped:'), `status should report skipped checks instead of hanging\n${result.stdout}`);
|
|
91
|
+
assert(/hung-extension: \[timeout \d+(ms|\.\d+s)\] hung-extension/.test(result.stdout), `hung extension should be reported as a timeout with elapsed time\n${result.stdout}`);
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log('ldm status timeout regression passed');
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fixture test for the Phase 1 source-types migration planner.
|
|
3
|
+
// See lib/registry-migrations.mjs and
|
|
4
|
+
// ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
5
|
+
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
planLegacyNpmSourcesMigration,
|
|
12
|
+
summaryHasChanges,
|
|
13
|
+
emptyLegacyNpmSourcesSummary,
|
|
14
|
+
executeDirectoryMoves,
|
|
15
|
+
} from '../lib/registry-migrations.mjs';
|
|
16
|
+
|
|
17
|
+
function fail(msg) {
|
|
18
|
+
throw new Error(msg);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertEqual(actual, expected, label) {
|
|
22
|
+
if (actual !== expected) {
|
|
23
|
+
fail(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertDeepEqual(actual, expected, label) {
|
|
28
|
+
const a = JSON.stringify(actual, Object.keys(actual || {}).sort());
|
|
29
|
+
const e = JSON.stringify(expected, Object.keys(expected || {}).sort());
|
|
30
|
+
if (a !== e) {
|
|
31
|
+
fail(`${label}: expected ${e}, got ${a}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Mirrors the shape we see on Parker's machine in the 2026-05-13 dogfood.
|
|
36
|
+
function buildFixtureRegistry() {
|
|
37
|
+
return {
|
|
38
|
+
_format: 'v2',
|
|
39
|
+
extensions: {
|
|
40
|
+
// 404 npm + on-disk: should migrate to untracked.
|
|
41
|
+
'cc-session-export': {
|
|
42
|
+
source: { type: 'github', npm: 'session-export', repo: 'wipcomputer/cc-session-export' },
|
|
43
|
+
installed: { version: '1.0.0' },
|
|
44
|
+
},
|
|
45
|
+
// Duplicate of cc-session-export: should be deduped (removed).
|
|
46
|
+
'session-export': {
|
|
47
|
+
source: { type: 'github', npm: 'session-export' },
|
|
48
|
+
installed: { version: '1.0.0' },
|
|
49
|
+
},
|
|
50
|
+
'compaction-indicator': {
|
|
51
|
+
source: { type: 'github', npm: 'compaction-indicator' },
|
|
52
|
+
installed: { version: '1.0.1' },
|
|
53
|
+
},
|
|
54
|
+
'lesa-bridge': {
|
|
55
|
+
source: { type: 'github', npm: 'lesa-bridge' },
|
|
56
|
+
installed: { version: '0.3.0' },
|
|
57
|
+
},
|
|
58
|
+
// Duplicate of wip-branch-guard: should be deduped.
|
|
59
|
+
'package': {
|
|
60
|
+
source: { type: 'github', npm: '@wipcomputer/wip-branch-guard' },
|
|
61
|
+
installed: { version: '1.0.0' },
|
|
62
|
+
},
|
|
63
|
+
'wip-branch-guard': {
|
|
64
|
+
source: { type: 'github', npm: '@wipcomputer/wip-branch-guard' },
|
|
65
|
+
installed: { version: '1.0.0' },
|
|
66
|
+
},
|
|
67
|
+
// Real npm package: should be left alone.
|
|
68
|
+
'memory-crystal': {
|
|
69
|
+
source: { type: 'github', npm: '@wipcomputer/memory-crystal' },
|
|
70
|
+
installed: { version: '2.0.0' },
|
|
71
|
+
},
|
|
72
|
+
// Phantom: no on-disk directory. Should be removed.
|
|
73
|
+
'tavily': {
|
|
74
|
+
source: { type: 'github', npm: '@wipcomputer/openclaw-tavily' },
|
|
75
|
+
installed: { version: '0.1.0' },
|
|
76
|
+
},
|
|
77
|
+
// Mystery row: no source info at all but on-disk. Should be classified
|
|
78
|
+
// untracked (Step 4 path in the planner).
|
|
79
|
+
//
|
|
80
|
+
// Note: on Parker's real machine the `run` entry has no on-disk
|
|
81
|
+
// directory, so it hits Step 1 (phantom removal) instead. We exercise
|
|
82
|
+
// the Step 4 path here via this fixture; Step 1 is covered by the
|
|
83
|
+
// `tavily` fixture below.
|
|
84
|
+
'run': {
|
|
85
|
+
installed: { version: 'unknown' },
|
|
86
|
+
},
|
|
87
|
+
// Already migrated: must be skipped (idempotency).
|
|
88
|
+
'already-untracked': {
|
|
89
|
+
updateSource: { type: 'untracked' },
|
|
90
|
+
provenance: { 'legacy-npm-name': 'already-untracked', untrackedSince: '2026-05-13T00:00:00.000Z' },
|
|
91
|
+
installed: { version: '0.1.0' },
|
|
92
|
+
},
|
|
93
|
+
// Probe will fail (timeout). Should be left alone, listed in probeFailures.
|
|
94
|
+
'flaky-network': {
|
|
95
|
+
source: { type: 'github', npm: 'flaky-network' },
|
|
96
|
+
installed: { version: '0.1.0' },
|
|
97
|
+
},
|
|
98
|
+
// Custom-path entry: declares `paths.ldm` pointing outside the default
|
|
99
|
+
// ~/.ldm/extensions/<name> location. The planner must NOT classify
|
|
100
|
+
// this as a phantom. Its source.npm is 404, so it should be migrated
|
|
101
|
+
// to untracked while the custom path is preserved.
|
|
102
|
+
'custom-path-untracked': {
|
|
103
|
+
source: { type: 'github', npm: 'custom-path-untracked' },
|
|
104
|
+
paths: { ldm: '/custom/location/path' },
|
|
105
|
+
installed: { version: '1.0.0' },
|
|
106
|
+
},
|
|
107
|
+
// Legacy custom-path field (`ldmPath` flat). Same expectation.
|
|
108
|
+
'legacy-custom-path': {
|
|
109
|
+
source: { type: 'github', npm: 'legacy-custom-path' },
|
|
110
|
+
ldmPath: '/legacy/custom/path',
|
|
111
|
+
installed: { version: '0.2.0' },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const NPM_404 = new Set([
|
|
118
|
+
'session-export',
|
|
119
|
+
'compaction-indicator',
|
|
120
|
+
'lesa-bridge',
|
|
121
|
+
'custom-path-untracked',
|
|
122
|
+
'legacy-custom-path',
|
|
123
|
+
// @wipcomputer/wip-branch-guard simulated as 200 (exists). But the dedupe
|
|
124
|
+
// happens first, so `package` is gone before the probe runs.
|
|
125
|
+
// The remaining `wip-branch-guard` will see exists=true and be left alone.
|
|
126
|
+
]);
|
|
127
|
+
const NPM_200 = new Set([
|
|
128
|
+
'@wipcomputer/memory-crystal',
|
|
129
|
+
'@wipcomputer/wip-branch-guard',
|
|
130
|
+
]);
|
|
131
|
+
const NPM_FAIL = new Set([
|
|
132
|
+
'flaky-network',
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
function fakeProbeNpm(name) {
|
|
136
|
+
if (NPM_FAIL.has(name)) return Promise.resolve(null);
|
|
137
|
+
if (NPM_200.has(name)) return Promise.resolve(true);
|
|
138
|
+
if (NPM_404.has(name)) return Promise.resolve(false);
|
|
139
|
+
// Fail loudly when the planner probes an npm name the test forgot to
|
|
140
|
+
// enumerate. Future planner changes that call probeNpm with new names
|
|
141
|
+
// must explicitly declare expected behavior here; silent 404 defaults
|
|
142
|
+
// would swallow regressions.
|
|
143
|
+
fail(`fakeProbeNpm called with un-enumerated name "${name}"`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// On-disk extension simulator. `tavily` is a phantom (no directory at any
|
|
147
|
+
// location). All other names get a default directory unless they declare a
|
|
148
|
+
// custom path, in which case we honor the custom path. The planner must
|
|
149
|
+
// pass the entry to extensionExists for this to work.
|
|
150
|
+
const PHANTOM_NAMES = new Set(['tavily']);
|
|
151
|
+
const CUSTOM_PATH_EXISTS = new Set(['/custom/location/path', '/legacy/custom/path']);
|
|
152
|
+
function fakeExtensionExists(name, entry) {
|
|
153
|
+
if (entry?.paths?.ldm) return CUSTOM_PATH_EXISTS.has(entry.paths.ldm);
|
|
154
|
+
if (entry?.ldmPath) return CUSTOM_PATH_EXISTS.has(entry.ldmPath);
|
|
155
|
+
return !PHANTOM_NAMES.has(name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
|
|
159
|
+
|
|
160
|
+
// ── Test 1: full migration on fixture ──────────────────────────────────────
|
|
161
|
+
{
|
|
162
|
+
const input = buildFixtureRegistry();
|
|
163
|
+
const inputSnapshot = JSON.stringify(input);
|
|
164
|
+
|
|
165
|
+
const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
|
|
166
|
+
registry: input,
|
|
167
|
+
probeNpm: fakeProbeNpm,
|
|
168
|
+
extensionExists: fakeExtensionExists,
|
|
169
|
+
now: FIXED_NOW,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Input must not be mutated.
|
|
173
|
+
assertEqual(JSON.stringify(input), inputSnapshot, 'input registry mutated');
|
|
174
|
+
|
|
175
|
+
// Phantoms removed.
|
|
176
|
+
assertEqual(summary.phantomsRemoved.length, 1, 'phantomsRemoved.length');
|
|
177
|
+
assertEqual(summary.phantomsRemoved[0].name, 'tavily', 'phantom name');
|
|
178
|
+
assertEqual(newRegistry.extensions.tavily, undefined, 'phantom still in registry');
|
|
179
|
+
|
|
180
|
+
// Duplicates removed.
|
|
181
|
+
assertEqual(summary.duplicatesRemoved.length, 2, 'duplicatesRemoved.length');
|
|
182
|
+
const removedDupes = summary.duplicatesRemoved.map(d => d.removed).sort();
|
|
183
|
+
assertDeepEqual(removedDupes, ['package', 'session-export'], 'dedupe targets');
|
|
184
|
+
assertEqual(newRegistry.extensions['session-export'], undefined, 'session-export still in registry');
|
|
185
|
+
assertEqual(newRegistry.extensions['package'], undefined, 'package still in registry');
|
|
186
|
+
if (!newRegistry.extensions['cc-session-export']) fail('canonical cc-session-export removed');
|
|
187
|
+
if (!newRegistry.extensions['wip-branch-guard']) fail('canonical wip-branch-guard removed');
|
|
188
|
+
|
|
189
|
+
// Each removed duplicate must also produce a directoryMoves entry. The
|
|
190
|
+
// wrapper in bin/ldm.js executes these moves after the registry write so
|
|
191
|
+
// autoDetectExtensions cannot re-register the duplicate on the same install
|
|
192
|
+
// run. Without this contract, the registry dedup reverts (see
|
|
193
|
+
// ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
|
|
194
|
+
assertEqual(summary.directoryMoves.length, 2, 'directoryMoves.length');
|
|
195
|
+
const moveNames = summary.directoryMoves.map(m => m.name).sort();
|
|
196
|
+
assertDeepEqual(moveNames, ['package', 'session-export'], 'directoryMoves names');
|
|
197
|
+
for (const m of summary.directoryMoves) {
|
|
198
|
+
assertEqual(m.reason, 'deduplicated', `directoryMoves[${m.name}].reason`);
|
|
199
|
+
if (!m.trashName.startsWith(`${m.name}-deduplicated-`)) {
|
|
200
|
+
fail(`directoryMoves[${m.name}].trashName should start with "${m.name}-deduplicated-" but was "${m.trashName}"`);
|
|
201
|
+
}
|
|
202
|
+
if (!m.trashName.includes('2026-05-13T18-00-00-000Z')) {
|
|
203
|
+
fail(`directoryMoves[${m.name}].trashName should embed the fixed-now timestamp but was "${m.trashName}"`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The planner is pure: it must NOT pre-populate directoryMovesPerformed or
|
|
208
|
+
// directoryMovesSkipped. Those are wrapper outputs from real filesystem I/O.
|
|
209
|
+
assertEqual(summary.directoryMovesPerformed.length, 0, 'directoryMovesPerformed should be empty from pure planner');
|
|
210
|
+
assertEqual(summary.directoryMovesSkipped.length, 0, 'directoryMovesSkipped should be empty from pure planner');
|
|
211
|
+
|
|
212
|
+
// Migrated entries: cc-session-export, compaction-indicator, lesa-bridge,
|
|
213
|
+
// run, plus the two custom-path entries (custom-path-untracked,
|
|
214
|
+
// legacy-custom-path). `session-export` and `package` were deduped before
|
|
215
|
+
// probe. `wip-branch-guard` exists on npm. `flaky-network` probe failed.
|
|
216
|
+
const migratedNames = summary.migrated.map(m => m.name).sort();
|
|
217
|
+
assertDeepEqual(migratedNames, [
|
|
218
|
+
'cc-session-export', 'compaction-indicator', 'custom-path-untracked',
|
|
219
|
+
'legacy-custom-path', 'lesa-bridge', 'run',
|
|
220
|
+
], 'migrated names');
|
|
221
|
+
|
|
222
|
+
// Custom-path entries must NOT be removed as phantoms. The planner must
|
|
223
|
+
// pass the entry to extensionExists so the custom path is honored.
|
|
224
|
+
// Regression guard for the round-3 Codex blocker (data-loss path on
|
|
225
|
+
// entries with entry.paths.ldm or entry.ldmPath).
|
|
226
|
+
const cpu = newRegistry.extensions['custom-path-untracked'];
|
|
227
|
+
if (!cpu) fail('custom-path-untracked was removed as phantom (extensionExists ignored entry.paths.ldm)');
|
|
228
|
+
assertEqual(cpu.updateSource?.type, 'untracked', 'custom-path-untracked.updateSource.type');
|
|
229
|
+
assertEqual(cpu.paths?.ldm, '/custom/location/path', 'custom-path-untracked.paths.ldm preserved');
|
|
230
|
+
const lcp = newRegistry.extensions['legacy-custom-path'];
|
|
231
|
+
if (!lcp) fail('legacy-custom-path was removed as phantom (extensionExists ignored entry.ldmPath)');
|
|
232
|
+
assertEqual(lcp.updateSource?.type, 'untracked', 'legacy-custom-path.updateSource.type');
|
|
233
|
+
assertEqual(lcp.ldmPath, '/legacy/custom/path', 'legacy-custom-path.ldmPath preserved');
|
|
234
|
+
|
|
235
|
+
// Each migrated entry has updateSource.type=untracked.
|
|
236
|
+
for (const name of migratedNames) {
|
|
237
|
+
const e = newRegistry.extensions[name];
|
|
238
|
+
if (!e) fail(`migrated entry ${name} missing from newRegistry`);
|
|
239
|
+
assertEqual(e.updateSource?.type, 'untracked', `${name}.updateSource.type`);
|
|
240
|
+
if ('source' in e) fail(`${name}.source should be deleted`);
|
|
241
|
+
if (!e.provenance) fail(`${name}.provenance missing`);
|
|
242
|
+
assertEqual(e.provenance.untrackedSince, '2026-05-13T18:00:00.000Z', `${name}.provenance.untrackedSince`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// cc-session-export specifically: legacy-npm-name + repo preserved.
|
|
246
|
+
const ccse = newRegistry.extensions['cc-session-export'];
|
|
247
|
+
assertEqual(ccse.provenance['legacy-npm-name'], 'session-export', 'cc-session-export legacy-npm-name');
|
|
248
|
+
assertEqual(ccse.provenance.repo, 'wipcomputer/cc-session-export', 'cc-session-export legacy repo');
|
|
249
|
+
|
|
250
|
+
// `run` had no source info: legacy-npm-name absent, no repo, but still classified.
|
|
251
|
+
const runEntry = newRegistry.extensions.run;
|
|
252
|
+
if ('legacy-npm-name' in runEntry.provenance) fail('run.provenance should not have legacy-npm-name');
|
|
253
|
+
if ('repo' in runEntry.provenance) fail('run.provenance should not have repo');
|
|
254
|
+
|
|
255
|
+
// Real npm package left alone.
|
|
256
|
+
const mc = newRegistry.extensions['memory-crystal'];
|
|
257
|
+
if (mc.updateSource) fail('memory-crystal should not be migrated (real npm pkg)');
|
|
258
|
+
assertEqual(mc.source?.npm, '@wipcomputer/memory-crystal', 'memory-crystal source preserved');
|
|
259
|
+
|
|
260
|
+
// wip-branch-guard left alone (real npm pkg).
|
|
261
|
+
const wbg = newRegistry.extensions['wip-branch-guard'];
|
|
262
|
+
if (wbg.updateSource) fail('wip-branch-guard should not be migrated (real npm pkg)');
|
|
263
|
+
|
|
264
|
+
// Already-untracked entry is unchanged.
|
|
265
|
+
const au = newRegistry.extensions['already-untracked'];
|
|
266
|
+
assertEqual(au.provenance.untrackedSince, '2026-05-13T00:00:00.000Z', 'already-untracked untracked since preserved');
|
|
267
|
+
|
|
268
|
+
// Probe failures recorded, entry untouched.
|
|
269
|
+
assertEqual(summary.probeFailures.length, 1, 'probeFailures.length');
|
|
270
|
+
assertEqual(summary.probeFailures[0].name, 'flaky-network', 'probe failure name');
|
|
271
|
+
const fn = newRegistry.extensions['flaky-network'];
|
|
272
|
+
if (fn.updateSource) fail('flaky-network should not be migrated (probe failed)');
|
|
273
|
+
assertEqual(fn.source?.npm, 'flaky-network', 'flaky-network source preserved');
|
|
274
|
+
|
|
275
|
+
// summaryHasChanges flips true when anything happens.
|
|
276
|
+
assertEqual(summaryHasChanges(summary), true, 'summaryHasChanges on populated summary');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Test 2: idempotency on a fully-migrated registry ───────────────────────
|
|
280
|
+
{
|
|
281
|
+
const registry = {
|
|
282
|
+
extensions: {
|
|
283
|
+
'a': {
|
|
284
|
+
updateSource: { type: 'untracked' },
|
|
285
|
+
provenance: { untrackedSince: '2026-05-13T00:00:00.000Z' },
|
|
286
|
+
installed: { version: '1.0.0' },
|
|
287
|
+
},
|
|
288
|
+
'b': {
|
|
289
|
+
updateSource: { type: 'untracked' },
|
|
290
|
+
provenance: { 'legacy-npm-name': 'b', untrackedSince: '2026-05-13T00:00:00.000Z' },
|
|
291
|
+
installed: { version: '0.1.0' },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
const before = JSON.stringify(registry);
|
|
296
|
+
const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
|
|
297
|
+
registry,
|
|
298
|
+
probeNpm: () => fail('probeNpm should not be called on fully-migrated registry'),
|
|
299
|
+
extensionExists: () => true,
|
|
300
|
+
now: FIXED_NOW,
|
|
301
|
+
});
|
|
302
|
+
assertEqual(JSON.stringify(registry), before, 'input mutated on idempotent run');
|
|
303
|
+
assertEqual(summary.migrated.length, 0, 'migrated.length on idempotent run');
|
|
304
|
+
assertEqual(summary.phantomsRemoved.length, 0, 'phantomsRemoved.length on idempotent run');
|
|
305
|
+
assertEqual(summary.duplicatesRemoved.length, 0, 'duplicatesRemoved.length on idempotent run');
|
|
306
|
+
assertEqual(summary.directoryMoves.length, 0, 'directoryMoves.length on idempotent run');
|
|
307
|
+
assertEqual(summaryHasChanges(summary), false, 'summaryHasChanges on empty summary');
|
|
308
|
+
assertDeepEqual(
|
|
309
|
+
Object.keys(newRegistry.extensions).sort(),
|
|
310
|
+
['a', 'b'],
|
|
311
|
+
'extensions preserved on idempotent run',
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Test 3: emptyLegacyNpmSourcesSummary returns the canonical shape ───────
|
|
316
|
+
{
|
|
317
|
+
const e = emptyLegacyNpmSourcesSummary();
|
|
318
|
+
assertDeepEqual(Object.keys(e).sort(), [
|
|
319
|
+
'directoryMoves',
|
|
320
|
+
'directoryMovesPerformed',
|
|
321
|
+
'directoryMovesSkipped',
|
|
322
|
+
'duplicatesRemoved',
|
|
323
|
+
'migrated',
|
|
324
|
+
'phantomsRemoved',
|
|
325
|
+
'probeFailures',
|
|
326
|
+
'probedCount',
|
|
327
|
+
'timestamp',
|
|
328
|
+
], 'empty summary keys');
|
|
329
|
+
// Wrapper-output fields start empty even though the planner doesn't
|
|
330
|
+
// populate them ... the wrapper is responsible for appending.
|
|
331
|
+
assertEqual(e.directoryMoves.length, 0, 'empty.directoryMoves');
|
|
332
|
+
assertEqual(e.directoryMovesPerformed.length, 0, 'empty.directoryMovesPerformed');
|
|
333
|
+
assertEqual(e.directoryMovesSkipped.length, 0, 'empty.directoryMovesSkipped');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Test 4: executeDirectoryMoves against a real temp filesystem ──────────
|
|
337
|
+
// Regression guard for the bug fixed by this PR
|
|
338
|
+
// (ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
|
|
339
|
+
// The planner emits directoryMoves entries; the executor must actually move
|
|
340
|
+
// the on-disk directories into trash so autoDetectExtensions cannot
|
|
341
|
+
// re-register them on the next install scan.
|
|
342
|
+
{
|
|
343
|
+
const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-'));
|
|
344
|
+
try {
|
|
345
|
+
const extensionsRoot = join(tmpHome, 'extensions');
|
|
346
|
+
const trashRoot = join(tmpHome, '_trash');
|
|
347
|
+
mkdirSync(extensionsRoot, { recursive: true });
|
|
348
|
+
|
|
349
|
+
// Stand up the two duplicate directories the planner would dedup.
|
|
350
|
+
for (const name of ['session-export', 'package']) {
|
|
351
|
+
const dir = join(extensionsRoot, name);
|
|
352
|
+
mkdirSync(dir, { recursive: true });
|
|
353
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: '1.0.0' }) + '\n');
|
|
354
|
+
}
|
|
355
|
+
// And a non-duplicate that must be left alone (proxy for cc-session-export).
|
|
356
|
+
const ccsePath = join(extensionsRoot, 'cc-session-export');
|
|
357
|
+
mkdirSync(ccsePath, { recursive: true });
|
|
358
|
+
writeFileSync(join(ccsePath, 'package.json'), JSON.stringify({ name: 'cc-session-export', version: '1.0.0' }) + '\n');
|
|
359
|
+
|
|
360
|
+
// Run the planner with the fixture to get a real directoryMoves plan.
|
|
361
|
+
const registry = {
|
|
362
|
+
extensions: {
|
|
363
|
+
'cc-session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
|
|
364
|
+
'session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
|
|
365
|
+
'wip-branch-guard': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
|
|
366
|
+
'package': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
const { summary } = await planLegacyNpmSourcesMigration({
|
|
370
|
+
registry,
|
|
371
|
+
probeNpm: (name) => Promise.resolve(name === '@wipcomputer/wip-branch-guard'),
|
|
372
|
+
extensionExists: () => true,
|
|
373
|
+
now: FIXED_NOW,
|
|
374
|
+
});
|
|
375
|
+
assertEqual(summary.directoryMoves.length, 2, 'dedup plan produces 2 directoryMoves');
|
|
376
|
+
|
|
377
|
+
// Execute the moves against the temp filesystem.
|
|
378
|
+
const { performed, skipped } = executeDirectoryMoves({
|
|
379
|
+
directoryMoves: summary.directoryMoves,
|
|
380
|
+
extensionsRoot,
|
|
381
|
+
trashRoot,
|
|
382
|
+
});
|
|
383
|
+
assertEqual(performed.length, 2, 'executor performed 2 moves');
|
|
384
|
+
assertEqual(skipped.length, 0, 'executor did not skip any moves');
|
|
385
|
+
|
|
386
|
+
// Source directories are gone.
|
|
387
|
+
if (existsSync(join(extensionsRoot, 'session-export'))) {
|
|
388
|
+
fail('session-export directory should have been moved out of extensions/');
|
|
389
|
+
}
|
|
390
|
+
if (existsSync(join(extensionsRoot, 'package'))) {
|
|
391
|
+
fail('package directory should have been moved out of extensions/');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Trash directory now has the moved entries with the deduplicated suffix.
|
|
395
|
+
const trashContents = readdirSync(trashRoot);
|
|
396
|
+
if (!trashContents.some(name => name.startsWith('session-export-deduplicated-'))) {
|
|
397
|
+
fail(`trash should contain session-export-deduplicated-* but had: ${trashContents.join(', ')}`);
|
|
398
|
+
}
|
|
399
|
+
if (!trashContents.some(name => name.startsWith('package-deduplicated-'))) {
|
|
400
|
+
fail(`trash should contain package-deduplicated-* but had: ${trashContents.join(', ')}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Non-duplicate must NOT have been touched.
|
|
404
|
+
if (!existsSync(ccsePath)) fail('cc-session-export directory should be untouched');
|
|
405
|
+
|
|
406
|
+
// autoDetectExtensions simulation: a fresh scan of extensionsRoot must
|
|
407
|
+
// NOT see the moved duplicates. We replicate the production logic
|
|
408
|
+
// (bin/ldm.js autoDetectExtensions): scan top-level dirs in
|
|
409
|
+
// extensionsRoot, skip dirs named `_trash` or starting with `.` or
|
|
410
|
+
// `ldm-install-`, and treat any remaining dir with a package.json as a
|
|
411
|
+
// candidate for auto-registration.
|
|
412
|
+
const candidatesAfterMove = readdirSync(extensionsRoot, { withFileTypes: true })
|
|
413
|
+
.filter(d => d.isDirectory())
|
|
414
|
+
.map(d => d.name)
|
|
415
|
+
.filter(name => name !== '_trash' && !name.startsWith('.') && !name.startsWith('ldm-install-'))
|
|
416
|
+
.filter(name => existsSync(join(extensionsRoot, name, 'package.json')))
|
|
417
|
+
.sort();
|
|
418
|
+
assertDeepEqual(
|
|
419
|
+
candidatesAfterMove,
|
|
420
|
+
['cc-session-export'],
|
|
421
|
+
'autoDetect should see only the non-duplicate after the moves; duplicates must be gone',
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Idempotency: running executeDirectoryMoves again must skip all
|
|
425
|
+
// (source-missing) and not fail.
|
|
426
|
+
const second = executeDirectoryMoves({
|
|
427
|
+
directoryMoves: summary.directoryMoves,
|
|
428
|
+
extensionsRoot,
|
|
429
|
+
trashRoot,
|
|
430
|
+
});
|
|
431
|
+
assertEqual(second.performed.length, 0, 'second execute call performs nothing');
|
|
432
|
+
assertEqual(second.skipped.length, 2, 'second execute call skips both moves');
|
|
433
|
+
for (const s of second.skipped) {
|
|
434
|
+
assertEqual(s.reason, 'source-missing', `second-call skip reason for ${s.name}`);
|
|
435
|
+
}
|
|
436
|
+
} finally {
|
|
437
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Test 5: executeDirectoryMoves with no moves is a no-op ───────────────
|
|
442
|
+
{
|
|
443
|
+
const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-empty-'));
|
|
444
|
+
try {
|
|
445
|
+
const result = executeDirectoryMoves({
|
|
446
|
+
directoryMoves: [],
|
|
447
|
+
extensionsRoot: join(tmpHome, 'extensions'),
|
|
448
|
+
trashRoot: join(tmpHome, '_trash'),
|
|
449
|
+
});
|
|
450
|
+
assertEqual(result.performed.length, 0, 'empty plan -> no performed');
|
|
451
|
+
assertEqual(result.skipped.length, 0, 'empty plan -> no skipped');
|
|
452
|
+
if (existsSync(join(tmpHome, '_trash'))) {
|
|
453
|
+
fail('executor should not pre-create trashRoot when there are no moves');
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
console.log('test-legacy-npm-sources-migration: all tests passed');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8");
|
|
5
|
+
const promptTemplate = readFileSync(
|
|
6
|
+
new URL("../shared/templates/install-prompt.md", import.meta.url),
|
|
7
|
+
"utf8",
|
|
8
|
+
);
|
|
9
|
+
const skill = readFileSync(new URL("../SKILL.md", import.meta.url), "utf8");
|
|
10
|
+
|
|
11
|
+
const promptStart = readme.indexOf("Read https://wip.computer/install/wip-ldm-os.txt");
|
|
12
|
+
const promptEnd = readme.indexOf("```", promptStart);
|
|
13
|
+
|
|
14
|
+
assert(promptStart >= 0, "README install prompt must point at the public install document");
|
|
15
|
+
assert(promptEnd > promptStart, "README install prompt must be fenced");
|
|
16
|
+
|
|
17
|
+
const readmePrompt = readme.slice(promptStart, promptEnd);
|
|
18
|
+
|
|
19
|
+
for (const [label, prompt] of [
|
|
20
|
+
["README install prompt", readmePrompt],
|
|
21
|
+
["shared install prompt template", promptTemplate],
|
|
22
|
+
]) {
|
|
23
|
+
assert(
|
|
24
|
+
prompt.includes("Read https://wip.computer/install/wip-ldm-os.txt"),
|
|
25
|
+
`${label} must delegate to the public install document`,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
assert(
|
|
29
|
+
prompt.includes("Use the install document and live local checks as the source of truth."),
|
|
30
|
+
`${label} must name the install document as source of truth`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
assert(
|
|
34
|
+
prompt.includes("use the selected track's dry-run path from the install document"),
|
|
35
|
+
`${label} must delegate dry-run command mapping to SKILL.md`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
assert(
|
|
39
|
+
prompt.includes("use the selected track's install path from the install document"),
|
|
40
|
+
`${label} must delegate install command mapping to SKILL.md`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
for (const forbidden of [
|
|
44
|
+
"Track choices:",
|
|
45
|
+
"ldm install --alpha",
|
|
46
|
+
"ldm install --beta",
|
|
47
|
+
"ldm install --dry-run",
|
|
48
|
+
]) {
|
|
49
|
+
assert(!prompt.includes(forbidden), `${label} must not include ${forbidden}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const required of [
|
|
54
|
+
"## Tracks",
|
|
55
|
+
"npm view @wipcomputer/wip-ldm-os dist-tags --json",
|
|
56
|
+
"stable/current/latest: `ldm install --dry-run`",
|
|
57
|
+
"beta/latest beta: `ldm install --beta --dry-run`",
|
|
58
|
+
"alpha/latest alpha: `ldm install --alpha --dry-run`",
|
|
59
|
+
"beta/latest beta: `npm install -g @wipcomputer/wip-ldm-os@beta`",
|
|
60
|
+
"alpha/latest alpha: `npm install -g @wipcomputer/wip-ldm-os@alpha`",
|
|
61
|
+
"The README prompt should stay short. This install document owns the detailed track rules.",
|
|
62
|
+
]) {
|
|
63
|
+
assert(skill.includes(required), `SKILL.md must own track-selection logic: ${required}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log("readme-install-prompt: prompt stays short and delegates track rules to SKILL.md");
|
|
@@ -6,7 +6,19 @@ Open your AI and paste this:
|
|
|
6
6
|
|
|
7
7
|
Read https://wip.computer/install/wip-ldm-os.txt
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Use the install document and live local checks as the source of truth.
|
|
10
|
+
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.
|
|
11
|
+
Do not run GitHub commands during this install flow. Do not run or request approval for `gh release`, `gh api`, or `gh search`. If release notes are not available from local or npm metadata, say that and do not fetch them from GitHub.
|
|
12
|
+
|
|
13
|
+
Check if LDM OS is installed (`which ldm && ldm --version`). Branch.
|
|
14
|
+
|
|
15
|
+
If installed: run `ldm status`, check available npm tracks from the install document, and show me the version table, what's new for each component, and which stable/current, beta, and alpha versions are available. Then ask:
|
|
16
|
+
- Do you have questions?
|
|
17
|
+
- Want to see a dry run?
|
|
18
|
+
|
|
19
|
+
If yes to dry run, use the selected track's dry-run path from the install document.
|
|
20
|
+
|
|
21
|
+
If I say install, use the selected track's install path from the install document, then run `ldm doctor`.
|
|
10
22
|
|
|
11
23
|
If not, walk me through setup and explain:
|
|
12
24
|
|
|
@@ -19,7 +31,13 @@ Then ask:
|
|
|
19
31
|
- Do you have questions?
|
|
20
32
|
- Want to see a dry run?
|
|
21
33
|
|
|
22
|
-
If
|
|
34
|
+
If yes to dry run, install the CLI first using the selected track's bootstrap command from the install document.
|
|
35
|
+
|
|
36
|
+
Then run:
|
|
37
|
+
`ldm init --dry-run`
|
|
38
|
+
|
|
39
|
+
If I say install, run:
|
|
40
|
+
`ldm init`
|
|
23
41
|
|
|
24
42
|
Show me exactly what will change. Don't install anything until I say "install".
|
|
25
43
|
|