@wipcomputer/wip-ldm-os 0.4.85-alpha.9 → 0.4.87-alpha.1
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 +976 -75
- package/lib/deploy.mjs +36 -1
- package/lib/registry-migrations.mjs +296 -0
- package/package.json +21 -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-commit-deployed.mjs +300 -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);
|
|
@@ -197,6 +198,8 @@ const CLEANUP_FLAG = args.includes('--cleanup');
|
|
|
197
198
|
const CHECK_FLAG = args.includes('--check');
|
|
198
199
|
const ALPHA_FLAG = args.includes('--alpha');
|
|
199
200
|
const BETA_FLAG = args.includes('--beta');
|
|
201
|
+
const COMMIT_DEPLOYED_FLAG = args.includes('--commit-deployed');
|
|
202
|
+
const INCLUDE_PERSONAL_FLAG = args.includes('--include-personal');
|
|
200
203
|
|
|
201
204
|
function readJSON(path) {
|
|
202
205
|
try {
|
|
@@ -211,6 +214,11 @@ function writeJSON(path, data) {
|
|
|
211
214
|
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
212
215
|
}
|
|
213
216
|
|
|
217
|
+
function parsePositiveInt(value, fallback) {
|
|
218
|
+
const parsed = Number.parseInt(value || '', 10);
|
|
219
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
220
|
+
}
|
|
221
|
+
|
|
214
222
|
// ── CLI version check (#29) ──
|
|
215
223
|
|
|
216
224
|
function checkCliVersion() {
|
|
@@ -229,6 +237,62 @@ function checkCliVersion() {
|
|
|
229
237
|
}
|
|
230
238
|
}
|
|
231
239
|
|
|
240
|
+
function selectedLdmNpmTrack() {
|
|
241
|
+
return ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function selectedLdmTrackLabel(npmTag) {
|
|
245
|
+
return npmTag === 'latest' ? '' : ` (${npmTag} track)`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function latestLdmCliForSelectedTrack() {
|
|
249
|
+
const npmTag = selectedLdmNpmTrack();
|
|
250
|
+
const npmViewCmd = npmTag === 'latest'
|
|
251
|
+
? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
|
|
252
|
+
: `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
|
|
253
|
+
const latest = execSync(npmViewCmd, {
|
|
254
|
+
encoding: 'utf8',
|
|
255
|
+
timeout: 15000,
|
|
256
|
+
}).trim();
|
|
257
|
+
return { latest, npmTag };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function maybeSelfUpdateLdmCliBeforeInstall() {
|
|
261
|
+
// This shared preflight covers both bare `ldm install` and targeted
|
|
262
|
+
// `ldm install <app>`. Dry runs never update, but still disclose skew.
|
|
263
|
+
if (process.env.LDM_SELF_UPDATED) return;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const { latest, npmTag } = latestLdmCliForSelectedTrack();
|
|
267
|
+
if (!latest || !semverNewer(latest, PKG_VERSION)) return;
|
|
268
|
+
|
|
269
|
+
const trackLabel = selectedLdmTrackLabel(npmTag);
|
|
270
|
+
|
|
271
|
+
if (DRY_RUN) {
|
|
272
|
+
console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel} is available.`);
|
|
273
|
+
console.log(` Dry run only: continuing with v${PKG_VERSION}.`);
|
|
274
|
+
console.log('');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
|
|
279
|
+
try {
|
|
280
|
+
execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
|
|
281
|
+
console.log(` CLI updated to v${latest}. Re-running with new code...`);
|
|
282
|
+
console.log('');
|
|
283
|
+
const reArgs = process.argv.slice(2);
|
|
284
|
+
const child = spawnSync('ldm', reArgs.length > 0 ? reArgs : ['install'], {
|
|
285
|
+
stdio: 'inherit',
|
|
286
|
+
env: { ...process.env, LDM_SELF_UPDATED: '1' },
|
|
287
|
+
});
|
|
288
|
+
if (child.error) throw child.error;
|
|
289
|
+
process.exit(child.status ?? 1);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
console.log(` ! Self-update failed: ${e.message}. Continuing with v${PKG_VERSION}.`);
|
|
292
|
+
}
|
|
293
|
+
} catch {}
|
|
294
|
+
}
|
|
295
|
+
|
|
232
296
|
// ── Dead backup trigger cleanup (#207) ──
|
|
233
297
|
// Three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
|
|
234
298
|
// This removes: broken cron entry (LDMDevTools.app), old com.wipcomputer.daily-backup.
|
|
@@ -348,9 +412,39 @@ function cleanStaleHooks() {
|
|
|
348
412
|
|
|
349
413
|
// ── Boot hook sync (#49) ──
|
|
350
414
|
|
|
415
|
+
// The registered SessionStart hook command is the single source of truth for
|
|
416
|
+
// WHERE the boot hook actually executes from. Historically syncBootHook()
|
|
417
|
+
// deployed to ~/.ldm/library/boot while the registered command pointed at
|
|
418
|
+
// ~/.ldm/shared/boot (installer.mjs BOOT_DIR): new code landed in library/,
|
|
419
|
+
// sessions ran the stale copy in shared/, and the install reported success.
|
|
420
|
+
// Resolve the deploy target from the registered entry so the two can never
|
|
421
|
+
// diverge again. Fallback is shared/boot, matching installer.mjs BOOT_DIR and
|
|
422
|
+
// configureSessionStartHook() so a fresh machine stays consistent.
|
|
423
|
+
// Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
|
|
424
|
+
function getRegisteredBootHookTarget() {
|
|
425
|
+
const settings = readJSON(join(HOME, '.claude', 'settings.json'));
|
|
426
|
+
const entries = settings?.hooks?.SessionStart;
|
|
427
|
+
if (!Array.isArray(entries)) return null;
|
|
428
|
+
for (const entry of entries) {
|
|
429
|
+
for (const h of (entry?.hooks || [])) {
|
|
430
|
+
const cmd = h?.command || '';
|
|
431
|
+
const m = cmd.match(/(\S*boot-hook\.mjs)/);
|
|
432
|
+
if (m) return m[1].replace(/^["']|["']$/g, '');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function resolveBootExecDir() {
|
|
439
|
+
const target = getRegisteredBootHookTarget();
|
|
440
|
+
if (target) return dirname(target);
|
|
441
|
+
// Canonical default: same as src/boot/installer.mjs BOOT_DIR.
|
|
442
|
+
return join(LDM_ROOT, 'shared', 'boot');
|
|
443
|
+
}
|
|
444
|
+
|
|
351
445
|
function syncBootHook() {
|
|
352
446
|
const srcBoot = join(__dirname, '..', 'src', 'boot', 'boot-hook.mjs');
|
|
353
|
-
const destBoot = join(
|
|
447
|
+
const destBoot = join(resolveBootExecDir(), 'boot-hook.mjs');
|
|
354
448
|
|
|
355
449
|
if (!existsSync(srcBoot)) return false;
|
|
356
450
|
|
|
@@ -1440,23 +1534,6 @@ async function showCatalogPicker() {
|
|
|
1440
1534
|
// ── ldm install ──
|
|
1441
1535
|
|
|
1442
1536
|
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
1537
|
// --help flag (#81)
|
|
1461
1538
|
if (args.includes('--help') || args.includes('-h')) {
|
|
1462
1539
|
console.log(`
|
|
@@ -1476,6 +1553,25 @@ async function cmdInstall() {
|
|
|
1476
1553
|
process.exit(0);
|
|
1477
1554
|
}
|
|
1478
1555
|
|
|
1556
|
+
maybeSelfUpdateLdmCliBeforeInstall();
|
|
1557
|
+
|
|
1558
|
+
if (!DRY_RUN && !acquireInstallLock()) return;
|
|
1559
|
+
|
|
1560
|
+
// Ensure LDM is initialized
|
|
1561
|
+
if (!existsSync(VERSION_PATH)) {
|
|
1562
|
+
console.log(' LDM OS not initialized. Running init first...');
|
|
1563
|
+
console.log('');
|
|
1564
|
+
cmdInit();
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const { setFlags, installFromPath, installSingleTool, installToolbox, detectHarnesses } = await import('../lib/deploy.mjs');
|
|
1568
|
+
const { detectInterfacesJSON } = await import('../lib/detect.mjs');
|
|
1569
|
+
|
|
1570
|
+
// Refresh harness detection (catches newly installed harnesses)
|
|
1571
|
+
detectHarnesses();
|
|
1572
|
+
|
|
1573
|
+
setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'manual' });
|
|
1574
|
+
|
|
1479
1575
|
// Find the target (skip flags)
|
|
1480
1576
|
const target = args.slice(1).find(a => !a.startsWith('--'));
|
|
1481
1577
|
|
|
@@ -1736,6 +1832,135 @@ function migrateRegistry() {
|
|
|
1736
1832
|
return migrated;
|
|
1737
1833
|
}
|
|
1738
1834
|
|
|
1835
|
+
// ── Legacy source.npm honest cleanup (Phase 1 of source-types refactor) ──
|
|
1836
|
+
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
1837
|
+
|
|
1838
|
+
async function migrateLegacyNpmSources({ dryRun = false } = {}) {
|
|
1839
|
+
const {
|
|
1840
|
+
planLegacyNpmSourcesMigration,
|
|
1841
|
+
summaryHasChanges,
|
|
1842
|
+
npmPackageExists,
|
|
1843
|
+
executeDirectoryMoves,
|
|
1844
|
+
} = await import('../lib/registry-migrations.mjs');
|
|
1845
|
+
|
|
1846
|
+
const registry = readJSON(REGISTRY_PATH);
|
|
1847
|
+
if (!registry?.extensions) return null;
|
|
1848
|
+
|
|
1849
|
+
const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
|
|
1850
|
+
registry,
|
|
1851
|
+
probeNpm: (name) => npmPackageExists(name, { timeoutMs: 2000 }),
|
|
1852
|
+
// Resolve the entry's on-disk location before declaring it a phantom.
|
|
1853
|
+
// Entries autoregistered with `ldmPath` (bin/ldm.js:1822 path) or with
|
|
1854
|
+
// a `paths.ldm` field (lib/deploy.mjs path) can live outside the
|
|
1855
|
+
// default ~/.ldm/extensions/<name> location. A naive check would
|
|
1856
|
+
// silently delete a working entry as "phantom."
|
|
1857
|
+
extensionExists: (name, entry) => {
|
|
1858
|
+
const customPath = entry?.paths?.ldm || entry?.ldmPath;
|
|
1859
|
+
if (customPath && existsSync(customPath)) return true;
|
|
1860
|
+
return existsSync(join(LDM_EXTENSIONS, name));
|
|
1861
|
+
},
|
|
1862
|
+
now: () => new Date(),
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
if (!summaryHasChanges(summary)) return summary;
|
|
1866
|
+
|
|
1867
|
+
if (!dryRun) {
|
|
1868
|
+
const stamp = summary.timestamp.replace(/[:.]/g, '-');
|
|
1869
|
+
const backupPath = `${REGISTRY_PATH}.bak-${stamp}`;
|
|
1870
|
+
copyFileSync(REGISTRY_PATH, backupPath);
|
|
1871
|
+
summary.backupPath = backupPath;
|
|
1872
|
+
writeJSON(REGISTRY_PATH, newRegistry);
|
|
1873
|
+
|
|
1874
|
+
// Execute directory moves AFTER the registry write. Each move sends a
|
|
1875
|
+
// deduplicated extension's on-disk directory to ~/.ldm/_trash/<name>-
|
|
1876
|
+
// deduplicated-<timestamp> so autoDetectExtensions (which only scans
|
|
1877
|
+
// ~/.ldm/extensions/) cannot find it on the next install pass.
|
|
1878
|
+
// Without this, the registry dedup reverts within the same install run.
|
|
1879
|
+
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md
|
|
1880
|
+
const { performed, skipped } = executeDirectoryMoves({
|
|
1881
|
+
directoryMoves: summary.directoryMoves,
|
|
1882
|
+
extensionsRoot: LDM_EXTENSIONS,
|
|
1883
|
+
trashRoot: join(LDM_ROOT, '_trash'),
|
|
1884
|
+
});
|
|
1885
|
+
summary.directoryMovesPerformed.push(...performed);
|
|
1886
|
+
summary.directoryMovesSkipped.push(...skipped);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
return summary;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function printLegacyNpmSourcesSummary(summary, { dryRun = false } = {}) {
|
|
1893
|
+
if (!summary) return;
|
|
1894
|
+
const writeableChanges =
|
|
1895
|
+
summary.migrated.length +
|
|
1896
|
+
summary.phantomsRemoved.length +
|
|
1897
|
+
summary.duplicatesRemoved.length;
|
|
1898
|
+
const probeFailureCount = summary.probeFailures?.length || 0;
|
|
1899
|
+
// Always surface probe failures even when nothing was writeable. If every
|
|
1900
|
+
// probe times out, the install would otherwise look like a no-op and the
|
|
1901
|
+
// [unavailable] rows in `ldm status` would survive silently.
|
|
1902
|
+
if (writeableChanges === 0 && probeFailureCount === 0) return;
|
|
1903
|
+
|
|
1904
|
+
console.log('');
|
|
1905
|
+
console.log(dryRun
|
|
1906
|
+
? ' Registry source.npm cleanup (dry run, no changes written):'
|
|
1907
|
+
: ' Registry source.npm cleanup:');
|
|
1908
|
+
|
|
1909
|
+
if (summary.migrated.length > 0) {
|
|
1910
|
+
console.log(` + ${dryRun ? 'Would migrate' : 'Migrated'} ${summary.migrated.length} entr${summary.migrated.length === 1 ? 'y' : 'ies'} to updateSource.type=untracked`);
|
|
1911
|
+
for (const m of summary.migrated) {
|
|
1912
|
+
const detail = m.legacyNpmName
|
|
1913
|
+
? ` (legacy source.npm "${m.legacyNpmName}" preserved in provenance)`
|
|
1914
|
+
: ' (no source info; classified as untracked)';
|
|
1915
|
+
console.log(` ${m.name}${detail}`);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (summary.phantomsRemoved.length > 0) {
|
|
1919
|
+
console.log(` + ${dryRun ? 'Would remove' : 'Removed'} ${summary.phantomsRemoved.length} phantom entr${summary.phantomsRemoved.length === 1 ? 'y' : 'ies'}:`);
|
|
1920
|
+
for (const p of summary.phantomsRemoved) {
|
|
1921
|
+
console.log(` ${p.name} (${p.reason})`);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
if (summary.duplicatesRemoved.length > 0) {
|
|
1925
|
+
console.log(` + ${dryRun ? 'Would remove' : 'Removed'} ${summary.duplicatesRemoved.length} duplicate entr${summary.duplicatesRemoved.length === 1 ? 'y' : 'ies'}:`);
|
|
1926
|
+
for (const d of summary.duplicatesRemoved) {
|
|
1927
|
+
console.log(` ${d.removed} (canonical: ${d.keep})`);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
if (summary.directoryMoves && summary.directoryMoves.length > 0) {
|
|
1931
|
+
if (dryRun) {
|
|
1932
|
+
console.log(` + Would move ${summary.directoryMoves.length} duplicate director${summary.directoryMoves.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (so autoDetectExtensions cannot re-register):`);
|
|
1933
|
+
for (const m of summary.directoryMoves) {
|
|
1934
|
+
console.log(` ~/.ldm/extensions/${m.name} -> ~/.ldm/_trash/${m.trashName}`);
|
|
1935
|
+
}
|
|
1936
|
+
} else {
|
|
1937
|
+
const performed = summary.directoryMovesPerformed || [];
|
|
1938
|
+
const skipped = summary.directoryMovesSkipped || [];
|
|
1939
|
+
if (performed.length > 0) {
|
|
1940
|
+
console.log(` + Moved ${performed.length} duplicate director${performed.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (autoDetectExtensions cannot re-register):`);
|
|
1941
|
+
for (const m of performed) {
|
|
1942
|
+
console.log(` ${m.name} -> ${m.destPath.replace(HOME, '~')}`);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
if (skipped.length > 0) {
|
|
1946
|
+
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):`);
|
|
1947
|
+
for (const m of skipped) {
|
|
1948
|
+
console.log(` ${m.name} (${m.reason})`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
if (summary.probeFailures && summary.probeFailures.length > 0) {
|
|
1954
|
+
console.log(` ! ${summary.probeFailures.length} npm probe${summary.probeFailures.length === 1 ? '' : 's'} could not complete (will retry on next install):`);
|
|
1955
|
+
for (const f of summary.probeFailures) {
|
|
1956
|
+
console.log(` ${f.name} (source.npm "${f.npmName}")`);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
if (!dryRun && summary.backupPath) {
|
|
1960
|
+
console.log(` + Registry backup: ${summary.backupPath.replace(HOME, '~')}`);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1739
1964
|
// ── Auto-detect unregistered extensions ──
|
|
1740
1965
|
|
|
1741
1966
|
function autoDetectExtensions() {
|
|
@@ -1921,38 +2146,6 @@ async function cmdInstallCatalog() {
|
|
|
1921
2146
|
// No lock here. cmdInstall() already holds it when calling this.
|
|
1922
2147
|
installLog(`ldm install started (v${PKG_VERSION}, DRY_RUN=${DRY_RUN})`);
|
|
1923
2148
|
|
|
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
2149
|
autoDetectExtensions();
|
|
1957
2150
|
|
|
1958
2151
|
// Migrate old registry entries to v2 format (#262)
|
|
@@ -1961,6 +2154,12 @@ async function cmdInstallCatalog() {
|
|
|
1961
2154
|
console.log(` + Migrated ${migrated} registry entries to v2 format (source info added)`);
|
|
1962
2155
|
}
|
|
1963
2156
|
|
|
2157
|
+
// Phase 1 of source-types refactor: clear bad source.npm entries, dedupe,
|
|
2158
|
+
// and classify mystery rows as untracked so `ldm status` stops lying.
|
|
2159
|
+
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
2160
|
+
const npmCleanupSummary = await migrateLegacyNpmSources({ dryRun: DRY_RUN });
|
|
2161
|
+
printLegacyNpmSourcesSummary(npmCleanupSummary, { dryRun: DRY_RUN });
|
|
2162
|
+
|
|
1964
2163
|
// Aggregate the bin ownership manifest BEFORE seedLocalCatalog,
|
|
1965
2164
|
// deployBridge, deployScripts, and the heal walk run. If two
|
|
1966
2165
|
// declarers claim the same file in ~/.ldm/bin/ we cannot safely
|
|
@@ -2841,7 +3040,373 @@ async function cmdUpdateAll() {
|
|
|
2841
3040
|
|
|
2842
3041
|
// ── ldm doctor ──
|
|
2843
3042
|
|
|
3043
|
+
// ── ldm doctor --commit-deployed ──
|
|
3044
|
+
//
|
|
3045
|
+
// Sanctioned path to record installer-deployed config drift in a tracked home
|
|
3046
|
+
// tree (~/.claude etc.). `ldm install` / `ldm doctor` rewrite tracked config
|
|
3047
|
+
// (hook wiring in settings.json, rules/, boot config) and nobody commits the
|
|
3048
|
+
// result; the tree stays dirty, which blocks `git pull` and collides with any
|
|
3049
|
+
// new config edit. This classifies each change into three classes and records
|
|
3050
|
+
// them cleanly:
|
|
3051
|
+
//
|
|
3052
|
+
// deployed (installer-owned: rules/, settings.json hook wiring) -> commit
|
|
3053
|
+
// transient (runtime junk: projects/, caches, plans/, ...) -> gitignore
|
|
3054
|
+
// personal (user choices: model, permission flags, statusLine) -> leave
|
|
3055
|
+
//
|
|
3056
|
+
// It runs entirely inside this ldm subprocess using git index + commit
|
|
3057
|
+
// operations, so it needs none of the agent-level file-writes on the primary
|
|
3058
|
+
// tree that the branch-guard blocks by hand. settings.json mixes deployed and
|
|
3059
|
+
// personal in one file, so it is handled at JSON-KEY granularity: only the
|
|
3060
|
+
// installer-owned keys are committed, the rest are left in the working tree.
|
|
3061
|
+
// A timestamped backup of every changed file is taken before any mutation.
|
|
3062
|
+
//
|
|
3063
|
+
// Ticket: wip-tracking-private-only/bugs/installer/open-tickets/2026-07-06--cc-mini--ldm-doctor-commit-deployed.md
|
|
3064
|
+
// Policy parent: wip-ldm-os-private/ai/product/bugs/os-level/open-tickets/2026-07-05--cc-mini--deployed-state-drift-commit-policy.md
|
|
3065
|
+
|
|
3066
|
+
// The deployed set below is audited against what `ldm install` actually writes
|
|
3067
|
+
// into the ~/.claude git repo: `settings.json` hook wiring (SessionStart /
|
|
3068
|
+
// UserPromptSubmit / Stop) and `rules/*.md` (deployRules). Notably CLAUDE.md is
|
|
3069
|
+
// NOT installer-deployed (bin/ldm.js "CLAUDE.md files are NEVER deployed by the
|
|
3070
|
+
// installer"; it is a git-tracked file changed only through branches + PRs), so
|
|
3071
|
+
// CLAUDE.md drift correctly classifies as personal and is left, not swept into
|
|
3072
|
+
// a chore(deploy) commit. The classification fails SAFE: anything unrecognized
|
|
3073
|
+
// falls to personal and is left untouched. A different home (~/.openclaw,
|
|
3074
|
+
// ~/.ldm) would extend these lists to match its own installer-owned surface.
|
|
3075
|
+
|
|
3076
|
+
// Only these top-level settings.json keys are installer-owned and get
|
|
3077
|
+
// committed. Every other changed key (model, permissions, statusLine, env, ...)
|
|
3078
|
+
// is personal and left in the working tree unless --include-personal.
|
|
3079
|
+
const DEPLOYED_SETTINGS_KEYS = ['hooks'];
|
|
3080
|
+
|
|
3081
|
+
// Whole-file deployed path prefixes (installer-owned dirs), committed as-is.
|
|
3082
|
+
const DEPLOYED_PREFIXES = ['rules/'];
|
|
3083
|
+
|
|
3084
|
+
// Transient / runtime paths: content is never committed; untracked ones are
|
|
3085
|
+
// gitignored. `skills/` is here because the ~/.claude installer regenerates it
|
|
3086
|
+
// and .gitignore already ignores it ("Rebuilt by ldm install"); if CC skills
|
|
3087
|
+
// ever become tracked + installer-deployed, move it to the deployed set.
|
|
3088
|
+
const TRANSIENT_PREFIXES = [
|
|
3089
|
+
'projects/', 'sessions/', 'session-env/', 'shell-snapshots/', 'debug/',
|
|
3090
|
+
'file-history/', 'paste-cache/', 'cache/', 'backups/', 'plugins/', 'tasks/',
|
|
3091
|
+
'todos/', 'telemetry/', 'downloads/', 'statsig/', 'plans/', 'skills/',
|
|
3092
|
+
];
|
|
3093
|
+
const TRANSIENT_FILES = ['history.jsonl', 'stats-cache.json', '.DS_Store', '.claude.lock'];
|
|
3094
|
+
|
|
3095
|
+
function gitIn(homeRepo, gitArgs, opts = {}) {
|
|
3096
|
+
return spawnSync('git', ['-C', homeRepo, ...gitArgs], { encoding: 'utf8', ...opts });
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
function safeParseJSON(s) {
|
|
3100
|
+
try { return JSON.parse(s); } catch { return null; }
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// Parse `git status --porcelain -z` (NUL-separated). Rename/copy entries carry
|
|
3104
|
+
// a second NUL-terminated source path token which we skip.
|
|
3105
|
+
function parsePorcelainZ(out) {
|
|
3106
|
+
const parts = (out || '').split('\0').filter((s) => s.length > 0);
|
|
3107
|
+
const entries = [];
|
|
3108
|
+
for (let i = 0; i < parts.length; i++) {
|
|
3109
|
+
const tok = parts[i];
|
|
3110
|
+
const xy = tok.slice(0, 2);
|
|
3111
|
+
const path = tok.slice(3);
|
|
3112
|
+
entries.push({ xy, path });
|
|
3113
|
+
if (xy[0] === 'R' || xy[0] === 'C') i++; // skip source path token
|
|
3114
|
+
}
|
|
3115
|
+
return entries;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
function classifyHomePath(p) {
|
|
3119
|
+
if (p === 'settings.json') return 'settings';
|
|
3120
|
+
if (TRANSIENT_FILES.includes(p)) return 'transient';
|
|
3121
|
+
if (TRANSIENT_PREFIXES.some((pre) => p.startsWith(pre))) return 'transient';
|
|
3122
|
+
if (DEPLOYED_PREFIXES.some((pre) => p.startsWith(pre))) return 'deployed';
|
|
3123
|
+
return 'personal';
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
// Decide which settings.json top-level keys are committed vs left, and build
|
|
3127
|
+
// the exact blob content to stage for the committed subset (HEAD values for
|
|
3128
|
+
// every key, overwritten by working values for the committed keys only).
|
|
3129
|
+
//
|
|
3130
|
+
// Returns { skip: reason } when either side is unparsable or the working file
|
|
3131
|
+
// is gone. Never treat malformed JSON as {}: doing so would drop the HEAD hooks
|
|
3132
|
+
// and commit their deletion. This matches the neighboring doctor behavior
|
|
3133
|
+
// (malformed settings.json is warned about and skipped, not rewritten).
|
|
3134
|
+
function planSettingsSplit(homeRepo, settingsPath) {
|
|
3135
|
+
const headShow = gitIn(homeRepo, ['show', 'HEAD:settings.json']);
|
|
3136
|
+
let head = {};
|
|
3137
|
+
if (headShow.status === 0) {
|
|
3138
|
+
head = safeParseJSON(headShow.stdout);
|
|
3139
|
+
if (head === null) return { skip: 'committed settings.json (HEAD) is not valid JSON' };
|
|
3140
|
+
}
|
|
3141
|
+
if (!existsSync(settingsPath)) return { skip: 'settings.json is missing from the working tree' };
|
|
3142
|
+
const working = safeParseJSON(readFileSync(settingsPath, 'utf8'));
|
|
3143
|
+
if (working === null) return { skip: 'settings.json is not valid JSON' };
|
|
3144
|
+
|
|
3145
|
+
const commitKeys = [];
|
|
3146
|
+
const leaveKeys = [];
|
|
3147
|
+
const allKeys = new Set([...Object.keys(head), ...Object.keys(working)]);
|
|
3148
|
+
for (const k of allKeys) {
|
|
3149
|
+
if (JSON.stringify(head[k]) === JSON.stringify(working[k])) continue; // unchanged
|
|
3150
|
+
if (INCLUDE_PERSONAL_FLAG || DEPLOYED_SETTINGS_KEYS.includes(k)) commitKeys.push(k);
|
|
3151
|
+
else leaveKeys.push(k);
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
const committed = JSON.parse(JSON.stringify(head));
|
|
3155
|
+
for (const k of commitKeys) {
|
|
3156
|
+
if (k in working) committed[k] = working[k];
|
|
3157
|
+
else delete committed[k];
|
|
3158
|
+
}
|
|
3159
|
+
const blobContent = JSON.stringify(committed, null, 2) + '\n';
|
|
3160
|
+
return { commitKeys, leaveKeys, blobContent };
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
// Write content into the object store and return its sha. Does NOT touch the
|
|
3164
|
+
// working tree, so personal keys stay in the live settings.json file.
|
|
3165
|
+
function writeGitBlob(homeRepo, relPath, content) {
|
|
3166
|
+
const r = spawnSync('git', ['-C', homeRepo, 'hash-object', '-w', '--path', relPath, '--stdin'],
|
|
3167
|
+
{ input: content, encoding: 'utf8' });
|
|
3168
|
+
return (r.stdout || '').trim();
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
// Untracked transient paths surfaced by `git status` are by definition not yet
|
|
3172
|
+
// ignored. Return the .gitignore patterns to add (top-level dir or filename),
|
|
3173
|
+
// deduped and skipping any already present.
|
|
3174
|
+
function computeGitignoreAdds(homeRepo, transientPaths) {
|
|
3175
|
+
const gi = join(homeRepo, '.gitignore');
|
|
3176
|
+
const existing = existsSync(gi)
|
|
3177
|
+
? readFileSync(gi, 'utf8').split('\n').map((s) => s.trim())
|
|
3178
|
+
: [];
|
|
3179
|
+
const adds = new Set();
|
|
3180
|
+
for (const p of transientPaths) {
|
|
3181
|
+
const seg = p.split('/')[0];
|
|
3182
|
+
const pattern = p.includes('/') ? `${seg}/` : seg;
|
|
3183
|
+
if (existing.includes(pattern) || existing.includes(pattern.replace(/\/$/, ''))) continue;
|
|
3184
|
+
adds.add(pattern);
|
|
3185
|
+
}
|
|
3186
|
+
return [...adds];
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
// Append the new ignore patterns and stage ONLY those. The working file keeps
|
|
3190
|
+
// whatever the user already had (so the transient paths are ignored on disk),
|
|
3191
|
+
// but the staged blob is HEAD + our block, never the working file. That way a
|
|
3192
|
+
// pre-existing uncommitted .gitignore edit is NOT swept into this commit: it
|
|
3193
|
+
// stays as a working-tree change the user owns. .gitignore is otherwise not
|
|
3194
|
+
// classified as deployed, so this is the only path that ever commits it.
|
|
3195
|
+
function stageGitignoreAdditions(homeRepo, adds) {
|
|
3196
|
+
const gi = join(homeRepo, '.gitignore');
|
|
3197
|
+
const block = '\n# Added by ldm doctor --commit-deployed (transient/runtime)\n'
|
|
3198
|
+
+ adds.map((p) => `${p}\n`).join('');
|
|
3199
|
+
|
|
3200
|
+
// Working file: append to current content (preserves any user edits).
|
|
3201
|
+
let working = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
|
3202
|
+
if (working.length && !working.endsWith('\n')) working += '\n';
|
|
3203
|
+
writeFileSync(gi, working + block);
|
|
3204
|
+
|
|
3205
|
+
// Staged blob: HEAD content (not working) + our block only.
|
|
3206
|
+
const headShow = gitIn(homeRepo, ['show', 'HEAD:.gitignore']);
|
|
3207
|
+
let headContent = headShow.status === 0 ? headShow.stdout : '';
|
|
3208
|
+
if (headContent.length && !headContent.endsWith('\n')) headContent += '\n';
|
|
3209
|
+
const sha = writeGitBlob(homeRepo, '.gitignore', headContent + block);
|
|
3210
|
+
gitIn(homeRepo, ['update-index', '--cacheinfo', `100644,${sha},.gitignore`]);
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
// Copy every changed file's current content plus a manifest (HEAD sha + status)
|
|
3214
|
+
// into ~/.ldm/backups/ before any mutation, so the pre-command state is
|
|
3215
|
+
// recoverable from git + backup.
|
|
3216
|
+
function backupHomeDrift(homeRepo, entries) {
|
|
3217
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3218
|
+
const backupDir = join(LDM_ROOT, 'backups', `commit-deployed-${basename(homeRepo)}-${ts}`);
|
|
3219
|
+
mkdirSync(backupDir, { recursive: true });
|
|
3220
|
+
const headSha = (gitIn(homeRepo, ['rev-parse', 'HEAD']).stdout || '').trim();
|
|
3221
|
+
const manifest = [`home: ${homeRepo}`, `head: ${headSha}`, 'changes:'];
|
|
3222
|
+
for (const e of entries) {
|
|
3223
|
+
manifest.push(` ${e.xy} ${e.path}`);
|
|
3224
|
+
const src = join(homeRepo, e.path);
|
|
3225
|
+
try {
|
|
3226
|
+
if (existsSync(src) && statSync(src).isFile()) {
|
|
3227
|
+
const dest = join(backupDir, e.path);
|
|
3228
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
3229
|
+
copyFileSync(src, dest);
|
|
3230
|
+
}
|
|
3231
|
+
} catch {}
|
|
3232
|
+
}
|
|
3233
|
+
writeFileSync(join(backupDir, 'MANIFEST.txt'), manifest.join('\n') + '\n');
|
|
3234
|
+
return backupDir;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function commitDeployedConfig() {
|
|
3238
|
+
const homeIdx = args.indexOf('--home');
|
|
3239
|
+
const homeRepo = (homeIdx !== -1 && args[homeIdx + 1] && !args[homeIdx + 1].startsWith('--'))
|
|
3240
|
+
? resolve(args[homeIdx + 1])
|
|
3241
|
+
: join(HOME, '.claude');
|
|
3242
|
+
|
|
3243
|
+
console.log('');
|
|
3244
|
+
console.log(' ldm doctor --commit-deployed');
|
|
3245
|
+
console.log(' ────────────────────────────────────');
|
|
3246
|
+
console.log(` Home repo: ${homeRepo.replace(HOME, '~')}`);
|
|
3247
|
+
|
|
3248
|
+
const inside = gitIn(homeRepo, ['rev-parse', '--is-inside-work-tree']);
|
|
3249
|
+
if (inside.status !== 0 || (inside.stdout || '').trim() !== 'true') {
|
|
3250
|
+
console.log(` x Not a git work tree: ${homeRepo.replace(HOME, '~')}`);
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
const st = gitIn(homeRepo, ['status', '--porcelain', '-z']);
|
|
3255
|
+
if (st.status !== 0) {
|
|
3256
|
+
console.log(` x git status failed: ${(st.stderr || '').trim()}`);
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
const entries = parsePorcelainZ(st.stdout);
|
|
3260
|
+
if (entries.length === 0) {
|
|
3261
|
+
console.log(' + Working tree already clean, nothing to record.');
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
const deployed = [];
|
|
3266
|
+
const transientUntracked = []; // '??' -> can be gitignored to drop from status
|
|
3267
|
+
const transientTracked = []; // tracked transient -> gitignore can't clean it; leave
|
|
3268
|
+
const personal = [];
|
|
3269
|
+
let settingsChanged = false;
|
|
3270
|
+
let gitignoreDrifted = false; // pre-existing .gitignore drift (user-owned)
|
|
3271
|
+
for (const e of entries) {
|
|
3272
|
+
// .gitignore is handled only through the ignore-add path (HEAD-based blob),
|
|
3273
|
+
// never as deployed/personal, so a pre-existing edit is never swept in.
|
|
3274
|
+
if (e.path === '.gitignore') { gitignoreDrifted = true; continue; }
|
|
3275
|
+
const cls = classifyHomePath(e.path);
|
|
3276
|
+
if (cls === 'settings') settingsChanged = true;
|
|
3277
|
+
else if (cls === 'deployed') deployed.push(e.path);
|
|
3278
|
+
else if (cls === 'transient') (e.xy === '??' ? transientUntracked : transientTracked).push(e.path);
|
|
3279
|
+
else personal.push(e.path);
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
const settingsPath = join(homeRepo, 'settings.json');
|
|
3283
|
+
const settingsPlan = settingsChanged ? planSettingsSplit(homeRepo, settingsPath) : null;
|
|
3284
|
+
const settingsSkip = settingsPlan && settingsPlan.skip ? settingsPlan.skip : null;
|
|
3285
|
+
const willCommitSettings = !!(settingsPlan && !settingsPlan.skip && settingsPlan.commitKeys.length > 0);
|
|
3286
|
+
const gitignoreAdds = computeGitignoreAdds(homeRepo, transientUntracked);
|
|
3287
|
+
const leaveKeys = settingsPlan && !settingsPlan.skip ? settingsPlan.leaveKeys : [];
|
|
3288
|
+
|
|
3289
|
+
const printLeft = () => {
|
|
3290
|
+
const hasLeft = personal.length || leaveKeys.length || transientTracked.length || gitignoreDrifted || settingsSkip;
|
|
3291
|
+
if (!hasLeft) return;
|
|
3292
|
+
console.log(' - Left uncommitted (yours to keep or commit):');
|
|
3293
|
+
for (const p of personal) console.log(` ${p} (personal)`);
|
|
3294
|
+
for (const k of leaveKeys) console.log(` settings.json (${k}) (personal)`);
|
|
3295
|
+
if (gitignoreDrifted) console.log(' .gitignore (your existing edits; only new ignore patterns were committed)');
|
|
3296
|
+
for (const p of transientTracked) console.log(` ${p} (tracked transient; gitignore cannot clean a tracked file, left as-is)`);
|
|
3297
|
+
if (settingsSkip) console.log(` settings.json (skipped: ${settingsSkip})`);
|
|
3298
|
+
if (leaveKeys.length && !INCLUDE_PERSONAL_FLAG) console.log(' Re-run with --include-personal to fold personal settings keys in.');
|
|
3299
|
+
};
|
|
3300
|
+
|
|
3301
|
+
if (settingsSkip) {
|
|
3302
|
+
console.log(` ! settings.json skipped: ${settingsSkip}. Left untouched (repair it, then re-run).`);
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
if (deployed.length === 0 && !willCommitSettings && gitignoreAdds.length === 0) {
|
|
3306
|
+
console.log(' + No deployed drift or new runtime paths to record.');
|
|
3307
|
+
printLeft();
|
|
3308
|
+
return;
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
if (DRY_RUN) {
|
|
3312
|
+
console.log(' [DRY RUN] Would record:');
|
|
3313
|
+
if (deployed.length) console.log(` deployed files: ${deployed.join(', ')}`);
|
|
3314
|
+
if (willCommitSettings) console.log(` settings.json keys: ${settingsPlan.commitKeys.join(', ')}`);
|
|
3315
|
+
if (gitignoreAdds.length) console.log(` gitignore: ${gitignoreAdds.join(', ')}`);
|
|
3316
|
+
printLeft();
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
const backupDir = backupHomeDrift(homeRepo, entries);
|
|
3321
|
+
console.log(` + Backup: ${backupDir.replace(HOME, '~')}`);
|
|
3322
|
+
|
|
3323
|
+
for (const p of deployed) gitIn(homeRepo, ['add', '--', p]);
|
|
3324
|
+
|
|
3325
|
+
if (willCommitSettings) {
|
|
3326
|
+
if (leaveKeys.length === 0) {
|
|
3327
|
+
// No personal drift in settings.json: stage the working file byte-exact.
|
|
3328
|
+
gitIn(homeRepo, ['add', '--', 'settings.json']);
|
|
3329
|
+
} else {
|
|
3330
|
+
// Mixed file: stage a key-split blob so personal keys stay in the tree.
|
|
3331
|
+
const sha = writeGitBlob(homeRepo, 'settings.json', settingsPlan.blobContent);
|
|
3332
|
+
gitIn(homeRepo, ['update-index', '--cacheinfo', `100644,${sha},settings.json`]);
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
if (gitignoreAdds.length > 0) {
|
|
3337
|
+
stageGitignoreAdditions(homeRepo, gitignoreAdds);
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
const msg = 'chore(deploy): record installer-deployed config drift';
|
|
3341
|
+
const body = [
|
|
3342
|
+
'Recorded by ldm doctor --commit-deployed.',
|
|
3343
|
+
'',
|
|
3344
|
+
'Co-Authored-By: Parker Todd Brooks <parkertoddbrooks@users.noreply.github.com>',
|
|
3345
|
+
'Co-Authored-By: Lēsa <lesaai@icloud.com>',
|
|
3346
|
+
'Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>',
|
|
3347
|
+
'Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>',
|
|
3348
|
+
].join('\n');
|
|
3349
|
+
|
|
3350
|
+
// Land the recorded commit on the home repo's current branch using git
|
|
3351
|
+
// plumbing (write-tree + commit-tree + update-ref) rather than `git commit`.
|
|
3352
|
+
//
|
|
3353
|
+
// Home repos (~/.claude) commit on main, and the global pre-commit hook
|
|
3354
|
+
// blocks hand-commits on main (that is why the by-hand recipe branches). This
|
|
3355
|
+
// command is the sanctioned exception (per the policy parent: record deployed
|
|
3356
|
+
// drift on the home repo's branch), applied to a backed-up, classified,
|
|
3357
|
+
// key-split index. Plumbing lands that index as a commit without invoking the
|
|
3358
|
+
// pre-commit hook. This is NOT a force-past-the-hook bypass of a safety check
|
|
3359
|
+
// being evaded: the hook's intent (no hand-edits on main) is preserved, the
|
|
3360
|
+
// work is tool-driven and recoverable, and it does not push.
|
|
3361
|
+
const branchRef = (gitIn(homeRepo, ['symbolic-ref', 'HEAD']).stdout || '').trim();
|
|
3362
|
+
if (!branchRef) {
|
|
3363
|
+
console.log(' x Home repo is in a detached HEAD state; not recording. Check out a branch first.');
|
|
3364
|
+
console.log(` Nothing lost. Backup at ${backupDir.replace(HOME, '~')}; unstage with: git -C ${homeRepo.replace(HOME, '~')} reset`);
|
|
3365
|
+
return;
|
|
3366
|
+
}
|
|
3367
|
+
const parent = (gitIn(homeRepo, ['rev-parse', 'HEAD']).stdout || '').trim();
|
|
3368
|
+
const treeRes = gitIn(homeRepo, ['write-tree']);
|
|
3369
|
+
const commitRes = treeRes.status === 0
|
|
3370
|
+
? spawnSync('git', ['-C', homeRepo, 'commit-tree', treeRes.stdout.trim(), '-p', parent],
|
|
3371
|
+
{ input: `${msg}\n\n${body}\n`, encoding: 'utf8' })
|
|
3372
|
+
: { status: 1, stderr: treeRes.stderr };
|
|
3373
|
+
const newSha = (commitRes.stdout || '').trim();
|
|
3374
|
+
const upd = commitRes.status === 0
|
|
3375
|
+
? gitIn(homeRepo, ['update-ref', branchRef, newSha, parent])
|
|
3376
|
+
: { status: 1 };
|
|
3377
|
+
if (treeRes.status !== 0 || commitRes.status !== 0 || upd.status !== 0) {
|
|
3378
|
+
const why = ((treeRes.stderr || '') + (commitRes.stderr || '') + (upd.stderr || '')).trim();
|
|
3379
|
+
console.log(` x recording failed: ${why || 'unknown git error'}`);
|
|
3380
|
+
console.log(` Nothing lost. Backup at ${backupDir.replace(HOME, '~')}; unstage with: git -C ${homeRepo.replace(HOME, '~')} reset`);
|
|
3381
|
+
return;
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
console.log(' + Committed deployed config:');
|
|
3385
|
+
for (const p of deployed) console.log(` ${p}`);
|
|
3386
|
+
if (willCommitSettings) console.log(` settings.json (${settingsPlan.commitKeys.join(', ')})`);
|
|
3387
|
+
if (gitignoreAdds.length > 0) {
|
|
3388
|
+
console.log(' + Gitignored transient/runtime paths:');
|
|
3389
|
+
for (const pat of gitignoreAdds) console.log(` ${pat}`);
|
|
3390
|
+
}
|
|
3391
|
+
printLeft();
|
|
3392
|
+
|
|
3393
|
+
const after = gitIn(homeRepo, ['status', '--porcelain']);
|
|
3394
|
+
const dirtyLeft = (after.stdout || '').trim().split('\n').filter(Boolean);
|
|
3395
|
+
if (dirtyLeft.length === 0) {
|
|
3396
|
+
console.log(' + Tree clean. `git pull` is unblocked.');
|
|
3397
|
+
} else {
|
|
3398
|
+
console.log(` + Deployed/transient drift recorded; ${dirtyLeft.length} left change(s) remain (personal/tracked-transient). Pull is unblocked once those are resolved by you.`);
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
|
|
2844
3402
|
async function cmdDoctor() {
|
|
3403
|
+
// Distinct mode: record installer-deployed config drift in a tracked home.
|
|
3404
|
+
// Runs its own flow and returns before the normal health checks.
|
|
3405
|
+
if (COMMIT_DEPLOYED_FLAG) {
|
|
3406
|
+
commitDeployedConfig();
|
|
3407
|
+
return;
|
|
3408
|
+
}
|
|
3409
|
+
|
|
2845
3410
|
console.log('');
|
|
2846
3411
|
console.log(' ldm doctor');
|
|
2847
3412
|
console.log(' ────────────────────────────────────');
|
|
@@ -2891,6 +3456,27 @@ async function cmdDoctor() {
|
|
|
2891
3456
|
}
|
|
2892
3457
|
}
|
|
2893
3458
|
|
|
3459
|
+
// Warn on registry entries whose legacy source.npm value 404s on npm.
|
|
3460
|
+
// `ldm install` migrates these to updateSource.type=untracked; this catches
|
|
3461
|
+
// future drift and any entries the install-time probe couldn't reach.
|
|
3462
|
+
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
3463
|
+
try {
|
|
3464
|
+
const { findLegacyNpm404Entries, npmPackageExists } = await import('../lib/registry-migrations.mjs');
|
|
3465
|
+
const docRegistry = readJSON(REGISTRY_PATH);
|
|
3466
|
+
const legacy404 = await findLegacyNpm404Entries({
|
|
3467
|
+
registry: docRegistry,
|
|
3468
|
+
probeNpm: (name) => npmPackageExists(name, { timeoutMs: 1500 }),
|
|
3469
|
+
});
|
|
3470
|
+
if (legacy404.length > 0) {
|
|
3471
|
+
console.log('');
|
|
3472
|
+
console.log(` ! ${legacy404.length} extension(s) declare an npm source whose package does not exist on npm:`);
|
|
3473
|
+
for (const { name, npmName } of legacy404) {
|
|
3474
|
+
console.log(` ${name} (source.npm "${npmName}")`);
|
|
3475
|
+
}
|
|
3476
|
+
console.log(' Run `ldm install` to migrate these to updateSource.type=untracked.');
|
|
3477
|
+
}
|
|
3478
|
+
} catch {}
|
|
3479
|
+
|
|
2894
3480
|
// --fix: clean up registered-missing entries
|
|
2895
3481
|
if (FIX_FLAG && registeredMissing.length > 0) {
|
|
2896
3482
|
const registry = readJSON(REGISTRY_PATH);
|
|
@@ -2912,6 +3498,161 @@ async function cmdDoctor() {
|
|
|
2912
3498
|
}
|
|
2913
3499
|
}
|
|
2914
3500
|
|
|
3501
|
+
// Duplicate hook entries + invalid model value in ~/.claude/settings.json.
|
|
3502
|
+
// Duplicates: the same hook (event + matcher + commands) registered more
|
|
3503
|
+
// than once runs once per copy every session. 10 duplicate SessionStart
|
|
3504
|
+
// boot-hook entries observed 2026-07-04 (~450KB of boot context per
|
|
3505
|
+
// session start). Invalid model: a paste carrying ANSI escape codes can
|
|
3506
|
+
// land in /model and get persisted (e.g. "opus" + ESC + "[1m"); every new
|
|
3507
|
+
// session then fails its first API call. The corruption signal is a
|
|
3508
|
+
// CONTROL character (ESC 0x1B etc.), never the visible charset: bracketed
|
|
3509
|
+
// IDs like "claude-fable-5[1m]" are legitimate 1M-context variants and
|
|
3510
|
+
// must be left untouched.
|
|
3511
|
+
// Reported always; collapsed/removed under --fix with a timestamped
|
|
3512
|
+
// backup written before the first mutation.
|
|
3513
|
+
// See ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--installer-sessionstart-hook-duplicate-registration.md
|
|
3514
|
+
// and ai/product/bugs/guard/2026-07-04--cc-mini--no-blessed-recipe-for-live-settings-remediation.md
|
|
3515
|
+
{
|
|
3516
|
+
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
3517
|
+
const settings = readJSON(settingsPath);
|
|
3518
|
+
if (!settings && existsSync(settingsPath)) {
|
|
3519
|
+
console.log(' ! ~/.claude/settings.json exists but is not valid JSON; skipping hook/model checks');
|
|
3520
|
+
issues++;
|
|
3521
|
+
}
|
|
3522
|
+
let settingsDirty = false;
|
|
3523
|
+
let backupDone = false;
|
|
3524
|
+
const backupSettingsOnce = () => {
|
|
3525
|
+
if (backupDone) return;
|
|
3526
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3527
|
+
const bak = `${settingsPath}.bak-${stamp}`;
|
|
3528
|
+
copyFileSync(settingsPath, bak);
|
|
3529
|
+
console.log(` + Backup written: ${bak}`);
|
|
3530
|
+
backupDone = true;
|
|
3531
|
+
};
|
|
3532
|
+
|
|
3533
|
+
// Duplicate hook entries (same event + matcher + hook command list).
|
|
3534
|
+
// Deliberately ignores type/timeout: entries differing only in timeout
|
|
3535
|
+
// are treated as duplicates and collapse to the first.
|
|
3536
|
+
if (settings?.hooks) {
|
|
3537
|
+
const dupes = [];
|
|
3538
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
3539
|
+
if (!Array.isArray(groups)) continue;
|
|
3540
|
+
const seen = new Map();
|
|
3541
|
+
groups.forEach((g, i) => {
|
|
3542
|
+
const key = JSON.stringify({
|
|
3543
|
+
matcher: g?.matcher,
|
|
3544
|
+
hooks: (g?.hooks || []).map((h) => h?.command),
|
|
3545
|
+
});
|
|
3546
|
+
if (!seen.has(key)) seen.set(key, []);
|
|
3547
|
+
seen.get(key).push(i);
|
|
3548
|
+
});
|
|
3549
|
+
for (const idxs of seen.values()) {
|
|
3550
|
+
if (idxs.length > 1) dupes.push({ event, idxs });
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
const extra = dupes.reduce((n, d) => n + d.idxs.length - 1, 0);
|
|
3554
|
+
if (extra > 0) {
|
|
3555
|
+
for (const d of dupes) {
|
|
3556
|
+
const cmd = settings.hooks[d.event][d.idxs[0]]?.hooks?.[0]?.command || '(no command)';
|
|
3557
|
+
console.log(` ! ${d.event}: ${d.idxs.length} identical hook entries for: ${cmd}`);
|
|
3558
|
+
}
|
|
3559
|
+
if (FIX_FLAG) {
|
|
3560
|
+
backupSettingsOnce();
|
|
3561
|
+
for (const d of dupes) {
|
|
3562
|
+
// Keep the first, drop the rest. Right-to-left so indices stay valid.
|
|
3563
|
+
for (let j = d.idxs.length - 1; j >= 1; j--) {
|
|
3564
|
+
settings.hooks[d.event].splice(d.idxs[j], 1);
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
settingsDirty = true;
|
|
3568
|
+
console.log(` + Collapsed ${extra} duplicate hook entr${extra === 1 ? 'y' : 'ies'}`);
|
|
3569
|
+
} else {
|
|
3570
|
+
console.log(` Run: ldm doctor --fix to collapse ${extra} duplicate hook entr${extra === 1 ? 'y' : 'ies'}`);
|
|
3571
|
+
issues += extra;
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
// Invalid model value. Corruption means CONTROL characters (ESC 0x1B
|
|
3577
|
+
// from ANSI fragments, NUL, DEL...) or an impossible length, never a
|
|
3578
|
+
// visible-charset judgment: "claude-fable-5[1m]" is a legitimate
|
|
3579
|
+
// 1M-context model ID and must pass.
|
|
3580
|
+
if (settings && typeof settings.model === 'string') {
|
|
3581
|
+
const valid =
|
|
3582
|
+
settings.model.length > 0 &&
|
|
3583
|
+
settings.model.length <= 256 &&
|
|
3584
|
+
!/[\x00-\x1f\x7f]/.test(settings.model);
|
|
3585
|
+
if (!valid) {
|
|
3586
|
+
console.log(` ! settings.json model value is invalid: ${JSON.stringify(settings.model)}`);
|
|
3587
|
+
if (FIX_FLAG) {
|
|
3588
|
+
backupSettingsOnce();
|
|
3589
|
+
delete settings.model;
|
|
3590
|
+
settingsDirty = true;
|
|
3591
|
+
console.log(' + Removed invalid model value (re-pick with /model in Claude Code)');
|
|
3592
|
+
} else {
|
|
3593
|
+
console.log(' Run: ldm doctor --fix to remove it, then re-pick with /model in Claude Code');
|
|
3594
|
+
issues++;
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
if (settingsDirty) {
|
|
3600
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
// Boot hook stale at execution path.
|
|
3605
|
+
//
|
|
3606
|
+
// Split-brain deploys are silent: version tracking can say the boot hook
|
|
3607
|
+
// updated while the SessionStart hook still executes an older copy on disk
|
|
3608
|
+
// (2026-07-05: install reported alpha.32's boot trim active, but the live
|
|
3609
|
+
// payload was the untrimmed 49KB because syncBootHook wrote library/boot
|
|
3610
|
+
// while the hook ran shared/boot). Hash-compare the file the registered
|
|
3611
|
+
// hook actually runs against this CLI's src/boot/boot-hook.mjs (the code the
|
|
3612
|
+
// installer would deploy). Divergence means the hook is running stale code.
|
|
3613
|
+
// Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
|
|
3614
|
+
{
|
|
3615
|
+
const execTarget = getRegisteredBootHookTarget();
|
|
3616
|
+
const srcBoot = join(__dirname, '..', 'src', 'boot', 'boot-hook.mjs');
|
|
3617
|
+
const hashFile = (p) => {
|
|
3618
|
+
try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
|
|
3619
|
+
};
|
|
3620
|
+
if (execTarget && existsSync(srcBoot)) {
|
|
3621
|
+
const srcHash = hashFile(srcBoot);
|
|
3622
|
+
const execExists = existsSync(execTarget);
|
|
3623
|
+
const execHash = execExists ? hashFile(execTarget) : null;
|
|
3624
|
+
// Informational: which of the known deploy locations already matches src.
|
|
3625
|
+
const freshCandidates = [
|
|
3626
|
+
join(LDM_ROOT, 'shared', 'boot', 'boot-hook.mjs'),
|
|
3627
|
+
join(LDM_ROOT, 'library', 'boot', 'boot-hook.mjs'),
|
|
3628
|
+
].filter((p) => existsSync(p) && hashFile(p) === srcHash);
|
|
3629
|
+
if (!execExists || execHash !== srcHash) {
|
|
3630
|
+
const short = (h) => (h ? h.slice(0, 12) : 'missing');
|
|
3631
|
+
console.log(` ! boot hook stale at execution path: ${execTarget.replace(HOME, '~')}`);
|
|
3632
|
+
console.log(` the registered SessionStart hook runs ${short(execHash)}, current is ${short(srcHash)}`);
|
|
3633
|
+
if (freshCandidates.length > 0) {
|
|
3634
|
+
console.log(` a current copy exists at: ${freshCandidates[0].replace(HOME, '~')}`);
|
|
3635
|
+
}
|
|
3636
|
+
if (FIX_FLAG) {
|
|
3637
|
+
try {
|
|
3638
|
+
if (execExists) {
|
|
3639
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3640
|
+
copyFileSync(execTarget, `${execTarget}.bak-${stamp}`);
|
|
3641
|
+
}
|
|
3642
|
+
mkdirSync(dirname(execTarget), { recursive: true });
|
|
3643
|
+
copyFileSync(srcBoot, execTarget);
|
|
3644
|
+
console.log(' + Redeployed the current boot hook to the execution path');
|
|
3645
|
+
} catch (e) {
|
|
3646
|
+
console.log(` x Could not redeploy boot hook to execution path: ${e.message}`);
|
|
3647
|
+
}
|
|
3648
|
+
} else {
|
|
3649
|
+
console.log(' Run: ldm doctor --fix to redeploy the current boot hook to the execution path');
|
|
3650
|
+
issues++;
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
|
|
2915
3656
|
// --fix: clean registry entries with /tmp/ sources or ldm-install- names (#54)
|
|
2916
3657
|
if (FIX_FLAG) {
|
|
2917
3658
|
const registry = readJSON(REGISTRY_PATH);
|
|
@@ -3251,7 +3992,91 @@ async function cmdDoctor() {
|
|
|
3251
3992
|
|
|
3252
3993
|
// ── ldm status ──
|
|
3253
3994
|
|
|
3254
|
-
|
|
3995
|
+
const STATUS_NPM_TIMEOUT_MS = parsePositiveInt(process.env.LDM_STATUS_NPM_TIMEOUT_MS, 5000);
|
|
3996
|
+
const STATUS_TOTAL_BUDGET_MS = parsePositiveInt(process.env.LDM_STATUS_TOTAL_BUDGET_MS, 60000);
|
|
3997
|
+
const STATUS_NPM_CONCURRENCY = parsePositiveInt(process.env.LDM_STATUS_NPM_CONCURRENCY, 8);
|
|
3998
|
+
const STATUS_NPM_REGISTRY_URL = process.env.LDM_STATUS_NPM_REGISTRY_URL || 'https://registry.npmjs.org';
|
|
3999
|
+
|
|
4000
|
+
async function npmViewVersionForStatus(pkg, timeoutMs) {
|
|
4001
|
+
const controller = new AbortController();
|
|
4002
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
4003
|
+
try {
|
|
4004
|
+
const registry = STATUS_NPM_REGISTRY_URL.replace(/\/+$/, '');
|
|
4005
|
+
const response = await fetch(`${registry}/${encodeURIComponent(pkg)}`, {
|
|
4006
|
+
signal: controller.signal,
|
|
4007
|
+
headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
|
|
4008
|
+
});
|
|
4009
|
+
if (!response.ok) {
|
|
4010
|
+
const error = new Error(`npm registry returned ${response.status}`);
|
|
4011
|
+
error.statusCode = response.status;
|
|
4012
|
+
throw error;
|
|
4013
|
+
}
|
|
4014
|
+
const metadata = await response.json();
|
|
4015
|
+
return metadata?.['dist-tags']?.latest || '';
|
|
4016
|
+
} finally {
|
|
4017
|
+
clearTimeout(timeout);
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
function remainingStatusBudgetMs(startedAt) {
|
|
4022
|
+
return Math.max(0, STATUS_TOTAL_BUDGET_MS - (Date.now() - startedAt));
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
function formatStatusElapsed(ms) {
|
|
4026
|
+
if (!Number.isFinite(ms) || ms <= 0) return '0ms';
|
|
4027
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
4028
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
function classifyStatusCheckError(error) {
|
|
4032
|
+
if (error?.name === 'AbortError' || error?.signal === 'SIGTERM' || error?.code === 'ETIMEDOUT' || String(error?.message || '').includes('ETIMEDOUT')) {
|
|
4033
|
+
return 'timeout';
|
|
4034
|
+
}
|
|
4035
|
+
return 'unavailable';
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
async function runStatusProbesWithConcurrency(items, concurrency, statusStartedAt) {
|
|
4039
|
+
if (items.length === 0) return [];
|
|
4040
|
+
|
|
4041
|
+
const results = new Array(items.length);
|
|
4042
|
+
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
|
4043
|
+
let nextIndex = 0;
|
|
4044
|
+
|
|
4045
|
+
async function worker() {
|
|
4046
|
+
while (nextIndex < items.length) {
|
|
4047
|
+
const index = nextIndex;
|
|
4048
|
+
nextIndex += 1;
|
|
4049
|
+
const item = items[index];
|
|
4050
|
+
const remaining = remainingStatusBudgetMs(statusStartedAt);
|
|
4051
|
+
|
|
4052
|
+
if (remaining <= 0) {
|
|
4053
|
+
results[index] = { ...item, status: 'skipped', reason: 'budget', elapsedMs: 0 };
|
|
4054
|
+
continue;
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
const timeout = Math.min(STATUS_NPM_TIMEOUT_MS, remaining);
|
|
4058
|
+
const probeStartedAt = Date.now();
|
|
4059
|
+
console.log(` ${item.name}: checking npm`);
|
|
4060
|
+
|
|
4061
|
+
try {
|
|
4062
|
+
const latest = await npmViewVersionForStatus(item.npm, timeout);
|
|
4063
|
+
results[index] = { ...item, status: 'ok', latest, elapsedMs: Date.now() - probeStartedAt };
|
|
4064
|
+
} catch (error) {
|
|
4065
|
+
results[index] = {
|
|
4066
|
+
...item,
|
|
4067
|
+
status: 'skipped',
|
|
4068
|
+
reason: classifyStatusCheckError(error),
|
|
4069
|
+
elapsedMs: Date.now() - probeStartedAt,
|
|
4070
|
+
};
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
4076
|
+
return results;
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
async function cmdStatus() {
|
|
3255
4080
|
const version = readJSON(VERSION_PATH);
|
|
3256
4081
|
const registry = readJSON(REGISTRY_PATH);
|
|
3257
4082
|
const extCount = Object.keys(registry?.extensions || {}).length;
|
|
@@ -3272,18 +4097,46 @@ function cmdStatus() {
|
|
|
3272
4097
|
return;
|
|
3273
4098
|
}
|
|
3274
4099
|
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
4100
|
+
console.log('');
|
|
4101
|
+
console.log(` LDM OS v${version.version}`);
|
|
4102
|
+
console.log(` Installed: ${version.installed?.split('T')[0] || 'unknown'}`);
|
|
4103
|
+
console.log(` Updated: ${version.updated?.split('T')[0] || 'unknown'}`);
|
|
4104
|
+
console.log(` Extensions: ${extCount}`);
|
|
4105
|
+
console.log(` Root: ${LDM_ROOT}`);
|
|
4106
|
+
|
|
4107
|
+
const statusStartedAt = Date.now();
|
|
4108
|
+
const skipped = [];
|
|
4109
|
+
|
|
4110
|
+
console.log('');
|
|
4111
|
+
console.log(' Checking updates:');
|
|
3283
4112
|
|
|
3284
4113
|
// Check extensions against npm using registry source info (#262)
|
|
4114
|
+
const probeItems = [{
|
|
4115
|
+
kind: 'cli',
|
|
4116
|
+
name: 'ldm cli',
|
|
4117
|
+
npm: '@wipcomputer/wip-ldm-os',
|
|
4118
|
+
current: PKG_VERSION,
|
|
4119
|
+
}];
|
|
4120
|
+
|
|
3285
4121
|
const updates = [];
|
|
4122
|
+
const untrackedEntries = [];
|
|
3286
4123
|
for (const [name, info] of Object.entries(registry?.extensions || {})) {
|
|
4124
|
+
// Phase 1 of source-types refactor: entries explicitly marked as untracked
|
|
4125
|
+
// are listed in a separate section and never probed. Honest reporting:
|
|
4126
|
+
// we don't know how to check their source yet.
|
|
4127
|
+
if (info?.updateSource?.type === 'untracked') {
|
|
4128
|
+
const currentVersion = info?.installed?.version || info.version || 'unknown';
|
|
4129
|
+
untrackedEntries.push({ name, version: currentVersion });
|
|
4130
|
+
continue;
|
|
4131
|
+
}
|
|
4132
|
+
// Defensive skip for any other Phase 2 updateSource types (git, bundled,
|
|
4133
|
+
// private, etc.) that may appear in the registry before their probe
|
|
4134
|
+
// logic ships. Falling through would use the legacy info.source.npm
|
|
4135
|
+
// path, which is wrong for these types and would print them as
|
|
4136
|
+
// [unavailable]. Phase 2 replaces this skip with proper dispatch.
|
|
4137
|
+
if (info?.updateSource && info.updateSource.type !== 'npm') {
|
|
4138
|
+
continue;
|
|
4139
|
+
}
|
|
3287
4140
|
// Use registry source.npm (v2) or fall back to extension's package.json
|
|
3288
4141
|
let npmPkg = info?.source?.npm || null;
|
|
3289
4142
|
if (!npmPkg) {
|
|
@@ -3294,22 +4147,52 @@ function cmdStatus() {
|
|
|
3294
4147
|
if (!npmPkg) continue;
|
|
3295
4148
|
const currentVersion = info?.installed?.version || info.version;
|
|
3296
4149
|
if (!currentVersion) continue;
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
4150
|
+
probeItems.push({
|
|
4151
|
+
kind: 'extension',
|
|
4152
|
+
name,
|
|
4153
|
+
npm: npmPkg,
|
|
4154
|
+
current: currentVersion,
|
|
4155
|
+
});
|
|
4156
|
+
}
|
|
4157
|
+
untrackedEntries.sort((a, b) => a.name.localeCompare(b.name));
|
|
4158
|
+
|
|
4159
|
+
let cliUpdate = null;
|
|
4160
|
+
const probeResults = await runStatusProbesWithConcurrency(probeItems, STATUS_NPM_CONCURRENCY, statusStartedAt);
|
|
4161
|
+
for (const result of probeResults) {
|
|
4162
|
+
if (!result) continue;
|
|
4163
|
+
if (result.status === 'skipped') {
|
|
4164
|
+
skipped.push({
|
|
4165
|
+
name: result.name,
|
|
4166
|
+
npm: result.npm,
|
|
4167
|
+
reason: result.reason,
|
|
4168
|
+
elapsedMs: result.elapsedMs,
|
|
4169
|
+
});
|
|
4170
|
+
continue;
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
if (result.kind === 'cli') {
|
|
4174
|
+
if (result.latest && semverNewer(result.latest, PKG_VERSION)) cliUpdate = result.latest;
|
|
4175
|
+
} else if (result.latest && semverNewer(result.latest, result.current)) {
|
|
4176
|
+
updates.push({
|
|
4177
|
+
name: result.name,
|
|
4178
|
+
current: result.current,
|
|
4179
|
+
latest: result.latest,
|
|
4180
|
+
npm: result.npm,
|
|
4181
|
+
});
|
|
4182
|
+
}
|
|
3305
4183
|
}
|
|
3306
4184
|
|
|
3307
4185
|
console.log('');
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
4186
|
+
if (updates.length === 0 && !cliUpdate && skipped.length === 0 && untrackedEntries.length === 0) {
|
|
4187
|
+
console.log(' Update summary: all up to date');
|
|
4188
|
+
} else {
|
|
4189
|
+
const summaryParts = [];
|
|
4190
|
+
if (updates.length > 0) summaryParts.push(`${updates.length} extension update(s) available`);
|
|
4191
|
+
if (cliUpdate) summaryParts.push('CLI update available');
|
|
4192
|
+
if (untrackedEntries.length > 0) summaryParts.push(`${untrackedEntries.length} untracked`);
|
|
4193
|
+
if (skipped.length > 0) summaryParts.push(`${skipped.length} update check(s) skipped`);
|
|
4194
|
+
console.log(` Update summary: ${summaryParts.join(', ')}`);
|
|
4195
|
+
}
|
|
3313
4196
|
|
|
3314
4197
|
if (updates.length > 0) {
|
|
3315
4198
|
console.log('');
|
|
@@ -3325,6 +4208,24 @@ function cmdStatus() {
|
|
|
3325
4208
|
console.log(` CLI update: npm install -g @wipcomputer/wip-ldm-os@${cliUpdate}`);
|
|
3326
4209
|
}
|
|
3327
4210
|
|
|
4211
|
+
if (untrackedEntries.length > 0) {
|
|
4212
|
+
console.log('');
|
|
4213
|
+
console.log(' Untracked extensions (pending reclassification):');
|
|
4214
|
+
const maxNameLen = Math.max(...untrackedEntries.map(e => e.name.length));
|
|
4215
|
+
for (const e of untrackedEntries) {
|
|
4216
|
+
console.log(` ${e.name.padEnd(maxNameLen)} v${e.version}`);
|
|
4217
|
+
}
|
|
4218
|
+
console.log(' (run `ldm doctor --reclassify-sources` to classify these)');
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4221
|
+
if (skipped.length > 0) {
|
|
4222
|
+
console.log('');
|
|
4223
|
+
console.log(' Update checks skipped:');
|
|
4224
|
+
for (const item of skipped) {
|
|
4225
|
+
console.log(` ${item.name}: [${item.reason} ${formatStatusElapsed(item.elapsedMs)}] ${item.npm}`);
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
|
|
3328
4229
|
console.log('');
|
|
3329
4230
|
}
|
|
3330
4231
|
|
|
@@ -4780,7 +5681,7 @@ async function main() {
|
|
|
4780
5681
|
await cmdDoctor();
|
|
4781
5682
|
break;
|
|
4782
5683
|
case 'status':
|
|
4783
|
-
cmdStatus();
|
|
5684
|
+
await cmdStatus();
|
|
4784
5685
|
break;
|
|
4785
5686
|
case 'sessions':
|
|
4786
5687
|
await cmdSessions();
|