@wipcomputer/wip-ldm-os 0.4.85-alpha.9 → 0.4.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -2
- package/SKILL.md +137 -15
- package/bin/ldm.js +608 -75
- package/lib/deploy.mjs +36 -1
- package/lib/registry-migrations.mjs +296 -0
- package/package.json +20 -3
- package/scripts/test-boot-dir-truth.mjs +158 -0
- package/scripts/test-boot-hook-registration.mjs +136 -0
- package/scripts/test-boot-payload-trim.mjs +200 -0
- package/scripts/test-crc-agentid-tenant-boundary.mjs +2 -2
- package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
- package/scripts/test-crc-e2ee-session-route.mjs +9 -2
- package/scripts/test-crc-pair-login-flow.mjs +76 -1
- package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
- package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
- package/scripts/test-deploy-hook-ownership.mjs +160 -0
- package/scripts/test-doctor-hook-dedupe.mjs +188 -0
- package/scripts/test-install-prompt-policy.mjs +84 -0
- package/scripts/test-installer-target-self-update.mjs +131 -0
- package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
- package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
- package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
- package/scripts/test-ldm-status-concurrency.mjs +118 -0
- package/scripts/test-ldm-status-timeout.mjs +96 -0
- package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
- package/scripts/test-readme-install-prompt.mjs +66 -0
- package/shared/boot/boot-config.json +18 -8
- package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
- package/shared/rules/security.md +4 -0
- package/shared/templates/install-prompt.md +20 -2
- package/src/boot/README.md +24 -1
- package/src/boot/boot-config.json +18 -8
- package/src/boot/boot-hook.mjs +118 -28
- package/src/boot/installer.mjs +33 -10
- package/src/hosted-mcp/.env.example +4 -0
- package/src/hosted-mcp/README.md +37 -0
- package/src/hosted-mcp/app/footer.js +2 -2
- package/src/hosted-mcp/app/kaleidoscope-login.html +489 -42
- package/src/hosted-mcp/app/pair.html +21 -3
- package/src/hosted-mcp/app/wip-logo.png +0 -0
- package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
- package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
- package/src/hosted-mcp/demo/footer.js +2 -2
- package/src/hosted-mcp/demo/index.html +166 -44
- package/src/hosted-mcp/demo/login.html +87 -23
- package/src/hosted-mcp/demo/privacy.html +4 -214
- package/src/hosted-mcp/demo/tos.html +4 -189
- package/src/hosted-mcp/deploy.sh +2 -0
- package/src/hosted-mcp/docs/self-host.md +268 -0
- package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
- package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
- package/src/hosted-mcp/legal/legal-footer.js +75 -0
- package/src/hosted-mcp/legal/legal.css +166 -0
- package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
- package/src/hosted-mcp/legal/privacy/index.html +253 -0
- package/src/hosted-mcp/server.mjs +920 -74
package/bin/ldm.js
CHANGED
|
@@ -20,10 +20,11 @@
|
|
|
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 { execSync } from 'node:child_process';
|
|
25
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
27
28
|
|
|
28
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
29
30
|
const __dirname = dirname(__filename);
|
|
@@ -211,6 +212,11 @@ function writeJSON(path, data) {
|
|
|
211
212
|
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
function parsePositiveInt(value, fallback) {
|
|
216
|
+
const parsed = Number.parseInt(value || '', 10);
|
|
217
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
218
|
+
}
|
|
219
|
+
|
|
214
220
|
// ── CLI version check (#29) ──
|
|
215
221
|
|
|
216
222
|
function checkCliVersion() {
|
|
@@ -229,6 +235,62 @@ function checkCliVersion() {
|
|
|
229
235
|
}
|
|
230
236
|
}
|
|
231
237
|
|
|
238
|
+
function selectedLdmNpmTrack() {
|
|
239
|
+
return ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function selectedLdmTrackLabel(npmTag) {
|
|
243
|
+
return npmTag === 'latest' ? '' : ` (${npmTag} track)`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function latestLdmCliForSelectedTrack() {
|
|
247
|
+
const npmTag = selectedLdmNpmTrack();
|
|
248
|
+
const npmViewCmd = npmTag === 'latest'
|
|
249
|
+
? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
|
|
250
|
+
: `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
|
|
251
|
+
const latest = execSync(npmViewCmd, {
|
|
252
|
+
encoding: 'utf8',
|
|
253
|
+
timeout: 15000,
|
|
254
|
+
}).trim();
|
|
255
|
+
return { latest, npmTag };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function maybeSelfUpdateLdmCliBeforeInstall() {
|
|
259
|
+
// This shared preflight covers both bare `ldm install` and targeted
|
|
260
|
+
// `ldm install <app>`. Dry runs never update, but still disclose skew.
|
|
261
|
+
if (process.env.LDM_SELF_UPDATED) return;
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const { latest, npmTag } = latestLdmCliForSelectedTrack();
|
|
265
|
+
if (!latest || !semverNewer(latest, PKG_VERSION)) return;
|
|
266
|
+
|
|
267
|
+
const trackLabel = selectedLdmTrackLabel(npmTag);
|
|
268
|
+
|
|
269
|
+
if (DRY_RUN) {
|
|
270
|
+
console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel} is available.`);
|
|
271
|
+
console.log(` Dry run only: continuing with v${PKG_VERSION}.`);
|
|
272
|
+
console.log('');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
|
|
277
|
+
try {
|
|
278
|
+
execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
|
|
279
|
+
console.log(` CLI updated to v${latest}. Re-running with new code...`);
|
|
280
|
+
console.log('');
|
|
281
|
+
const reArgs = process.argv.slice(2);
|
|
282
|
+
const child = spawnSync('ldm', reArgs.length > 0 ? reArgs : ['install'], {
|
|
283
|
+
stdio: 'inherit',
|
|
284
|
+
env: { ...process.env, LDM_SELF_UPDATED: '1' },
|
|
285
|
+
});
|
|
286
|
+
if (child.error) throw child.error;
|
|
287
|
+
process.exit(child.status ?? 1);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
console.log(` ! Self-update failed: ${e.message}. Continuing with v${PKG_VERSION}.`);
|
|
290
|
+
}
|
|
291
|
+
} catch {}
|
|
292
|
+
}
|
|
293
|
+
|
|
232
294
|
// ── Dead backup trigger cleanup (#207) ──
|
|
233
295
|
// Three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
|
|
234
296
|
// This removes: broken cron entry (LDMDevTools.app), old com.wipcomputer.daily-backup.
|
|
@@ -348,9 +410,39 @@ function cleanStaleHooks() {
|
|
|
348
410
|
|
|
349
411
|
// ── Boot hook sync (#49) ──
|
|
350
412
|
|
|
413
|
+
// The registered SessionStart hook command is the single source of truth for
|
|
414
|
+
// WHERE the boot hook actually executes from. Historically syncBootHook()
|
|
415
|
+
// deployed to ~/.ldm/library/boot while the registered command pointed at
|
|
416
|
+
// ~/.ldm/shared/boot (installer.mjs BOOT_DIR): new code landed in library/,
|
|
417
|
+
// sessions ran the stale copy in shared/, and the install reported success.
|
|
418
|
+
// Resolve the deploy target from the registered entry so the two can never
|
|
419
|
+
// diverge again. Fallback is shared/boot, matching installer.mjs BOOT_DIR and
|
|
420
|
+
// configureSessionStartHook() so a fresh machine stays consistent.
|
|
421
|
+
// Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
|
|
422
|
+
function getRegisteredBootHookTarget() {
|
|
423
|
+
const settings = readJSON(join(HOME, '.claude', 'settings.json'));
|
|
424
|
+
const entries = settings?.hooks?.SessionStart;
|
|
425
|
+
if (!Array.isArray(entries)) return null;
|
|
426
|
+
for (const entry of entries) {
|
|
427
|
+
for (const h of (entry?.hooks || [])) {
|
|
428
|
+
const cmd = h?.command || '';
|
|
429
|
+
const m = cmd.match(/(\S*boot-hook\.mjs)/);
|
|
430
|
+
if (m) return m[1].replace(/^["']|["']$/g, '');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function resolveBootExecDir() {
|
|
437
|
+
const target = getRegisteredBootHookTarget();
|
|
438
|
+
if (target) return dirname(target);
|
|
439
|
+
// Canonical default: same as src/boot/installer.mjs BOOT_DIR.
|
|
440
|
+
return join(LDM_ROOT, 'shared', 'boot');
|
|
441
|
+
}
|
|
442
|
+
|
|
351
443
|
function syncBootHook() {
|
|
352
444
|
const srcBoot = join(__dirname, '..', 'src', 'boot', 'boot-hook.mjs');
|
|
353
|
-
const destBoot = join(
|
|
445
|
+
const destBoot = join(resolveBootExecDir(), 'boot-hook.mjs');
|
|
354
446
|
|
|
355
447
|
if (!existsSync(srcBoot)) return false;
|
|
356
448
|
|
|
@@ -1440,23 +1532,6 @@ async function showCatalogPicker() {
|
|
|
1440
1532
|
// ── ldm install ──
|
|
1441
1533
|
|
|
1442
1534
|
async function cmdInstall() {
|
|
1443
|
-
if (!DRY_RUN && !acquireInstallLock()) return;
|
|
1444
|
-
|
|
1445
|
-
// Ensure LDM is initialized
|
|
1446
|
-
if (!existsSync(VERSION_PATH)) {
|
|
1447
|
-
console.log(' LDM OS not initialized. Running init first...');
|
|
1448
|
-
console.log('');
|
|
1449
|
-
cmdInit();
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
const { setFlags, installFromPath, installSingleTool, installToolbox, detectHarnesses } = await import('../lib/deploy.mjs');
|
|
1453
|
-
const { detectInterfacesJSON } = await import('../lib/detect.mjs');
|
|
1454
|
-
|
|
1455
|
-
// Refresh harness detection (catches newly installed harnesses)
|
|
1456
|
-
detectHarnesses();
|
|
1457
|
-
|
|
1458
|
-
setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'manual' });
|
|
1459
|
-
|
|
1460
1535
|
// --help flag (#81)
|
|
1461
1536
|
if (args.includes('--help') || args.includes('-h')) {
|
|
1462
1537
|
console.log(`
|
|
@@ -1476,6 +1551,25 @@ async function cmdInstall() {
|
|
|
1476
1551
|
process.exit(0);
|
|
1477
1552
|
}
|
|
1478
1553
|
|
|
1554
|
+
maybeSelfUpdateLdmCliBeforeInstall();
|
|
1555
|
+
|
|
1556
|
+
if (!DRY_RUN && !acquireInstallLock()) return;
|
|
1557
|
+
|
|
1558
|
+
// Ensure LDM is initialized
|
|
1559
|
+
if (!existsSync(VERSION_PATH)) {
|
|
1560
|
+
console.log(' LDM OS not initialized. Running init first...');
|
|
1561
|
+
console.log('');
|
|
1562
|
+
cmdInit();
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const { setFlags, installFromPath, installSingleTool, installToolbox, detectHarnesses } = await import('../lib/deploy.mjs');
|
|
1566
|
+
const { detectInterfacesJSON } = await import('../lib/detect.mjs');
|
|
1567
|
+
|
|
1568
|
+
// Refresh harness detection (catches newly installed harnesses)
|
|
1569
|
+
detectHarnesses();
|
|
1570
|
+
|
|
1571
|
+
setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'manual' });
|
|
1572
|
+
|
|
1479
1573
|
// Find the target (skip flags)
|
|
1480
1574
|
const target = args.slice(1).find(a => !a.startsWith('--'));
|
|
1481
1575
|
|
|
@@ -1736,6 +1830,135 @@ function migrateRegistry() {
|
|
|
1736
1830
|
return migrated;
|
|
1737
1831
|
}
|
|
1738
1832
|
|
|
1833
|
+
// ── Legacy source.npm honest cleanup (Phase 1 of source-types refactor) ──
|
|
1834
|
+
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
1835
|
+
|
|
1836
|
+
async function migrateLegacyNpmSources({ dryRun = false } = {}) {
|
|
1837
|
+
const {
|
|
1838
|
+
planLegacyNpmSourcesMigration,
|
|
1839
|
+
summaryHasChanges,
|
|
1840
|
+
npmPackageExists,
|
|
1841
|
+
executeDirectoryMoves,
|
|
1842
|
+
} = await import('../lib/registry-migrations.mjs');
|
|
1843
|
+
|
|
1844
|
+
const registry = readJSON(REGISTRY_PATH);
|
|
1845
|
+
if (!registry?.extensions) return null;
|
|
1846
|
+
|
|
1847
|
+
const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
|
|
1848
|
+
registry,
|
|
1849
|
+
probeNpm: (name) => npmPackageExists(name, { timeoutMs: 2000 }),
|
|
1850
|
+
// Resolve the entry's on-disk location before declaring it a phantom.
|
|
1851
|
+
// Entries autoregistered with `ldmPath` (bin/ldm.js:1822 path) or with
|
|
1852
|
+
// a `paths.ldm` field (lib/deploy.mjs path) can live outside the
|
|
1853
|
+
// default ~/.ldm/extensions/<name> location. A naive check would
|
|
1854
|
+
// silently delete a working entry as "phantom."
|
|
1855
|
+
extensionExists: (name, entry) => {
|
|
1856
|
+
const customPath = entry?.paths?.ldm || entry?.ldmPath;
|
|
1857
|
+
if (customPath && existsSync(customPath)) return true;
|
|
1858
|
+
return existsSync(join(LDM_EXTENSIONS, name));
|
|
1859
|
+
},
|
|
1860
|
+
now: () => new Date(),
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
if (!summaryHasChanges(summary)) return summary;
|
|
1864
|
+
|
|
1865
|
+
if (!dryRun) {
|
|
1866
|
+
const stamp = summary.timestamp.replace(/[:.]/g, '-');
|
|
1867
|
+
const backupPath = `${REGISTRY_PATH}.bak-${stamp}`;
|
|
1868
|
+
copyFileSync(REGISTRY_PATH, backupPath);
|
|
1869
|
+
summary.backupPath = backupPath;
|
|
1870
|
+
writeJSON(REGISTRY_PATH, newRegistry);
|
|
1871
|
+
|
|
1872
|
+
// Execute directory moves AFTER the registry write. Each move sends a
|
|
1873
|
+
// deduplicated extension's on-disk directory to ~/.ldm/_trash/<name>-
|
|
1874
|
+
// deduplicated-<timestamp> so autoDetectExtensions (which only scans
|
|
1875
|
+
// ~/.ldm/extensions/) cannot find it on the next install pass.
|
|
1876
|
+
// Without this, the registry dedup reverts within the same install run.
|
|
1877
|
+
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md
|
|
1878
|
+
const { performed, skipped } = executeDirectoryMoves({
|
|
1879
|
+
directoryMoves: summary.directoryMoves,
|
|
1880
|
+
extensionsRoot: LDM_EXTENSIONS,
|
|
1881
|
+
trashRoot: join(LDM_ROOT, '_trash'),
|
|
1882
|
+
});
|
|
1883
|
+
summary.directoryMovesPerformed.push(...performed);
|
|
1884
|
+
summary.directoryMovesSkipped.push(...skipped);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
return summary;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function printLegacyNpmSourcesSummary(summary, { dryRun = false } = {}) {
|
|
1891
|
+
if (!summary) return;
|
|
1892
|
+
const writeableChanges =
|
|
1893
|
+
summary.migrated.length +
|
|
1894
|
+
summary.phantomsRemoved.length +
|
|
1895
|
+
summary.duplicatesRemoved.length;
|
|
1896
|
+
const probeFailureCount = summary.probeFailures?.length || 0;
|
|
1897
|
+
// Always surface probe failures even when nothing was writeable. If every
|
|
1898
|
+
// probe times out, the install would otherwise look like a no-op and the
|
|
1899
|
+
// [unavailable] rows in `ldm status` would survive silently.
|
|
1900
|
+
if (writeableChanges === 0 && probeFailureCount === 0) return;
|
|
1901
|
+
|
|
1902
|
+
console.log('');
|
|
1903
|
+
console.log(dryRun
|
|
1904
|
+
? ' Registry source.npm cleanup (dry run, no changes written):'
|
|
1905
|
+
: ' Registry source.npm cleanup:');
|
|
1906
|
+
|
|
1907
|
+
if (summary.migrated.length > 0) {
|
|
1908
|
+
console.log(` + ${dryRun ? 'Would migrate' : 'Migrated'} ${summary.migrated.length} entr${summary.migrated.length === 1 ? 'y' : 'ies'} to updateSource.type=untracked`);
|
|
1909
|
+
for (const m of summary.migrated) {
|
|
1910
|
+
const detail = m.legacyNpmName
|
|
1911
|
+
? ` (legacy source.npm "${m.legacyNpmName}" preserved in provenance)`
|
|
1912
|
+
: ' (no source info; classified as untracked)';
|
|
1913
|
+
console.log(` ${m.name}${detail}`);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
if (summary.phantomsRemoved.length > 0) {
|
|
1917
|
+
console.log(` + ${dryRun ? 'Would remove' : 'Removed'} ${summary.phantomsRemoved.length} phantom entr${summary.phantomsRemoved.length === 1 ? 'y' : 'ies'}:`);
|
|
1918
|
+
for (const p of summary.phantomsRemoved) {
|
|
1919
|
+
console.log(` ${p.name} (${p.reason})`);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
if (summary.duplicatesRemoved.length > 0) {
|
|
1923
|
+
console.log(` + ${dryRun ? 'Would remove' : 'Removed'} ${summary.duplicatesRemoved.length} duplicate entr${summary.duplicatesRemoved.length === 1 ? 'y' : 'ies'}:`);
|
|
1924
|
+
for (const d of summary.duplicatesRemoved) {
|
|
1925
|
+
console.log(` ${d.removed} (canonical: ${d.keep})`);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
if (summary.directoryMoves && summary.directoryMoves.length > 0) {
|
|
1929
|
+
if (dryRun) {
|
|
1930
|
+
console.log(` + Would move ${summary.directoryMoves.length} duplicate director${summary.directoryMoves.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (so autoDetectExtensions cannot re-register):`);
|
|
1931
|
+
for (const m of summary.directoryMoves) {
|
|
1932
|
+
console.log(` ~/.ldm/extensions/${m.name} -> ~/.ldm/_trash/${m.trashName}`);
|
|
1933
|
+
}
|
|
1934
|
+
} else {
|
|
1935
|
+
const performed = summary.directoryMovesPerformed || [];
|
|
1936
|
+
const skipped = summary.directoryMovesSkipped || [];
|
|
1937
|
+
if (performed.length > 0) {
|
|
1938
|
+
console.log(` + Moved ${performed.length} duplicate director${performed.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (autoDetectExtensions cannot re-register):`);
|
|
1939
|
+
for (const m of performed) {
|
|
1940
|
+
console.log(` ${m.name} -> ${m.destPath.replace(HOME, '~')}`);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
if (skipped.length > 0) {
|
|
1944
|
+
console.log(` ! ${skipped.length} duplicate director${skipped.length === 1 ? 'y' : 'ies'} could not be moved (registry entries are removed; autoDetect may re-add on next install):`);
|
|
1945
|
+
for (const m of skipped) {
|
|
1946
|
+
console.log(` ${m.name} (${m.reason})`);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
if (summary.probeFailures && summary.probeFailures.length > 0) {
|
|
1952
|
+
console.log(` ! ${summary.probeFailures.length} npm probe${summary.probeFailures.length === 1 ? '' : 's'} could not complete (will retry on next install):`);
|
|
1953
|
+
for (const f of summary.probeFailures) {
|
|
1954
|
+
console.log(` ${f.name} (source.npm "${f.npmName}")`);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
if (!dryRun && summary.backupPath) {
|
|
1958
|
+
console.log(` + Registry backup: ${summary.backupPath.replace(HOME, '~')}`);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1739
1962
|
// ── Auto-detect unregistered extensions ──
|
|
1740
1963
|
|
|
1741
1964
|
function autoDetectExtensions() {
|
|
@@ -1921,38 +2144,6 @@ async function cmdInstallCatalog() {
|
|
|
1921
2144
|
// No lock here. cmdInstall() already holds it when calling this.
|
|
1922
2145
|
installLog(`ldm install started (v${PKG_VERSION}, DRY_RUN=${DRY_RUN})`);
|
|
1923
2146
|
|
|
1924
|
-
// Self-update: check if CLI itself is outdated. Update first, then re-exec.
|
|
1925
|
-
// This breaks the chicken-and-egg: new features in ldm install are always
|
|
1926
|
-
// available because the installer upgrades itself before doing anything else.
|
|
1927
|
-
// --alpha and --beta flags check the corresponding npm dist-tag instead of @latest.
|
|
1928
|
-
if (!DRY_RUN && !process.env.LDM_SELF_UPDATED) {
|
|
1929
|
-
try {
|
|
1930
|
-
const npmTag = ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
|
|
1931
|
-
const trackLabel = npmTag === 'latest' ? '' : ` (${npmTag} track)`;
|
|
1932
|
-
const npmViewCmd = npmTag === 'latest'
|
|
1933
|
-
? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
|
|
1934
|
-
: `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
|
|
1935
|
-
const latest = execSync(npmViewCmd, {
|
|
1936
|
-
encoding: 'utf8', timeout: 15000,
|
|
1937
|
-
}).trim();
|
|
1938
|
-
if (latest && semverNewer(latest, PKG_VERSION)) {
|
|
1939
|
-
console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
|
|
1940
|
-
try {
|
|
1941
|
-
execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
|
|
1942
|
-
console.log(` CLI updated to v${latest}. Re-running with new code...`);
|
|
1943
|
-
console.log('');
|
|
1944
|
-
// Re-exec with the new binary. LDM_SELF_UPDATED prevents infinite loop.
|
|
1945
|
-
// process.argv.slice(2) skips 'node' and the script path, keeps just 'install' + flags
|
|
1946
|
-
const reArgs = process.argv.slice(2).join(' ') || 'install';
|
|
1947
|
-
execSync(`LDM_SELF_UPDATED=1 ldm ${reArgs}`, { stdio: 'inherit' });
|
|
1948
|
-
process.exit(0);
|
|
1949
|
-
} catch (e) {
|
|
1950
|
-
console.log(` ! Self-update failed: ${e.message}. Continuing with v${PKG_VERSION}.`);
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
} catch {}
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
2147
|
autoDetectExtensions();
|
|
1957
2148
|
|
|
1958
2149
|
// Migrate old registry entries to v2 format (#262)
|
|
@@ -1961,6 +2152,12 @@ async function cmdInstallCatalog() {
|
|
|
1961
2152
|
console.log(` + Migrated ${migrated} registry entries to v2 format (source info added)`);
|
|
1962
2153
|
}
|
|
1963
2154
|
|
|
2155
|
+
// Phase 1 of source-types refactor: clear bad source.npm entries, dedupe,
|
|
2156
|
+
// and classify mystery rows as untracked so `ldm status` stops lying.
|
|
2157
|
+
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
2158
|
+
const npmCleanupSummary = await migrateLegacyNpmSources({ dryRun: DRY_RUN });
|
|
2159
|
+
printLegacyNpmSourcesSummary(npmCleanupSummary, { dryRun: DRY_RUN });
|
|
2160
|
+
|
|
1964
2161
|
// Aggregate the bin ownership manifest BEFORE seedLocalCatalog,
|
|
1965
2162
|
// deployBridge, deployScripts, and the heal walk run. If two
|
|
1966
2163
|
// declarers claim the same file in ~/.ldm/bin/ we cannot safely
|
|
@@ -2891,6 +3088,27 @@ async function cmdDoctor() {
|
|
|
2891
3088
|
}
|
|
2892
3089
|
}
|
|
2893
3090
|
|
|
3091
|
+
// Warn on registry entries whose legacy source.npm value 404s on npm.
|
|
3092
|
+
// `ldm install` migrates these to updateSource.type=untracked; this catches
|
|
3093
|
+
// future drift and any entries the install-time probe couldn't reach.
|
|
3094
|
+
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
3095
|
+
try {
|
|
3096
|
+
const { findLegacyNpm404Entries, npmPackageExists } = await import('../lib/registry-migrations.mjs');
|
|
3097
|
+
const docRegistry = readJSON(REGISTRY_PATH);
|
|
3098
|
+
const legacy404 = await findLegacyNpm404Entries({
|
|
3099
|
+
registry: docRegistry,
|
|
3100
|
+
probeNpm: (name) => npmPackageExists(name, { timeoutMs: 1500 }),
|
|
3101
|
+
});
|
|
3102
|
+
if (legacy404.length > 0) {
|
|
3103
|
+
console.log('');
|
|
3104
|
+
console.log(` ! ${legacy404.length} extension(s) declare an npm source whose package does not exist on npm:`);
|
|
3105
|
+
for (const { name, npmName } of legacy404) {
|
|
3106
|
+
console.log(` ${name} (source.npm "${npmName}")`);
|
|
3107
|
+
}
|
|
3108
|
+
console.log(' Run `ldm install` to migrate these to updateSource.type=untracked.');
|
|
3109
|
+
}
|
|
3110
|
+
} catch {}
|
|
3111
|
+
|
|
2894
3112
|
// --fix: clean up registered-missing entries
|
|
2895
3113
|
if (FIX_FLAG && registeredMissing.length > 0) {
|
|
2896
3114
|
const registry = readJSON(REGISTRY_PATH);
|
|
@@ -2912,6 +3130,161 @@ async function cmdDoctor() {
|
|
|
2912
3130
|
}
|
|
2913
3131
|
}
|
|
2914
3132
|
|
|
3133
|
+
// Duplicate hook entries + invalid model value in ~/.claude/settings.json.
|
|
3134
|
+
// Duplicates: the same hook (event + matcher + commands) registered more
|
|
3135
|
+
// than once runs once per copy every session. 10 duplicate SessionStart
|
|
3136
|
+
// boot-hook entries observed 2026-07-04 (~450KB of boot context per
|
|
3137
|
+
// session start). Invalid model: a paste carrying ANSI escape codes can
|
|
3138
|
+
// land in /model and get persisted (e.g. "opus" + ESC + "[1m"); every new
|
|
3139
|
+
// session then fails its first API call. The corruption signal is a
|
|
3140
|
+
// CONTROL character (ESC 0x1B etc.), never the visible charset: bracketed
|
|
3141
|
+
// IDs like "claude-fable-5[1m]" are legitimate 1M-context variants and
|
|
3142
|
+
// must be left untouched.
|
|
3143
|
+
// Reported always; collapsed/removed under --fix with a timestamped
|
|
3144
|
+
// backup written before the first mutation.
|
|
3145
|
+
// See ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--installer-sessionstart-hook-duplicate-registration.md
|
|
3146
|
+
// and ai/product/bugs/guard/2026-07-04--cc-mini--no-blessed-recipe-for-live-settings-remediation.md
|
|
3147
|
+
{
|
|
3148
|
+
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
3149
|
+
const settings = readJSON(settingsPath);
|
|
3150
|
+
if (!settings && existsSync(settingsPath)) {
|
|
3151
|
+
console.log(' ! ~/.claude/settings.json exists but is not valid JSON; skipping hook/model checks');
|
|
3152
|
+
issues++;
|
|
3153
|
+
}
|
|
3154
|
+
let settingsDirty = false;
|
|
3155
|
+
let backupDone = false;
|
|
3156
|
+
const backupSettingsOnce = () => {
|
|
3157
|
+
if (backupDone) return;
|
|
3158
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3159
|
+
const bak = `${settingsPath}.bak-${stamp}`;
|
|
3160
|
+
copyFileSync(settingsPath, bak);
|
|
3161
|
+
console.log(` + Backup written: ${bak}`);
|
|
3162
|
+
backupDone = true;
|
|
3163
|
+
};
|
|
3164
|
+
|
|
3165
|
+
// Duplicate hook entries (same event + matcher + hook command list).
|
|
3166
|
+
// Deliberately ignores type/timeout: entries differing only in timeout
|
|
3167
|
+
// are treated as duplicates and collapse to the first.
|
|
3168
|
+
if (settings?.hooks) {
|
|
3169
|
+
const dupes = [];
|
|
3170
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
3171
|
+
if (!Array.isArray(groups)) continue;
|
|
3172
|
+
const seen = new Map();
|
|
3173
|
+
groups.forEach((g, i) => {
|
|
3174
|
+
const key = JSON.stringify({
|
|
3175
|
+
matcher: g?.matcher,
|
|
3176
|
+
hooks: (g?.hooks || []).map((h) => h?.command),
|
|
3177
|
+
});
|
|
3178
|
+
if (!seen.has(key)) seen.set(key, []);
|
|
3179
|
+
seen.get(key).push(i);
|
|
3180
|
+
});
|
|
3181
|
+
for (const idxs of seen.values()) {
|
|
3182
|
+
if (idxs.length > 1) dupes.push({ event, idxs });
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
const extra = dupes.reduce((n, d) => n + d.idxs.length - 1, 0);
|
|
3186
|
+
if (extra > 0) {
|
|
3187
|
+
for (const d of dupes) {
|
|
3188
|
+
const cmd = settings.hooks[d.event][d.idxs[0]]?.hooks?.[0]?.command || '(no command)';
|
|
3189
|
+
console.log(` ! ${d.event}: ${d.idxs.length} identical hook entries for: ${cmd}`);
|
|
3190
|
+
}
|
|
3191
|
+
if (FIX_FLAG) {
|
|
3192
|
+
backupSettingsOnce();
|
|
3193
|
+
for (const d of dupes) {
|
|
3194
|
+
// Keep the first, drop the rest. Right-to-left so indices stay valid.
|
|
3195
|
+
for (let j = d.idxs.length - 1; j >= 1; j--) {
|
|
3196
|
+
settings.hooks[d.event].splice(d.idxs[j], 1);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
settingsDirty = true;
|
|
3200
|
+
console.log(` + Collapsed ${extra} duplicate hook entr${extra === 1 ? 'y' : 'ies'}`);
|
|
3201
|
+
} else {
|
|
3202
|
+
console.log(` Run: ldm doctor --fix to collapse ${extra} duplicate hook entr${extra === 1 ? 'y' : 'ies'}`);
|
|
3203
|
+
issues += extra;
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// Invalid model value. Corruption means CONTROL characters (ESC 0x1B
|
|
3209
|
+
// from ANSI fragments, NUL, DEL...) or an impossible length, never a
|
|
3210
|
+
// visible-charset judgment: "claude-fable-5[1m]" is a legitimate
|
|
3211
|
+
// 1M-context model ID and must pass.
|
|
3212
|
+
if (settings && typeof settings.model === 'string') {
|
|
3213
|
+
const valid =
|
|
3214
|
+
settings.model.length > 0 &&
|
|
3215
|
+
settings.model.length <= 256 &&
|
|
3216
|
+
!/[\x00-\x1f\x7f]/.test(settings.model);
|
|
3217
|
+
if (!valid) {
|
|
3218
|
+
console.log(` ! settings.json model value is invalid: ${JSON.stringify(settings.model)}`);
|
|
3219
|
+
if (FIX_FLAG) {
|
|
3220
|
+
backupSettingsOnce();
|
|
3221
|
+
delete settings.model;
|
|
3222
|
+
settingsDirty = true;
|
|
3223
|
+
console.log(' + Removed invalid model value (re-pick with /model in Claude Code)');
|
|
3224
|
+
} else {
|
|
3225
|
+
console.log(' Run: ldm doctor --fix to remove it, then re-pick with /model in Claude Code');
|
|
3226
|
+
issues++;
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
if (settingsDirty) {
|
|
3232
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// Boot hook stale at execution path.
|
|
3237
|
+
//
|
|
3238
|
+
// Split-brain deploys are silent: version tracking can say the boot hook
|
|
3239
|
+
// updated while the SessionStart hook still executes an older copy on disk
|
|
3240
|
+
// (2026-07-05: install reported alpha.32's boot trim active, but the live
|
|
3241
|
+
// payload was the untrimmed 49KB because syncBootHook wrote library/boot
|
|
3242
|
+
// while the hook ran shared/boot). Hash-compare the file the registered
|
|
3243
|
+
// hook actually runs against this CLI's src/boot/boot-hook.mjs (the code the
|
|
3244
|
+
// installer would deploy). Divergence means the hook is running stale code.
|
|
3245
|
+
// Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
|
|
3246
|
+
{
|
|
3247
|
+
const execTarget = getRegisteredBootHookTarget();
|
|
3248
|
+
const srcBoot = join(__dirname, '..', 'src', 'boot', 'boot-hook.mjs');
|
|
3249
|
+
const hashFile = (p) => {
|
|
3250
|
+
try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
|
|
3251
|
+
};
|
|
3252
|
+
if (execTarget && existsSync(srcBoot)) {
|
|
3253
|
+
const srcHash = hashFile(srcBoot);
|
|
3254
|
+
const execExists = existsSync(execTarget);
|
|
3255
|
+
const execHash = execExists ? hashFile(execTarget) : null;
|
|
3256
|
+
// Informational: which of the known deploy locations already matches src.
|
|
3257
|
+
const freshCandidates = [
|
|
3258
|
+
join(LDM_ROOT, 'shared', 'boot', 'boot-hook.mjs'),
|
|
3259
|
+
join(LDM_ROOT, 'library', 'boot', 'boot-hook.mjs'),
|
|
3260
|
+
].filter((p) => existsSync(p) && hashFile(p) === srcHash);
|
|
3261
|
+
if (!execExists || execHash !== srcHash) {
|
|
3262
|
+
const short = (h) => (h ? h.slice(0, 12) : 'missing');
|
|
3263
|
+
console.log(` ! boot hook stale at execution path: ${execTarget.replace(HOME, '~')}`);
|
|
3264
|
+
console.log(` the registered SessionStart hook runs ${short(execHash)}, current is ${short(srcHash)}`);
|
|
3265
|
+
if (freshCandidates.length > 0) {
|
|
3266
|
+
console.log(` a current copy exists at: ${freshCandidates[0].replace(HOME, '~')}`);
|
|
3267
|
+
}
|
|
3268
|
+
if (FIX_FLAG) {
|
|
3269
|
+
try {
|
|
3270
|
+
if (execExists) {
|
|
3271
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3272
|
+
copyFileSync(execTarget, `${execTarget}.bak-${stamp}`);
|
|
3273
|
+
}
|
|
3274
|
+
mkdirSync(dirname(execTarget), { recursive: true });
|
|
3275
|
+
copyFileSync(srcBoot, execTarget);
|
|
3276
|
+
console.log(' + Redeployed the current boot hook to the execution path');
|
|
3277
|
+
} catch (e) {
|
|
3278
|
+
console.log(` x Could not redeploy boot hook to execution path: ${e.message}`);
|
|
3279
|
+
}
|
|
3280
|
+
} else {
|
|
3281
|
+
console.log(' Run: ldm doctor --fix to redeploy the current boot hook to the execution path');
|
|
3282
|
+
issues++;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
|
|
2915
3288
|
// --fix: clean registry entries with /tmp/ sources or ldm-install- names (#54)
|
|
2916
3289
|
if (FIX_FLAG) {
|
|
2917
3290
|
const registry = readJSON(REGISTRY_PATH);
|
|
@@ -3251,7 +3624,91 @@ async function cmdDoctor() {
|
|
|
3251
3624
|
|
|
3252
3625
|
// ── ldm status ──
|
|
3253
3626
|
|
|
3254
|
-
|
|
3627
|
+
const STATUS_NPM_TIMEOUT_MS = parsePositiveInt(process.env.LDM_STATUS_NPM_TIMEOUT_MS, 5000);
|
|
3628
|
+
const STATUS_TOTAL_BUDGET_MS = parsePositiveInt(process.env.LDM_STATUS_TOTAL_BUDGET_MS, 60000);
|
|
3629
|
+
const STATUS_NPM_CONCURRENCY = parsePositiveInt(process.env.LDM_STATUS_NPM_CONCURRENCY, 8);
|
|
3630
|
+
const STATUS_NPM_REGISTRY_URL = process.env.LDM_STATUS_NPM_REGISTRY_URL || 'https://registry.npmjs.org';
|
|
3631
|
+
|
|
3632
|
+
async function npmViewVersionForStatus(pkg, timeoutMs) {
|
|
3633
|
+
const controller = new AbortController();
|
|
3634
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
3635
|
+
try {
|
|
3636
|
+
const registry = STATUS_NPM_REGISTRY_URL.replace(/\/+$/, '');
|
|
3637
|
+
const response = await fetch(`${registry}/${encodeURIComponent(pkg)}`, {
|
|
3638
|
+
signal: controller.signal,
|
|
3639
|
+
headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
|
|
3640
|
+
});
|
|
3641
|
+
if (!response.ok) {
|
|
3642
|
+
const error = new Error(`npm registry returned ${response.status}`);
|
|
3643
|
+
error.statusCode = response.status;
|
|
3644
|
+
throw error;
|
|
3645
|
+
}
|
|
3646
|
+
const metadata = await response.json();
|
|
3647
|
+
return metadata?.['dist-tags']?.latest || '';
|
|
3648
|
+
} finally {
|
|
3649
|
+
clearTimeout(timeout);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
function remainingStatusBudgetMs(startedAt) {
|
|
3654
|
+
return Math.max(0, STATUS_TOTAL_BUDGET_MS - (Date.now() - startedAt));
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
function formatStatusElapsed(ms) {
|
|
3658
|
+
if (!Number.isFinite(ms) || ms <= 0) return '0ms';
|
|
3659
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
3660
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
function classifyStatusCheckError(error) {
|
|
3664
|
+
if (error?.name === 'AbortError' || error?.signal === 'SIGTERM' || error?.code === 'ETIMEDOUT' || String(error?.message || '').includes('ETIMEDOUT')) {
|
|
3665
|
+
return 'timeout';
|
|
3666
|
+
}
|
|
3667
|
+
return 'unavailable';
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
async function runStatusProbesWithConcurrency(items, concurrency, statusStartedAt) {
|
|
3671
|
+
if (items.length === 0) return [];
|
|
3672
|
+
|
|
3673
|
+
const results = new Array(items.length);
|
|
3674
|
+
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
|
3675
|
+
let nextIndex = 0;
|
|
3676
|
+
|
|
3677
|
+
async function worker() {
|
|
3678
|
+
while (nextIndex < items.length) {
|
|
3679
|
+
const index = nextIndex;
|
|
3680
|
+
nextIndex += 1;
|
|
3681
|
+
const item = items[index];
|
|
3682
|
+
const remaining = remainingStatusBudgetMs(statusStartedAt);
|
|
3683
|
+
|
|
3684
|
+
if (remaining <= 0) {
|
|
3685
|
+
results[index] = { ...item, status: 'skipped', reason: 'budget', elapsedMs: 0 };
|
|
3686
|
+
continue;
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
const timeout = Math.min(STATUS_NPM_TIMEOUT_MS, remaining);
|
|
3690
|
+
const probeStartedAt = Date.now();
|
|
3691
|
+
console.log(` ${item.name}: checking npm`);
|
|
3692
|
+
|
|
3693
|
+
try {
|
|
3694
|
+
const latest = await npmViewVersionForStatus(item.npm, timeout);
|
|
3695
|
+
results[index] = { ...item, status: 'ok', latest, elapsedMs: Date.now() - probeStartedAt };
|
|
3696
|
+
} catch (error) {
|
|
3697
|
+
results[index] = {
|
|
3698
|
+
...item,
|
|
3699
|
+
status: 'skipped',
|
|
3700
|
+
reason: classifyStatusCheckError(error),
|
|
3701
|
+
elapsedMs: Date.now() - probeStartedAt,
|
|
3702
|
+
};
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
3708
|
+
return results;
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
async function cmdStatus() {
|
|
3255
3712
|
const version = readJSON(VERSION_PATH);
|
|
3256
3713
|
const registry = readJSON(REGISTRY_PATH);
|
|
3257
3714
|
const extCount = Object.keys(registry?.extensions || {}).length;
|
|
@@ -3272,18 +3729,46 @@ function cmdStatus() {
|
|
|
3272
3729
|
return;
|
|
3273
3730
|
}
|
|
3274
3731
|
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3732
|
+
console.log('');
|
|
3733
|
+
console.log(` LDM OS v${version.version}`);
|
|
3734
|
+
console.log(` Installed: ${version.installed?.split('T')[0] || 'unknown'}`);
|
|
3735
|
+
console.log(` Updated: ${version.updated?.split('T')[0] || 'unknown'}`);
|
|
3736
|
+
console.log(` Extensions: ${extCount}`);
|
|
3737
|
+
console.log(` Root: ${LDM_ROOT}`);
|
|
3738
|
+
|
|
3739
|
+
const statusStartedAt = Date.now();
|
|
3740
|
+
const skipped = [];
|
|
3741
|
+
|
|
3742
|
+
console.log('');
|
|
3743
|
+
console.log(' Checking updates:');
|
|
3283
3744
|
|
|
3284
3745
|
// Check extensions against npm using registry source info (#262)
|
|
3746
|
+
const probeItems = [{
|
|
3747
|
+
kind: 'cli',
|
|
3748
|
+
name: 'ldm cli',
|
|
3749
|
+
npm: '@wipcomputer/wip-ldm-os',
|
|
3750
|
+
current: PKG_VERSION,
|
|
3751
|
+
}];
|
|
3752
|
+
|
|
3285
3753
|
const updates = [];
|
|
3754
|
+
const untrackedEntries = [];
|
|
3286
3755
|
for (const [name, info] of Object.entries(registry?.extensions || {})) {
|
|
3756
|
+
// Phase 1 of source-types refactor: entries explicitly marked as untracked
|
|
3757
|
+
// are listed in a separate section and never probed. Honest reporting:
|
|
3758
|
+
// we don't know how to check their source yet.
|
|
3759
|
+
if (info?.updateSource?.type === 'untracked') {
|
|
3760
|
+
const currentVersion = info?.installed?.version || info.version || 'unknown';
|
|
3761
|
+
untrackedEntries.push({ name, version: currentVersion });
|
|
3762
|
+
continue;
|
|
3763
|
+
}
|
|
3764
|
+
// Defensive skip for any other Phase 2 updateSource types (git, bundled,
|
|
3765
|
+
// private, etc.) that may appear in the registry before their probe
|
|
3766
|
+
// logic ships. Falling through would use the legacy info.source.npm
|
|
3767
|
+
// path, which is wrong for these types and would print them as
|
|
3768
|
+
// [unavailable]. Phase 2 replaces this skip with proper dispatch.
|
|
3769
|
+
if (info?.updateSource && info.updateSource.type !== 'npm') {
|
|
3770
|
+
continue;
|
|
3771
|
+
}
|
|
3287
3772
|
// Use registry source.npm (v2) or fall back to extension's package.json
|
|
3288
3773
|
let npmPkg = info?.source?.npm || null;
|
|
3289
3774
|
if (!npmPkg) {
|
|
@@ -3294,22 +3779,52 @@ function cmdStatus() {
|
|
|
3294
3779
|
if (!npmPkg) continue;
|
|
3295
3780
|
const currentVersion = info?.installed?.version || info.version;
|
|
3296
3781
|
if (!currentVersion) continue;
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3782
|
+
probeItems.push({
|
|
3783
|
+
kind: 'extension',
|
|
3784
|
+
name,
|
|
3785
|
+
npm: npmPkg,
|
|
3786
|
+
current: currentVersion,
|
|
3787
|
+
});
|
|
3788
|
+
}
|
|
3789
|
+
untrackedEntries.sort((a, b) => a.name.localeCompare(b.name));
|
|
3790
|
+
|
|
3791
|
+
let cliUpdate = null;
|
|
3792
|
+
const probeResults = await runStatusProbesWithConcurrency(probeItems, STATUS_NPM_CONCURRENCY, statusStartedAt);
|
|
3793
|
+
for (const result of probeResults) {
|
|
3794
|
+
if (!result) continue;
|
|
3795
|
+
if (result.status === 'skipped') {
|
|
3796
|
+
skipped.push({
|
|
3797
|
+
name: result.name,
|
|
3798
|
+
npm: result.npm,
|
|
3799
|
+
reason: result.reason,
|
|
3800
|
+
elapsedMs: result.elapsedMs,
|
|
3801
|
+
});
|
|
3802
|
+
continue;
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
if (result.kind === 'cli') {
|
|
3806
|
+
if (result.latest && semverNewer(result.latest, PKG_VERSION)) cliUpdate = result.latest;
|
|
3807
|
+
} else if (result.latest && semverNewer(result.latest, result.current)) {
|
|
3808
|
+
updates.push({
|
|
3809
|
+
name: result.name,
|
|
3810
|
+
current: result.current,
|
|
3811
|
+
latest: result.latest,
|
|
3812
|
+
npm: result.npm,
|
|
3813
|
+
});
|
|
3814
|
+
}
|
|
3305
3815
|
}
|
|
3306
3816
|
|
|
3307
3817
|
console.log('');
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3818
|
+
if (updates.length === 0 && !cliUpdate && skipped.length === 0 && untrackedEntries.length === 0) {
|
|
3819
|
+
console.log(' Update summary: all up to date');
|
|
3820
|
+
} else {
|
|
3821
|
+
const summaryParts = [];
|
|
3822
|
+
if (updates.length > 0) summaryParts.push(`${updates.length} extension update(s) available`);
|
|
3823
|
+
if (cliUpdate) summaryParts.push('CLI update available');
|
|
3824
|
+
if (untrackedEntries.length > 0) summaryParts.push(`${untrackedEntries.length} untracked`);
|
|
3825
|
+
if (skipped.length > 0) summaryParts.push(`${skipped.length} update check(s) skipped`);
|
|
3826
|
+
console.log(` Update summary: ${summaryParts.join(', ')}`);
|
|
3827
|
+
}
|
|
3313
3828
|
|
|
3314
3829
|
if (updates.length > 0) {
|
|
3315
3830
|
console.log('');
|
|
@@ -3325,6 +3840,24 @@ function cmdStatus() {
|
|
|
3325
3840
|
console.log(` CLI update: npm install -g @wipcomputer/wip-ldm-os@${cliUpdate}`);
|
|
3326
3841
|
}
|
|
3327
3842
|
|
|
3843
|
+
if (untrackedEntries.length > 0) {
|
|
3844
|
+
console.log('');
|
|
3845
|
+
console.log(' Untracked extensions (pending reclassification):');
|
|
3846
|
+
const maxNameLen = Math.max(...untrackedEntries.map(e => e.name.length));
|
|
3847
|
+
for (const e of untrackedEntries) {
|
|
3848
|
+
console.log(` ${e.name.padEnd(maxNameLen)} v${e.version}`);
|
|
3849
|
+
}
|
|
3850
|
+
console.log(' (run `ldm doctor --reclassify-sources` to classify these)');
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
if (skipped.length > 0) {
|
|
3854
|
+
console.log('');
|
|
3855
|
+
console.log(' Update checks skipped:');
|
|
3856
|
+
for (const item of skipped) {
|
|
3857
|
+
console.log(` ${item.name}: [${item.reason} ${formatStatusElapsed(item.elapsedMs)}] ${item.npm}`);
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3328
3861
|
console.log('');
|
|
3329
3862
|
}
|
|
3330
3863
|
|
|
@@ -4780,7 +5313,7 @@ async function main() {
|
|
|
4780
5313
|
await cmdDoctor();
|
|
4781
5314
|
break;
|
|
4782
5315
|
case 'status':
|
|
4783
|
-
cmdStatus();
|
|
5316
|
+
await cmdStatus();
|
|
4784
5317
|
break;
|
|
4785
5318
|
case 'sessions':
|
|
4786
5319
|
await cmdSessions();
|