@wipcomputer/wip-ldm-os 0.4.61 → 0.4.62

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.61"
12
+ version: "0.4.62"
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
@@ -9,6 +9,9 @@
9
9
  * ldm install Install/update all registered components
10
10
  * ldm doctor Check health of all extensions
11
11
  * ldm status Show LDM OS version and extension count
12
+ * ldm backup Run a full backup now
13
+ * ldm backup --dry-run Preview what would be backed up (with sizes)
14
+ * ldm backup --pin "x" Pin the latest backup so rotation skips it
12
15
  * ldm sessions List active sessions
13
16
  * ldm msg send <to> <b> Send a message to a session
14
17
  * ldm msg list List pending messages
@@ -17,7 +20,7 @@
17
20
  * ldm --version Show version
18
21
  */
19
22
 
20
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync } from 'node:fs';
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync } from 'node:fs';
21
24
  import { join, basename, resolve, dirname } from 'node:path';
22
25
  import { execSync } from 'node:child_process';
23
26
  import { fileURLToPath } from 'node:url';
@@ -112,6 +115,26 @@ function acquireInstallLock() {
112
115
  }
113
116
 
114
117
  const args = process.argv.slice(2);
118
+
119
+ // Normalize dry-run flag variants before parsing (#239)
120
+ // --dryrun -> --dry-run
121
+ // --dry run (two words) -> --dry-run (consume the stray "run" so it doesn't
122
+ // become a package target and install random npm packages)
123
+ for (let i = 0; i < args.length; i++) {
124
+ if (args[i] === '--dryrun') {
125
+ args[i] = '--dry-run';
126
+ } else if (args[i] === '--dry') {
127
+ if (args[i + 1] === 'run') {
128
+ args[i] = '--dry-run';
129
+ args.splice(i + 1, 1);
130
+ } else {
131
+ // Bare --dry with no "run" after it. Treat as --dry-run since there
132
+ // is no other --dry flag and the intent is obvious.
133
+ args[i] = '--dry-run';
134
+ }
135
+ }
136
+ }
137
+
115
138
  const command = args[0];
116
139
  const DRY_RUN = args.includes('--dry-run');
117
140
  const JSON_OUTPUT = args.includes('--json');
@@ -152,6 +175,60 @@ function checkCliVersion() {
152
175
  }
153
176
  }
154
177
 
178
+ // ── Dead backup trigger cleanup (#207) ──
179
+ // Three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
180
+ // This removes: broken cron entry (LDMDevTools.app), old com.wipcomputer.daily-backup.
181
+
182
+ function cleanDeadBackupTriggers() {
183
+ let cleaned = 0;
184
+
185
+ // 1. Remove broken cron entries referencing LDMDevTools.app
186
+ // Matches both "LDMDevTools.app" and "LDM Dev Tools.app" (old naming)
187
+ try {
188
+ const crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
189
+ const lines = crontab.split('\n');
190
+ const filtered = lines.filter(line => {
191
+ const lower = line.toLowerCase();
192
+ // Remove any line (active or commented) that references LDMDevTools
193
+ if (lower.includes('ldmdevtools.app') || lower.includes('ldm dev tools.app')) {
194
+ cleaned++;
195
+ return false;
196
+ }
197
+ // Remove orphaned descriptive comment for the old backup verification cron
198
+ if (line.trim() === '# Verify daily backup ran - 00:30 PST') return false;
199
+ return true;
200
+ });
201
+ if (cleaned > 0) {
202
+ // Write filtered crontab via temp file (avoids shell escaping issues)
203
+ const tmpCron = join(LDM_TMP, 'crontab.tmp');
204
+ mkdirSync(LDM_TMP, { recursive: true });
205
+ writeFileSync(tmpCron, filtered.join('\n'));
206
+ execSync(`crontab "${tmpCron}"`, { stdio: 'pipe' });
207
+ try { unlinkSync(tmpCron); } catch {}
208
+ console.log(` + Removed ${cleaned} dead cron entry(s) (LDMDevTools.app)`);
209
+ }
210
+ } catch {
211
+ // No crontab or crontab command failed. Not critical.
212
+ }
213
+
214
+ // 2. Unload and disable com.wipcomputer.daily-backup LaunchAgent
215
+ const oldPlist = join(HOME, 'Library', 'LaunchAgents', 'com.wipcomputer.daily-backup.plist');
216
+ const disabledPlist = oldPlist + '.disabled';
217
+ if (existsSync(oldPlist)) {
218
+ try { execSync(`launchctl unload "${oldPlist}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
219
+ try {
220
+ renameSync(oldPlist, disabledPlist);
221
+ } catch {
222
+ // If rename fails, just try to remove it
223
+ try { unlinkSync(oldPlist); } catch {}
224
+ }
225
+ console.log(' + Disabled dead LaunchAgent: com.wipcomputer.daily-backup');
226
+ cleaned++;
227
+ }
228
+
229
+ return cleaned;
230
+ }
231
+
155
232
  // ── Stale hook cleanup (#30) ──
156
233
 
157
234
  function cleanStaleHooks() {
@@ -648,29 +725,57 @@ async function cmdInit() {
648
725
  }
649
726
 
650
727
  // Deploy LaunchAgents to ~/Library/LaunchAgents/
728
+ // Templates use {{HOME}} and {{OPENCLAW_GATEWAY_TOKEN}} placeholders, replaced at deploy time.
651
729
  const launchSrc = join(__dirname, '..', 'shared', 'launchagents');
652
730
  const launchDest = join(HOME, 'Library', 'LaunchAgents');
653
731
  if (existsSync(launchSrc) && existsSync(launchDest)) {
732
+ // Ensure log directory exists for LaunchAgent output
733
+ mkdirSync(join(LDM_ROOT, 'logs'), { recursive: true });
734
+
735
+ // Read gateway token from openclaw.json (if it exists)
736
+ let gatewayToken = '';
737
+ try {
738
+ const ocConfig = JSON.parse(readFileSync(join(HOME, '.openclaw', 'openclaw.json'), 'utf8'));
739
+ gatewayToken = ocConfig?.gateway?.auth?.token || '';
740
+ } catch {}
741
+
654
742
  let launchCount = 0;
743
+ let launchUpToDate = 0;
655
744
  for (const file of readdirSync(launchSrc)) {
656
745
  if (!file.endsWith('.plist')) continue;
657
746
  const src = join(launchSrc, file);
658
747
  const dest = join(launchDest, file);
659
- const srcContent = readFileSync(src, 'utf8');
748
+ // Replace template placeholders with actual values
749
+ let srcContent = readFileSync(src, 'utf8');
750
+ srcContent = srcContent.replace(/\{\{HOME\}\}/g, HOME);
751
+ srcContent = srcContent.replace(/\{\{OPENCLAW_GATEWAY_TOKEN\}\}/g, gatewayToken);
660
752
  const destContent = existsSync(dest) ? readFileSync(dest, 'utf8') : '';
661
753
  if (srcContent !== destContent) {
662
- // Unload old, write new, load new
754
+ // Unload old agent before overwriting
663
755
  try { execSync(`launchctl unload "${dest}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
664
- cpSync(src, dest);
756
+ writeFileSync(dest, srcContent);
665
757
  try { execSync(`launchctl load "${dest}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
758
+ const label = file.replace('.plist', '');
759
+ console.log(` + ${label} deployed and loaded`);
760
+ installLog(`LaunchAgent deployed: ${file}`);
666
761
  launchCount++;
762
+ } else {
763
+ launchUpToDate++;
667
764
  }
668
765
  }
669
766
  if (launchCount > 0) {
670
767
  console.log(` + ${launchCount} LaunchAgent(s) deployed to ~/Library/LaunchAgents/`);
671
768
  }
769
+ if (launchUpToDate > 0) {
770
+ console.log(` - ${launchUpToDate} LaunchAgent(s) already up to date`);
771
+ }
672
772
  }
673
773
 
774
+ // Clean up dead backup triggers (#207)
775
+ // Bug: three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
776
+ // The old cron entry (LDMDevTools.app) and com.wipcomputer.daily-backup are dead.
777
+ cleanDeadBackupTriggers();
778
+
674
779
  console.log('');
675
780
  console.log(` LDM OS v${PKG_VERSION} initialized at ${LDM_ROOT}`);
676
781
  console.log('');
@@ -1041,54 +1146,53 @@ async function cmdInstallCatalog() {
1041
1146
  const state = detectSystemState();
1042
1147
  const reconciled = reconcileState(state);
1043
1148
 
1044
- // Show the real system state
1045
- console.log(formatReconciliation(reconciled));
1046
-
1047
1149
  // Check catalog: use registryMatches + cliMatches to detect what's really installed
1048
1150
  const registry = readJSON(REGISTRY_PATH);
1049
- const registeredNames = Object.keys(registry?.extensions || {});
1050
- const reconciledNames = Object.keys(reconciled);
1051
1151
  const components = loadCatalog();
1052
1152
 
1053
- function isCatalogItemInstalled(c) {
1054
- // Direct ID match
1055
- if (registeredNames.includes(c.id) || reconciled[c.id]) return true;
1056
- // Check registryMatches (aliases)
1057
- const matches = c.registryMatches || [];
1058
- if (matches.some(m => registeredNames.includes(m) || reconciled[m])) return true;
1059
- // Check CLI binaries
1060
- const cliMatches = c.cliMatches || [];
1061
- if (cliMatches.some(b => state.cliBinaries[b])) return true;
1062
- return false;
1063
- }
1064
-
1065
- const available = components.filter(c =>
1066
- c.status !== 'coming-soon' && !isCatalogItemInstalled(c)
1067
- );
1068
-
1069
- if (available.length > 0) {
1070
- console.log(' Available in catalog (not yet installed):');
1071
- for (const c of available) {
1072
- console.log(` [ ] ${c.name} ... ${c.description}`);
1073
- }
1074
- console.log('');
1075
- } else {
1076
- console.log(' All catalog components are installed.');
1077
- console.log('');
1078
- }
1079
-
1080
1153
  // Clean ghost entries from registry (#134, #135)
1154
+ // Run BEFORE system state display so ghosts don't appear in the installed list.
1081
1155
  if (registry?.extensions) {
1082
1156
  const names = Object.keys(registry.extensions);
1083
1157
  let cleaned = 0;
1084
1158
  for (const name of names) {
1085
1159
  // Remove -private duplicates (e.g. wip-xai-grok-private when wip-xai-grok exists)
1160
+ // Only public versions should be installed as extensions. Private repos are for development.
1086
1161
  const publicName = name.replace(/-private$/, '');
1087
1162
  if (name !== publicName && registry.extensions[publicName]) {
1088
1163
  delete registry.extensions[name];
1164
+ if (!DRY_RUN) {
1165
+ for (const base of [LDM_EXTENSIONS, join(HOME, '.openclaw', 'extensions')]) {
1166
+ const ghostDir = join(base, name);
1167
+ if (existsSync(ghostDir)) {
1168
+ const trashDir = join(LDM_TRASH, `${name}.ghost-${Date.now()}`);
1169
+ try { execSync(`mv "${ghostDir}" "${trashDir}"`, { stdio: 'pipe' }); } catch {}
1170
+ }
1171
+ }
1172
+ }
1089
1173
  cleaned++;
1090
1174
  continue;
1091
1175
  }
1176
+ // Fix -private path mismatch: registry says "wip-xai-x" but paths point to "wip-xai-x-private".
1177
+ // This happens when the installer cloned a public repo whose package.json had a -private name.
1178
+ // Rename the directories to match the public registry name.
1179
+ const ext = registry.extensions[name];
1180
+ if (ext && !name.endsWith('-private')) {
1181
+ const privateName = name + '-private';
1182
+ let pathFixed = false;
1183
+ for (const [pathKey, base] of [['ldmPath', LDM_EXTENSIONS], ['ocPath', join(HOME, '.openclaw', 'extensions')]]) {
1184
+ if (ext[pathKey] && ext[pathKey].endsWith(privateName)) {
1185
+ const privateDir = join(base, privateName);
1186
+ const publicDir = join(base, name);
1187
+ if (!DRY_RUN && existsSync(privateDir) && !existsSync(publicDir)) {
1188
+ try { execSync(`mv "${privateDir}" "${publicDir}"`, { stdio: 'pipe' }); } catch {}
1189
+ }
1190
+ ext[pathKey] = publicDir;
1191
+ pathFixed = true;
1192
+ }
1193
+ }
1194
+ if (pathFixed) cleaned++;
1195
+ }
1092
1196
  // Rename ldm-install- prefixed entries to clean names (#141)
1093
1197
  if (name.startsWith('ldm-install-')) {
1094
1198
  const cleanName = name.replace(/^ldm-install-/, '');
@@ -1127,6 +1231,122 @@ async function cmdInstallCatalog() {
1127
1231
  }
1128
1232
  }
1129
1233
 
1234
+ // Clean orphaned -private directories (#132)
1235
+ // Pre-v0.4.30 installs could create -private extension dirs that linger
1236
+ // even after registry entries are cleaned. If the public name is in the
1237
+ // registry, rename the directory (or trash it if public dir already exists).
1238
+ try {
1239
+ const extDirs = readdirSync(LDM_EXTENSIONS, { withFileTypes: true })
1240
+ .filter(d => d.isDirectory() && d.name.endsWith('-private'));
1241
+ for (const d of extDirs) {
1242
+ const publicName = d.name.replace(/-private$/, '');
1243
+ // Only act if the public name is known (registry entry or catalog match)
1244
+ const inRegistry = !!registry?.extensions?.[publicName];
1245
+ const inCatalog = components.some(c =>
1246
+ c.id === publicName || (c.registryMatches || []).includes(publicName)
1247
+ );
1248
+ if (!inRegistry && !inCatalog) continue;
1249
+
1250
+ const ghostDir = join(LDM_EXTENSIONS, d.name);
1251
+ const publicDir = join(LDM_EXTENSIONS, publicName);
1252
+
1253
+ if (!DRY_RUN) {
1254
+ if (!existsSync(publicDir)) {
1255
+ // No public dir yet. Rename -private to public name.
1256
+ console.log(` Renaming ghost: ${d.name} -> ${publicName}`);
1257
+ try { execSync(`mv "${ghostDir}" "${publicDir}"`, { stdio: 'pipe' }); } catch {}
1258
+ } else {
1259
+ // Public dir exists. Trash the ghost.
1260
+ console.log(` Trashing ghost: ${d.name} (public "${publicName}" exists)`);
1261
+ const trashDir = join(LDM_EXTENSIONS, '_trash', d.name + '--' + new Date().toISOString().slice(0, 10));
1262
+ try {
1263
+ mkdirSync(join(LDM_EXTENSIONS, '_trash'), { recursive: true });
1264
+ execSync(`mv "${ghostDir}" "${trashDir}"`, { stdio: 'pipe' });
1265
+ } catch {}
1266
+ }
1267
+ // Fix registry paths that still reference the -private name
1268
+ if (registry?.extensions?.[publicName]) {
1269
+ const entry = registry.extensions[publicName];
1270
+ if (entry.ldmPath && entry.ldmPath.includes(d.name)) {
1271
+ entry.ldmPath = entry.ldmPath.replace(d.name, publicName);
1272
+ }
1273
+ if (entry.ocPath && entry.ocPath.includes(d.name)) {
1274
+ entry.ocPath = entry.ocPath.replace(d.name, publicName);
1275
+ }
1276
+ writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2));
1277
+ }
1278
+ } else {
1279
+ if (!existsSync(publicDir)) {
1280
+ console.log(` Would rename ghost: ${d.name} -> ${publicName}`);
1281
+ } else {
1282
+ console.log(` Would trash ghost: ${d.name} (public "${publicName}" exists)`);
1283
+ }
1284
+ }
1285
+ // Remove from reconciled so it doesn't appear in installed list or update checks
1286
+ delete reconciled[d.name];
1287
+ }
1288
+ // Same for OC extensions
1289
+ const ocExtDir = join(HOME, '.openclaw', 'extensions');
1290
+ if (existsSync(ocExtDir)) {
1291
+ const ocDirs = readdirSync(ocExtDir, { withFileTypes: true })
1292
+ .filter(d => d.isDirectory() && d.name.endsWith('-private'));
1293
+ for (const d of ocDirs) {
1294
+ const publicName = d.name.replace(/-private$/, '');
1295
+ const publicDir = join(ocExtDir, publicName);
1296
+ const ghostDir = join(ocExtDir, d.name);
1297
+ const inCatalog = components.some(c =>
1298
+ c.id === publicName || (c.registryMatches || []).includes(publicName)
1299
+ );
1300
+ if (!inCatalog) continue;
1301
+ if (!DRY_RUN) {
1302
+ if (!existsSync(publicDir)) {
1303
+ try { execSync(`mv "${ghostDir}" "${publicDir}"`, { stdio: 'pipe' }); } catch {}
1304
+ } else {
1305
+ const trashDir = join(ocExtDir, '_trash', d.name + '--' + new Date().toISOString().slice(0, 10));
1306
+ try {
1307
+ mkdirSync(join(ocExtDir, '_trash'), { recursive: true });
1308
+ execSync(`mv "${ghostDir}" "${trashDir}"`, { stdio: 'pipe' });
1309
+ } catch {}
1310
+ }
1311
+ }
1312
+ delete reconciled[d.name];
1313
+ }
1314
+ }
1315
+ } catch {}
1316
+
1317
+ // Show the system state (after ghost cleanup, so ghosts don't appear)
1318
+ console.log(formatReconciliation(reconciled));
1319
+
1320
+ const registeredNames = Object.keys(registry?.extensions || {});
1321
+ const reconciledNames = Object.keys(reconciled);
1322
+
1323
+ function isCatalogItemInstalled(c) {
1324
+ // Direct ID match
1325
+ if (registeredNames.includes(c.id) || reconciled[c.id]) return true;
1326
+ // Check registryMatches (aliases)
1327
+ const matches = c.registryMatches || [];
1328
+ if (matches.some(m => registeredNames.includes(m) || reconciled[m])) return true;
1329
+ // Check CLI binaries
1330
+ const cliMatches = c.cliMatches || [];
1331
+ if (cliMatches.some(b => state.cliBinaries[b])) return true;
1332
+ return false;
1333
+ }
1334
+
1335
+ const available = components.filter(c =>
1336
+ c.status !== 'coming-soon' && !isCatalogItemInstalled(c)
1337
+ );
1338
+
1339
+ if (available.length > 0) {
1340
+ console.log(' Available in catalog (not yet installed):');
1341
+ for (const c of available) {
1342
+ console.log(` [ ] ${c.name} ... ${c.description}`);
1343
+ }
1344
+ console.log('');
1345
+ } else {
1346
+ console.log(' All catalog components are installed.');
1347
+ console.log('');
1348
+ }
1349
+
1130
1350
  // Build the update plan: check ALL installed extensions against npm (#55)
1131
1351
  const npmUpdates = [];
1132
1352
 
@@ -1249,9 +1469,12 @@ async function cmdInstallCatalog() {
1249
1469
  encoding: 'utf8', timeout: 10000,
1250
1470
  }).trim();
1251
1471
  if (latest && latest !== currentVersion) {
1252
- // Remove any sub-tool entries that duplicate this parent
1472
+ // Remove any sub-tool entries that belong to this parent.
1473
+ // Match by name in registryMatches (sub-tools have their own npm names,
1474
+ // not the parent's, so catalogNpm comparison doesn't work).
1475
+ const parentMatches = new Set(comp.registryMatches || []);
1253
1476
  for (let i = npmUpdates.length - 1; i >= 0; i--) {
1254
- if (npmUpdates[i].catalogNpm === comp.npm && !npmUpdates[i].isCLI) {
1477
+ if (!npmUpdates[i].isCLI && parentMatches.has(npmUpdates[i].name)) {
1255
1478
  npmUpdates.splice(i, 1);
1256
1479
  }
1257
1480
  }
@@ -1782,6 +2005,69 @@ async function cmdDoctor() {
1782
2005
  console.log(` + CLI binaries: ${Object.keys(state.cliBinaries).join(', ')}`);
1783
2006
  }
1784
2007
 
2008
+ // 8. LaunchAgents health check
2009
+ const managedAgents = [
2010
+ 'ai.openclaw.ldm-backup',
2011
+ 'ai.openclaw.healthcheck',
2012
+ 'ai.openclaw.gateway',
2013
+ ];
2014
+ const launchAgentsDir = join(HOME, 'Library', 'LaunchAgents');
2015
+ const launchAgentsSrc = join(__dirname, '..', 'shared', 'launchagents');
2016
+
2017
+ // Read gateway token for template comparison
2018
+ let doctorGatewayToken = '';
2019
+ try {
2020
+ const ocConfig = JSON.parse(readFileSync(join(HOME, '.openclaw', 'openclaw.json'), 'utf8'));
2021
+ doctorGatewayToken = ocConfig?.gateway?.auth?.token || '';
2022
+ } catch {}
2023
+
2024
+ let launchOk = 0;
2025
+ let launchIssues = 0;
2026
+ for (const label of managedAgents) {
2027
+ const plistFile = `${label}.plist`;
2028
+ const deployedPath = join(launchAgentsDir, plistFile);
2029
+ const srcPath = join(launchAgentsSrc, plistFile);
2030
+
2031
+ if (!existsSync(deployedPath)) {
2032
+ console.log(` x LaunchAgent ${label}: plist missing from ~/Library/LaunchAgents/`);
2033
+ launchIssues++;
2034
+ continue;
2035
+ }
2036
+
2037
+ // Check if deployed plist matches source template (after placeholder substitution)
2038
+ if (existsSync(srcPath)) {
2039
+ let srcContent = readFileSync(srcPath, 'utf8');
2040
+ srcContent = srcContent.replace(/\{\{HOME\}\}/g, HOME);
2041
+ srcContent = srcContent.replace(/\{\{OPENCLAW_GATEWAY_TOKEN\}\}/g, doctorGatewayToken);
2042
+ const deployedContent = readFileSync(deployedPath, 'utf8');
2043
+ if (srcContent !== deployedContent) {
2044
+ console.log(` ! LaunchAgent ${label}: plist out of date (run: ldm install)`);
2045
+ launchIssues++;
2046
+ continue;
2047
+ }
2048
+ }
2049
+
2050
+ // Check if loaded via launchctl
2051
+ try {
2052
+ const result = execSync(`launchctl list 2>/dev/null | grep "${label}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
2053
+ if (result.trim()) {
2054
+ launchOk++;
2055
+ } else {
2056
+ console.log(` ! LaunchAgent ${label}: plist exists but not loaded`);
2057
+ launchIssues++;
2058
+ }
2059
+ } catch {
2060
+ console.log(` ! LaunchAgent ${label}: plist exists but not loaded`);
2061
+ launchIssues++;
2062
+ }
2063
+ }
2064
+ if (launchOk > 0) {
2065
+ console.log(` + LaunchAgents: ${launchOk}/${managedAgents.length} loaded`);
2066
+ }
2067
+ if (launchIssues > 0) {
2068
+ issues += launchIssues;
2069
+ }
2070
+
1785
2071
  console.log('');
1786
2072
  if (issues === 0) {
1787
2073
  console.log(' All checks passed.');
@@ -1882,16 +2168,29 @@ async function cmdBackup() {
1882
2168
  const backupArgs = [];
1883
2169
  if (DRY_RUN) backupArgs.push('--dry-run');
1884
2170
 
2171
+ // --full is explicit but currently all backups are full (incrementals are Phase 2)
2172
+ // Accept it as a no-op so the command reads naturally: ldm backup --full
2173
+ const FULL_FLAG = args.includes('--full');
2174
+
2175
+ // --keep N: pass through to backup script
2176
+ const keepIndex = args.indexOf('--keep');
2177
+ if (keepIndex !== -1 && args[keepIndex + 1]) {
2178
+ backupArgs.push('--keep', args[keepIndex + 1]);
2179
+ }
2180
+
1885
2181
  // --pin: mark the latest backup to skip rotation
1886
2182
  const pinIndex = args.indexOf('--pin');
1887
2183
  if (pinIndex !== -1) {
1888
2184
  const reason = args[pinIndex + 1] || 'pinned';
1889
2185
  // Find latest backup dir
1890
2186
  const backupRoot = join(LDM_ROOT, 'backups');
1891
- const dirs = readdirSync(backupRoot)
1892
- .filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
1893
- .sort()
1894
- .reverse();
2187
+ let dirs = [];
2188
+ try {
2189
+ dirs = readdirSync(backupRoot)
2190
+ .filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
2191
+ .sort()
2192
+ .reverse();
2193
+ } catch {}
1895
2194
  if (dirs.length === 0) {
1896
2195
  console.error(' x No backups found to pin.');
1897
2196
  process.exit(1);
@@ -1904,7 +2203,70 @@ async function cmdBackup() {
1904
2203
  return;
1905
2204
  }
1906
2205
 
1907
- console.log(' Running backup...');
2206
+ // --unpin: remove .pinned marker from the latest (or specified) backup
2207
+ const unpinIndex = args.indexOf('--unpin');
2208
+ if (unpinIndex !== -1) {
2209
+ const backupRoot = join(LDM_ROOT, 'backups');
2210
+ let dirs = [];
2211
+ try {
2212
+ dirs = readdirSync(backupRoot)
2213
+ .filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
2214
+ .sort()
2215
+ .reverse();
2216
+ } catch {}
2217
+ // Find first pinned backup
2218
+ let unpinned = false;
2219
+ for (const d of dirs) {
2220
+ const pinFile = join(backupRoot, d, '.pinned');
2221
+ if (existsSync(pinFile)) {
2222
+ unlinkSync(pinFile);
2223
+ console.log(` - Unpinned backup ${d}`);
2224
+ unpinned = true;
2225
+ break;
2226
+ }
2227
+ }
2228
+ if (!unpinned) {
2229
+ console.log(' No pinned backups found.');
2230
+ }
2231
+ return;
2232
+ }
2233
+
2234
+ // --list: show existing backups with pinned status
2235
+ const LIST_FLAG = args.includes('--list');
2236
+ if (LIST_FLAG) {
2237
+ const backupRoot = join(LDM_ROOT, 'backups');
2238
+ let dirs = [];
2239
+ try {
2240
+ dirs = readdirSync(backupRoot)
2241
+ .filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
2242
+ .sort()
2243
+ .reverse();
2244
+ } catch {}
2245
+ if (dirs.length === 0) {
2246
+ console.log(' No backups found.');
2247
+ return;
2248
+ }
2249
+ console.log('');
2250
+ console.log(' Backups:');
2251
+ for (const d of dirs) {
2252
+ const pinFile = join(backupRoot, d, '.pinned');
2253
+ const pinned = existsSync(pinFile);
2254
+ let size = '?';
2255
+ try {
2256
+ size = execSync(`du -sh "${join(backupRoot, d)}" | cut -f1`, { encoding: 'utf8', timeout: 10000 }).trim();
2257
+ } catch {}
2258
+ const marker = pinned ? ' [pinned]' : '';
2259
+ console.log(` ${d} ${size}${marker}`);
2260
+ }
2261
+ console.log('');
2262
+ return;
2263
+ }
2264
+
2265
+ if (FULL_FLAG) {
2266
+ console.log(' Running full backup...');
2267
+ } else {
2268
+ console.log(' Running backup...');
2269
+ }
1908
2270
  console.log('');
1909
2271
  try {
1910
2272
  execSync(`bash "${BACKUP_SCRIPT}" ${backupArgs.join(' ')}`, {
@@ -2449,6 +2811,10 @@ async function main() {
2449
2811
  console.log(' ldm msg broadcast <body> Send to all sessions');
2450
2812
  console.log(' ldm stack list Show available stacks');
2451
2813
  console.log(' ldm stack install <name> Install a stack (core, web, all)');
2814
+ console.log(' ldm backup Run a full backup now');
2815
+ console.log(' ldm backup --dry-run Preview what would be backed up (with sizes)');
2816
+ console.log(' ldm backup --keep N Keep last N backups (default: 7)');
2817
+ console.log(' ldm backup --pin "reason" Pin latest backup so rotation skips it');
2452
2818
  console.log(' ldm updates Show available updates from cache');
2453
2819
  console.log(' ldm updates --check Re-check npm registry for updates');
2454
2820
  console.log('');
package/catalog.json CHANGED
@@ -116,7 +116,8 @@
116
116
  "wip-license-guard",
117
117
  "wip-repo-init",
118
118
  "wip-readme-format",
119
- "wip-branch-guard"
119
+ "wip-branch-guard",
120
+ "universal-installer"
120
121
  ],
121
122
  "cliMatches": [
122
123
  "wip-release",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.61",
3
+ "version": "0.4.62",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -81,14 +81,23 @@ if [ "$DRY_RUN" = true ]; then
81
81
  echo " ~/.ldm/state/ (cp -a)"
82
82
  echo " ~/.ldm/config.json (cp)"
83
83
  [ -f "$OC_HOME/memory/main.sqlite" ] && echo " ~/.openclaw/memory/main.sqlite (sqlite3 .backup) [$(du -sh "$OC_HOME/memory/main.sqlite" | cut -f1)]"
84
- [ -f "$OC_HOME/memory/context-embeddings.sqlite" ] && echo " ~/.openclaw/memory/context-embeddings.sqlite (sqlite3 .backup)"
85
- [ -d "$OC_HOME/workspace" ] && echo " ~/.openclaw/workspace/ (tar)"
86
- [ -d "$OC_HOME/agents/main/sessions" ] && echo " ~/.openclaw/agents/main/sessions/ (tar)"
84
+ [ -f "$OC_HOME/memory/context-embeddings.sqlite" ] && echo " ~/.openclaw/memory/context-embeddings.sqlite (sqlite3 .backup) [$(du -sh "$OC_HOME/memory/context-embeddings.sqlite" | cut -f1)]"
85
+ [ -d "$OC_HOME/workspace" ] && echo " ~/.openclaw/workspace/ (tar) [$(du -sh "$OC_HOME/workspace" | cut -f1)]"
86
+ [ -d "$OC_HOME/agents/main/sessions" ] && echo " ~/.openclaw/agents/main/sessions/ (tar) [$(du -sh "$OC_HOME/agents/main/sessions" | cut -f1)]"
87
87
  [ -f "$OC_HOME/openclaw.json" ] && echo " ~/.openclaw/openclaw.json (cp)"
88
88
  [ -f "$CLAUDE_HOME/CLAUDE.md" ] && echo " ~/.claude/CLAUDE.md (cp)"
89
89
  [ -f "$CLAUDE_HOME/settings.json" ] && echo " ~/.claude/settings.json (cp)"
90
- [ -d "$CLAUDE_HOME/projects" ] && echo " ~/.claude/projects/ (tar)"
91
- [ -n "$WORKSPACE" ] && echo " $WORKSPACE/ (tar, excludes node_modules/.git/objects)"
90
+ [ -d "$CLAUDE_HOME/projects" ] && echo " ~/.claude/projects/ (tar) [$(du -sh "$CLAUDE_HOME/projects" | cut -f1)]"
91
+ if [ -n "$WORKSPACE" ] && [ -d "$WORKSPACE" ]; then
92
+ # macOS du uses -I for exclusions (not --exclude)
93
+ WS_KB=$(du -sk -I "node_modules" -I ".git" -I "_temp" -I "_trash" "$WORKSPACE" 2>/dev/null | cut -f1 || echo "?")
94
+ WS_MB=$((WS_KB / 1024))
95
+ echo " $WORKSPACE/ (tar, excludes node_modules/.git/objects/_temp/_trash)"
96
+ echo " estimated size: ${WS_MB}MB (${WS_KB}KB)"
97
+ if [ "$WS_KB" -gt 10000000 ] 2>/dev/null; then
98
+ echo " WARNING: exceeds 10GB limit. Backup would abort."
99
+ fi
100
+ fi
92
101
  [ "$INCLUDE_SECRETS" = true ] && echo " ~/.ldm/secrets/ (cp -a)"
93
102
  echo ""
94
103
  echo "[DRY RUN] No files modified."
@@ -195,7 +204,8 @@ if [ -n "$WORKSPACE" ] && [ -d "$WORKSPACE" ]; then
195
204
  echo "--- $WORKSPACE/ ---"
196
205
 
197
206
  # Size guard: estimate workspace size before tarring
198
- ESTIMATED_KB=$(du -sk --exclude="node_modules" --exclude=".git" --exclude="_temp/_archive" --exclude="_trash" "$WORKSPACE" 2>/dev/null | cut -f1 || echo "0")
207
+ # macOS du uses -I for exclusions (not --exclude)
208
+ ESTIMATED_KB=$(du -sk -I "node_modules" -I ".git" -I "_temp" -I "_trash" "$WORKSPACE" 2>/dev/null | cut -f1 || echo "0")
199
209
  MAX_KB=10000000 # 10GB
200
210
  if [ "$ESTIMATED_KB" -gt "$MAX_KB" ] 2>/dev/null; then
201
211
  echo " ERROR: Workspace estimated at ${ESTIMATED_KB}KB (>10GB). Aborting tar to prevent disk fill."
@@ -0,0 +1,41 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>ai.openclaw.gateway</string>
7
+ <key>Comment</key>
8
+ <string>OpenClaw Gateway (managed by ldm install)</string>
9
+ <key>RunAtLoad</key>
10
+ <true/>
11
+ <key>KeepAlive</key>
12
+ <true/>
13
+ <key>ProgramArguments</key>
14
+ <array>
15
+ <string>/opt/homebrew/bin/node</string>
16
+ <string>/opt/homebrew/lib/node_modules/openclaw/dist/index.js</string>
17
+ <string>gateway</string>
18
+ <string>--port</string>
19
+ <string>18789</string>
20
+ </array>
21
+ <key>StandardOutPath</key>
22
+ <string>{{HOME}}/.openclaw/logs/gateway.log</string>
23
+ <key>StandardErrorPath</key>
24
+ <string>{{HOME}}/.openclaw/logs/gateway.err.log</string>
25
+ <key>EnvironmentVariables</key>
26
+ <dict>
27
+ <key>HOME</key>
28
+ <string>{{HOME}}</string>
29
+ <key>PATH</key>
30
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
31
+ <key>OPENCLAW_GATEWAY_PORT</key>
32
+ <string>18789</string>
33
+ <key>OPENCLAW_LAUNCHD_LABEL</key>
34
+ <string>ai.openclaw.gateway</string>
35
+ <key>OPENCLAW_GATEWAY_TOKEN</key>
36
+ <string>{{OPENCLAW_GATEWAY_TOKEN}}</string>
37
+ <key>NODE_OPTIONS</key>
38
+ <string>--max-old-space-size=8192</string>
39
+ </dict>
40
+ </dict>
41
+ </plist>
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>ai.openclaw.healthcheck</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>/opt/homebrew/bin/node</string>
10
+ <string>{{HOME}}/.openclaw/wip-healthcheck/healthcheck.mjs</string>
11
+ </array>
12
+ <key>StartInterval</key>
13
+ <integer>180</integer>
14
+ <key>StandardOutPath</key>
15
+ <string>{{HOME}}/.ldm/logs/healthcheck-stdout.log</string>
16
+ <key>StandardErrorPath</key>
17
+ <string>{{HOME}}/.ldm/logs/healthcheck-stderr.log</string>
18
+ <key>EnvironmentVariables</key>
19
+ <dict>
20
+ <key>HOME</key>
21
+ <string>{{HOME}}</string>
22
+ <key>PATH</key>
23
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
24
+ </dict>
25
+ <key>RunAtLoad</key>
26
+ <true/>
27
+ </dict>
28
+ </plist>
@@ -7,7 +7,7 @@
7
7
  <key>ProgramArguments</key>
8
8
  <array>
9
9
  <string>bash</string>
10
- <string>/Users/lesa/.ldm/bin/ldm-backup.sh</string>
10
+ <string>{{HOME}}/.ldm/bin/ldm-backup.sh</string>
11
11
  </array>
12
12
  <key>StartCalendarInterval</key>
13
13
  <dict>
@@ -17,13 +17,13 @@
17
17
  <integer>0</integer>
18
18
  </dict>
19
19
  <key>StandardOutPath</key>
20
- <string>/Users/lesa/.ldm/logs/backup.log</string>
20
+ <string>{{HOME}}/.ldm/logs/backup.log</string>
21
21
  <key>StandardErrorPath</key>
22
- <string>/Users/lesa/.ldm/logs/backup.log</string>
22
+ <string>{{HOME}}/.ldm/logs/backup.log</string>
23
23
  <key>EnvironmentVariables</key>
24
24
  <dict>
25
25
  <key>HOME</key>
26
- <string>/Users/lesa</string>
26
+ <string>{{HOME}}</string>
27
27
  <key>PATH</key>
28
28
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
29
29
  </dict>