@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 +1 -1
- package/bin/ldm.js +242 -60
- package/catalog.json +18 -0
- package/lib/deploy.mjs +112 -25
- package/lib/state.mjs +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
const
|
|
1559
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
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
|
-
|
|
1582
|
-
|
|
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
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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
|
-
|
|
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
|
|
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 =>
|
|
1819
|
+
const installedMatch = comp.registryMatches.find(m => registry?.extensions?.[m]);
|
|
1647
1820
|
if (!installedMatch) continue;
|
|
1648
1821
|
|
|
1649
|
-
const
|
|
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-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
const
|
|
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,
|