@wipcomputer/wip-ldm-os 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,7 +103,7 @@ The OS connects your AIs. Add-ons are what they actually use. Each one is a full
103
103
 
104
104
  ## More Info
105
105
 
106
- - [Architecture, principles, and technical details](docs/universal-installer/TECHNICAL.md)
106
+ - [Architecture, principles, and technical details](TECHNICAL.md)
107
107
 
108
108
  ## License
109
109
 
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ license: MIT
5
5
  interface: [cli, skill]
6
6
  metadata:
7
7
  display-name: "LDM OS"
8
- version: "0.4.2"
8
+ version: "0.4.4"
9
9
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
10
10
  author: "Parker Todd Brooks"
11
11
  category: infrastructure
package/bin/ldm.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * ldm --version Show version
18
18
  */
19
19
 
20
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync } from 'node:fs';
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync } from 'node:fs';
21
21
  import { join, basename, resolve, dirname } from 'node:path';
22
22
  import { execSync } from 'node:child_process';
23
23
  import { fileURLToPath } from 'node:url';
@@ -58,6 +58,38 @@ if (existsSync(VERSION_PATH)) {
58
58
  } catch {}
59
59
  }
60
60
 
61
+ // ── Install lockfile (#57) ──
62
+
63
+ const LOCK_PATH = join(LDM_ROOT, 'state', '.ldm-install.lock');
64
+
65
+ function acquireInstallLock() {
66
+ try {
67
+ if (existsSync(LOCK_PATH)) {
68
+ const lock = JSON.parse(readFileSync(LOCK_PATH, 'utf8'));
69
+ // Check if PID is still alive
70
+ try {
71
+ process.kill(lock.pid, 0); // signal 0 = just check if alive
72
+ console.log(` Another ldm install is running (PID ${lock.pid}, started ${lock.started}).`);
73
+ console.log(` Wait for it to finish, or remove ~/.ldm/state/.ldm-install.lock`);
74
+ return false;
75
+ } catch {
76
+ // PID is dead, stale lock. Clean it up.
77
+ }
78
+ }
79
+ mkdirSync(dirname(LOCK_PATH), { recursive: true });
80
+ writeFileSync(LOCK_PATH, JSON.stringify({ pid: process.pid, started: new Date().toISOString() }));
81
+
82
+ // Clean up on exit
83
+ const cleanup = () => { try { if (existsSync(LOCK_PATH)) { const l = JSON.parse(readFileSync(LOCK_PATH, 'utf8')); if (l.pid === process.pid) unlinkSync(LOCK_PATH); } } catch {} };
84
+ process.on('exit', cleanup);
85
+ process.on('SIGINT', () => { cleanup(); process.exit(1); });
86
+ process.on('SIGTERM', () => { cleanup(); process.exit(1); });
87
+ return true;
88
+ } catch {
89
+ return true; // if lock fails, allow install anyway
90
+ }
91
+ }
92
+
61
93
  const args = process.argv.slice(2);
62
94
  const command = args[0];
63
95
  const DRY_RUN = args.includes('--dry-run');
@@ -371,6 +403,8 @@ async function showCatalogPicker() {
371
403
  // ── ldm install ──
372
404
 
373
405
  async function cmdInstall() {
406
+ if (!DRY_RUN && !acquireInstallLock()) return;
407
+
374
408
  // Ensure LDM is initialized
375
409
  if (!existsSync(VERSION_PATH)) {
376
410
  console.log(' LDM OS not initialized. Running init first...');
@@ -522,7 +556,7 @@ function autoDetectExtensions() {
522
556
  const dirs = readdirSync(LDM_EXTENSIONS, { withFileTypes: true });
523
557
  for (const dir of dirs) {
524
558
  if (!dir.isDirectory()) continue;
525
- if (dir.name === '_trash' || dir.name.startsWith('.')) continue;
559
+ if (dir.name === '_trash' || dir.name.startsWith('.') || dir.name.startsWith('ldm-install-')) continue;
526
560
 
527
561
  const extPath = join(LDM_EXTENSIONS, dir.name);
528
562
  const pkgPath = join(extPath, 'package.json');
@@ -561,6 +595,8 @@ function autoDetectExtensions() {
561
595
  // ── ldm install (bare): scan system, show real state, update if needed ──
562
596
 
563
597
  async function cmdInstallCatalog() {
598
+ if (!DRY_RUN && !acquireInstallLock()) return;
599
+
564
600
  autoDetectExtensions();
565
601
 
566
602
  const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
@@ -603,14 +639,57 @@ async function cmdInstallCatalog() {
603
639
  console.log('');
604
640
  }
605
641
 
606
- if (DRY_RUN) {
607
- // Show what an update would do
608
- const updatable = Object.values(reconciled).filter(e =>
609
- e.registryHasSource
610
- );
642
+ // Build the update plan: check ALL installed extensions against npm (#55)
643
+ const npmUpdates = [];
644
+
645
+ // Check every installed extension against npm via catalog
646
+ console.log(' Checking npm for updates...');
647
+ for (const [name, entry] of Object.entries(reconciled)) {
648
+ if (!entry.deployedLdm && !entry.deployedOc) continue; // not installed
649
+
650
+ // Get npm package name from the installed extension's own package.json
651
+ const extPkgPath = join(LDM_EXTENSIONS, name, 'package.json');
652
+ const extPkg = readJSON(extPkgPath);
653
+ const npmPkg = extPkg?.name;
654
+ if (!npmPkg || !npmPkg.startsWith('@')) continue; // skip unscoped packages
655
+
656
+ // Find catalog entry for the repo URL (used for clone if update needed)
657
+ const catalogEntry = components.find(c => {
658
+ const matches = c.registryMatches || [c.id];
659
+ return matches.includes(name) || c.id === name;
660
+ });
661
+
662
+ const currentVersion = entry.ldmVersion || entry.ocVersion;
663
+ if (!currentVersion) continue;
664
+
665
+ try {
666
+ const latestVersion = execSync(`npm view ${npmPkg} version 2>/dev/null`, {
667
+ encoding: 'utf8', timeout: 10000,
668
+ }).trim();
669
+
670
+ if (latestVersion && latestVersion !== currentVersion) {
671
+ npmUpdates.push({
672
+ ...entry,
673
+ catalogRepo: catalogEntry?.repo || null,
674
+ catalogNpm: npmPkg,
675
+ currentVersion,
676
+ latestVersion,
677
+ hasUpdate: true,
678
+ });
679
+ }
680
+ } catch {}
681
+ }
611
682
 
612
- if (updatable.length > 0) {
613
- console.log(` Would update ${updatable.length} extension(s) from source repos.`);
683
+ const totalUpdates = npmUpdates.length;
684
+
685
+ if (DRY_RUN) {
686
+ if (npmUpdates.length > 0) {
687
+ console.log(` Would update ${npmUpdates.length} extension(s) from npm:`);
688
+ for (const e of npmUpdates) {
689
+ console.log(` ${e.name}: v${e.currentVersion} -> v${e.latestVersion} (${e.catalogNpm})`);
690
+ }
691
+ }
692
+ if (totalUpdates > 0) {
614
693
  console.log(' No data (crystal.db, agent files) would be touched.');
615
694
  console.log(' Old versions would be moved to ~/.ldm/_trash/ (never deleted).');
616
695
  } else {
@@ -623,18 +702,15 @@ async function cmdInstallCatalog() {
623
702
  return;
624
703
  }
625
704
 
626
- // Real update: only touch things with linked source repos
627
- const updatable = Object.values(reconciled).filter(e =>
628
- e.registryHasSource
629
- );
630
-
631
- if (updatable.length === 0) {
632
- console.log(' No extensions have linked source repos to update from.');
633
- console.log(' Link them with: ldm install <org/repo>');
705
+ if (totalUpdates === 0 && available.length === 0) {
706
+ console.log(' Everything is up to date.');
634
707
  console.log('');
708
+ return;
709
+ }
635
710
 
636
- // Still offer catalog install if TTY
637
- if (available.length > 0 && !YES_FLAG && !NONE_FLAG && process.stdin.isTTY) {
711
+ if (totalUpdates === 0 && available.length > 0) {
712
+ // Nothing to update, but catalog items available
713
+ if (!YES_FLAG && !NONE_FLAG && process.stdin.isTTY) {
638
714
  const { createInterface } = await import('node:readline');
639
715
  const rl = createInterface({ input: process.stdin, output: process.stdout });
640
716
  const answer = await new Promise((resolve) => {
@@ -643,7 +719,6 @@ async function cmdInstallCatalog() {
643
719
  resolve(a.trim().toLowerCase());
644
720
  });
645
721
  });
646
-
647
722
  if (answer && answer !== 'none' && answer !== 'n') {
648
723
  let toInstall = [];
649
724
  if (answer === 'all' || answer === 'a') {
@@ -668,12 +743,13 @@ async function cmdInstallCatalog() {
668
743
  // Write revert manifest before starting
669
744
  const { createRevertManifest } = await import('../lib/safe.mjs');
670
745
  const manifestPath = createRevertManifest(
671
- `ldm install (update ${updatable.length} extensions)`,
672
- updatable.map(e => ({
673
- action: 'update',
746
+ `ldm install (update ${totalUpdates} extensions)`,
747
+ npmUpdates.map(e => ({
748
+ action: 'update-from-catalog',
674
749
  name: e.name,
675
- currentVersion: e.ldmVersion || e.registryVersion,
676
- source: e.registrySource,
750
+ currentVersion: e.currentVersion,
751
+ latestVersion: e.latestVersion,
752
+ repo: e.catalogRepo,
677
753
  }))
678
754
  );
679
755
  console.log(` Revert plan saved: ${manifestPath}`);
@@ -683,9 +759,16 @@ async function cmdInstallCatalog() {
683
759
  setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT });
684
760
 
685
761
  let updated = 0;
686
- for (const entry of updatable) {
687
- await installFromPath(entry.registrySource);
688
- updated++;
762
+
763
+ // Update from npm via catalog repos (#55)
764
+ for (const entry of npmUpdates) {
765
+ console.log(` Updating ${entry.name} v${entry.currentVersion} -> v${entry.latestVersion} (from ${entry.catalogRepo})...`);
766
+ try {
767
+ execSync(`ldm install ${entry.catalogRepo}`, { stdio: 'inherit' });
768
+ updated++;
769
+ } catch (e) {
770
+ console.error(` x Failed to update ${entry.name}: ${e.message}`);
771
+ }
689
772
  }
690
773
 
691
774
  // Sync boot hook from npm package (#49)
@@ -694,7 +777,7 @@ async function cmdInstallCatalog() {
694
777
  }
695
778
 
696
779
  console.log('');
697
- console.log(` Updated ${updated}/${updatable.length} extension(s).`);
780
+ console.log(` Updated ${updated}/${totalUpdates} extension(s).`);
698
781
 
699
782
  // Check if CLI itself is outdated (#29)
700
783
  checkCliVersion();
@@ -780,6 +863,30 @@ async function cmdDoctor() {
780
863
  }
781
864
  }
782
865
 
866
+ // --fix: clean registry entries with /tmp/ sources or ldm-install- names (#54)
867
+ if (FIX_FLAG) {
868
+ const registry = readJSON(REGISTRY_PATH);
869
+ if (registry?.extensions) {
870
+ const staleNames = [];
871
+ for (const [name, info] of Object.entries(registry.extensions)) {
872
+ const src = info?.source || '';
873
+ const isTmpSource = src.startsWith('/tmp/') || src.startsWith('/private/tmp/');
874
+ const isTmpName = name.startsWith('ldm-install-');
875
+ if (isTmpSource || isTmpName) {
876
+ staleNames.push(name);
877
+ }
878
+ }
879
+ for (const name of staleNames) {
880
+ delete registry.extensions[name];
881
+ console.log(` + Removed stale registry entry: ${name} (/tmp/ clone)`);
882
+ issues = Math.max(0, issues - 1);
883
+ }
884
+ if (staleNames.length > 0) {
885
+ writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2) + '\n');
886
+ }
887
+ }
888
+ }
889
+
783
890
  // 4. Check sacred locations
784
891
  const sacred = [
785
892
  { path: join(LDM_ROOT, 'memory'), label: 'memory/' },
@@ -79,4 +79,4 @@ Your AIs are only as powerful as what you give them. Here's everything available
79
79
 
80
80
  ---
81
81
 
82
- [Technical Reference](./TECHNICAL.md)
82
+ [Technical Reference](../universal-installer/TECHNICAL.md)
@@ -243,6 +243,49 @@ The `ai/` folder is the development process. It is not part of the published pro
243
243
 
244
244
  **Public/private split:** If a repo is public, the `ai/` folder should not ship. The recommended pattern is to maintain a private working repo (with `ai/`) and a public repo (everything except `ai/`). The public repo has everything an LLM or human needs to understand and use the tool. The `ai/` folder is operational context for the team building it.
245
245
 
246
+ ## Catalog
247
+
248
+ Skills are defined in `catalog.json` at the LDM OS root. Each entry has:
249
+
250
+ ```json
251
+ {
252
+ "id": "memory-crystal",
253
+ "name": "Memory Crystal",
254
+ "description": "Persistent memory for your AI.",
255
+ "npm": "@wipcomputer/memory-crystal",
256
+ "repo": "wipcomputer/memory-crystal",
257
+ "registryMatches": ["memory-crystal"],
258
+ "cliMatches": ["crystal"],
259
+ "recommended": true,
260
+ "status": "stable"
261
+ }
262
+ ```
263
+
264
+ ## Stacks
265
+
266
+ Stacks group skills for team installs. Defined in `catalog.json`:
267
+
268
+ ```json
269
+ {
270
+ "stacks": {
271
+ "core": {
272
+ "name": "WIP Core",
273
+ "components": ["memory-crystal", "wip-ai-devops-toolbox", "wip-1password", "wip-markdown-viewer"],
274
+ "mcpServers": []
275
+ },
276
+ "web": {
277
+ "name": "Web Development",
278
+ "components": [],
279
+ "mcpServers": [
280
+ { "name": "playwright", "command": "npx", "args": ["-y", "@playwright/mcp@latest"] }
281
+ ]
282
+ }
283
+ }
284
+ }
285
+ ```
286
+
287
+ Stacks are composable via the `includes` field.
288
+
246
289
  ## The Installer
247
290
 
248
291
  `ldm install` scans any repo, detects which interfaces exist, and installs them all. One command.
package/lib/deploy.mjs CHANGED
@@ -682,10 +682,18 @@ export function installSingleTool(toolPath) {
682
682
  }
683
683
 
684
684
  let installed = 0;
685
+ // Don't store /tmp/ clone paths as source (#54). Use the repo URL from package.json if available.
686
+ let source = toolPath;
687
+ const isTmpPath = toolPath.startsWith('/tmp/') || toolPath.startsWith('/private/tmp/');
688
+ if (isTmpPath && pkg?.repository?.url) {
689
+ source = pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
690
+ } else if (isTmpPath) {
691
+ source = null; // better than a /tmp/ path
692
+ }
685
693
  const registryInfo = {
686
694
  name: toolName,
687
695
  version: pkg?.version || 'unknown',
688
- source: toolPath,
696
+ source,
689
697
  interfaces: ifaceNames,
690
698
  };
691
699
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "main": "src/boot/boot-hook.mjs",
@@ -1,67 +0,0 @@
1
- # Skills ... Technical Reference
2
-
3
- ## Catalog
4
-
5
- Skills are defined in `catalog.json` at the LDM OS root. Each entry has:
6
-
7
- ```json
8
- {
9
- "id": "memory-crystal",
10
- "name": "Memory Crystal",
11
- "description": "Persistent memory for your AI.",
12
- "npm": "@wipcomputer/memory-crystal",
13
- "repo": "wipcomputer/memory-crystal",
14
- "registryMatches": ["memory-crystal"],
15
- "cliMatches": ["crystal"],
16
- "recommended": true,
17
- "status": "stable"
18
- }
19
- ```
20
-
21
- ## Stacks
22
-
23
- Stacks group skills for team installs. Defined in `catalog.json`:
24
-
25
- ```json
26
- {
27
- "stacks": {
28
- "core": {
29
- "name": "WIP Core",
30
- "components": ["memory-crystal", "wip-ai-devops-toolbox", "wip-1password", "wip-markdown-viewer"],
31
- "mcpServers": []
32
- },
33
- "web": {
34
- "name": "Web Development",
35
- "components": [],
36
- "mcpServers": [
37
- { "name": "playwright", "command": "npx", "args": ["-y", "@playwright/mcp@latest"] }
38
- ]
39
- }
40
- }
41
- }
42
- ```
43
-
44
- Stacks are composable via the `includes` field.
45
-
46
- ## Interface Detection
47
-
48
- `ldm install` auto-detects which interfaces a repo supports:
49
-
50
- | Pattern | Interface | Install Action |
51
- |---------|-----------|---------------|
52
- | `package.json` with `bin` | CLI | `npm install -g` from registry |
53
- | `main` or `exports` | Module | Reports import path |
54
- | `mcp-server.mjs` | MCP | `claude mcp add --scope user` |
55
- | `openclaw.plugin.json` | OpenClaw Plugin | Deploy to `~/.ldm/extensions/` + `~/.openclaw/extensions/` |
56
- | `SKILL.md` | Skill | Deploy to `~/.openclaw/skills/` |
57
- | `guard.mjs` or `claudeCode.hook` | CC Hook | Add to `~/.claude/settings.json` |
58
- | `.claude-plugin/plugin.json` | CC Plugin | Register with marketplace |
59
-
60
- ## Key Files
61
-
62
- | File | What |
63
- |------|------|
64
- | `catalog.json` | Component + stack definitions |
65
- | `lib/detect.mjs` | Interface detection engine |
66
- | `lib/deploy.mjs` | Deployment engine |
67
- | `bin/ldm.js` | `ldm stack` and `ldm install` commands |