@wipcomputer/wip-ldm-os 0.4.69 → 0.4.71

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/SKILL.md CHANGED
@@ -9,7 +9,7 @@ license: MIT
9
9
  compatibility: Requires git, npm, node. Node.js 18+.
10
10
  metadata:
11
11
  display-name: "LDM OS"
12
- version: "0.4.69"
12
+ version: "0.4.71"
13
13
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
14
14
  author: "Parker Todd Brooks"
15
15
  category: infrastructure
package/bin/ldm.js CHANGED
@@ -53,11 +53,16 @@ try {
53
53
  PKG_VERSION = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
54
54
  } catch {}
55
55
 
56
- // Read catalog
57
- const catalogPath = join(__dirname, '..', 'catalog.json');
56
+ // Read catalog: prefer ~/.ldm/catalog.json (user-editable), fall back to npm package (#262)
57
+ const localCatalogPath = join(LDM_ROOT, 'catalog.json');
58
+ const packageCatalogPath = join(__dirname, '..', 'catalog.json');
58
59
  let CATALOG = { components: [] };
59
60
  try {
60
- CATALOG = JSON.parse(readFileSync(catalogPath, 'utf8'));
61
+ if (existsSync(localCatalogPath)) {
62
+ CATALOG = JSON.parse(readFileSync(localCatalogPath, 'utf8'));
63
+ } else {
64
+ CATALOG = JSON.parse(readFileSync(packageCatalogPath, 'utf8'));
65
+ }
61
66
  } catch {}
62
67
 
63
68
  // Auto-sync version.json when CLI version drifts (#33)
@@ -320,6 +325,19 @@ function loadCatalog() {
320
325
  return CATALOG.components || [];
321
326
  }
322
327
 
328
+ // Seed ~/.ldm/catalog.json from the npm package if it doesn't exist (#262)
329
+ function seedLocalCatalog() {
330
+ if (existsSync(localCatalogPath)) return false;
331
+ try {
332
+ const pkgCatalog = readFileSync(packageCatalogPath, 'utf8');
333
+ mkdirSync(LDM_ROOT, { recursive: true });
334
+ writeFileSync(localCatalogPath, pkgCatalog);
335
+ return true;
336
+ } catch {
337
+ return false;
338
+ }
339
+ }
340
+
323
341
  function findInCatalog(id) {
324
342
  const q = id.toLowerCase();
325
343
  // Strip org/ prefix for matching (e.g. "wipcomputer/openclaw-tavily" -> "openclaw-tavily")
@@ -357,7 +375,8 @@ function findInCatalog(id) {
357
375
  // Replaces the old execSync('ldm install ${c.repo}') which spawned
358
376
  // a full installer process for each component.
359
377
  async function installCatalogComponent(c) {
360
- const { installFromPath } = await import('../lib/deploy.mjs');
378
+ const { installFromPath, setFlags: setDeployFlags } = await import('../lib/deploy.mjs');
379
+ setDeployFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'catalog' }); // #262
361
380
  const repoTarget = c.repo;
362
381
  const repoName = basename(repoTarget);
363
382
  const repoPath = join(LDM_TMP, repoName);
@@ -653,10 +672,15 @@ async function cmdInit() {
653
672
 
654
673
  // Seed registry if missing
655
674
  if (!existsSync(REGISTRY_PATH)) {
656
- writeJSON(REGISTRY_PATH, { _format: 'v1', extensions: {} });
675
+ writeJSON(REGISTRY_PATH, { _format: 'v2', extensions: {} });
657
676
  console.log(` + registry.json created`);
658
677
  }
659
678
 
679
+ // Seed local catalog from npm package (#262)
680
+ if (seedLocalCatalog()) {
681
+ console.log(` + catalog.json seeded to ~/.ldm/catalog.json`);
682
+ }
683
+
660
684
  // Install global git pre-commit hook (blocks commits on main)
661
685
  const hooksDir = join(LDM_ROOT, 'hooks');
662
686
  const preCommitDest = join(hooksDir, 'pre-commit');
@@ -1064,7 +1088,7 @@ async function cmdInstall() {
1064
1088
  // Refresh harness detection (catches newly installed harnesses)
1065
1089
  detectHarnesses();
1066
1090
 
1067
- setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT });
1091
+ setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'manual' });
1068
1092
 
1069
1093
  // --help flag (#81)
1070
1094
  if (args.includes('--help') || args.includes('-h')) {
@@ -1108,6 +1132,7 @@ async function cmdInstall() {
1108
1132
  // Check if target is a catalog ID (e.g. "memory-crystal")
1109
1133
  const catalogEntry = findInCatalog(resolvedTarget);
1110
1134
  if (catalogEntry) {
1135
+ setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'catalog' }); // #262
1111
1136
  console.log('');
1112
1137
  console.log(` Resolved "${target}" via catalog to ${catalogEntry.repo}`);
1113
1138
 
@@ -1234,6 +1259,98 @@ async function cmdInstall() {
1234
1259
  }
1235
1260
  }
1236
1261
 
1262
+ // ── Registry migration (#262) ──
1263
+ // Upgrades old v1 registry entries to v2 format with source info.
1264
+ // Runs once per install. Idempotent: entries that already have source are skipped.
1265
+
1266
+ function migrateRegistry() {
1267
+ const registry = readJSON(REGISTRY_PATH);
1268
+ if (!registry?.extensions) return 0;
1269
+
1270
+ const components = loadCatalog();
1271
+ let migrated = 0;
1272
+
1273
+ for (const [name, entry] of Object.entries(registry.extensions)) {
1274
+ // Skip entries that have already been migrated to v2 format.
1275
+ // An entry is fully migrated if it has: installed (object), paths, origin,
1276
+ // and source is either structured (object with type) or explicitly null.
1277
+ const hasV2Installed = entry.installed && typeof entry.installed === 'object' && entry.installed.version;
1278
+ const sourceIsResolved = entry.source === null || (typeof entry.source === 'object' && entry.source?.type);
1279
+ if (hasV2Installed && entry.paths && entry.origin && sourceIsResolved) continue;
1280
+
1281
+ const newSource = { type: 'github' };
1282
+ let hasSource = false;
1283
+
1284
+ // Try 1: match against catalog for source info
1285
+ const catalogMatch = components.find(c => {
1286
+ const matches = c.registryMatches || [c.id];
1287
+ return matches.includes(name) || c.id === name;
1288
+ });
1289
+ if (catalogMatch) {
1290
+ if (catalogMatch.repo) { newSource.repo = catalogMatch.repo; hasSource = true; }
1291
+ if (catalogMatch.npm) { newSource.npm = catalogMatch.npm; hasSource = true; }
1292
+ if (!entry.origin) entry.origin = 'catalog';
1293
+ }
1294
+
1295
+ // Try 2: read from the installed extension's package.json repository field
1296
+ if (!hasSource || !newSource.repo) {
1297
+ const extPkgPath = join(LDM_EXTENSIONS, name, 'package.json');
1298
+ const extPkg = readJSON(extPkgPath);
1299
+ if (extPkg?.name && !newSource.npm) {
1300
+ newSource.npm = extPkg.name;
1301
+ hasSource = true;
1302
+ }
1303
+ if (extPkg?.repository) {
1304
+ const raw = typeof extPkg.repository === 'string'
1305
+ ? extPkg.repository
1306
+ : extPkg.repository.url || '';
1307
+ const ghMatch = raw.match(/github\.com[:/]([^/]+\/[^/.]+)/);
1308
+ if (ghMatch) {
1309
+ newSource.repo = ghMatch[1].replace(/\.git$/, '');
1310
+ hasSource = true;
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ if (hasSource) {
1316
+ entry.source = newSource;
1317
+ } else if (typeof entry.source === 'string') {
1318
+ // Legacy string source (path or URL). Clear it since we couldn't build structured source.
1319
+ entry.source = null;
1320
+ }
1321
+
1322
+ // Migrate flat version to installed block
1323
+ if (!entry.installed || typeof entry.installed !== 'object') {
1324
+ entry.installed = {
1325
+ version: entry.version || 'unknown',
1326
+ installedAt: entry.updatedAt || new Date().toISOString(),
1327
+ updatedAt: entry.updatedAt || new Date().toISOString(),
1328
+ };
1329
+ }
1330
+
1331
+ // Migrate flat paths to paths block
1332
+ if (!entry.paths) {
1333
+ entry.paths = {};
1334
+ if (entry.ldmPath) entry.paths.ldm = entry.ldmPath;
1335
+ if (entry.ocPath) entry.paths.openclaw = entry.ocPath;
1336
+ }
1337
+
1338
+ // Set origin if missing
1339
+ if (!entry.origin) {
1340
+ entry.origin = 'manual';
1341
+ }
1342
+
1343
+ migrated++;
1344
+ }
1345
+
1346
+ if (migrated > 0) {
1347
+ registry._format = 'v2';
1348
+ writeJSON(REGISTRY_PATH, registry);
1349
+ }
1350
+
1351
+ return migrated;
1352
+ }
1353
+
1237
1354
  // ── Auto-detect unregistered extensions ──
1238
1355
 
1239
1356
  function autoDetectExtensions() {
@@ -1318,6 +1435,17 @@ async function cmdInstallCatalog() {
1318
1435
 
1319
1436
  autoDetectExtensions();
1320
1437
 
1438
+ // Migrate old registry entries to v2 format (#262)
1439
+ const migrated = migrateRegistry();
1440
+ if (migrated > 0) {
1441
+ console.log(` + Migrated ${migrated} registry entries to v2 format (source info added)`);
1442
+ }
1443
+
1444
+ // Seed local catalog if missing (#262)
1445
+ if (seedLocalCatalog()) {
1446
+ console.log(` + catalog.json seeded to ~/.ldm/catalog.json`);
1447
+ }
1448
+
1321
1449
  // Deploy bridge files after self-update or on every catalog install (#245, #251)
1322
1450
  // After npm install -g, the new bridge files are in the npm package but not
1323
1451
  // in the extension directories. This copies them to both LDM and OpenClaw targets.
@@ -1528,7 +1656,9 @@ async function cmdInstallCatalog() {
1528
1656
  console.log('');
1529
1657
  }
1530
1658
 
1531
- // Build the update plan: check ALL installed extensions against npm (#55)
1659
+ // Build the update plan from REGISTRY entries (#262)
1660
+ // The registry is the source of truth. Each entry has source info (npm, repo)
1661
+ // that tells us where to check for updates.
1532
1662
  const npmUpdates = [];
1533
1663
 
1534
1664
  // Check CLI self-update (#132)
@@ -1548,59 +1678,104 @@ async function cmdInstallCatalog() {
1548
1678
  }
1549
1679
  } catch {}
1550
1680
 
1551
- // Check every installed extension against npm via catalog
1552
- console.log(' Checking npm for updates...');
1553
- for (const [name, entry] of Object.entries(reconciled)) {
1554
- if (!entry.deployedLdm && !entry.deployedOc) continue; // not installed
1681
+ // Check every registered extension for updates (#262)
1682
+ // Source of truth: registry entry's source.npm and source.repo fields.
1683
+ // Fallback: extension's package.json (for old entries without source info).
1684
+ console.log(' Checking for updates...');
1685
+ const registryEntries = Object.entries(registry?.extensions || {});
1686
+ const checkedNames = new Set(); // track what we've checked
1555
1687
 
1556
- // Get npm package name from the installed extension's own package.json
1557
- const extPkgPath = join(LDM_EXTENSIONS, name, 'package.json');
1558
- const extPkg = readJSON(extPkgPath);
1559
- const npmPkg = extPkg?.name;
1560
- if (!npmPkg) continue; // no package name, skip
1688
+ for (const [name, regEntry] of registryEntries) {
1689
+ // Skip entries with no installed version
1690
+ const currentVersion = regEntry?.installed?.version || regEntry?.version;
1691
+ if (!currentVersion) continue;
1561
1692
 
1562
- // Find catalog entry for the repo URL (used for clone if update needed)
1693
+ // Skip pinned components (e.g. OpenClaw)
1563
1694
  const catalogEntry = components.find(c => {
1564
1695
  const matches = c.registryMatches || [c.id];
1565
1696
  return matches.includes(name) || c.id === name;
1566
1697
  });
1567
-
1568
- // Skip pinned components (e.g. OpenClaw). Upgrades must be explicit.
1569
1698
  if (catalogEntry?.pinned) continue;
1570
1699
 
1571
- // Fallback: use repository.url from extension's package.json (#82)
1572
- let repoUrl = catalogEntry?.repo || null;
1573
- if (!repoUrl && extPkg?.repository) {
1574
- const raw = typeof extPkg.repository === 'string'
1575
- ? extPkg.repository
1576
- : extPkg.repository.url || '';
1577
- const ghMatch = raw.match(/github\.com[:/]([^/]+\/[^/.]+)/);
1578
- if (ghMatch) repoUrl = ghMatch[1];
1700
+ // Get npm package name from registry source (v2) or extension's package.json (legacy)
1701
+ const sourceNpm = regEntry?.source?.npm;
1702
+ const sourceRepo = regEntry?.source?.repo;
1703
+ let npmPkg = sourceNpm || null;
1704
+
1705
+ // Fallback: read from installed extension's package.json
1706
+ if (!npmPkg) {
1707
+ const extPkgPath = join(LDM_EXTENSIONS, name, 'package.json');
1708
+ const extPkg = readJSON(extPkgPath);
1709
+ npmPkg = extPkg?.name || null;
1579
1710
  }
1580
1711
 
1581
- const currentVersion = entry.ldmVersion || entry.ocVersion;
1582
- if (!currentVersion) continue;
1712
+ // Determine repo URL for cloning updates
1713
+ let repoUrl = sourceRepo || catalogEntry?.repo || null;
1714
+ if (!repoUrl) {
1715
+ const extPkgPath = join(LDM_EXTENSIONS, name, 'package.json');
1716
+ const extPkg = readJSON(extPkgPath);
1717
+ if (extPkg?.repository) {
1718
+ const raw = typeof extPkg.repository === 'string'
1719
+ ? extPkg.repository
1720
+ : extPkg.repository.url || '';
1721
+ const ghMatch = raw.match(/github\.com[:/]([^/]+\/[^/.]+)/);
1722
+ if (ghMatch) repoUrl = ghMatch[1];
1723
+ }
1724
+ }
1583
1725
 
1584
- try {
1585
- const latestVersion = execSync(`npm view ${npmPkg} version 2>/dev/null`, {
1586
- encoding: 'utf8', timeout: 10000,
1587
- }).trim();
1726
+ // Check npm for updates (fast, one HTTP call)
1727
+ if (npmPkg) {
1728
+ try {
1729
+ const latestVersion = execSync(`npm view ${npmPkg} version 2>/dev/null`, {
1730
+ encoding: 'utf8', timeout: 10000,
1731
+ }).trim();
1588
1732
 
1589
- if (latestVersion && latestVersion !== currentVersion) {
1590
- npmUpdates.push({
1591
- ...entry,
1592
- catalogRepo: repoUrl,
1593
- catalogNpm: npmPkg,
1594
- currentVersion,
1595
- latestVersion,
1596
- hasUpdate: true,
1733
+ if (latestVersion && latestVersion !== currentVersion) {
1734
+ npmUpdates.push({
1735
+ name,
1736
+ catalogRepo: repoUrl,
1737
+ catalogNpm: npmPkg,
1738
+ currentVersion,
1739
+ latestVersion,
1740
+ hasUpdate: true,
1741
+ });
1742
+ }
1743
+ } catch {}
1744
+ checkedNames.add(name);
1745
+ continue;
1746
+ }
1747
+
1748
+ // No npm package. Check GitHub tags via git ls-remote (#262).
1749
+ // Works for private repos with SSH access.
1750
+ if (repoUrl) {
1751
+ try {
1752
+ const sshUrl = `git@github.com:${repoUrl}.git`;
1753
+ const tags = execSync(`git ls-remote --tags --sort=-v:refname "${sshUrl}" 2>/dev/null`, {
1754
+ encoding: 'utf8', timeout: 15000,
1597
1755
  });
1598
- }
1599
- } catch {}
1756
+ // Parse latest semver tag
1757
+ const tagMatch = tags.match(/refs\/tags\/v?(\d+\.\d+\.\d+)/);
1758
+ if (tagMatch) {
1759
+ const latestVersion = tagMatch[1];
1760
+ if (latestVersion !== currentVersion) {
1761
+ npmUpdates.push({
1762
+ name,
1763
+ catalogRepo: repoUrl,
1764
+ catalogNpm: repoUrl, // display repo URL since no npm package
1765
+ currentVersion,
1766
+ latestVersion,
1767
+ hasUpdate: true,
1768
+ });
1769
+ }
1770
+ }
1771
+ } catch {}
1772
+ checkedNames.add(name);
1773
+ }
1600
1774
  }
1601
1775
 
1602
- // Check global CLIs not tracked by extension loop (#81)
1776
+ // Check global CLIs not tracked by registry (#81)
1603
1777
  for (const [binName, binInfo] of Object.entries(state.cliBinaries || {})) {
1778
+ if (checkedNames.has(binName)) continue;
1604
1779
  const catalogComp = components.find(c =>
1605
1780
  (c.cliMatches || []).includes(binName)
1606
1781
  );
@@ -1635,18 +1810,17 @@ async function cmdInstallCatalog() {
1635
1810
  // Check parent packages for toolbox-style repos (#132)
1636
1811
  // If sub-tools are installed but the parent npm package has a newer version,
1637
1812
  // report the parent as needing an update (not the individual sub-tool).
1638
- // Don't skip packages already found by the extension loop. The parent check
1639
- // REPLACES sub-tool entries with the parent name.
1640
1813
  const checkedParentNpm = new Set();
1641
1814
  for (const comp of components) {
1642
1815
  if (!comp.npm || checkedParentNpm.has(comp.npm)) continue;
1643
1816
  if (!comp.registryMatches || comp.registryMatches.length === 0) continue;
1644
1817
 
1645
1818
  // If any registryMatch is installed, check the parent package
1646
- const installedMatch = comp.registryMatches.find(m => reconciled[m]);
1819
+ const installedMatch = comp.registryMatches.find(m => registry?.extensions?.[m]);
1647
1820
  if (!installedMatch) continue;
1648
1821
 
1649
- const currentVersion = reconciled[installedMatch]?.ldmVersion || reconciled[installedMatch]?.ocVersion || '?';
1822
+ const matchEntry = registry.extensions[installedMatch];
1823
+ const currentVersion = matchEntry?.installed?.version || matchEntry?.version || '?';
1650
1824
 
1651
1825
  try {
1652
1826
  const latest = execSync(`npm view ${comp.npm} version 2>/dev/null`, {
@@ -1654,8 +1828,6 @@ async function cmdInstallCatalog() {
1654
1828
  }).trim();
1655
1829
  if (latest && latest !== currentVersion) {
1656
1830
  // Remove any sub-tool entries that belong to this parent.
1657
- // Match by name in registryMatches (sub-tools have their own npm names,
1658
- // not the parent's, so catalogNpm comparison doesn't work).
1659
1831
  const parentMatches = new Set(comp.registryMatches || []);
1660
1832
  for (let i = npmUpdates.length - 1; i >= 0; i--) {
1661
1833
  if (!npmUpdates[i].isCLI && parentMatches.has(npmUpdates[i].name)) {
@@ -1841,7 +2013,7 @@ async function cmdInstallCatalog() {
1841
2013
  const manifestPath = createRevertManifest(
1842
2014
  `ldm install (update ${totalUpdates} extensions)`,
1843
2015
  npmUpdates.map(e => ({
1844
- action: 'update-from-catalog',
2016
+ action: 'update-from-registry',
1845
2017
  name: e.name,
1846
2018
  currentVersion: e.currentVersion,
1847
2019
  latestVersion: e.latestVersion,
@@ -1852,11 +2024,11 @@ async function cmdInstallCatalog() {
1852
2024
  console.log('');
1853
2025
 
1854
2026
  const { setFlags, installFromPath } = await import('../lib/deploy.mjs');
1855
- setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT });
2027
+ setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'catalog' }); // #262
1856
2028
 
1857
2029
  let updated = 0;
1858
2030
 
1859
- // Update from npm via catalog repos (#55) and CLIs (#81)
2031
+ // Update from registry sources (#262, replaces old catalog-based update loop)
1860
2032
  for (const entry of npmUpdates) {
1861
2033
  // CLI self-update is handled by the self-update block at the top of cmdInstallCatalog()
1862
2034
  if (entry.isCLI) continue;
@@ -1882,14 +2054,20 @@ async function cmdInstallCatalog() {
1882
2054
  execSync(`ldm install ${entry.catalogRepo}`, { stdio: 'inherit' });
1883
2055
  updated++;
1884
2056
 
1885
- // For parent packages, update registry version for all sub-tools (#139)
2057
+ // For parent packages, update registry version for all sub-tools (#139, #262)
1886
2058
  if (entry.isParent && entry.registryMatches) {
1887
2059
  const registry = readJSON(REGISTRY_PATH);
1888
2060
  if (registry?.extensions) {
2061
+ const now = new Date().toISOString();
1889
2062
  for (const subTool of entry.registryMatches) {
1890
2063
  if (registry.extensions[subTool]) {
1891
2064
  registry.extensions[subTool].version = entry.latestVersion;
1892
- registry.extensions[subTool].updatedAt = new Date().toISOString();
2065
+ registry.extensions[subTool].updatedAt = now;
2066
+ // Also update v2 installed block
2067
+ if (registry.extensions[subTool].installed) {
2068
+ registry.extensions[subTool].installed.version = entry.latestVersion;
2069
+ registry.extensions[subTool].installed.updatedAt = now;
2070
+ }
1893
2071
  }
1894
2072
  }
1895
2073
  writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2));
@@ -2293,14 +2471,18 @@ function cmdStatus() {
2293
2471
  if (latest && latest !== PKG_VERSION) cliUpdate = latest;
2294
2472
  } catch {}
2295
2473
 
2296
- // Check extensions against npm
2474
+ // Check extensions against npm using registry source info (#262)
2297
2475
  const updates = [];
2298
2476
  for (const [name, info] of Object.entries(registry?.extensions || {})) {
2299
- const extPkgPath = join(LDM_EXTENSIONS, name, 'package.json');
2300
- const extPkg = readJSON(extPkgPath);
2301
- const npmPkg = extPkg?.name;
2477
+ // Use registry source.npm (v2) or fall back to extension's package.json
2478
+ let npmPkg = info?.source?.npm || null;
2479
+ if (!npmPkg) {
2480
+ const extPkgPath = join(LDM_EXTENSIONS, name, 'package.json');
2481
+ const extPkg = readJSON(extPkgPath);
2482
+ npmPkg = extPkg?.name;
2483
+ }
2302
2484
  if (!npmPkg) continue;
2303
- const currentVersion = extPkg.version || info.version;
2485
+ const currentVersion = info?.installed?.version || info.version;
2304
2486
  if (!currentVersion) continue;
2305
2487
  try {
2306
2488
  const latest = execSync(`npm view ${npmPkg} version 2>/dev/null`, {
package/catalog.json CHANGED
@@ -301,6 +301,24 @@
301
301
  "installs": {
302
302
  "ocPlugin": "Web search and content extraction"
303
303
  }
304
+ },
305
+ {
306
+ "id": "private-mode",
307
+ "name": "Private Mode",
308
+ "description": "Privacy controls for AI agents. Pause memory capture, scan storage, wipe history.",
309
+ "npm": "private-mode",
310
+ "repo": "wipcomputer/wip-private-mode-private",
311
+ "registryMatches": [
312
+ "private-mode",
313
+ "wip-private-mode"
314
+ ],
315
+ "cliMatches": [],
316
+ "recommended": false,
317
+ "status": "stable",
318
+ "postInstall": null,
319
+ "installs": {
320
+ "ocPlugin": "Private mode toggle, memory status indicator, wipe scan/search/execute"
321
+ }
304
322
  }
305
323
  ]
306
324
  }
package/lib/deploy.mjs CHANGED
@@ -30,10 +30,12 @@ const REGISTRY_PATH = join(LDM_EXTENSIONS, 'registry.json');
30
30
 
31
31
  let DRY_RUN = false;
32
32
  let JSON_OUTPUT = false;
33
+ let INSTALL_ORIGIN = 'manual'; // #262: tracks how an extension was installed
33
34
 
34
35
  export function setFlags(opts = {}) {
35
36
  DRY_RUN = opts.dryRun || false;
36
37
  JSON_OUTPUT = opts.jsonOutput || false;
38
+ if (opts.origin) INSTALL_ORIGIN = opts.origin;
37
39
  }
38
40
 
39
41
  function log(msg) { if (!JSON_OUTPUT) console.log(` ${msg}`); }
@@ -164,16 +166,104 @@ function getHarnesses() {
164
166
  function updateRegistry(name, info) {
165
167
  const registry = loadRegistry();
166
168
  const existing = registry.extensions[name];
167
- const isCore = CORE_EXTENSIONS.has(name);
169
+ const now = new Date().toISOString();
170
+
171
+ // Build the v2 registry entry (#262)
172
+ // Merge source info: keep existing source unless new info provides it
173
+ const existingSource = existing?.source;
174
+ let newSource = info._source || existingSource || null;
175
+ // Legacy: info.source was a string (path or URL). Convert to object.
176
+ if (typeof existingSource === 'string' && !newSource) {
177
+ newSource = null; // Drop legacy string source, migration will fix it
178
+ }
179
+ if (typeof info.source === 'string') {
180
+ // Legacy caller passing a string. Don't overwrite structured source.
181
+ if (!newSource || typeof newSource === 'string') newSource = null;
182
+ }
183
+
184
+ // Build paths object from ldmPath/ocPath
185
+ const paths = existing?.paths || {};
186
+ if (info.ldmPath) paths.ldm = info.ldmPath;
187
+ if (info.ocPath) paths.openclaw = info.ocPath;
188
+ // Backwards compat: also keep flat ldmPath/ocPath
189
+ const ldmPath = info.ldmPath || existing?.ldmPath || paths.ldm;
190
+ const ocPath = info.ocPath || existing?.ocPath || paths.openclaw;
191
+
192
+ // Build installed block
193
+ const installed = existing?.installed || {};
194
+ if (typeof installed === 'object' && installed !== null) {
195
+ installed.version = info.version || installed.version || existing?.version;
196
+ if (!installed.installedAt) installed.installedAt = now;
197
+ installed.updatedAt = now;
198
+ }
199
+
200
+ // Origin: keep existing, or use from info, or default to "manual"
201
+ const origin = info._origin || existing?.origin || 'manual';
202
+
168
203
  registry.extensions[name] = {
169
- ...existing,
170
- ...info,
171
- enabled: existing?.enabled ?? true, // New installs are enabled by default. User runs ldm disable to turn off.
172
- updatedAt: new Date().toISOString(),
204
+ // v2 structured fields (#262)
205
+ source: newSource,
206
+ installed,
207
+ paths,
208
+ interfaces: info.interfaces || existing?.interfaces || [],
209
+ origin,
210
+ // Backwards-compatible flat fields (read by existing code)
211
+ name: info.name || existing?.name || name,
212
+ version: info.version || existing?.version || 'unknown',
213
+ ldmPath,
214
+ ocPath,
215
+ enabled: existing?.enabled ?? true,
216
+ updatedAt: now,
173
217
  };
174
218
  saveRegistry(registry);
175
219
  }
176
220
 
221
+ /**
222
+ * Build structured source info from a repo path and package.json (#262).
223
+ * Returns { type, repo, npm } or null if we can't determine the source.
224
+ */
225
+ function buildSourceInfo(repoPath, pkg) {
226
+ const source = { type: 'github' };
227
+ let hasInfo = false;
228
+
229
+ // Extract GitHub repo from package.json repository field
230
+ if (pkg?.repository) {
231
+ const raw = typeof pkg.repository === 'string'
232
+ ? pkg.repository
233
+ : pkg.repository.url || '';
234
+ const ghMatch = raw.match(/github\.com[:/]([^/]+\/[^/.]+)/);
235
+ if (ghMatch) {
236
+ source.repo = ghMatch[1].replace(/\.git$/, '');
237
+ hasInfo = true;
238
+ }
239
+ }
240
+
241
+ // Extract npm package name
242
+ if (pkg?.name) {
243
+ source.npm = pkg.name;
244
+ hasInfo = true;
245
+ }
246
+
247
+ // If the repo path is inside ~/.ldm/tmp/, it was cloned from somewhere.
248
+ // Try to get the remote URL from git.
249
+ if (!source.repo) {
250
+ try {
251
+ const remote = execSync('git remote get-url origin 2>/dev/null', {
252
+ cwd: repoPath,
253
+ encoding: 'utf8',
254
+ timeout: 5000,
255
+ }).trim();
256
+ const ghMatch = remote.match(/github\.com[:/]([^/]+\/[^/.]+)/);
257
+ if (ghMatch) {
258
+ source.repo = ghMatch[1].replace(/\.git$/, '');
259
+ hasInfo = true;
260
+ }
261
+ } catch {}
262
+ }
263
+
264
+ return hasInfo ? source : null;
265
+ }
266
+
177
267
  // ── Migration detection ──
178
268
 
179
269
  function findExistingInstalls(toolName, pkg, ocPluginConfig) {
@@ -240,17 +330,17 @@ function resolveLocalDeps(repoPath) {
240
330
  const extDir = join(LDM_EXTENSIONS, name);
241
331
  if (existsSync(extDir)) {
242
332
  const targetModules = join(repoPath, 'node_modules', name);
243
- if (!existsSync(targetModules)) {
244
- mkdirSync(join(repoPath, 'node_modules'), { recursive: true });
245
- // Handle scoped packages (e.g. @scope/name)
246
- const scopeDir = dirname(targetModules);
247
- if (scopeDir !== join(repoPath, 'node_modules')) {
248
- mkdirSync(scopeDir, { recursive: true });
249
- }
250
- symlinkSync(extDir, targetModules);
251
- log(`Linked local dep: ${name} -> ${extDir}`);
252
- resolved++;
333
+ mkdirSync(join(repoPath, 'node_modules'), { recursive: true });
334
+ // Handle scoped packages (e.g. @scope/name)
335
+ const scopeDir = dirname(targetModules);
336
+ if (scopeDir !== join(repoPath, 'node_modules')) {
337
+ mkdirSync(scopeDir, { recursive: true });
253
338
  }
339
+ // Remove existing entry (broken symlink or dir from npm) before creating fresh symlink
340
+ try { rmSync(targetModules, { recursive: true, force: true }); } catch {}
341
+ symlinkSync(extDir, targetModules);
342
+ log(`Linked local dep: ${name} -> ${extDir}`);
343
+ resolved++;
254
344
  } else {
255
345
  log(`Dep ${name} not installed at ${extDir}, build may fail for this feature`);
256
346
  }
@@ -905,18 +995,15 @@ export function installSingleTool(toolPath) {
905
995
  }
906
996
 
907
997
  let installed = 0;
908
- // Don't store /tmp/ clone paths as source (#54). Use the repo URL from package.json if available.
909
- let source = toolPath;
910
- const isTmpPath = toolPath.startsWith('/tmp/') || toolPath.startsWith('/private/tmp/');
911
- if (isTmpPath && pkg?.repository?.url) {
912
- source = pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
913
- } else if (isTmpPath) {
914
- source = null; // better than a /tmp/ path
915
- }
998
+
999
+ // Build structured source info for registry (#262)
1000
+ const sourceInfo = buildSourceInfo(toolPath, pkg);
916
1001
  const registryInfo = {
917
1002
  name: toolName,
918
1003
  version: pkg?.version || 'unknown',
919
- source,
1004
+ source: null, // legacy field, kept for backwards compat
1005
+ _source: sourceInfo, // v2 structured source, consumed by updateRegistry
1006
+ _origin: INSTALL_ORIGIN, // #262: "catalog", "manual", or "dependency"
920
1007
  interfaces: ifaceNames,
921
1008
  };
922
1009
 
@@ -1140,4 +1227,4 @@ export function disableExtension(name) {
1140
1227
 
1141
1228
  // ── Exports for ldm CLI ──
1142
1229
 
1143
- export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, resolveLocalDeps, CORE_EXTENSIONS };
1230
+ export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, resolveLocalDeps, buildSourceInfo, CORE_EXTENSIONS };
package/lib/state.mjs CHANGED
@@ -160,7 +160,7 @@ export function reconcileState(systemState) {
160
160
  inRegistry: !!reg,
161
161
  registryVersion: reg?.version || null,
162
162
  registrySource: reg?.source || null,
163
- registryHasSource: !!(reg?.source && existsSync(reg.source)),
163
+ registryHasSource: !!(reg?.source && (typeof reg.source === 'string' ? existsSync(reg.source) : !!reg.source.repo)),
164
164
  registryInterfaces: reg?.interfaces || [],
165
165
  // Deployed
166
166
  deployedLdm: !!ldm,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.69",
3
+ "version": "0.4.71",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {