@wipcomputer/wip-ldm-os 0.2.2 → 0.2.3
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 +12 -5
- package/SKILL.md +1 -1
- package/bin/ldm.mjs +106 -100
- package/lib/deploy.mjs +4 -2
- package/lib/safe.mjs +113 -0
- package/lib/state.mjs +306 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,16 +23,23 @@ Open your AI and paste this:
|
|
|
23
23
|
Read the SKILL.md at github.com/wipcomputer/wip-ldm-os/blob/main/SKILL.md.
|
|
24
24
|
|
|
25
25
|
Then explain to me:
|
|
26
|
-
1. What
|
|
27
|
-
2. What
|
|
28
|
-
3. What would
|
|
26
|
+
1. What are these tools?
|
|
27
|
+
2. What do they do?
|
|
28
|
+
3. What would change about how we work together?
|
|
29
|
+
4. What would it change about how all my AI work?
|
|
30
|
+
|
|
31
|
+
Then ask me:
|
|
32
|
+
- Do you have more questions?
|
|
33
|
+
- Do you want to install it?
|
|
29
34
|
|
|
30
35
|
If I say yes, run: ldm install --dry-run
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
And show me exactly what will change on my system.
|
|
38
|
+
|
|
39
|
+
When I'm ready, I'll tell you to install for real.
|
|
33
40
|
```
|
|
34
41
|
|
|
35
|
-
That's it. Your AI reads the spec, explains what
|
|
42
|
+
That's it. Your AI reads the spec, explains what it does, and walks you through a dry run before touching anything.
|
|
36
43
|
|
|
37
44
|
## Included Skills
|
|
38
45
|
|
package/SKILL.md
CHANGED
package/bin/ldm.mjs
CHANGED
|
@@ -399,49 +399,82 @@ function autoDetectExtensions() {
|
|
|
399
399
|
return found;
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
-
// ── ldm install (bare): show
|
|
402
|
+
// ── ldm install (bare): scan system, show real state, update if needed ──
|
|
403
403
|
|
|
404
404
|
async function cmdInstallCatalog() {
|
|
405
405
|
autoDetectExtensions();
|
|
406
|
+
|
|
407
|
+
const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
|
|
408
|
+
const state = detectSystemState();
|
|
409
|
+
const reconciled = reconcileState(state);
|
|
410
|
+
|
|
411
|
+
// Show the real system state
|
|
412
|
+
console.log(formatReconciliation(reconciled));
|
|
413
|
+
|
|
414
|
+
// Show catalog items not yet managed
|
|
406
415
|
const registry = readJSON(REGISTRY_PATH);
|
|
407
|
-
const
|
|
416
|
+
const registeredNames = Object.keys(registry?.extensions || {});
|
|
408
417
|
const components = loadCatalog();
|
|
418
|
+
const available = components.filter(c =>
|
|
419
|
+
c.status !== 'coming-soon'
|
|
420
|
+
&& !registeredNames.includes(c.id)
|
|
421
|
+
// Also check if it's in reconciled as external (already installed outside LDM)
|
|
422
|
+
&& !reconciled[c.id]
|
|
423
|
+
);
|
|
409
424
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
console.log(' Installed components:');
|
|
415
|
-
for (const name of installed) {
|
|
416
|
-
const info = registry.extensions[name];
|
|
417
|
-
console.log(` [x] ${name} v${info?.version || '?'}`);
|
|
425
|
+
if (available.length > 0) {
|
|
426
|
+
console.log(' Available in catalog (not yet installed):');
|
|
427
|
+
for (const c of available) {
|
|
428
|
+
console.log(` [ ] ${c.name} ... ${c.description}`);
|
|
418
429
|
}
|
|
419
430
|
console.log('');
|
|
420
431
|
}
|
|
421
432
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
433
|
+
if (DRY_RUN) {
|
|
434
|
+
// Show what an update would do
|
|
435
|
+
const updatable = Object.values(reconciled).filter(e =>
|
|
436
|
+
e.status === 'healthy' && e.registryHasSource
|
|
437
|
+
);
|
|
438
|
+
const unlinked = Object.values(reconciled).filter(e =>
|
|
439
|
+
e.status === 'installed-unlinked'
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
if (updatable.length > 0) {
|
|
443
|
+
console.log(` Would update ${updatable.length} extension(s) from source repos.`);
|
|
444
|
+
console.log(' No data (crystal.db, secrets, agent files) would be touched.');
|
|
445
|
+
console.log(' Old versions would be moved to ~/.ldm/_trash/ (never deleted).');
|
|
446
|
+
} else {
|
|
447
|
+
console.log(' Nothing to update from source repos.');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (unlinked.length > 0) {
|
|
451
|
+
console.log('');
|
|
452
|
+
console.log(` ${unlinked.length} extension(s) are installed but have no source repo linked.`);
|
|
453
|
+
console.log(' These are safe. Link them with: ldm install <org/repo>');
|
|
436
454
|
}
|
|
455
|
+
|
|
456
|
+
console.log('');
|
|
457
|
+
console.log(' Dry run complete. No changes made.');
|
|
458
|
+
console.log('');
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Real update: only touch things with linked source repos
|
|
463
|
+
const updatable = Object.values(reconciled).filter(e =>
|
|
464
|
+
e.registryHasSource
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
if (updatable.length === 0) {
|
|
468
|
+
console.log(' No extensions have linked source repos to update from.');
|
|
469
|
+
console.log(' Link them with: ldm install <org/repo>');
|
|
437
470
|
console.log('');
|
|
438
471
|
|
|
439
|
-
//
|
|
440
|
-
if (
|
|
472
|
+
// Still offer catalog install if TTY
|
|
473
|
+
if (available.length > 0 && !YES_FLAG && !NONE_FLAG && process.stdin.isTTY) {
|
|
441
474
|
const { createInterface } = await import('node:readline');
|
|
442
475
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
443
476
|
const answer = await new Promise((resolve) => {
|
|
444
|
-
rl.question(' Install
|
|
477
|
+
rl.question(' Install from catalog? [number,all,none]: ', (a) => {
|
|
445
478
|
rl.close();
|
|
446
479
|
resolve(a.trim().toLowerCase());
|
|
447
480
|
});
|
|
@@ -450,10 +483,10 @@ async function cmdInstallCatalog() {
|
|
|
450
483
|
if (answer && answer !== 'none' && answer !== 'n') {
|
|
451
484
|
let toInstall = [];
|
|
452
485
|
if (answer === 'all' || answer === 'a') {
|
|
453
|
-
toInstall =
|
|
486
|
+
toInstall = available;
|
|
454
487
|
} else {
|
|
455
488
|
const nums = answer.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
|
456
|
-
toInstall = nums.map(n =>
|
|
489
|
+
toInstall = nums.map(n => available[n - 1]).filter(Boolean);
|
|
457
490
|
}
|
|
458
491
|
for (const c of toInstall) {
|
|
459
492
|
console.log(` Installing ${c.name}...`);
|
|
@@ -465,51 +498,45 @@ async function cmdInstallCatalog() {
|
|
|
465
498
|
}
|
|
466
499
|
}
|
|
467
500
|
}
|
|
501
|
+
return;
|
|
468
502
|
}
|
|
469
503
|
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const registry = readJSON(REGISTRY_PATH);
|
|
484
|
-
if (!registry?.extensions || Object.keys(registry.extensions).length === 0) return;
|
|
504
|
+
// Write revert manifest before starting
|
|
505
|
+
const { createRevertManifest } = await import('../lib/safe.mjs');
|
|
506
|
+
const manifestPath = createRevertManifest(
|
|
507
|
+
`ldm install (update ${updatable.length} extensions)`,
|
|
508
|
+
updatable.map(e => ({
|
|
509
|
+
action: 'update',
|
|
510
|
+
name: e.name,
|
|
511
|
+
currentVersion: e.ldmVersion || e.registryVersion,
|
|
512
|
+
source: e.registrySource,
|
|
513
|
+
}))
|
|
514
|
+
);
|
|
515
|
+
console.log(` Revert plan saved: ${manifestPath}`);
|
|
516
|
+
console.log('');
|
|
485
517
|
|
|
486
518
|
const { setFlags, installFromPath } = await import('../lib/deploy.mjs');
|
|
487
519
|
setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT });
|
|
488
520
|
|
|
489
|
-
const extensions = Object.entries(registry.extensions);
|
|
490
|
-
console.log(` Updating ${extensions.length} registered extension(s)...`);
|
|
491
|
-
console.log('');
|
|
492
|
-
|
|
493
521
|
let updated = 0;
|
|
494
|
-
for (const
|
|
495
|
-
|
|
496
|
-
if (!source || !existsSync(source)) {
|
|
497
|
-
console.log(` x ${name}: source not found at ${source || '(none)'}`);
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
await installFromPath(source);
|
|
522
|
+
for (const entry of updatable) {
|
|
523
|
+
await installFromPath(entry.registrySource);
|
|
502
524
|
updated++;
|
|
503
525
|
}
|
|
504
526
|
|
|
505
527
|
console.log('');
|
|
506
|
-
console.log(` Updated ${updated}/${
|
|
528
|
+
console.log(` Updated ${updated}/${updatable.length} extension(s).`);
|
|
507
529
|
console.log('');
|
|
508
530
|
}
|
|
509
531
|
|
|
532
|
+
async function cmdUpdateAll() {
|
|
533
|
+
// Delegate to the catalog flow which now has full state awareness
|
|
534
|
+
return cmdInstallCatalog();
|
|
535
|
+
}
|
|
536
|
+
|
|
510
537
|
// ── ldm doctor ──
|
|
511
538
|
|
|
512
|
-
function cmdDoctor() {
|
|
539
|
+
async function cmdDoctor() {
|
|
513
540
|
console.log('');
|
|
514
541
|
console.log(' ldm doctor');
|
|
515
542
|
console.log(' ────────────────────────────────────');
|
|
@@ -539,43 +566,19 @@ function cmdDoctor() {
|
|
|
539
566
|
console.log(` + version.json: v${version.version} (installed ${version.installed?.split('T')[0]})`);
|
|
540
567
|
}
|
|
541
568
|
|
|
542
|
-
// 3.
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
issues++;
|
|
547
|
-
} else {
|
|
548
|
-
const extCount = Object.keys(registry.extensions || {}).length;
|
|
549
|
-
console.log(` + registry.json: ${extCount} extension(s)`);
|
|
550
|
-
|
|
551
|
-
// Check each extension
|
|
552
|
-
for (const [name, info] of Object.entries(registry.extensions || {})) {
|
|
553
|
-
const ldmPath = info.ldmPath || join(LDM_EXTENSIONS, name);
|
|
554
|
-
const hasLdm = existsSync(ldmPath);
|
|
555
|
-
const hasPkg = hasLdm && existsSync(join(ldmPath, 'package.json'));
|
|
556
|
-
|
|
557
|
-
if (!hasLdm) {
|
|
558
|
-
console.log(` x ${name}: not found at ${ldmPath}`);
|
|
559
|
-
issues++;
|
|
560
|
-
} else if (!hasPkg) {
|
|
561
|
-
console.log(` x ${name}: deployed but missing package.json`);
|
|
562
|
-
issues++;
|
|
563
|
-
} else {
|
|
564
|
-
const pkg = readJSON(join(ldmPath, 'package.json'));
|
|
565
|
-
const ver = pkg?.version || '?';
|
|
566
|
-
const registeredVer = info.version || '?';
|
|
567
|
-
if (ver !== registeredVer) {
|
|
568
|
-
console.log(` ! ${name}: deployed v${ver} but registry says v${registeredVer}`);
|
|
569
|
-
} else {
|
|
570
|
-
console.log(` + ${name}: v${ver}`);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
569
|
+
// 3. Full system state scan
|
|
570
|
+
const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
|
|
571
|
+
const state = detectSystemState();
|
|
572
|
+
const reconciled = reconcileState(state);
|
|
573
573
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
574
|
+
// Show reconciled view
|
|
575
|
+
console.log(formatReconciliation(reconciled, { verbose: true }));
|
|
576
|
+
|
|
577
|
+
// Count issues from reconciliation
|
|
578
|
+
for (const entry of Object.values(reconciled)) {
|
|
579
|
+
if (entry.status === 'registered-missing') issues++;
|
|
580
|
+
if (entry.issues.length > 0 && entry.status !== 'installed-unlinked' && entry.status !== 'external') {
|
|
581
|
+
issues += entry.issues.length;
|
|
579
582
|
}
|
|
580
583
|
}
|
|
581
584
|
|
|
@@ -591,6 +594,7 @@ function cmdDoctor() {
|
|
|
591
594
|
console.log(` + ${s.label} exists`);
|
|
592
595
|
} else {
|
|
593
596
|
console.log(` ! ${s.label} missing (run: ldm init)`);
|
|
597
|
+
issues++;
|
|
594
598
|
}
|
|
595
599
|
}
|
|
596
600
|
|
|
@@ -604,12 +608,14 @@ function cmdDoctor() {
|
|
|
604
608
|
console.log(` - Claude Code hooks: none configured`);
|
|
605
609
|
}
|
|
606
610
|
|
|
607
|
-
// 6.
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
611
|
+
// 6. MCP servers
|
|
612
|
+
const mcpCount = Object.keys(state.mcp).length;
|
|
613
|
+
console.log(` + MCP servers: ${mcpCount} registered`);
|
|
614
|
+
|
|
615
|
+
// 7. CLI binaries
|
|
616
|
+
const binCount = Object.keys(state.cliBinaries).length;
|
|
617
|
+
if (binCount > 0) {
|
|
618
|
+
console.log(` + CLI binaries: ${Object.keys(state.cliBinaries).join(', ')}`);
|
|
613
619
|
}
|
|
614
620
|
|
|
615
621
|
console.log('');
|
package/lib/deploy.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { join, basename, resolve, dirname } from 'node:path';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
19
|
import { detectInterfaces, describeInterfaces, detectToolbox } from './detect.mjs';
|
|
20
|
+
import { moveToTrash, appendToManifest } from './safe.mjs';
|
|
20
21
|
|
|
21
22
|
const HOME = process.env.HOME || '';
|
|
22
23
|
const LDM_ROOT = join(HOME, '.ldm');
|
|
@@ -199,9 +200,10 @@ function safeDeployDir(repoPath, destDir, name) {
|
|
|
199
200
|
}
|
|
200
201
|
renameSync(tempPath, finalPath);
|
|
201
202
|
|
|
202
|
-
// 5.
|
|
203
|
+
// 5. Trash the old version (never delete)
|
|
203
204
|
if (existsSync(backupPath)) {
|
|
204
|
-
|
|
205
|
+
const trashed = moveToTrash(backupPath);
|
|
206
|
+
if (trashed) ok(`Old version moved to ${trashed}`);
|
|
205
207
|
}
|
|
206
208
|
|
|
207
209
|
return true;
|
package/lib/safe.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/safe.mjs
|
|
3
|
+
* Safe file operations. Never delete. Always trash. Always write revert plans.
|
|
4
|
+
* Follows the _trash/ pattern from the DevOps Toolkit.
|
|
5
|
+
* Zero dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, renameSync, writeFileSync, readFileSync, cpSync } from 'node:fs';
|
|
9
|
+
import { join, basename, dirname } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const HOME = process.env.HOME || '';
|
|
12
|
+
const LDM_ROOT = join(HOME, '.ldm');
|
|
13
|
+
const TRASH_ROOT = join(LDM_ROOT, '_trash');
|
|
14
|
+
|
|
15
|
+
function readJSON(path) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Trash ──
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Move a file or directory to ~/.ldm/_trash/YYYY-MM-DD/ instead of deleting.
|
|
27
|
+
* Returns the trash destination path, or null if source didn't exist.
|
|
28
|
+
*/
|
|
29
|
+
export function moveToTrash(sourcePath) {
|
|
30
|
+
if (!existsSync(sourcePath)) return null;
|
|
31
|
+
|
|
32
|
+
const date = new Date().toISOString().split('T')[0];
|
|
33
|
+
const trashDir = join(TRASH_ROOT, date);
|
|
34
|
+
const name = basename(sourcePath);
|
|
35
|
+
|
|
36
|
+
mkdirSync(trashDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
let dest = join(trashDir, name);
|
|
39
|
+
if (existsSync(dest)) {
|
|
40
|
+
dest = join(trashDir, `${name}-${Date.now()}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
renameSync(sourcePath, dest);
|
|
44
|
+
return dest;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Revert Manifests ──
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a revert manifest before a batch of operations.
|
|
51
|
+
* Each operation: { action, name, description, originalPath, trashPath }
|
|
52
|
+
* Returns the manifest file path.
|
|
53
|
+
*/
|
|
54
|
+
export function createRevertManifest(description, operations = []) {
|
|
55
|
+
const date = new Date().toISOString().split('T')[0];
|
|
56
|
+
const time = new Date().toISOString().replace(/[:.]/g, '-');
|
|
57
|
+
const manifestDir = join(TRASH_ROOT, date);
|
|
58
|
+
const manifestPath = join(manifestDir, `revert-${time}.json`);
|
|
59
|
+
|
|
60
|
+
mkdirSync(manifestDir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
const manifest = {
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
description,
|
|
65
|
+
operations,
|
|
66
|
+
howToRevert: 'For each operation with a trashPath, move it back to originalPath.',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
70
|
+
return manifestPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Append a completed operation to an existing revert manifest.
|
|
75
|
+
*/
|
|
76
|
+
export function appendToManifest(manifestPath, operation) {
|
|
77
|
+
if (!existsSync(manifestPath)) return;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
81
|
+
manifest.operations.push(operation);
|
|
82
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Safe copy (backup before overwrite) ──
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Copy a directory, but if the destination already exists, trash it first.
|
|
90
|
+
* Returns { trashPath, destPath } or null on failure.
|
|
91
|
+
*/
|
|
92
|
+
export function safeCopy(srcPath, destPath) {
|
|
93
|
+
let trashPath = null;
|
|
94
|
+
|
|
95
|
+
if (existsSync(destPath)) {
|
|
96
|
+
trashPath = moveToTrash(destPath);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
101
|
+
cpSync(srcPath, destPath, {
|
|
102
|
+
recursive: true,
|
|
103
|
+
filter: (s) => !s.includes('.git') && !s.includes('node_modules') && !s.includes('/ai/'),
|
|
104
|
+
});
|
|
105
|
+
return { trashPath, destPath };
|
|
106
|
+
} catch (e) {
|
|
107
|
+
// Rollback: restore from trash if copy failed
|
|
108
|
+
if (trashPath && existsSync(trashPath) && !existsSync(destPath)) {
|
|
109
|
+
try { renameSync(trashPath, destPath); } catch {}
|
|
110
|
+
}
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/state.mjs
|
|
3
|
+
* System state detection and reconciliation.
|
|
4
|
+
* Scans the actual system (MCP servers, extensions, CLIs) to find what's
|
|
5
|
+
* really installed, regardless of what the LDM registry thinks.
|
|
6
|
+
* Zero dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { join, basename } from 'node:path';
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const HOME = process.env.HOME || '';
|
|
14
|
+
const LDM_ROOT = join(HOME, '.ldm');
|
|
15
|
+
const LDM_EXTENSIONS = join(LDM_ROOT, 'extensions');
|
|
16
|
+
const OC_ROOT = join(HOME, '.openclaw');
|
|
17
|
+
const OC_EXTENSIONS = join(OC_ROOT, 'extensions');
|
|
18
|
+
const CC_USER_PATH = join(HOME, '.claude.json');
|
|
19
|
+
const REGISTRY_PATH = join(LDM_EXTENSIONS, 'registry.json');
|
|
20
|
+
|
|
21
|
+
function readJSON(path) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Scanners ──
|
|
30
|
+
|
|
31
|
+
function detectMCPServers() {
|
|
32
|
+
const ccUser = readJSON(CC_USER_PATH);
|
|
33
|
+
const servers = {};
|
|
34
|
+
if (!ccUser?.mcpServers) return servers;
|
|
35
|
+
|
|
36
|
+
for (const [name, config] of Object.entries(ccUser.mcpServers)) {
|
|
37
|
+
const args = config.args || [];
|
|
38
|
+
servers[name] = {
|
|
39
|
+
name,
|
|
40
|
+
command: config.command,
|
|
41
|
+
args,
|
|
42
|
+
path: args[0] || null,
|
|
43
|
+
env: config.env || {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return servers;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scanExtensionDir(dir) {
|
|
50
|
+
const extensions = {};
|
|
51
|
+
if (!existsSync(dir)) return extensions;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (entry.name === 'registry.json') continue;
|
|
57
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
|
58
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
59
|
+
|
|
60
|
+
const dirPath = join(dir, entry.name);
|
|
61
|
+
const pkg = readJSON(join(dirPath, 'package.json'));
|
|
62
|
+
const plugin = readJSON(join(dirPath, 'openclaw.plugin.json'));
|
|
63
|
+
|
|
64
|
+
extensions[entry.name] = {
|
|
65
|
+
name: entry.name,
|
|
66
|
+
path: dirPath,
|
|
67
|
+
version: pkg?.version || null,
|
|
68
|
+
packageName: pkg?.name || null,
|
|
69
|
+
pluginId: plugin?.id || null,
|
|
70
|
+
hasPackageJson: !!pkg,
|
|
71
|
+
hasPluginJson: !!plugin,
|
|
72
|
+
hasMcpServer: existsSync(join(dirPath, 'mcp-server.mjs'))
|
|
73
|
+
|| existsSync(join(dirPath, 'mcp-server.js'))
|
|
74
|
+
|| existsSync(join(dirPath, 'dist', 'mcp-server.js')),
|
|
75
|
+
hasSkill: existsSync(join(dirPath, 'SKILL.md')),
|
|
76
|
+
isSymlink: entry.isSymbolicLink(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
return extensions;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function detectCLIBinaries() {
|
|
84
|
+
const knownBins = [
|
|
85
|
+
'crystal', 'mdview', 'wip-release', 'wip-repos', 'wip-file-guard',
|
|
86
|
+
'ldm', 'ldm-scaffold', 'wip-install', 'wip-license',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const binaries = {};
|
|
90
|
+
for (const bin of knownBins) {
|
|
91
|
+
try {
|
|
92
|
+
const path = execSync(`which ${bin} 2>/dev/null`, { encoding: 'utf8' }).trim();
|
|
93
|
+
if (path) {
|
|
94
|
+
binaries[bin] = { path };
|
|
95
|
+
try {
|
|
96
|
+
const ver = execSync(`${bin} --version 2>/dev/null`, { encoding: 'utf8' }).trim().split('\n')[0];
|
|
97
|
+
binaries[bin].version = ver;
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
return binaries;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function detectSkills() {
|
|
106
|
+
const skillsDir = join(OC_ROOT, 'skills');
|
|
107
|
+
const skills = {};
|
|
108
|
+
if (!existsSync(skillsDir)) return skills;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (!entry.isDirectory()) continue;
|
|
114
|
+
const skillPath = join(skillsDir, entry.name, 'SKILL.md');
|
|
115
|
+
if (existsSync(skillPath)) {
|
|
116
|
+
skills[entry.name] = { path: skillPath };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
return skills;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Main detection ──
|
|
124
|
+
|
|
125
|
+
export function detectSystemState() {
|
|
126
|
+
const registry = readJSON(REGISTRY_PATH) || { _format: 'v1', extensions: {} };
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
mcp: detectMCPServers(),
|
|
130
|
+
ldmExtensions: scanExtensionDir(LDM_EXTENSIONS),
|
|
131
|
+
ocExtensions: scanExtensionDir(OC_EXTENSIONS),
|
|
132
|
+
cliBinaries: detectCLIBinaries(),
|
|
133
|
+
skills: detectSkills(),
|
|
134
|
+
registry: registry.extensions || {},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Reconciliation ──
|
|
139
|
+
|
|
140
|
+
export function reconcileState(systemState) {
|
|
141
|
+
const all = new Set();
|
|
142
|
+
|
|
143
|
+
// Collect all known names from every source
|
|
144
|
+
for (const name of Object.keys(systemState.registry)) all.add(name);
|
|
145
|
+
for (const name of Object.keys(systemState.ldmExtensions)) all.add(name);
|
|
146
|
+
for (const name of Object.keys(systemState.ocExtensions)) all.add(name);
|
|
147
|
+
for (const name of Object.keys(systemState.mcp)) all.add(name);
|
|
148
|
+
|
|
149
|
+
const reconciled = {};
|
|
150
|
+
|
|
151
|
+
for (const name of all) {
|
|
152
|
+
const reg = systemState.registry[name];
|
|
153
|
+
const ldm = systemState.ldmExtensions[name];
|
|
154
|
+
const oc = systemState.ocExtensions[name];
|
|
155
|
+
const mcp = systemState.mcp[name];
|
|
156
|
+
|
|
157
|
+
const entry = {
|
|
158
|
+
name,
|
|
159
|
+
// Registry
|
|
160
|
+
inRegistry: !!reg,
|
|
161
|
+
registryVersion: reg?.version || null,
|
|
162
|
+
registrySource: reg?.source || null,
|
|
163
|
+
registryHasSource: !!(reg?.source && existsSync(reg.source)),
|
|
164
|
+
registryInterfaces: reg?.interfaces || [],
|
|
165
|
+
// Deployed
|
|
166
|
+
deployedLdm: !!ldm,
|
|
167
|
+
ldmVersion: ldm?.version || null,
|
|
168
|
+
deployedOc: !!oc,
|
|
169
|
+
ocVersion: oc?.version || null,
|
|
170
|
+
// MCP
|
|
171
|
+
mcpRegistered: !!mcp,
|
|
172
|
+
mcpPath: mcp?.path || null,
|
|
173
|
+
// Computed
|
|
174
|
+
status: 'unknown',
|
|
175
|
+
issues: [],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// ── Determine status ──
|
|
179
|
+
|
|
180
|
+
if (entry.inRegistry && entry.deployedLdm && entry.registryHasSource) {
|
|
181
|
+
entry.status = 'healthy';
|
|
182
|
+
} else if (entry.inRegistry && (entry.deployedLdm || entry.deployedOc) && !entry.registryHasSource) {
|
|
183
|
+
// Deployed and registered, but no source repo linked
|
|
184
|
+
entry.status = 'installed-unlinked';
|
|
185
|
+
if (!reg?.source) {
|
|
186
|
+
entry.issues.push('No source repo linked. Run: ldm install <org/repo> to link.');
|
|
187
|
+
} else {
|
|
188
|
+
entry.issues.push(`Source not found at: ${reg.source}`);
|
|
189
|
+
}
|
|
190
|
+
} else if (entry.inRegistry && !entry.deployedLdm && !entry.deployedOc) {
|
|
191
|
+
entry.status = 'registered-missing';
|
|
192
|
+
entry.issues.push('In registry but not deployed anywhere.');
|
|
193
|
+
} else if (!entry.inRegistry && (entry.deployedLdm || entry.deployedOc)) {
|
|
194
|
+
entry.status = 'deployed-unregistered';
|
|
195
|
+
entry.issues.push('Deployed but not in LDM registry.');
|
|
196
|
+
} else if (!entry.inRegistry && !entry.deployedLdm && !entry.deployedOc && entry.mcpRegistered) {
|
|
197
|
+
entry.status = 'mcp-only';
|
|
198
|
+
entry.issues.push('MCP server registered but not managed by LDM.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Version mismatches
|
|
202
|
+
if (entry.ldmVersion && entry.ocVersion && entry.ldmVersion !== entry.ocVersion) {
|
|
203
|
+
entry.issues.push(`Version mismatch: LDM v${entry.ldmVersion} vs OC v${entry.ocVersion}`);
|
|
204
|
+
}
|
|
205
|
+
if (entry.registryVersion && entry.ldmVersion && entry.registryVersion !== entry.ldmVersion) {
|
|
206
|
+
entry.issues.push(`Registry says v${entry.registryVersion} but deployed is v${entry.ldmVersion}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// MCP path sanity
|
|
210
|
+
if (entry.mcpRegistered && entry.deployedLdm && entry.mcpPath) {
|
|
211
|
+
const expectedBase = join(LDM_EXTENSIONS, name);
|
|
212
|
+
if (!entry.mcpPath.startsWith(expectedBase)) {
|
|
213
|
+
entry.issues.push(`MCP path does not match LDM extension location.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
reconciled[name] = entry;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return reconciled;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Display ──
|
|
224
|
+
|
|
225
|
+
export function formatReconciliation(reconciled, { verbose = false } = {}) {
|
|
226
|
+
const healthy = [];
|
|
227
|
+
const unlinked = [];
|
|
228
|
+
const needsAttention = [];
|
|
229
|
+
const external = [];
|
|
230
|
+
|
|
231
|
+
for (const entry of Object.values(reconciled)) {
|
|
232
|
+
switch (entry.status) {
|
|
233
|
+
case 'healthy':
|
|
234
|
+
healthy.push(entry);
|
|
235
|
+
break;
|
|
236
|
+
case 'installed-unlinked':
|
|
237
|
+
unlinked.push(entry);
|
|
238
|
+
break;
|
|
239
|
+
case 'mcp-only':
|
|
240
|
+
case 'deployed-unregistered':
|
|
241
|
+
external.push(entry);
|
|
242
|
+
break;
|
|
243
|
+
default:
|
|
244
|
+
needsAttention.push(entry);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const sort = (a, b) => a.name.localeCompare(b.name);
|
|
249
|
+
const lines = [];
|
|
250
|
+
|
|
251
|
+
lines.push('');
|
|
252
|
+
lines.push(' System State');
|
|
253
|
+
lines.push(' ────────────────────────────────────');
|
|
254
|
+
|
|
255
|
+
if (healthy.length > 0) {
|
|
256
|
+
lines.push('');
|
|
257
|
+
lines.push(` Managed by LDM (${healthy.length}):`);
|
|
258
|
+
for (const e of healthy.sort(sort)) {
|
|
259
|
+
const ver = e.ldmVersion || e.registryVersion || '?';
|
|
260
|
+
const ifaces = e.registryInterfaces.length > 0 ? ` (${e.registryInterfaces.join(', ')})` : '';
|
|
261
|
+
const mcp = e.mcpRegistered ? ' [MCP]' : '';
|
|
262
|
+
lines.push(` [x] ${e.name} v${ver}${ifaces}${mcp}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (unlinked.length > 0) {
|
|
267
|
+
lines.push('');
|
|
268
|
+
lines.push(` Installed, no source repo linked (${unlinked.length}):`);
|
|
269
|
+
lines.push(` (These work fine. Link them to enable updates via ldm install.)`);
|
|
270
|
+
for (const e of unlinked.sort(sort)) {
|
|
271
|
+
const ver = e.ldmVersion || e.ocVersion || e.registryVersion || '?';
|
|
272
|
+
const mcp = e.mcpRegistered ? ' [MCP]' : '';
|
|
273
|
+
lines.push(` [-] ${e.name} v${ver}${mcp}`);
|
|
274
|
+
if (verbose) {
|
|
275
|
+
for (const issue of e.issues) {
|
|
276
|
+
lines.push(` ${issue}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (external.length > 0) {
|
|
283
|
+
lines.push('');
|
|
284
|
+
lines.push(` Not managed by LDM (${external.length}):`);
|
|
285
|
+
for (const e of external.sort(sort)) {
|
|
286
|
+
const ver = e.ldmVersion || e.ocVersion || '?';
|
|
287
|
+
const detail = e.mcpRegistered ? 'MCP registered' : 'extension deployed';
|
|
288
|
+
lines.push(` [ ] ${e.name} v${ver} ... ${detail}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (needsAttention.length > 0) {
|
|
293
|
+
lines.push('');
|
|
294
|
+
lines.push(` Needs attention (${needsAttention.length}):`);
|
|
295
|
+
for (const e of needsAttention.sort(sort)) {
|
|
296
|
+
const ver = e.ldmVersion || e.ocVersion || e.registryVersion || '?';
|
|
297
|
+
lines.push(` [!] ${e.name} v${ver} ... ${e.status}`);
|
|
298
|
+
for (const issue of e.issues) {
|
|
299
|
+
lines.push(` ${issue}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
lines.push('');
|
|
305
|
+
return lines.join('\n');
|
|
306
|
+
}
|