@wipcomputer/wip-ldm-os 0.4.85-alpha.27 → 0.4.85-alpha.29

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 CHANGED
@@ -28,14 +28,13 @@ Do not run GitHub commands during this install flow. Do not run or request appro
28
28
 
29
29
  Check if LDM OS is installed (`which ldm && ldm --version`). Branch.
30
30
 
31
- If installed: run `ldm status` and show me the version table and what's new for each component. Then ask:
31
+ 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:
32
32
  - Do you have questions?
33
33
  - Want to see a dry run?
34
34
 
35
- If yes to dry run, run `ldm install --dry-run`.
35
+ If yes to dry run, use the selected track's dry-run path from the install document.
36
36
 
37
- If I say install, run:
38
- `npm install -g @wipcomputer/wip-ldm-os@latest && ldm install && ldm doctor`
37
+ If I say install, use the selected track's install path from the install document, then run `ldm doctor`.
39
38
 
40
39
  If not, walk me through setup and explain:
41
40
 
@@ -48,8 +47,7 @@ Then ask:
48
47
  - Do you have questions?
49
48
  - Want to see a dry run?
50
49
 
51
- If yes to dry run, install the CLI first:
52
- `npm install -g @wipcomputer/wip-ldm-os`
50
+ If yes to dry run, install the CLI first using the selected track's bootstrap command from the install document.
53
51
 
54
52
  Then run:
55
53
  `ldm init --dry-run`
package/SKILL.md CHANGED
@@ -35,6 +35,92 @@ Use this install document and live local checks as the source of truth. Do not s
35
35
 
36
36
  Do not run GitHub commands during the install-state flow. 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. Install-state answers should come from local commands, `ldm status`, and npm package metadata.
37
37
 
38
+ ## Tracks
39
+
40
+ LDM OS ships on three npm dist-tags. Each tag is just a name pointing at a specific version. Show the user the installed version and the available tracks in plain English. Do not paste raw JSON.
41
+
42
+ - **stable** (`ldm install`) ... npm `@latest`. Use this only when `@latest` resolves to a non-prerelease version. Default for production releases.
43
+ - **beta** (`ldm install --beta`) ... npm `@beta`. Prerelease track. Stabilization candidates.
44
+ - **alpha** (`ldm install --alpha`) ... npm `@alpha`. Canary track. Earliest access; expect breakage.
45
+
46
+ ### Pick the right track
47
+
48
+ Run this to get the dist-tags. Read the output and translate it into track names and versions.
49
+
50
+ ```bash
51
+ npm view @wipcomputer/wip-ldm-os dist-tags --json
52
+ ```
53
+
54
+ The npm `latest` tag is the stable/current track for user language. It is not guaranteed to be the newest prerelease. If `latest` points at a prerelease, explain that a stable release is not available yet.
55
+
56
+ User language maps to tracks like this:
57
+
58
+ - `stable`, `current`, or `latest` means `ldm install`
59
+ - `beta` or `latest beta` means `ldm install --beta`
60
+ - `alpha` or `latest alpha` means `ldm install --alpha`
61
+
62
+ Dry-run commands use the same selected track:
63
+
64
+ - stable/current/latest: `ldm install --dry-run`
65
+ - beta/latest beta: `ldm install --beta --dry-run`
66
+ - alpha/latest alpha: `ldm install --alpha --dry-run`
67
+
68
+ Install commands use the same selected track:
69
+
70
+ - stable/current/latest: `ldm install`
71
+ - beta/latest beta: `ldm install --beta`
72
+ - alpha/latest alpha: `ldm install --alpha`
73
+
74
+ First-time CLI bootstrap commands use the same selected track:
75
+
76
+ - stable/current/latest: `npm install -g @wipcomputer/wip-ldm-os`
77
+ - beta/latest beta: `npm install -g @wipcomputer/wip-ldm-os@beta`
78
+ - alpha/latest alpha: `npm install -g @wipcomputer/wip-ldm-os@alpha`
79
+
80
+ If the user already named a track, do not force a generic chooser. Show the exact package, available version, track, and command you will run. Then wait for dry-run or install consent as appropriate.
81
+
82
+ If the user has not named a track, show what is installed and what is available, then ask which track they want to dry run or install.
83
+
84
+ Never pin a specific prerelease version in an install command, such as `@wipcomputer/wip-ldm-os@0.4.85-alpha.29`. Use the moving track flags.
85
+
86
+ ### How to phrase the track to the user
87
+
88
+ The README prompt should stay short. This install document owns the detailed track rules.
89
+
90
+ When the user asks for install state, say this first:
91
+
92
+ > You have `<LOCAL>` installed. Available: stable/current `<NPM_LATEST_OR_NONE>`, beta `<NPM_BETA_OR_NONE>`, alpha `<NPM_ALPHA_OR_NONE>`.
93
+
94
+ If no local install exists, say:
95
+
96
+ > LDM OS is not installed. Available: stable/current `<NPM_LATEST_OR_NONE>`, beta `<NPM_BETA_OR_NONE>`, alpha `<NPM_ALPHA_OR_NONE>`.
97
+
98
+ Then continue with the setup explanation and ask whether the user has questions or wants a dry run.
99
+
100
+ If the user named a track, answer directly:
101
+
102
+ > You asked for `<TRACK>`. The available `<TRACK>` version is `<VERSION>`. The dry-run command is `<COMMAND>`.
103
+
104
+ If the user says `stable`, `current`, or `latest` and npm `@latest` is a prerelease, say:
105
+
106
+ > Stable is not available yet. The stable/current track currently points at prerelease `<NPM_LATEST>`. Available prerelease tracks are beta `<NPM_BETA_OR_NONE>` and alpha `<NPM_ALPHA_OR_NONE>`. Which track do you want?
107
+
108
+ Do not treat public install docs as beta-only or alpha-only. Alpha, beta, and stable are all public npm tracks. Disclose risk and let the user choose.
109
+
110
+ Track risk language:
111
+
112
+ - stable/current/latest: normal public path, once it is a real non-prerelease release.
113
+ - beta: public prerelease path.
114
+ - alpha: canary path, likely rougher, but installable if the user asks for it.
115
+
116
+ #### Anti-patterns ... do NOT print any of these.
117
+
118
+ - Do not print raw `npm view ... dist-tags` JSON.
119
+ - Do not use `latest` as a synonym for newest prerelease.
120
+ - Do not give a hardcoded single-track recommendation when the user asked what is available.
121
+ - Do not force a generic chooser after the user already said `latest alpha`, `alpha`, `latest beta`, `beta`, `stable`, `current`, or `latest`.
122
+ - Do not run `gh release list` during install-state detection.
123
+
38
124
  ## Step 1: Check if installed
39
125
 
40
126
  ```bash
@@ -95,15 +181,23 @@ Show 2-3 bullets per component. Then:
95
181
 
96
182
  Do you have questions? Want to see a dry run?
97
183
 
98
- ```bash
99
- ldm install --dry-run
100
- ```
184
+ Use the selected track from **Pick the right track**:
185
+
186
+ - stable/current/latest: `ldm install --dry-run`
187
+ - beta/latest beta: `ldm install --beta --dry-run`
188
+ - alpha/latest alpha: `ldm install --alpha --dry-run`
101
189
 
102
190
  Don't install until the user says "install".
103
191
 
192
+ Use the selected track from **Pick the right track**:
193
+
194
+ - stable/current/latest: `ldm install`
195
+ - beta/latest beta: `ldm install --beta`
196
+ - alpha/latest alpha: `ldm install --alpha`
197
+
198
+ Then verify with:
199
+
104
200
  ```bash
105
- npm install -g @wipcomputer/wip-ldm-os@latest
106
- ldm install
107
201
  ldm doctor
108
202
  ```
109
203
 
@@ -129,10 +223,11 @@ Read [references/SKILLS-CATALOG.md](references/SKILLS-CATALOG.md). Present the i
129
223
 
130
224
  Do you have questions? Want to see a dry run?
131
225
 
132
- Install the CLI first:
133
- ```bash
134
- npm install -g @wipcomputer/wip-ldm-os
135
- ```
226
+ Install the CLI first using the selected track from **Pick the right track**:
227
+
228
+ - stable/current/latest: `npm install -g @wipcomputer/wip-ldm-os`
229
+ - beta/latest beta: `npm install -g @wipcomputer/wip-ldm-os@beta`
230
+ - alpha/latest alpha: `npm install -g @wipcomputer/wip-ldm-os@alpha`
136
231
 
137
232
  If npm/node is not installed: Node.js 18+ from https://nodejs.org first.
138
233
 
@@ -173,6 +268,14 @@ ldm doctor --fix
173
268
  - **Dry-run first.** Always. Only install when the user says "install".
174
269
  - **Never touch sacred data.** crystal.db, agent data, secrets, state files are never overwritten.
175
270
 
271
+ ## Track caveats
272
+
273
+ Tell the user, scaled to the track they're on:
274
+
275
+ - **alpha**: canary path, earliest access, breakage possible. Use only when the user explicitly opts in.
276
+ - **beta**: stabilization candidate. Same shape as alpha but feature-frozen for the cut.
277
+ - **stable**: production. The user should be on this unless they've asked otherwise.
278
+
176
279
  ## Reference files
177
280
 
178
281
  For detailed information, read these on demand (not on every activation):
package/bin/ldm.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * ldm --version Show version
21
21
  */
22
22
 
23
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync, statSync, lstatSync, symlinkSync } from 'node:fs';
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync, statSync, lstatSync, symlinkSync, copyFileSync } from 'node:fs';
24
24
  import { join, basename, resolve, dirname } from 'node:path';
25
25
  import { execSync, spawnSync } from 'node:child_process';
26
26
  import { fileURLToPath } from 'node:url';
@@ -1799,6 +1799,97 @@ function migrateRegistry() {
1799
1799
  return migrated;
1800
1800
  }
1801
1801
 
1802
+ // ── Legacy source.npm honest cleanup (Phase 1 of source-types refactor) ──
1803
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
1804
+
1805
+ async function migrateLegacyNpmSources({ dryRun = false } = {}) {
1806
+ const {
1807
+ planLegacyNpmSourcesMigration,
1808
+ summaryHasChanges,
1809
+ npmPackageExists,
1810
+ } = await import('../lib/registry-migrations.mjs');
1811
+
1812
+ const registry = readJSON(REGISTRY_PATH);
1813
+ if (!registry?.extensions) return null;
1814
+
1815
+ const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
1816
+ registry,
1817
+ probeNpm: (name) => npmPackageExists(name, { timeoutMs: 2000 }),
1818
+ // Resolve the entry's on-disk location before declaring it a phantom.
1819
+ // Entries autoregistered with `ldmPath` (bin/ldm.js:1822 path) or with
1820
+ // a `paths.ldm` field (lib/deploy.mjs path) can live outside the
1821
+ // default ~/.ldm/extensions/<name> location. A naive check would
1822
+ // silently delete a working entry as "phantom."
1823
+ extensionExists: (name, entry) => {
1824
+ const customPath = entry?.paths?.ldm || entry?.ldmPath;
1825
+ if (customPath && existsSync(customPath)) return true;
1826
+ return existsSync(join(LDM_EXTENSIONS, name));
1827
+ },
1828
+ now: () => new Date(),
1829
+ });
1830
+
1831
+ if (!summaryHasChanges(summary)) return summary;
1832
+
1833
+ if (!dryRun) {
1834
+ const stamp = summary.timestamp.replace(/[:.]/g, '-');
1835
+ const backupPath = `${REGISTRY_PATH}.bak-${stamp}`;
1836
+ copyFileSync(REGISTRY_PATH, backupPath);
1837
+ summary.backupPath = backupPath;
1838
+ writeJSON(REGISTRY_PATH, newRegistry);
1839
+ }
1840
+
1841
+ return summary;
1842
+ }
1843
+
1844
+ function printLegacyNpmSourcesSummary(summary, { dryRun = false } = {}) {
1845
+ if (!summary) return;
1846
+ const writeableChanges =
1847
+ summary.migrated.length +
1848
+ summary.phantomsRemoved.length +
1849
+ summary.duplicatesRemoved.length;
1850
+ const probeFailureCount = summary.probeFailures?.length || 0;
1851
+ // Always surface probe failures even when nothing was writeable. If every
1852
+ // probe times out, the install would otherwise look like a no-op and the
1853
+ // [unavailable] rows in `ldm status` would survive silently.
1854
+ if (writeableChanges === 0 && probeFailureCount === 0) return;
1855
+
1856
+ console.log('');
1857
+ console.log(dryRun
1858
+ ? ' Registry source.npm cleanup (dry run, no changes written):'
1859
+ : ' Registry source.npm cleanup:');
1860
+
1861
+ if (summary.migrated.length > 0) {
1862
+ console.log(` + ${dryRun ? 'Would migrate' : 'Migrated'} ${summary.migrated.length} entr${summary.migrated.length === 1 ? 'y' : 'ies'} to updateSource.type=untracked`);
1863
+ for (const m of summary.migrated) {
1864
+ const detail = m.legacyNpmName
1865
+ ? ` (legacy source.npm "${m.legacyNpmName}" preserved in provenance)`
1866
+ : ' (no source info; classified as untracked)';
1867
+ console.log(` ${m.name}${detail}`);
1868
+ }
1869
+ }
1870
+ if (summary.phantomsRemoved.length > 0) {
1871
+ console.log(` + ${dryRun ? 'Would remove' : 'Removed'} ${summary.phantomsRemoved.length} phantom entr${summary.phantomsRemoved.length === 1 ? 'y' : 'ies'}:`);
1872
+ for (const p of summary.phantomsRemoved) {
1873
+ console.log(` ${p.name} (${p.reason})`);
1874
+ }
1875
+ }
1876
+ if (summary.duplicatesRemoved.length > 0) {
1877
+ console.log(` + ${dryRun ? 'Would remove' : 'Removed'} ${summary.duplicatesRemoved.length} duplicate entr${summary.duplicatesRemoved.length === 1 ? 'y' : 'ies'}:`);
1878
+ for (const d of summary.duplicatesRemoved) {
1879
+ console.log(` ${d.removed} (canonical: ${d.keep})`);
1880
+ }
1881
+ }
1882
+ if (summary.probeFailures && summary.probeFailures.length > 0) {
1883
+ console.log(` ! ${summary.probeFailures.length} npm probe${summary.probeFailures.length === 1 ? '' : 's'} could not complete (will retry on next install):`);
1884
+ for (const f of summary.probeFailures) {
1885
+ console.log(` ${f.name} (source.npm "${f.npmName}")`);
1886
+ }
1887
+ }
1888
+ if (!dryRun && summary.backupPath) {
1889
+ console.log(` + Registry backup: ${summary.backupPath.replace(HOME, '~')}`);
1890
+ }
1891
+ }
1892
+
1802
1893
  // ── Auto-detect unregistered extensions ──
1803
1894
 
1804
1895
  function autoDetectExtensions() {
@@ -1992,6 +2083,12 @@ async function cmdInstallCatalog() {
1992
2083
  console.log(` + Migrated ${migrated} registry entries to v2 format (source info added)`);
1993
2084
  }
1994
2085
 
2086
+ // Phase 1 of source-types refactor: clear bad source.npm entries, dedupe,
2087
+ // and classify mystery rows as untracked so `ldm status` stops lying.
2088
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
2089
+ const npmCleanupSummary = await migrateLegacyNpmSources({ dryRun: DRY_RUN });
2090
+ printLegacyNpmSourcesSummary(npmCleanupSummary, { dryRun: DRY_RUN });
2091
+
1995
2092
  // Aggregate the bin ownership manifest BEFORE seedLocalCatalog,
1996
2093
  // deployBridge, deployScripts, and the heal walk run. If two
1997
2094
  // declarers claim the same file in ~/.ldm/bin/ we cannot safely
@@ -2922,6 +3019,27 @@ async function cmdDoctor() {
2922
3019
  }
2923
3020
  }
2924
3021
 
3022
+ // Warn on registry entries whose legacy source.npm value 404s on npm.
3023
+ // `ldm install` migrates these to updateSource.type=untracked; this catches
3024
+ // future drift and any entries the install-time probe couldn't reach.
3025
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
3026
+ try {
3027
+ const { findLegacyNpm404Entries, npmPackageExists } = await import('../lib/registry-migrations.mjs');
3028
+ const docRegistry = readJSON(REGISTRY_PATH);
3029
+ const legacy404 = await findLegacyNpm404Entries({
3030
+ registry: docRegistry,
3031
+ probeNpm: (name) => npmPackageExists(name, { timeoutMs: 1500 }),
3032
+ });
3033
+ if (legacy404.length > 0) {
3034
+ console.log('');
3035
+ console.log(` ! ${legacy404.length} extension(s) declare an npm source whose package does not exist on npm:`);
3036
+ for (const { name, npmName } of legacy404) {
3037
+ console.log(` ${name} (source.npm "${npmName}")`);
3038
+ }
3039
+ console.log(' Run `ldm install` to migrate these to updateSource.type=untracked.');
3040
+ }
3041
+ } catch {}
3042
+
2925
3043
  // --fix: clean up registered-missing entries
2926
3044
  if (FIX_FLAG && registeredMissing.length > 0) {
2927
3045
  const registry = readJSON(REGISTRY_PATH);
@@ -3409,7 +3527,24 @@ async function cmdStatus() {
3409
3527
  }];
3410
3528
 
3411
3529
  const updates = [];
3530
+ const untrackedEntries = [];
3412
3531
  for (const [name, info] of Object.entries(registry?.extensions || {})) {
3532
+ // Phase 1 of source-types refactor: entries explicitly marked as untracked
3533
+ // are listed in a separate section and never probed. Honest reporting:
3534
+ // we don't know how to check their source yet.
3535
+ if (info?.updateSource?.type === 'untracked') {
3536
+ const currentVersion = info?.installed?.version || info.version || 'unknown';
3537
+ untrackedEntries.push({ name, version: currentVersion });
3538
+ continue;
3539
+ }
3540
+ // Defensive skip for any other Phase 2 updateSource types (git, bundled,
3541
+ // private, etc.) that may appear in the registry before their probe
3542
+ // logic ships. Falling through would use the legacy info.source.npm
3543
+ // path, which is wrong for these types and would print them as
3544
+ // [unavailable]. Phase 2 replaces this skip with proper dispatch.
3545
+ if (info?.updateSource && info.updateSource.type !== 'npm') {
3546
+ continue;
3547
+ }
3413
3548
  // Use registry source.npm (v2) or fall back to extension's package.json
3414
3549
  let npmPkg = info?.source?.npm || null;
3415
3550
  if (!npmPkg) {
@@ -3427,6 +3562,7 @@ async function cmdStatus() {
3427
3562
  current: currentVersion,
3428
3563
  });
3429
3564
  }
3565
+ untrackedEntries.sort((a, b) => a.name.localeCompare(b.name));
3430
3566
 
3431
3567
  let cliUpdate = null;
3432
3568
  const probeResults = await runStatusProbesWithConcurrency(probeItems, STATUS_NPM_CONCURRENCY, statusStartedAt);
@@ -3455,12 +3591,13 @@ async function cmdStatus() {
3455
3591
  }
3456
3592
 
3457
3593
  console.log('');
3458
- if (updates.length === 0 && !cliUpdate && skipped.length === 0) {
3594
+ if (updates.length === 0 && !cliUpdate && skipped.length === 0 && untrackedEntries.length === 0) {
3459
3595
  console.log(' Update summary: all up to date');
3460
3596
  } else {
3461
3597
  const summaryParts = [];
3462
3598
  if (updates.length > 0) summaryParts.push(`${updates.length} extension update(s) available`);
3463
3599
  if (cliUpdate) summaryParts.push('CLI update available');
3600
+ if (untrackedEntries.length > 0) summaryParts.push(`${untrackedEntries.length} untracked`);
3464
3601
  if (skipped.length > 0) summaryParts.push(`${skipped.length} update check(s) skipped`);
3465
3602
  console.log(` Update summary: ${summaryParts.join(', ')}`);
3466
3603
  }
@@ -3479,6 +3616,16 @@ async function cmdStatus() {
3479
3616
  console.log(` CLI update: npm install -g @wipcomputer/wip-ldm-os@${cliUpdate}`);
3480
3617
  }
3481
3618
 
3619
+ if (untrackedEntries.length > 0) {
3620
+ console.log('');
3621
+ console.log(' Untracked extensions (pending reclassification):');
3622
+ const maxNameLen = Math.max(...untrackedEntries.map(e => e.name.length));
3623
+ for (const e of untrackedEntries) {
3624
+ console.log(` ${e.name.padEnd(maxNameLen)} v${e.version}`);
3625
+ }
3626
+ console.log(' (run `ldm doctor --reclassify-sources` to classify these)');
3627
+ }
3628
+
3482
3629
  if (skipped.length > 0) {
3483
3630
  console.log('');
3484
3631
  console.log(' Update checks skipped:');
@@ -0,0 +1,214 @@
1
+ // Registry migrations for ~/.ldm/extensions/registry.json.
2
+ //
3
+ // Phase 1 of the source-types refactor. Pure planner + npm-probe helper.
4
+ // Called by bin/ldm.js during `ldm install`. Idempotent: entries that already
5
+ // carry `updateSource` are skipped.
6
+ //
7
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
8
+ // and the parent design ai/product/bugs/installer/2026-05-13--cc-mini--installer-registry-source-types-architecture.md
9
+
10
+ const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
11
+
12
+ // Phase 1 expedient. The known duplicate pairs surfaced on Parker's machine
13
+ // during the 2026-05-13 dogfood. General-case duplicate detection is the
14
+ // hygiene-audit ticket's Check 1
15
+ // (ai/product/bugs/installer/2026-05-13--cc-mini--installer-registry-hygiene-audit.md);
16
+ // do NOT extend this list as a long-term shape. Future entries belong in
17
+ // that audit, not here.
18
+ //
19
+ // Dedupe drops the duplicate's `installed` block entirely. Today both rows
20
+ // in each pair carry the same version, so no data is lost. If a duplicate
21
+ // ever carried a newer version than its canonical, the dedup would silently
22
+ // discard that information. Acceptable for the known pairs; not a general
23
+ // safe pattern.
24
+ const KNOWN_DUPLICATE_PAIRS = [
25
+ { keep: 'cc-session-export', remove: 'session-export' },
26
+ { keep: 'wip-branch-guard', remove: 'package' },
27
+ ];
28
+
29
+ export function emptyLegacyNpmSourcesSummary() {
30
+ return {
31
+ migrated: [],
32
+ phantomsRemoved: [],
33
+ duplicatesRemoved: [],
34
+ probedCount: 0,
35
+ probeFailures: [],
36
+ timestamp: new Date().toISOString(),
37
+ };
38
+ }
39
+
40
+ export function summaryHasChanges(summary) {
41
+ return summary.migrated.length > 0
42
+ || summary.phantomsRemoved.length > 0
43
+ || summary.duplicatesRemoved.length > 0;
44
+ }
45
+
46
+ // Pure planner. Returns { newRegistry, summary } without touching the
47
+ // filesystem. Tests pass an in-memory registry, a fake `probeNpm`, and a
48
+ // fake `extensionExists`. Real callers inject the file-backed versions.
49
+ //
50
+ // probeNpm contract:
51
+ // returns true if the package exists on npm
52
+ // returns false if the package returns 404 (definitely doesn't exist)
53
+ // returns null if the probe failed (timeout / network) ... entry is left
54
+ // alone and retried on the next install
55
+ //
56
+ // extensionExists contract:
57
+ // called as (name, entry) -> boolean
58
+ // The entry is provided so the resolver can honor `entry.paths.ldm` and
59
+ // the legacy `entry.ldmPath` field before falling back to the default
60
+ // ~/.ldm/extensions/<name> path. A naive resolver that only checks the
61
+ // default location would falsely classify custom-path entries as
62
+ // phantoms and remove them. Real callers must check both.
63
+ export async function planLegacyNpmSourcesMigration({
64
+ registry,
65
+ probeNpm,
66
+ extensionExists,
67
+ now,
68
+ }) {
69
+ const summary = emptyLegacyNpmSourcesSummary();
70
+ if (now) summary.timestamp = now().toISOString();
71
+ if (!registry?.extensions) return { newRegistry: registry, summary };
72
+
73
+ // Shallow-clone the top level and each entry so the input is not mutated.
74
+ const newRegistry = { ...registry, extensions: {} };
75
+ for (const [name, entry] of Object.entries(registry.extensions)) {
76
+ newRegistry.extensions[name] = { ...entry };
77
+ }
78
+
79
+ // Step 1: phantoms. Registry rows with no on-disk extension directory.
80
+ // The acceptance criterion says these are removed entirely; they're
81
+ // surfaced as an explicit summary delta, not silent.
82
+ //
83
+ // extensionExists is called with both name and entry so the resolver can
84
+ // honor entry.paths.ldm / entry.ldmPath. A custom-path entry must not be
85
+ // misclassified as phantom.
86
+ for (const [name, entry] of Object.entries(newRegistry.extensions)) {
87
+ if (entry.updateSource) continue;
88
+ if (extensionExists(name, entry)) continue;
89
+ summary.phantomsRemoved.push({
90
+ name,
91
+ reason: 'directory missing',
92
+ legacyNpmName: entry.source?.npm || null,
93
+ });
94
+ delete newRegistry.extensions[name];
95
+ }
96
+
97
+ // Step 2: dedupe known pairs. Pure structural fix; the canonical row stays
98
+ // untouched. Future drift is the hygiene-audit ticket's job, not this one.
99
+ for (const { keep, remove } of KNOWN_DUPLICATE_PAIRS) {
100
+ if (newRegistry.extensions[keep] && newRegistry.extensions[remove]) {
101
+ summary.duplicatesRemoved.push({ keep, removed: remove });
102
+ delete newRegistry.extensions[remove];
103
+ }
104
+ }
105
+
106
+ // Step 3: probe entries that still carry a legacy `source.npm` value.
107
+ // 404 -> migrate to untracked + provenance. 200 -> leave alone. Unknown ->
108
+ // leave alone; the next install will retry.
109
+ const probeTargets = [];
110
+ for (const [name, entry] of Object.entries(newRegistry.extensions)) {
111
+ if (entry.updateSource) continue;
112
+ const npmName = entry.source?.npm;
113
+ if (!npmName) continue;
114
+ probeTargets.push({ name, npmName });
115
+ }
116
+
117
+ const probeResults = await Promise.all(
118
+ probeTargets.map(async ({ name, npmName }) => {
119
+ const exists = await probeNpm(npmName);
120
+ summary.probedCount++;
121
+ return { name, npmName, exists };
122
+ })
123
+ );
124
+
125
+ for (const { name, npmName, exists } of probeResults) {
126
+ if (exists === true) continue;
127
+ if (exists === null) {
128
+ summary.probeFailures.push({ name, npmName });
129
+ continue;
130
+ }
131
+ const entry = newRegistry.extensions[name];
132
+ const legacyRepo = entry.source?.repo || null;
133
+ summary.migrated.push({ name, legacyNpmName: npmName, legacyRepo });
134
+ entry.updateSource = { type: 'untracked' };
135
+ entry.provenance = { ...(entry.provenance || {}) };
136
+ entry.provenance['legacy-npm-name'] = npmName;
137
+ if (legacyRepo) entry.provenance.repo = legacyRepo;
138
+ entry.provenance.untrackedSince = summary.timestamp;
139
+ delete entry.source;
140
+ }
141
+
142
+ // Step 4: entries with no source info at all (the mystery `run`-style row).
143
+ // Per the ticket, migrate to untracked so they stay visible in `ldm status`.
144
+ for (const [name, entry] of Object.entries(newRegistry.extensions)) {
145
+ if (entry.updateSource) continue;
146
+ if (entry.source?.npm || entry.source?.repo) continue;
147
+ summary.migrated.push({
148
+ name,
149
+ legacyNpmName: null,
150
+ legacyRepo: null,
151
+ reason: 'no-source-info',
152
+ });
153
+ entry.updateSource = { type: 'untracked' };
154
+ entry.provenance = { ...(entry.provenance || {}) };
155
+ entry.provenance.untrackedSince = summary.timestamp;
156
+ if ('source' in entry) delete entry.source;
157
+ }
158
+
159
+ return { newRegistry, summary };
160
+ }
161
+
162
+ // Real npm-registry probe. Returns true/false/null per the planner contract.
163
+ // Mirrors the fetch pattern used by npmViewVersionForStatus in bin/ldm.js.
164
+ export async function npmPackageExists(pkgName, opts = {}) {
165
+ const registryUrl = (opts.registryUrl || DEFAULT_NPM_REGISTRY).replace(/\/+$/, '');
166
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 2000;
167
+ const controller = new AbortController();
168
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
169
+ try {
170
+ const url = `${registryUrl}/${encodeURIComponent(pkgName)}`;
171
+ const response = await fetch(url, {
172
+ signal: controller.signal,
173
+ headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
174
+ });
175
+ if (response.status === 404) return false;
176
+ if (response.ok) return true;
177
+ return null;
178
+ } catch {
179
+ return null;
180
+ } finally {
181
+ clearTimeout(timeout);
182
+ }
183
+ }
184
+
185
+ // Doctor check: returns a list of registry entries that still carry a
186
+ // `source.npm` value pointing at a package that returns 404. These are
187
+ // candidates for migration on the next `ldm install`, but if the doctor
188
+ // runs first (Parker checks status / doctor before running install) we
189
+ // warn so it's not invisible.
190
+ //
191
+ // Returns: [{ name, npmName }] in lexical order.
192
+ export async function findLegacyNpm404Entries({
193
+ registry,
194
+ probeNpm,
195
+ }) {
196
+ if (!registry?.extensions) return [];
197
+ const targets = [];
198
+ for (const [name, entry] of Object.entries(registry.extensions)) {
199
+ if (entry.updateSource) continue;
200
+ const npmName = entry.source?.npm;
201
+ if (!npmName) continue;
202
+ targets.push({ name, npmName });
203
+ }
204
+ const results = await Promise.all(
205
+ targets.map(async ({ name, npmName }) => {
206
+ const exists = await probeNpm(npmName);
207
+ return { name, npmName, exists };
208
+ })
209
+ );
210
+ return results
211
+ .filter(r => r.exists === false)
212
+ .map(({ name, npmName }) => ({ name, npmName }))
213
+ .sort((a, b) => a.name.localeCompare(b.name));
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.85-alpha.27",
3
+ "version": "0.4.85-alpha.29",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -18,12 +18,14 @@
18
18
  "scripts": {
19
19
  "build:bridge": "cd src/bridge && npm install && npx tsup core.ts mcp-server.ts cli.ts openclaw.ts --format esm --dts --clean --outDir ../../dist/bridge",
20
20
  "build": "npm run build:bridge",
21
- "prepublishOnly": "npm run build:bridge && npm run validate:bin-manifest && npm run test:install-prompt-policy && npm run test:ldm-status-timeout && npm run test:ldm-status-concurrency",
21
+ "prepublishOnly": "npm run build:bridge && npm run validate:bin-manifest && npm run test:install-prompt-policy && npm run test:readme-install-prompt && npm run test:ldm-status-timeout && npm run test:ldm-status-concurrency && npm run test:legacy-npm-sources-migration",
22
22
  "validate:bin-manifest": "node scripts/validate-bin-manifest.mjs",
23
23
  "test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
24
24
  "test:install-prompt-policy": "node scripts/test-install-prompt-policy.mjs",
25
+ "test:readme-install-prompt": "node scripts/test-readme-install-prompt.mjs",
25
26
  "test:ldm-status-timeout": "node scripts/test-ldm-status-timeout.mjs",
26
27
  "test:ldm-status-concurrency": "node scripts/test-ldm-status-concurrency.mjs",
28
+ "test:legacy-npm-sources-migration": "node scripts/test-legacy-npm-sources-migration.mjs",
27
29
  "test:installer-update-tracks": "node scripts/test-installer-update-tracks.mjs",
28
30
  "test:installer-hook-toolname": "node scripts/test-installer-hook-toolname.mjs",
29
31
  "test:installer-target-self-update": "node scripts/test-installer-target-self-update.mjs",
@@ -24,8 +24,10 @@ for (const file of ["README.md", "shared/templates/install-prompt.md"]) {
24
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
25
  "If release notes are not available from local or npm metadata, say that and do not fetch them from GitHub.",
26
26
  "If installed: run `ldm status`",
27
- "If yes to dry run, run `ldm install --dry-run`.",
28
- "`npm install -g @wipcomputer/wip-ldm-os@latest && ldm install && ldm doctor`",
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",
29
31
  "Then run:\n`ldm init --dry-run`",
30
32
  "If I say install, run:\n`ldm init`",
31
33
  ]) {
@@ -45,6 +47,13 @@ for (const phrase of [
45
47
  "Read that document and run those commands. Do not pre-load other context.",
46
48
  "Do not run GitHub commands during the install-state flow.",
47
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`",
48
57
  "Use the output of `ldm status`, installed package metadata, and npm metadata.",
49
58
  "Do not use GitHub commands here.",
50
59
  "If npm metadata for a package does not include release notes:",
@@ -0,0 +1,299 @@
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 {
7
+ planLegacyNpmSourcesMigration,
8
+ summaryHasChanges,
9
+ emptyLegacyNpmSourcesSummary,
10
+ } from '../lib/registry-migrations.mjs';
11
+
12
+ function fail(msg) {
13
+ throw new Error(msg);
14
+ }
15
+
16
+ function assertEqual(actual, expected, label) {
17
+ if (actual !== expected) {
18
+ fail(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
19
+ }
20
+ }
21
+
22
+ function assertDeepEqual(actual, expected, label) {
23
+ const a = JSON.stringify(actual, Object.keys(actual || {}).sort());
24
+ const e = JSON.stringify(expected, Object.keys(expected || {}).sort());
25
+ if (a !== e) {
26
+ fail(`${label}: expected ${e}, got ${a}`);
27
+ }
28
+ }
29
+
30
+ // Mirrors the shape we see on Parker's machine in the 2026-05-13 dogfood.
31
+ function buildFixtureRegistry() {
32
+ return {
33
+ _format: 'v2',
34
+ extensions: {
35
+ // 404 npm + on-disk: should migrate to untracked.
36
+ 'cc-session-export': {
37
+ source: { type: 'github', npm: 'session-export', repo: 'wipcomputer/cc-session-export' },
38
+ installed: { version: '1.0.0' },
39
+ },
40
+ // Duplicate of cc-session-export: should be deduped (removed).
41
+ 'session-export': {
42
+ source: { type: 'github', npm: 'session-export' },
43
+ installed: { version: '1.0.0' },
44
+ },
45
+ 'compaction-indicator': {
46
+ source: { type: 'github', npm: 'compaction-indicator' },
47
+ installed: { version: '1.0.1' },
48
+ },
49
+ 'lesa-bridge': {
50
+ source: { type: 'github', npm: 'lesa-bridge' },
51
+ installed: { version: '0.3.0' },
52
+ },
53
+ // Duplicate of wip-branch-guard: should be deduped.
54
+ 'package': {
55
+ source: { type: 'github', npm: '@wipcomputer/wip-branch-guard' },
56
+ installed: { version: '1.0.0' },
57
+ },
58
+ 'wip-branch-guard': {
59
+ source: { type: 'github', npm: '@wipcomputer/wip-branch-guard' },
60
+ installed: { version: '1.0.0' },
61
+ },
62
+ // Real npm package: should be left alone.
63
+ 'memory-crystal': {
64
+ source: { type: 'github', npm: '@wipcomputer/memory-crystal' },
65
+ installed: { version: '2.0.0' },
66
+ },
67
+ // Phantom: no on-disk directory. Should be removed.
68
+ 'tavily': {
69
+ source: { type: 'github', npm: '@wipcomputer/openclaw-tavily' },
70
+ installed: { version: '0.1.0' },
71
+ },
72
+ // Mystery row: no source info at all but on-disk. Should be classified
73
+ // untracked (Step 4 path in the planner).
74
+ //
75
+ // Note: on Parker's real machine the `run` entry has no on-disk
76
+ // directory, so it hits Step 1 (phantom removal) instead. We exercise
77
+ // the Step 4 path here via this fixture; Step 1 is covered by the
78
+ // `tavily` fixture below.
79
+ 'run': {
80
+ installed: { version: 'unknown' },
81
+ },
82
+ // Already migrated: must be skipped (idempotency).
83
+ 'already-untracked': {
84
+ updateSource: { type: 'untracked' },
85
+ provenance: { 'legacy-npm-name': 'already-untracked', untrackedSince: '2026-05-13T00:00:00.000Z' },
86
+ installed: { version: '0.1.0' },
87
+ },
88
+ // Probe will fail (timeout). Should be left alone, listed in probeFailures.
89
+ 'flaky-network': {
90
+ source: { type: 'github', npm: 'flaky-network' },
91
+ installed: { version: '0.1.0' },
92
+ },
93
+ // Custom-path entry: declares `paths.ldm` pointing outside the default
94
+ // ~/.ldm/extensions/<name> location. The planner must NOT classify
95
+ // this as a phantom. Its source.npm is 404, so it should be migrated
96
+ // to untracked while the custom path is preserved.
97
+ 'custom-path-untracked': {
98
+ source: { type: 'github', npm: 'custom-path-untracked' },
99
+ paths: { ldm: '/custom/location/path' },
100
+ installed: { version: '1.0.0' },
101
+ },
102
+ // Legacy custom-path field (`ldmPath` flat). Same expectation.
103
+ 'legacy-custom-path': {
104
+ source: { type: 'github', npm: 'legacy-custom-path' },
105
+ ldmPath: '/legacy/custom/path',
106
+ installed: { version: '0.2.0' },
107
+ },
108
+ },
109
+ };
110
+ }
111
+
112
+ const NPM_404 = new Set([
113
+ 'session-export',
114
+ 'compaction-indicator',
115
+ 'lesa-bridge',
116
+ 'custom-path-untracked',
117
+ 'legacy-custom-path',
118
+ // @wipcomputer/wip-branch-guard simulated as 200 (exists). But the dedupe
119
+ // happens first, so `package` is gone before the probe runs.
120
+ // The remaining `wip-branch-guard` will see exists=true and be left alone.
121
+ ]);
122
+ const NPM_200 = new Set([
123
+ '@wipcomputer/memory-crystal',
124
+ '@wipcomputer/wip-branch-guard',
125
+ ]);
126
+ const NPM_FAIL = new Set([
127
+ 'flaky-network',
128
+ ]);
129
+
130
+ function fakeProbeNpm(name) {
131
+ if (NPM_FAIL.has(name)) return Promise.resolve(null);
132
+ if (NPM_200.has(name)) return Promise.resolve(true);
133
+ if (NPM_404.has(name)) return Promise.resolve(false);
134
+ // Fail loudly when the planner probes an npm name the test forgot to
135
+ // enumerate. Future planner changes that call probeNpm with new names
136
+ // must explicitly declare expected behavior here; silent 404 defaults
137
+ // would swallow regressions.
138
+ fail(`fakeProbeNpm called with un-enumerated name "${name}"`);
139
+ }
140
+
141
+ // On-disk extension simulator. `tavily` is a phantom (no directory at any
142
+ // location). All other names get a default directory unless they declare a
143
+ // custom path, in which case we honor the custom path. The planner must
144
+ // pass the entry to extensionExists for this to work.
145
+ const PHANTOM_NAMES = new Set(['tavily']);
146
+ const CUSTOM_PATH_EXISTS = new Set(['/custom/location/path', '/legacy/custom/path']);
147
+ function fakeExtensionExists(name, entry) {
148
+ if (entry?.paths?.ldm) return CUSTOM_PATH_EXISTS.has(entry.paths.ldm);
149
+ if (entry?.ldmPath) return CUSTOM_PATH_EXISTS.has(entry.ldmPath);
150
+ return !PHANTOM_NAMES.has(name);
151
+ }
152
+
153
+ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
154
+
155
+ // ── Test 1: full migration on fixture ──────────────────────────────────────
156
+ {
157
+ const input = buildFixtureRegistry();
158
+ const inputSnapshot = JSON.stringify(input);
159
+
160
+ const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
161
+ registry: input,
162
+ probeNpm: fakeProbeNpm,
163
+ extensionExists: fakeExtensionExists,
164
+ now: FIXED_NOW,
165
+ });
166
+
167
+ // Input must not be mutated.
168
+ assertEqual(JSON.stringify(input), inputSnapshot, 'input registry mutated');
169
+
170
+ // Phantoms removed.
171
+ assertEqual(summary.phantomsRemoved.length, 1, 'phantomsRemoved.length');
172
+ assertEqual(summary.phantomsRemoved[0].name, 'tavily', 'phantom name');
173
+ assertEqual(newRegistry.extensions.tavily, undefined, 'phantom still in registry');
174
+
175
+ // Duplicates removed.
176
+ assertEqual(summary.duplicatesRemoved.length, 2, 'duplicatesRemoved.length');
177
+ const removedDupes = summary.duplicatesRemoved.map(d => d.removed).sort();
178
+ assertDeepEqual(removedDupes, ['package', 'session-export'], 'dedupe targets');
179
+ assertEqual(newRegistry.extensions['session-export'], undefined, 'session-export still in registry');
180
+ assertEqual(newRegistry.extensions['package'], undefined, 'package still in registry');
181
+ if (!newRegistry.extensions['cc-session-export']) fail('canonical cc-session-export removed');
182
+ if (!newRegistry.extensions['wip-branch-guard']) fail('canonical wip-branch-guard removed');
183
+
184
+ // Migrated entries: cc-session-export, compaction-indicator, lesa-bridge,
185
+ // run, plus the two custom-path entries (custom-path-untracked,
186
+ // legacy-custom-path). `session-export` and `package` were deduped before
187
+ // probe. `wip-branch-guard` exists on npm. `flaky-network` probe failed.
188
+ const migratedNames = summary.migrated.map(m => m.name).sort();
189
+ assertDeepEqual(migratedNames, [
190
+ 'cc-session-export', 'compaction-indicator', 'custom-path-untracked',
191
+ 'legacy-custom-path', 'lesa-bridge', 'run',
192
+ ], 'migrated names');
193
+
194
+ // Custom-path entries must NOT be removed as phantoms. The planner must
195
+ // pass the entry to extensionExists so the custom path is honored.
196
+ // Regression guard for the round-3 Codex blocker (data-loss path on
197
+ // entries with entry.paths.ldm or entry.ldmPath).
198
+ const cpu = newRegistry.extensions['custom-path-untracked'];
199
+ if (!cpu) fail('custom-path-untracked was removed as phantom (extensionExists ignored entry.paths.ldm)');
200
+ assertEqual(cpu.updateSource?.type, 'untracked', 'custom-path-untracked.updateSource.type');
201
+ assertEqual(cpu.paths?.ldm, '/custom/location/path', 'custom-path-untracked.paths.ldm preserved');
202
+ const lcp = newRegistry.extensions['legacy-custom-path'];
203
+ if (!lcp) fail('legacy-custom-path was removed as phantom (extensionExists ignored entry.ldmPath)');
204
+ assertEqual(lcp.updateSource?.type, 'untracked', 'legacy-custom-path.updateSource.type');
205
+ assertEqual(lcp.ldmPath, '/legacy/custom/path', 'legacy-custom-path.ldmPath preserved');
206
+
207
+ // Each migrated entry has updateSource.type=untracked.
208
+ for (const name of migratedNames) {
209
+ const e = newRegistry.extensions[name];
210
+ if (!e) fail(`migrated entry ${name} missing from newRegistry`);
211
+ assertEqual(e.updateSource?.type, 'untracked', `${name}.updateSource.type`);
212
+ if ('source' in e) fail(`${name}.source should be deleted`);
213
+ if (!e.provenance) fail(`${name}.provenance missing`);
214
+ assertEqual(e.provenance.untrackedSince, '2026-05-13T18:00:00.000Z', `${name}.provenance.untrackedSince`);
215
+ }
216
+
217
+ // cc-session-export specifically: legacy-npm-name + repo preserved.
218
+ const ccse = newRegistry.extensions['cc-session-export'];
219
+ assertEqual(ccse.provenance['legacy-npm-name'], 'session-export', 'cc-session-export legacy-npm-name');
220
+ assertEqual(ccse.provenance.repo, 'wipcomputer/cc-session-export', 'cc-session-export legacy repo');
221
+
222
+ // `run` had no source info: legacy-npm-name absent, no repo, but still classified.
223
+ const runEntry = newRegistry.extensions.run;
224
+ if ('legacy-npm-name' in runEntry.provenance) fail('run.provenance should not have legacy-npm-name');
225
+ if ('repo' in runEntry.provenance) fail('run.provenance should not have repo');
226
+
227
+ // Real npm package left alone.
228
+ const mc = newRegistry.extensions['memory-crystal'];
229
+ if (mc.updateSource) fail('memory-crystal should not be migrated (real npm pkg)');
230
+ assertEqual(mc.source?.npm, '@wipcomputer/memory-crystal', 'memory-crystal source preserved');
231
+
232
+ // wip-branch-guard left alone (real npm pkg).
233
+ const wbg = newRegistry.extensions['wip-branch-guard'];
234
+ if (wbg.updateSource) fail('wip-branch-guard should not be migrated (real npm pkg)');
235
+
236
+ // Already-untracked entry is unchanged.
237
+ const au = newRegistry.extensions['already-untracked'];
238
+ assertEqual(au.provenance.untrackedSince, '2026-05-13T00:00:00.000Z', 'already-untracked untracked since preserved');
239
+
240
+ // Probe failures recorded, entry untouched.
241
+ assertEqual(summary.probeFailures.length, 1, 'probeFailures.length');
242
+ assertEqual(summary.probeFailures[0].name, 'flaky-network', 'probe failure name');
243
+ const fn = newRegistry.extensions['flaky-network'];
244
+ if (fn.updateSource) fail('flaky-network should not be migrated (probe failed)');
245
+ assertEqual(fn.source?.npm, 'flaky-network', 'flaky-network source preserved');
246
+
247
+ // summaryHasChanges flips true when anything happens.
248
+ assertEqual(summaryHasChanges(summary), true, 'summaryHasChanges on populated summary');
249
+ }
250
+
251
+ // ── Test 2: idempotency on a fully-migrated registry ───────────────────────
252
+ {
253
+ const registry = {
254
+ extensions: {
255
+ 'a': {
256
+ updateSource: { type: 'untracked' },
257
+ provenance: { untrackedSince: '2026-05-13T00:00:00.000Z' },
258
+ installed: { version: '1.0.0' },
259
+ },
260
+ 'b': {
261
+ updateSource: { type: 'untracked' },
262
+ provenance: { 'legacy-npm-name': 'b', untrackedSince: '2026-05-13T00:00:00.000Z' },
263
+ installed: { version: '0.1.0' },
264
+ },
265
+ },
266
+ };
267
+ const before = JSON.stringify(registry);
268
+ const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
269
+ registry,
270
+ probeNpm: () => fail('probeNpm should not be called on fully-migrated registry'),
271
+ extensionExists: () => true,
272
+ now: FIXED_NOW,
273
+ });
274
+ assertEqual(JSON.stringify(registry), before, 'input mutated on idempotent run');
275
+ assertEqual(summary.migrated.length, 0, 'migrated.length on idempotent run');
276
+ assertEqual(summary.phantomsRemoved.length, 0, 'phantomsRemoved.length on idempotent run');
277
+ assertEqual(summary.duplicatesRemoved.length, 0, 'duplicatesRemoved.length on idempotent run');
278
+ assertEqual(summaryHasChanges(summary), false, 'summaryHasChanges on empty summary');
279
+ assertDeepEqual(
280
+ Object.keys(newRegistry.extensions).sort(),
281
+ ['a', 'b'],
282
+ 'extensions preserved on idempotent run',
283
+ );
284
+ }
285
+
286
+ // ── Test 3: emptyLegacyNpmSourcesSummary returns the canonical shape ───────
287
+ {
288
+ const e = emptyLegacyNpmSourcesSummary();
289
+ assertDeepEqual(Object.keys(e).sort(), [
290
+ 'duplicatesRemoved',
291
+ 'migrated',
292
+ 'phantomsRemoved',
293
+ 'probeFailures',
294
+ 'probedCount',
295
+ 'timestamp',
296
+ ], 'empty summary keys');
297
+ }
298
+
299
+ 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");
@@ -12,14 +12,13 @@ Do not run GitHub commands during this install flow. Do not run or request appro
12
12
 
13
13
  Check if LDM OS is installed (`which ldm && ldm --version`). Branch.
14
14
 
15
- If installed: run `ldm status` and show me the version table and what's new for each component. Then ask:
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
16
  - Do you have questions?
17
17
  - Want to see a dry run?
18
18
 
19
- If yes to dry run, run `ldm install --dry-run`.
19
+ If yes to dry run, use the selected track's dry-run path from the install document.
20
20
 
21
- If I say install, run:
22
- `npm install -g @wipcomputer/wip-ldm-os@latest && ldm install && ldm doctor`
21
+ If I say install, use the selected track's install path from the install document, then run `ldm doctor`.
23
22
 
24
23
  If not, walk me through setup and explain:
25
24
 
@@ -32,8 +31,7 @@ Then ask:
32
31
  - Do you have questions?
33
32
  - Want to see a dry run?
34
33
 
35
- If yes to dry run, install the CLI first:
36
- `npm install -g @wipcomputer/wip-ldm-os`
34
+ If yes to dry run, install the CLI first using the selected track's bootstrap command from the install document.
37
35
 
38
36
  Then run:
39
37
  `ldm init --dry-run`