@wipcomputer/wip-ldm-os 0.4.85-alpha.26 → 0.4.85-alpha.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ldm.js
CHANGED
|
@@ -20,9 +20,9 @@
|
|
|
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
|
-
import {
|
|
25
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
|
|
28
28
|
const __filename = fileURLToPath(import.meta.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);
|
|
@@ -3285,21 +3403,27 @@ async function cmdDoctor() {
|
|
|
3285
3403
|
const STATUS_NPM_TIMEOUT_MS = parsePositiveInt(process.env.LDM_STATUS_NPM_TIMEOUT_MS, 5000);
|
|
3286
3404
|
const STATUS_TOTAL_BUDGET_MS = parsePositiveInt(process.env.LDM_STATUS_TOTAL_BUDGET_MS, 60000);
|
|
3287
3405
|
const STATUS_NPM_CONCURRENCY = parsePositiveInt(process.env.LDM_STATUS_NPM_CONCURRENCY, 8);
|
|
3406
|
+
const STATUS_NPM_REGISTRY_URL = process.env.LDM_STATUS_NPM_REGISTRY_URL || 'https://registry.npmjs.org';
|
|
3288
3407
|
|
|
3289
|
-
function npmViewVersionForStatus(pkg, timeoutMs) {
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
rejectPromise(error);
|
|
3298
|
-
return;
|
|
3299
|
-
}
|
|
3300
|
-
resolvePromise(String(stdout || '').trim());
|
|
3408
|
+
async function npmViewVersionForStatus(pkg, timeoutMs) {
|
|
3409
|
+
const controller = new AbortController();
|
|
3410
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
3411
|
+
try {
|
|
3412
|
+
const registry = STATUS_NPM_REGISTRY_URL.replace(/\/+$/, '');
|
|
3413
|
+
const response = await fetch(`${registry}/${encodeURIComponent(pkg)}`, {
|
|
3414
|
+
signal: controller.signal,
|
|
3415
|
+
headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
|
|
3301
3416
|
});
|
|
3302
|
-
|
|
3417
|
+
if (!response.ok) {
|
|
3418
|
+
const error = new Error(`npm registry returned ${response.status}`);
|
|
3419
|
+
error.statusCode = response.status;
|
|
3420
|
+
throw error;
|
|
3421
|
+
}
|
|
3422
|
+
const metadata = await response.json();
|
|
3423
|
+
return metadata?.['dist-tags']?.latest || '';
|
|
3424
|
+
} finally {
|
|
3425
|
+
clearTimeout(timeout);
|
|
3426
|
+
}
|
|
3303
3427
|
}
|
|
3304
3428
|
|
|
3305
3429
|
function remainingStatusBudgetMs(startedAt) {
|
|
@@ -3313,7 +3437,7 @@ function formatStatusElapsed(ms) {
|
|
|
3313
3437
|
}
|
|
3314
3438
|
|
|
3315
3439
|
function classifyStatusCheckError(error) {
|
|
3316
|
-
if (error?.signal === 'SIGTERM' || error?.code === 'ETIMEDOUT' || String(error?.message || '').includes('ETIMEDOUT')) {
|
|
3440
|
+
if (error?.name === 'AbortError' || error?.signal === 'SIGTERM' || error?.code === 'ETIMEDOUT' || String(error?.message || '').includes('ETIMEDOUT')) {
|
|
3317
3441
|
return 'timeout';
|
|
3318
3442
|
}
|
|
3319
3443
|
return 'unavailable';
|
|
@@ -3403,7 +3527,24 @@ async function cmdStatus() {
|
|
|
3403
3527
|
}];
|
|
3404
3528
|
|
|
3405
3529
|
const updates = [];
|
|
3530
|
+
const untrackedEntries = [];
|
|
3406
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
|
+
}
|
|
3407
3548
|
// Use registry source.npm (v2) or fall back to extension's package.json
|
|
3408
3549
|
let npmPkg = info?.source?.npm || null;
|
|
3409
3550
|
if (!npmPkg) {
|
|
@@ -3421,6 +3562,7 @@ async function cmdStatus() {
|
|
|
3421
3562
|
current: currentVersion,
|
|
3422
3563
|
});
|
|
3423
3564
|
}
|
|
3565
|
+
untrackedEntries.sort((a, b) => a.name.localeCompare(b.name));
|
|
3424
3566
|
|
|
3425
3567
|
let cliUpdate = null;
|
|
3426
3568
|
const probeResults = await runStatusProbesWithConcurrency(probeItems, STATUS_NPM_CONCURRENCY, statusStartedAt);
|
|
@@ -3449,12 +3591,13 @@ async function cmdStatus() {
|
|
|
3449
3591
|
}
|
|
3450
3592
|
|
|
3451
3593
|
console.log('');
|
|
3452
|
-
if (updates.length === 0 && !cliUpdate && skipped.length === 0) {
|
|
3594
|
+
if (updates.length === 0 && !cliUpdate && skipped.length === 0 && untrackedEntries.length === 0) {
|
|
3453
3595
|
console.log(' Update summary: all up to date');
|
|
3454
3596
|
} else {
|
|
3455
3597
|
const summaryParts = [];
|
|
3456
3598
|
if (updates.length > 0) summaryParts.push(`${updates.length} extension update(s) available`);
|
|
3457
3599
|
if (cliUpdate) summaryParts.push('CLI update available');
|
|
3600
|
+
if (untrackedEntries.length > 0) summaryParts.push(`${untrackedEntries.length} untracked`);
|
|
3458
3601
|
if (skipped.length > 0) summaryParts.push(`${skipped.length} update check(s) skipped`);
|
|
3459
3602
|
console.log(` Update summary: ${summaryParts.join(', ')}`);
|
|
3460
3603
|
}
|
|
@@ -3473,6 +3616,16 @@ async function cmdStatus() {
|
|
|
3473
3616
|
console.log(` CLI update: npm install -g @wipcomputer/wip-ldm-os@${cliUpdate}`);
|
|
3474
3617
|
}
|
|
3475
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
|
+
|
|
3476
3629
|
if (skipped.length > 0) {
|
|
3477
3630
|
console.log('');
|
|
3478
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.
|
|
3
|
+
"version": "0.4.85-alpha.28",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
|
|
6
6
|
"engines": {
|
|
@@ -18,12 +18,13 @@
|
|
|
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: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
25
|
"test:ldm-status-timeout": "node scripts/test-ldm-status-timeout.mjs",
|
|
26
26
|
"test:ldm-status-concurrency": "node scripts/test-ldm-status-concurrency.mjs",
|
|
27
|
+
"test:legacy-npm-sources-migration": "node scripts/test-legacy-npm-sources-migration.mjs",
|
|
27
28
|
"test:installer-update-tracks": "node scripts/test-installer-update-tracks.mjs",
|
|
28
29
|
"test:installer-hook-toolname": "node scripts/test-installer-hook-toolname.mjs",
|
|
29
30
|
"test:installer-target-self-update": "node scripts/test-installer-target-self-update.mjs",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import {
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
|
|
8
9
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -13,7 +14,28 @@ function assert(condition, message) {
|
|
|
13
14
|
if (!condition) throw new Error(message);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
function
|
|
17
|
+
function listen(server) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
server.listen(0, '127.0.0.1', () => resolve(server.address()));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createRegistryServer(delayMs) {
|
|
24
|
+
let active = 0;
|
|
25
|
+
let maxActive = 0;
|
|
26
|
+
const server = createServer((_req, res) => {
|
|
27
|
+
active += 1;
|
|
28
|
+
maxActive = Math.max(maxActive, active);
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
res.setHeader('content-type', 'application/json');
|
|
31
|
+
res.end(JSON.stringify({ 'dist-tags': { latest: '1.0.0' } }));
|
|
32
|
+
active -= 1;
|
|
33
|
+
}, delayMs);
|
|
34
|
+
});
|
|
35
|
+
return { server, getMaxActive: () => maxActive };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeFixture(home) {
|
|
17
39
|
const extensions = join(home, '.ldm', 'extensions');
|
|
18
40
|
mkdirSync(extensions, { recursive: true });
|
|
19
41
|
writeFileSync(join(home, '.ldm', 'version.json'), JSON.stringify({
|
|
@@ -30,55 +52,63 @@ function writeFixture(home, fakeBin) {
|
|
|
30
52
|
};
|
|
31
53
|
}
|
|
32
54
|
writeFileSync(join(extensions, 'registry.json'), JSON.stringify(registry, null, 2) + '\n');
|
|
33
|
-
|
|
34
|
-
mkdirSync(fakeBin, { recursive: true });
|
|
35
|
-
const fakeNpm = join(fakeBin, 'npm');
|
|
36
|
-
writeFileSync(fakeNpm, `#!/usr/bin/env bash
|
|
37
|
-
if [ "$1" = "view" ]; then
|
|
38
|
-
sleep "\${FAKE_NPM_SLEEP:-1}"
|
|
39
|
-
echo "1.0.0"
|
|
40
|
-
exit 0
|
|
41
|
-
fi
|
|
42
|
-
echo "unexpected npm command: $*" >&2
|
|
43
|
-
exit 64
|
|
44
|
-
`);
|
|
45
|
-
chmodSync(fakeNpm, 0o755);
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
function runStatus({ concurrency,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
function runStatus({ concurrency, registryUrl, home }) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const child = spawn(process.execPath, [join(root, 'bin', 'ldm.js'), 'status'], {
|
|
60
|
+
cwd: root,
|
|
61
|
+
env: {
|
|
62
|
+
...process.env,
|
|
63
|
+
HOME: home,
|
|
64
|
+
LDM_STATUS_NPM_REGISTRY_URL: registryUrl,
|
|
65
|
+
LDM_STATUS_NPM_CONCURRENCY: String(concurrency),
|
|
66
|
+
LDM_STATUS_NPM_TIMEOUT_MS: '2000',
|
|
67
|
+
LDM_STATUS_TOTAL_BUDGET_MS: '10000',
|
|
68
|
+
},
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let stdout = '';
|
|
73
|
+
let stderr = '';
|
|
74
|
+
child.stdout.setEncoding('utf8');
|
|
75
|
+
child.stderr.setEncoding('utf8');
|
|
76
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
77
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
78
|
+
child.on('close', status => resolve({ status, stdout, stderr }));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
52
81
|
|
|
82
|
+
async function runFixture({ concurrency, delayMs }) {
|
|
83
|
+
const home = join(tempRoot, `home-${concurrency}-${delayMs}`);
|
|
84
|
+
writeFixture(home);
|
|
85
|
+
const registry = createRegistryServer(delayMs);
|
|
86
|
+
const address = await listen(registry.server);
|
|
53
87
|
const startedAt = Date.now();
|
|
54
|
-
const result =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
env: {
|
|
59
|
-
...process.env,
|
|
60
|
-
HOME: home,
|
|
61
|
-
PATH: `${fakeBin}:${process.env.PATH || ''}`,
|
|
62
|
-
FAKE_NPM_SLEEP: String(sleepSeconds),
|
|
63
|
-
LDM_STATUS_NPM_CONCURRENCY: String(concurrency),
|
|
64
|
-
LDM_STATUS_NPM_TIMEOUT_MS: '2000',
|
|
65
|
-
LDM_STATUS_TOTAL_BUDGET_MS: '10000',
|
|
66
|
-
},
|
|
88
|
+
const result = await runStatus({
|
|
89
|
+
concurrency,
|
|
90
|
+
home,
|
|
91
|
+
registryUrl: `http://${address.address}:${address.port}`,
|
|
67
92
|
});
|
|
68
|
-
|
|
93
|
+
const elapsedMs = Date.now() - startedAt;
|
|
94
|
+
registry.server.closeAllConnections();
|
|
95
|
+
registry.server.close();
|
|
96
|
+
return { result, elapsedMs, maxActive: registry.getMaxActive() };
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
try {
|
|
72
|
-
const concurrent =
|
|
100
|
+
const concurrent = await runFixture({ concurrency: 4, delayMs: 500 });
|
|
73
101
|
assert(concurrent.result.status === 0, `concurrent ldm status exited ${concurrent.result.status}\nstdout:\n${concurrent.result.stdout}\nstderr:\n${concurrent.result.stderr}`);
|
|
74
|
-
assert(concurrent.elapsedMs <
|
|
102
|
+
assert(concurrent.elapsedMs < 3000, `concurrent ldm status should finish well before serial runtime; elapsed ${concurrent.elapsedMs}ms`);
|
|
103
|
+
assert(concurrent.maxActive >= 4, `registry server should see concurrent probes; max active ${concurrent.maxActive}`);
|
|
75
104
|
assert(concurrent.result.stdout.includes(`LDM OS v${sourceVersion}`), `status should print installed LDM OS version\n${concurrent.result.stdout}`);
|
|
76
105
|
assert(concurrent.result.stdout.includes('Extensions: 8'), `status should print extension count\n${concurrent.result.stdout}`);
|
|
77
106
|
assert(concurrent.result.stdout.includes('ext-8: checking npm'), `status should check every staged extension\n${concurrent.result.stdout}`);
|
|
78
107
|
assert(!concurrent.result.stdout.includes('Update checks skipped:'), `concurrent status should not skip checks in this fixture\n${concurrent.result.stdout}`);
|
|
79
108
|
|
|
80
|
-
const serialFallback =
|
|
109
|
+
const serialFallback = await runFixture({ concurrency: 1, delayMs: 10 });
|
|
81
110
|
assert(serialFallback.result.status === 0, `serial fallback ldm status exited ${serialFallback.result.status}\nstdout:\n${serialFallback.result.stdout}\nstderr:\n${serialFallback.result.stderr}`);
|
|
111
|
+
assert(serialFallback.maxActive === 1, `serial fallback should only run one probe at a time; max active ${serialFallback.maxActive}`);
|
|
82
112
|
assert(serialFallback.result.stdout.includes('ext-8: checking npm'), `serial fallback should still check every staged extension\n${serialFallback.result.stdout}`);
|
|
83
113
|
assert(!serialFallback.result.stdout.includes('Update checks skipped:'), `serial fallback should not skip checks in this fixture\n${serialFallback.result.stdout}`);
|
|
84
114
|
} finally {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import {
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
|
|
8
9
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -13,9 +14,38 @@ function assert(condition, message) {
|
|
|
13
14
|
if (!condition) throw new Error(message);
|
|
14
15
|
}
|
|
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
|
+
|
|
16
47
|
try {
|
|
17
48
|
const home = join(tempRoot, 'home');
|
|
18
|
-
const fakeBin = join(tempRoot, 'bin');
|
|
19
49
|
const extensions = join(home, '.ldm', 'extensions');
|
|
20
50
|
|
|
21
51
|
mkdirSync(extensions, { recursive: true });
|
|
@@ -37,33 +67,19 @@ try {
|
|
|
37
67
|
},
|
|
38
68
|
}, null, 2) + '\n');
|
|
39
69
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
fi
|
|
48
|
-
echo "unexpected npm command: $*" >&2
|
|
49
|
-
exit 64
|
|
50
|
-
`);
|
|
51
|
-
chmodSync(fakeNpm, 0o755);
|
|
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);
|
|
52
77
|
|
|
53
78
|
const startedAt = Date.now();
|
|
54
|
-
const result =
|
|
55
|
-
cwd: root,
|
|
56
|
-
encoding: 'utf8',
|
|
57
|
-
timeout: 3000,
|
|
58
|
-
env: {
|
|
59
|
-
...process.env,
|
|
60
|
-
HOME: home,
|
|
61
|
-
PATH: `${fakeBin}:${process.env.PATH || ''}`,
|
|
62
|
-
LDM_STATUS_NPM_TIMEOUT_MS: '75',
|
|
63
|
-
LDM_STATUS_TOTAL_BUDGET_MS: '250',
|
|
64
|
-
},
|
|
65
|
-
});
|
|
79
|
+
const result = await runStatus({ home, registryUrl: `http://${address.address}:${address.port}` });
|
|
66
80
|
const elapsedMs = Date.now() - startedAt;
|
|
81
|
+
server.closeAllConnections();
|
|
82
|
+
server.close();
|
|
67
83
|
|
|
68
84
|
assert(result.status === 0, `ldm status exited ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
|
69
85
|
assert(elapsedMs < 2500, `ldm status should return before the process timeout; elapsed ${elapsedMs}ms`);
|
|
@@ -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');
|