@wipcomputer/wip-ldm-os 0.4.61 → 0.4.63
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 +457 -47
- package/catalog.json +4 -1
- package/dist/bridge/{chunk-I5FNBIR2.js → chunk-QZ4DNVJM.js} +1 -1
- package/dist/bridge/cli.js +1 -1
- package/dist/bridge/core.js +1 -1
- package/dist/bridge/mcp-server.js +1 -1
- package/package.json +1 -1
- package/scripts/ldm-backup.sh +34 -15
- package/shared/launchagents/ai.openclaw.gateway.plist +41 -0
- package/shared/launchagents/ai.openclaw.healthcheck.plist +28 -0
- package/shared/launchagents/ai.openclaw.ldm-backup.plist +4 -4
- package/src/bridge/core.ts +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.63"
|
|
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,76 @@ 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
|
+
// 3. Unload and disable com.wipcomputer.cc-watcher LaunchAgent
|
|
230
|
+
// Broken since Mar 24 migration (old iCloud path, wrong node path).
|
|
231
|
+
// The agent communication channel needs redesign, not screen automation.
|
|
232
|
+
const ccWatcherPlist = join(HOME, 'Library', 'LaunchAgents', 'com.wipcomputer.cc-watcher.plist');
|
|
233
|
+
const ccWatcherDisabled = ccWatcherPlist + '.disabled';
|
|
234
|
+
if (existsSync(ccWatcherPlist)) {
|
|
235
|
+
try { execSync(`launchctl unload "${ccWatcherPlist}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
|
|
236
|
+
try {
|
|
237
|
+
renameSync(ccWatcherPlist, ccWatcherDisabled);
|
|
238
|
+
} catch {
|
|
239
|
+
try { unlinkSync(ccWatcherPlist); } catch {}
|
|
240
|
+
}
|
|
241
|
+
console.log(' + Disabled dead LaunchAgent: com.wipcomputer.cc-watcher');
|
|
242
|
+
cleaned++;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return cleaned;
|
|
246
|
+
}
|
|
247
|
+
|
|
155
248
|
// ── Stale hook cleanup (#30) ──
|
|
156
249
|
|
|
157
250
|
function cleanStaleHooks() {
|
|
@@ -319,6 +412,32 @@ async function cmdInit() {
|
|
|
319
412
|
join(LDM_ROOT, 'hooks'),
|
|
320
413
|
];
|
|
321
414
|
|
|
415
|
+
// Migrate config-from-home.json into config.json (one-time merge)
|
|
416
|
+
// config-from-home.json held org identity (coAuthors, paths, agents, backup, etc.)
|
|
417
|
+
// config.json held runtime/harness info. Now they are one file.
|
|
418
|
+
const configFromHomePath = join(LDM_ROOT, 'config-from-home.json');
|
|
419
|
+
if (existsSync(configFromHomePath) && existsSync(join(LDM_ROOT, 'config.json'))) {
|
|
420
|
+
try {
|
|
421
|
+
const existing = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
|
|
422
|
+
const fromHome = JSON.parse(readFileSync(configFromHomePath, 'utf8'));
|
|
423
|
+
// Merge: config-from-home.json wins where keys overlap (richer data)
|
|
424
|
+
const merged = { ...existing, ...fromHome };
|
|
425
|
+
// Preserve harnesses from existing config (not in config-from-home.json)
|
|
426
|
+
if (existing.harnesses) merged.harnesses = existing.harnesses;
|
|
427
|
+
// Preserve version and created from existing config
|
|
428
|
+
if (existing.version) merged.version = existing.version;
|
|
429
|
+
if (existing.created) merged.created = existing.created;
|
|
430
|
+
// Update timestamp
|
|
431
|
+
merged.updatedAt = new Date().toISOString();
|
|
432
|
+
writeFileSync(join(LDM_ROOT, 'config.json'), JSON.stringify(merged, null, 2) + '\n');
|
|
433
|
+
renameSync(configFromHomePath, configFromHomePath + '.migrated');
|
|
434
|
+
console.log(` + config-from-home.json merged into config.json`);
|
|
435
|
+
console.log(` + config-from-home.json renamed to config-from-home.json.migrated`);
|
|
436
|
+
} catch (e) {
|
|
437
|
+
console.log(` ! config-from-home.json migration failed: ${e.message}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
322
441
|
// Scaffold per-agent memory dirs
|
|
323
442
|
try {
|
|
324
443
|
const config = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
|
|
@@ -597,10 +716,9 @@ async function cmdInit() {
|
|
|
597
716
|
mkdirSync(docsDest, { recursive: true });
|
|
598
717
|
let docsCount = 0;
|
|
599
718
|
|
|
600
|
-
// Build template values from
|
|
601
|
-
//
|
|
602
|
-
const
|
|
603
|
-
const sc = settingsConfig;
|
|
719
|
+
// Build template values from ~/.ldm/config.json (unified config)
|
|
720
|
+
// Legacy: settings/config.json was a separate file, now merged into config.json
|
|
721
|
+
const sc = ldmConfig;
|
|
604
722
|
const lc = ldmConfig;
|
|
605
723
|
|
|
606
724
|
// Agents from settings config (rich objects with harness/machine/prefix)
|
|
@@ -648,29 +766,57 @@ async function cmdInit() {
|
|
|
648
766
|
}
|
|
649
767
|
|
|
650
768
|
// Deploy LaunchAgents to ~/Library/LaunchAgents/
|
|
769
|
+
// Templates use {{HOME}} and {{OPENCLAW_GATEWAY_TOKEN}} placeholders, replaced at deploy time.
|
|
651
770
|
const launchSrc = join(__dirname, '..', 'shared', 'launchagents');
|
|
652
771
|
const launchDest = join(HOME, 'Library', 'LaunchAgents');
|
|
653
772
|
if (existsSync(launchSrc) && existsSync(launchDest)) {
|
|
773
|
+
// Ensure log directory exists for LaunchAgent output
|
|
774
|
+
mkdirSync(join(LDM_ROOT, 'logs'), { recursive: true });
|
|
775
|
+
|
|
776
|
+
// Read gateway token from openclaw.json (if it exists)
|
|
777
|
+
let gatewayToken = '';
|
|
778
|
+
try {
|
|
779
|
+
const ocConfig = JSON.parse(readFileSync(join(HOME, '.openclaw', 'openclaw.json'), 'utf8'));
|
|
780
|
+
gatewayToken = ocConfig?.gateway?.auth?.token || '';
|
|
781
|
+
} catch {}
|
|
782
|
+
|
|
654
783
|
let launchCount = 0;
|
|
784
|
+
let launchUpToDate = 0;
|
|
655
785
|
for (const file of readdirSync(launchSrc)) {
|
|
656
786
|
if (!file.endsWith('.plist')) continue;
|
|
657
787
|
const src = join(launchSrc, file);
|
|
658
788
|
const dest = join(launchDest, file);
|
|
659
|
-
|
|
789
|
+
// Replace template placeholders with actual values
|
|
790
|
+
let srcContent = readFileSync(src, 'utf8');
|
|
791
|
+
srcContent = srcContent.replace(/\{\{HOME\}\}/g, HOME);
|
|
792
|
+
srcContent = srcContent.replace(/\{\{OPENCLAW_GATEWAY_TOKEN\}\}/g, gatewayToken);
|
|
660
793
|
const destContent = existsSync(dest) ? readFileSync(dest, 'utf8') : '';
|
|
661
794
|
if (srcContent !== destContent) {
|
|
662
|
-
// Unload old
|
|
795
|
+
// Unload old agent before overwriting
|
|
663
796
|
try { execSync(`launchctl unload "${dest}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
|
|
664
|
-
|
|
797
|
+
writeFileSync(dest, srcContent);
|
|
665
798
|
try { execSync(`launchctl load "${dest}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
|
|
799
|
+
const label = file.replace('.plist', '');
|
|
800
|
+
console.log(` + ${label} deployed and loaded`);
|
|
801
|
+
installLog(`LaunchAgent deployed: ${file}`);
|
|
666
802
|
launchCount++;
|
|
803
|
+
} else {
|
|
804
|
+
launchUpToDate++;
|
|
667
805
|
}
|
|
668
806
|
}
|
|
669
807
|
if (launchCount > 0) {
|
|
670
808
|
console.log(` + ${launchCount} LaunchAgent(s) deployed to ~/Library/LaunchAgents/`);
|
|
671
809
|
}
|
|
810
|
+
if (launchUpToDate > 0) {
|
|
811
|
+
console.log(` - ${launchUpToDate} LaunchAgent(s) already up to date`);
|
|
812
|
+
}
|
|
672
813
|
}
|
|
673
814
|
|
|
815
|
+
// Clean up dead backup triggers (#207)
|
|
816
|
+
// Bug: three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
|
|
817
|
+
// The old cron entry (LDMDevTools.app) and com.wipcomputer.daily-backup are dead.
|
|
818
|
+
cleanDeadBackupTriggers();
|
|
819
|
+
|
|
674
820
|
console.log('');
|
|
675
821
|
console.log(` LDM OS v${PKG_VERSION} initialized at ${LDM_ROOT}`);
|
|
676
822
|
console.log('');
|
|
@@ -1041,54 +1187,53 @@ async function cmdInstallCatalog() {
|
|
|
1041
1187
|
const state = detectSystemState();
|
|
1042
1188
|
const reconciled = reconcileState(state);
|
|
1043
1189
|
|
|
1044
|
-
// Show the real system state
|
|
1045
|
-
console.log(formatReconciliation(reconciled));
|
|
1046
|
-
|
|
1047
1190
|
// Check catalog: use registryMatches + cliMatches to detect what's really installed
|
|
1048
1191
|
const registry = readJSON(REGISTRY_PATH);
|
|
1049
|
-
const registeredNames = Object.keys(registry?.extensions || {});
|
|
1050
|
-
const reconciledNames = Object.keys(reconciled);
|
|
1051
1192
|
const components = loadCatalog();
|
|
1052
1193
|
|
|
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
1194
|
// Clean ghost entries from registry (#134, #135)
|
|
1195
|
+
// Run BEFORE system state display so ghosts don't appear in the installed list.
|
|
1081
1196
|
if (registry?.extensions) {
|
|
1082
1197
|
const names = Object.keys(registry.extensions);
|
|
1083
1198
|
let cleaned = 0;
|
|
1084
1199
|
for (const name of names) {
|
|
1085
1200
|
// Remove -private duplicates (e.g. wip-xai-grok-private when wip-xai-grok exists)
|
|
1201
|
+
// Only public versions should be installed as extensions. Private repos are for development.
|
|
1086
1202
|
const publicName = name.replace(/-private$/, '');
|
|
1087
1203
|
if (name !== publicName && registry.extensions[publicName]) {
|
|
1088
1204
|
delete registry.extensions[name];
|
|
1205
|
+
if (!DRY_RUN) {
|
|
1206
|
+
for (const base of [LDM_EXTENSIONS, join(HOME, '.openclaw', 'extensions')]) {
|
|
1207
|
+
const ghostDir = join(base, name);
|
|
1208
|
+
if (existsSync(ghostDir)) {
|
|
1209
|
+
const trashDir = join(LDM_TRASH, `${name}.ghost-${Date.now()}`);
|
|
1210
|
+
try { execSync(`mv "${ghostDir}" "${trashDir}"`, { stdio: 'pipe' }); } catch {}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1089
1214
|
cleaned++;
|
|
1090
1215
|
continue;
|
|
1091
1216
|
}
|
|
1217
|
+
// Fix -private path mismatch: registry says "wip-xai-x" but paths point to "wip-xai-x-private".
|
|
1218
|
+
// This happens when the installer cloned a public repo whose package.json had a -private name.
|
|
1219
|
+
// Rename the directories to match the public registry name.
|
|
1220
|
+
const ext = registry.extensions[name];
|
|
1221
|
+
if (ext && !name.endsWith('-private')) {
|
|
1222
|
+
const privateName = name + '-private';
|
|
1223
|
+
let pathFixed = false;
|
|
1224
|
+
for (const [pathKey, base] of [['ldmPath', LDM_EXTENSIONS], ['ocPath', join(HOME, '.openclaw', 'extensions')]]) {
|
|
1225
|
+
if (ext[pathKey] && ext[pathKey].endsWith(privateName)) {
|
|
1226
|
+
const privateDir = join(base, privateName);
|
|
1227
|
+
const publicDir = join(base, name);
|
|
1228
|
+
if (!DRY_RUN && existsSync(privateDir) && !existsSync(publicDir)) {
|
|
1229
|
+
try { execSync(`mv "${privateDir}" "${publicDir}"`, { stdio: 'pipe' }); } catch {}
|
|
1230
|
+
}
|
|
1231
|
+
ext[pathKey] = publicDir;
|
|
1232
|
+
pathFixed = true;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (pathFixed) cleaned++;
|
|
1236
|
+
}
|
|
1092
1237
|
// Rename ldm-install- prefixed entries to clean names (#141)
|
|
1093
1238
|
if (name.startsWith('ldm-install-')) {
|
|
1094
1239
|
const cleanName = name.replace(/^ldm-install-/, '');
|
|
@@ -1127,6 +1272,122 @@ async function cmdInstallCatalog() {
|
|
|
1127
1272
|
}
|
|
1128
1273
|
}
|
|
1129
1274
|
|
|
1275
|
+
// Clean orphaned -private directories (#132)
|
|
1276
|
+
// Pre-v0.4.30 installs could create -private extension dirs that linger
|
|
1277
|
+
// even after registry entries are cleaned. If the public name is in the
|
|
1278
|
+
// registry, rename the directory (or trash it if public dir already exists).
|
|
1279
|
+
try {
|
|
1280
|
+
const extDirs = readdirSync(LDM_EXTENSIONS, { withFileTypes: true })
|
|
1281
|
+
.filter(d => d.isDirectory() && d.name.endsWith('-private'));
|
|
1282
|
+
for (const d of extDirs) {
|
|
1283
|
+
const publicName = d.name.replace(/-private$/, '');
|
|
1284
|
+
// Only act if the public name is known (registry entry or catalog match)
|
|
1285
|
+
const inRegistry = !!registry?.extensions?.[publicName];
|
|
1286
|
+
const inCatalog = components.some(c =>
|
|
1287
|
+
c.id === publicName || (c.registryMatches || []).includes(publicName)
|
|
1288
|
+
);
|
|
1289
|
+
if (!inRegistry && !inCatalog) continue;
|
|
1290
|
+
|
|
1291
|
+
const ghostDir = join(LDM_EXTENSIONS, d.name);
|
|
1292
|
+
const publicDir = join(LDM_EXTENSIONS, publicName);
|
|
1293
|
+
|
|
1294
|
+
if (!DRY_RUN) {
|
|
1295
|
+
if (!existsSync(publicDir)) {
|
|
1296
|
+
// No public dir yet. Rename -private to public name.
|
|
1297
|
+
console.log(` Renaming ghost: ${d.name} -> ${publicName}`);
|
|
1298
|
+
try { execSync(`mv "${ghostDir}" "${publicDir}"`, { stdio: 'pipe' }); } catch {}
|
|
1299
|
+
} else {
|
|
1300
|
+
// Public dir exists. Trash the ghost.
|
|
1301
|
+
console.log(` Trashing ghost: ${d.name} (public "${publicName}" exists)`);
|
|
1302
|
+
const trashDir = join(LDM_EXTENSIONS, '_trash', d.name + '--' + new Date().toISOString().slice(0, 10));
|
|
1303
|
+
try {
|
|
1304
|
+
mkdirSync(join(LDM_EXTENSIONS, '_trash'), { recursive: true });
|
|
1305
|
+
execSync(`mv "${ghostDir}" "${trashDir}"`, { stdio: 'pipe' });
|
|
1306
|
+
} catch {}
|
|
1307
|
+
}
|
|
1308
|
+
// Fix registry paths that still reference the -private name
|
|
1309
|
+
if (registry?.extensions?.[publicName]) {
|
|
1310
|
+
const entry = registry.extensions[publicName];
|
|
1311
|
+
if (entry.ldmPath && entry.ldmPath.includes(d.name)) {
|
|
1312
|
+
entry.ldmPath = entry.ldmPath.replace(d.name, publicName);
|
|
1313
|
+
}
|
|
1314
|
+
if (entry.ocPath && entry.ocPath.includes(d.name)) {
|
|
1315
|
+
entry.ocPath = entry.ocPath.replace(d.name, publicName);
|
|
1316
|
+
}
|
|
1317
|
+
writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2));
|
|
1318
|
+
}
|
|
1319
|
+
} else {
|
|
1320
|
+
if (!existsSync(publicDir)) {
|
|
1321
|
+
console.log(` Would rename ghost: ${d.name} -> ${publicName}`);
|
|
1322
|
+
} else {
|
|
1323
|
+
console.log(` Would trash ghost: ${d.name} (public "${publicName}" exists)`);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
// Remove from reconciled so it doesn't appear in installed list or update checks
|
|
1327
|
+
delete reconciled[d.name];
|
|
1328
|
+
}
|
|
1329
|
+
// Same for OC extensions
|
|
1330
|
+
const ocExtDir = join(HOME, '.openclaw', 'extensions');
|
|
1331
|
+
if (existsSync(ocExtDir)) {
|
|
1332
|
+
const ocDirs = readdirSync(ocExtDir, { withFileTypes: true })
|
|
1333
|
+
.filter(d => d.isDirectory() && d.name.endsWith('-private'));
|
|
1334
|
+
for (const d of ocDirs) {
|
|
1335
|
+
const publicName = d.name.replace(/-private$/, '');
|
|
1336
|
+
const publicDir = join(ocExtDir, publicName);
|
|
1337
|
+
const ghostDir = join(ocExtDir, d.name);
|
|
1338
|
+
const inCatalog = components.some(c =>
|
|
1339
|
+
c.id === publicName || (c.registryMatches || []).includes(publicName)
|
|
1340
|
+
);
|
|
1341
|
+
if (!inCatalog) continue;
|
|
1342
|
+
if (!DRY_RUN) {
|
|
1343
|
+
if (!existsSync(publicDir)) {
|
|
1344
|
+
try { execSync(`mv "${ghostDir}" "${publicDir}"`, { stdio: 'pipe' }); } catch {}
|
|
1345
|
+
} else {
|
|
1346
|
+
const trashDir = join(ocExtDir, '_trash', d.name + '--' + new Date().toISOString().slice(0, 10));
|
|
1347
|
+
try {
|
|
1348
|
+
mkdirSync(join(ocExtDir, '_trash'), { recursive: true });
|
|
1349
|
+
execSync(`mv "${ghostDir}" "${trashDir}"`, { stdio: 'pipe' });
|
|
1350
|
+
} catch {}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
delete reconciled[d.name];
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
} catch {}
|
|
1357
|
+
|
|
1358
|
+
// Show the system state (after ghost cleanup, so ghosts don't appear)
|
|
1359
|
+
console.log(formatReconciliation(reconciled));
|
|
1360
|
+
|
|
1361
|
+
const registeredNames = Object.keys(registry?.extensions || {});
|
|
1362
|
+
const reconciledNames = Object.keys(reconciled);
|
|
1363
|
+
|
|
1364
|
+
function isCatalogItemInstalled(c) {
|
|
1365
|
+
// Direct ID match
|
|
1366
|
+
if (registeredNames.includes(c.id) || reconciled[c.id]) return true;
|
|
1367
|
+
// Check registryMatches (aliases)
|
|
1368
|
+
const matches = c.registryMatches || [];
|
|
1369
|
+
if (matches.some(m => registeredNames.includes(m) || reconciled[m])) return true;
|
|
1370
|
+
// Check CLI binaries
|
|
1371
|
+
const cliMatches = c.cliMatches || [];
|
|
1372
|
+
if (cliMatches.some(b => state.cliBinaries[b])) return true;
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const available = components.filter(c =>
|
|
1377
|
+
c.status !== 'coming-soon' && !isCatalogItemInstalled(c)
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1380
|
+
if (available.length > 0) {
|
|
1381
|
+
console.log(' Available in catalog (not yet installed):');
|
|
1382
|
+
for (const c of available) {
|
|
1383
|
+
console.log(` [ ] ${c.name} ... ${c.description}`);
|
|
1384
|
+
}
|
|
1385
|
+
console.log('');
|
|
1386
|
+
} else {
|
|
1387
|
+
console.log(' All catalog components are installed.');
|
|
1388
|
+
console.log('');
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1130
1391
|
// Build the update plan: check ALL installed extensions against npm (#55)
|
|
1131
1392
|
const npmUpdates = [];
|
|
1132
1393
|
|
|
@@ -1164,6 +1425,9 @@ async function cmdInstallCatalog() {
|
|
|
1164
1425
|
return matches.includes(name) || c.id === name;
|
|
1165
1426
|
});
|
|
1166
1427
|
|
|
1428
|
+
// Skip pinned components (e.g. OpenClaw). Upgrades must be explicit.
|
|
1429
|
+
if (catalogEntry?.pinned) continue;
|
|
1430
|
+
|
|
1167
1431
|
// Fallback: use repository.url from extension's package.json (#82)
|
|
1168
1432
|
let repoUrl = catalogEntry?.repo || null;
|
|
1169
1433
|
if (!repoUrl && extPkg?.repository) {
|
|
@@ -1249,9 +1513,12 @@ async function cmdInstallCatalog() {
|
|
|
1249
1513
|
encoding: 'utf8', timeout: 10000,
|
|
1250
1514
|
}).trim();
|
|
1251
1515
|
if (latest && latest !== currentVersion) {
|
|
1252
|
-
// Remove any sub-tool entries that
|
|
1516
|
+
// Remove any sub-tool entries that belong to this parent.
|
|
1517
|
+
// Match by name in registryMatches (sub-tools have their own npm names,
|
|
1518
|
+
// not the parent's, so catalogNpm comparison doesn't work).
|
|
1519
|
+
const parentMatches = new Set(comp.registryMatches || []);
|
|
1253
1520
|
for (let i = npmUpdates.length - 1; i >= 0; i--) {
|
|
1254
|
-
if (npmUpdates[i].
|
|
1521
|
+
if (!npmUpdates[i].isCLI && parentMatches.has(npmUpdates[i].name)) {
|
|
1255
1522
|
npmUpdates.splice(i, 1);
|
|
1256
1523
|
}
|
|
1257
1524
|
}
|
|
@@ -1782,6 +2049,69 @@ async function cmdDoctor() {
|
|
|
1782
2049
|
console.log(` + CLI binaries: ${Object.keys(state.cliBinaries).join(', ')}`);
|
|
1783
2050
|
}
|
|
1784
2051
|
|
|
2052
|
+
// 8. LaunchAgents health check
|
|
2053
|
+
const managedAgents = [
|
|
2054
|
+
'ai.openclaw.ldm-backup',
|
|
2055
|
+
'ai.openclaw.healthcheck',
|
|
2056
|
+
'ai.openclaw.gateway',
|
|
2057
|
+
];
|
|
2058
|
+
const launchAgentsDir = join(HOME, 'Library', 'LaunchAgents');
|
|
2059
|
+
const launchAgentsSrc = join(__dirname, '..', 'shared', 'launchagents');
|
|
2060
|
+
|
|
2061
|
+
// Read gateway token for template comparison
|
|
2062
|
+
let doctorGatewayToken = '';
|
|
2063
|
+
try {
|
|
2064
|
+
const ocConfig = JSON.parse(readFileSync(join(HOME, '.openclaw', 'openclaw.json'), 'utf8'));
|
|
2065
|
+
doctorGatewayToken = ocConfig?.gateway?.auth?.token || '';
|
|
2066
|
+
} catch {}
|
|
2067
|
+
|
|
2068
|
+
let launchOk = 0;
|
|
2069
|
+
let launchIssues = 0;
|
|
2070
|
+
for (const label of managedAgents) {
|
|
2071
|
+
const plistFile = `${label}.plist`;
|
|
2072
|
+
const deployedPath = join(launchAgentsDir, plistFile);
|
|
2073
|
+
const srcPath = join(launchAgentsSrc, plistFile);
|
|
2074
|
+
|
|
2075
|
+
if (!existsSync(deployedPath)) {
|
|
2076
|
+
console.log(` x LaunchAgent ${label}: plist missing from ~/Library/LaunchAgents/`);
|
|
2077
|
+
launchIssues++;
|
|
2078
|
+
continue;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
// Check if deployed plist matches source template (after placeholder substitution)
|
|
2082
|
+
if (existsSync(srcPath)) {
|
|
2083
|
+
let srcContent = readFileSync(srcPath, 'utf8');
|
|
2084
|
+
srcContent = srcContent.replace(/\{\{HOME\}\}/g, HOME);
|
|
2085
|
+
srcContent = srcContent.replace(/\{\{OPENCLAW_GATEWAY_TOKEN\}\}/g, doctorGatewayToken);
|
|
2086
|
+
const deployedContent = readFileSync(deployedPath, 'utf8');
|
|
2087
|
+
if (srcContent !== deployedContent) {
|
|
2088
|
+
console.log(` ! LaunchAgent ${label}: plist out of date (run: ldm install)`);
|
|
2089
|
+
launchIssues++;
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// Check if loaded via launchctl
|
|
2095
|
+
try {
|
|
2096
|
+
const result = execSync(`launchctl list 2>/dev/null | grep "${label}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
2097
|
+
if (result.trim()) {
|
|
2098
|
+
launchOk++;
|
|
2099
|
+
} else {
|
|
2100
|
+
console.log(` ! LaunchAgent ${label}: plist exists but not loaded`);
|
|
2101
|
+
launchIssues++;
|
|
2102
|
+
}
|
|
2103
|
+
} catch {
|
|
2104
|
+
console.log(` ! LaunchAgent ${label}: plist exists but not loaded`);
|
|
2105
|
+
launchIssues++;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
if (launchOk > 0) {
|
|
2109
|
+
console.log(` + LaunchAgents: ${launchOk}/${managedAgents.length} loaded`);
|
|
2110
|
+
}
|
|
2111
|
+
if (launchIssues > 0) {
|
|
2112
|
+
issues += launchIssues;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
1785
2115
|
console.log('');
|
|
1786
2116
|
if (issues === 0) {
|
|
1787
2117
|
console.log(' All checks passed.');
|
|
@@ -1882,16 +2212,29 @@ async function cmdBackup() {
|
|
|
1882
2212
|
const backupArgs = [];
|
|
1883
2213
|
if (DRY_RUN) backupArgs.push('--dry-run');
|
|
1884
2214
|
|
|
2215
|
+
// --full is explicit but currently all backups are full (incrementals are Phase 2)
|
|
2216
|
+
// Accept it as a no-op so the command reads naturally: ldm backup --full
|
|
2217
|
+
const FULL_FLAG = args.includes('--full');
|
|
2218
|
+
|
|
2219
|
+
// --keep N: pass through to backup script
|
|
2220
|
+
const keepIndex = args.indexOf('--keep');
|
|
2221
|
+
if (keepIndex !== -1 && args[keepIndex + 1]) {
|
|
2222
|
+
backupArgs.push('--keep', args[keepIndex + 1]);
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1885
2225
|
// --pin: mark the latest backup to skip rotation
|
|
1886
2226
|
const pinIndex = args.indexOf('--pin');
|
|
1887
2227
|
if (pinIndex !== -1) {
|
|
1888
2228
|
const reason = args[pinIndex + 1] || 'pinned';
|
|
1889
2229
|
// Find latest backup dir
|
|
1890
2230
|
const backupRoot = join(LDM_ROOT, 'backups');
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2231
|
+
let dirs = [];
|
|
2232
|
+
try {
|
|
2233
|
+
dirs = readdirSync(backupRoot)
|
|
2234
|
+
.filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
|
|
2235
|
+
.sort()
|
|
2236
|
+
.reverse();
|
|
2237
|
+
} catch {}
|
|
1895
2238
|
if (dirs.length === 0) {
|
|
1896
2239
|
console.error(' x No backups found to pin.');
|
|
1897
2240
|
process.exit(1);
|
|
@@ -1904,7 +2247,70 @@ async function cmdBackup() {
|
|
|
1904
2247
|
return;
|
|
1905
2248
|
}
|
|
1906
2249
|
|
|
1907
|
-
|
|
2250
|
+
// --unpin: remove .pinned marker from the latest (or specified) backup
|
|
2251
|
+
const unpinIndex = args.indexOf('--unpin');
|
|
2252
|
+
if (unpinIndex !== -1) {
|
|
2253
|
+
const backupRoot = join(LDM_ROOT, 'backups');
|
|
2254
|
+
let dirs = [];
|
|
2255
|
+
try {
|
|
2256
|
+
dirs = readdirSync(backupRoot)
|
|
2257
|
+
.filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
|
|
2258
|
+
.sort()
|
|
2259
|
+
.reverse();
|
|
2260
|
+
} catch {}
|
|
2261
|
+
// Find first pinned backup
|
|
2262
|
+
let unpinned = false;
|
|
2263
|
+
for (const d of dirs) {
|
|
2264
|
+
const pinFile = join(backupRoot, d, '.pinned');
|
|
2265
|
+
if (existsSync(pinFile)) {
|
|
2266
|
+
unlinkSync(pinFile);
|
|
2267
|
+
console.log(` - Unpinned backup ${d}`);
|
|
2268
|
+
unpinned = true;
|
|
2269
|
+
break;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
if (!unpinned) {
|
|
2273
|
+
console.log(' No pinned backups found.');
|
|
2274
|
+
}
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// --list: show existing backups with pinned status
|
|
2279
|
+
const LIST_FLAG = args.includes('--list');
|
|
2280
|
+
if (LIST_FLAG) {
|
|
2281
|
+
const backupRoot = join(LDM_ROOT, 'backups');
|
|
2282
|
+
let dirs = [];
|
|
2283
|
+
try {
|
|
2284
|
+
dirs = readdirSync(backupRoot)
|
|
2285
|
+
.filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
|
|
2286
|
+
.sort()
|
|
2287
|
+
.reverse();
|
|
2288
|
+
} catch {}
|
|
2289
|
+
if (dirs.length === 0) {
|
|
2290
|
+
console.log(' No backups found.');
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
console.log('');
|
|
2294
|
+
console.log(' Backups:');
|
|
2295
|
+
for (const d of dirs) {
|
|
2296
|
+
const pinFile = join(backupRoot, d, '.pinned');
|
|
2297
|
+
const pinned = existsSync(pinFile);
|
|
2298
|
+
let size = '?';
|
|
2299
|
+
try {
|
|
2300
|
+
size = execSync(`du -sh "${join(backupRoot, d)}" | cut -f1`, { encoding: 'utf8', timeout: 10000 }).trim();
|
|
2301
|
+
} catch {}
|
|
2302
|
+
const marker = pinned ? ' [pinned]' : '';
|
|
2303
|
+
console.log(` ${d} ${size}${marker}`);
|
|
2304
|
+
}
|
|
2305
|
+
console.log('');
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
if (FULL_FLAG) {
|
|
2310
|
+
console.log(' Running full backup...');
|
|
2311
|
+
} else {
|
|
2312
|
+
console.log(' Running backup...');
|
|
2313
|
+
}
|
|
1908
2314
|
console.log('');
|
|
1909
2315
|
try {
|
|
1910
2316
|
execSync(`bash "${BACKUP_SCRIPT}" ${backupArgs.join(' ')}`, {
|
|
@@ -2449,6 +2855,10 @@ async function main() {
|
|
|
2449
2855
|
console.log(' ldm msg broadcast <body> Send to all sessions');
|
|
2450
2856
|
console.log(' ldm stack list Show available stacks');
|
|
2451
2857
|
console.log(' ldm stack install <name> Install a stack (core, web, all)');
|
|
2858
|
+
console.log(' ldm backup Run a full backup now');
|
|
2859
|
+
console.log(' ldm backup --dry-run Preview what would be backed up (with sizes)');
|
|
2860
|
+
console.log(' ldm backup --keep N Keep last N backups (default: 7)');
|
|
2861
|
+
console.log(' ldm backup --pin "reason" Pin latest backup so rotation skips it');
|
|
2452
2862
|
console.log(' ldm updates Show available updates from cache');
|
|
2453
2863
|
console.log(' ldm updates --check Re-check npm registry for updates');
|
|
2454
2864
|
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",
|
|
@@ -254,6 +255,8 @@
|
|
|
254
255
|
],
|
|
255
256
|
"recommended": false,
|
|
256
257
|
"status": "stable",
|
|
258
|
+
"pinned": true,
|
|
259
|
+
"pinnedReason": "OpenClaw is the runtime. Upgrades overwrite dist patches and can change API behavior. Use: ldm upgrade openclaw",
|
|
257
260
|
"postInstall": null,
|
|
258
261
|
"installs": {
|
|
259
262
|
"cli": [
|
package/dist/bridge/cli.js
CHANGED
package/dist/bridge/core.js
CHANGED
package/package.json
CHANGED
package/scripts/ldm-backup.sh
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# ldm-backup.sh — Unified backup for LDM OS
|
|
3
|
-
# Backs up: ~/.ldm/, ~/.openclaw/, ~/.claude/,
|
|
3
|
+
# Backs up: ~/.ldm/, ~/.openclaw/, ~/.claude/, $WORKSPACE/
|
|
4
4
|
# Handles SQLite safely (sqlite3 .backup). Tars to iCloud for offsite.
|
|
5
5
|
#
|
|
6
6
|
# Source of truth: wip-ldm-os-private/scripts/ldm-backup.sh
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# ldm-backup.sh --keep 14 # keep last 14 backups (default: 7)
|
|
13
13
|
# ldm-backup.sh --include-secrets # include ~/.ldm/secrets/
|
|
14
14
|
#
|
|
15
|
-
# Config: ~/.ldm/config.json (workspace path
|
|
15
|
+
# Config: ~/.ldm/config.json (workspace path, backup settings, iCloud path)
|
|
16
16
|
|
|
17
17
|
set -euo pipefail
|
|
18
18
|
|
|
@@ -45,20 +45,29 @@ if [ -z "$WORKSPACE" ]; then
|
|
|
45
45
|
echo "WARNING: No workspace in ~/.ldm/config.json. Skipping workspace backup."
|
|
46
46
|
fi
|
|
47
47
|
|
|
48
|
-
# Read
|
|
48
|
+
# Read org name from config (used for tar filename)
|
|
49
|
+
ORG=""
|
|
50
|
+
if [ -f "$LDM_HOME/config.json" ]; then
|
|
51
|
+
ORG=$(python3 -c "import json; print(json.load(open('$LDM_HOME/config.json')).get('org',''))" 2>/dev/null || true)
|
|
52
|
+
fi
|
|
53
|
+
if [ -z "$ORG" ]; then
|
|
54
|
+
ORG="workspace"
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# Read iCloud backup path from ~/.ldm/config.json
|
|
49
58
|
ICLOUD_BACKUP=""
|
|
50
|
-
if [ -
|
|
59
|
+
if [ -f "$LDM_HOME/config.json" ]; then
|
|
51
60
|
ICLOUD_BACKUP=$(python3 -c "
|
|
52
61
|
import json, os
|
|
53
|
-
c = json.load(open('$
|
|
62
|
+
c = json.load(open('$LDM_HOME/config.json'))
|
|
54
63
|
p = c.get('paths',{}).get('icloudBackup','')
|
|
55
64
|
print(os.path.expanduser(p))
|
|
56
65
|
" 2>/dev/null || true)
|
|
57
66
|
fi
|
|
58
67
|
|
|
59
|
-
# Read keep from
|
|
60
|
-
if [ -
|
|
61
|
-
CONFIG_KEEP=$(python3 -c "import json; print(json.load(open('$
|
|
68
|
+
# Read keep from ~/.ldm/config.json (override if set there)
|
|
69
|
+
if [ -f "$LDM_HOME/config.json" ]; then
|
|
70
|
+
CONFIG_KEEP=$(python3 -c "import json; print(json.load(open('$LDM_HOME/config.json')).get('backup',{}).get('keep',0))" 2>/dev/null || true)
|
|
62
71
|
if [ -n "$CONFIG_KEEP" ] && [ "$CONFIG_KEEP" -gt 0 ] 2>/dev/null; then
|
|
63
72
|
KEEP="$CONFIG_KEEP"
|
|
64
73
|
fi
|
|
@@ -81,14 +90,23 @@ if [ "$DRY_RUN" = true ]; then
|
|
|
81
90
|
echo " ~/.ldm/state/ (cp -a)"
|
|
82
91
|
echo " ~/.ldm/config.json (cp)"
|
|
83
92
|
[ -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)"
|
|
93
|
+
[ -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)]"
|
|
94
|
+
[ -d "$OC_HOME/workspace" ] && echo " ~/.openclaw/workspace/ (tar) [$(du -sh "$OC_HOME/workspace" | cut -f1)]"
|
|
95
|
+
[ -d "$OC_HOME/agents/main/sessions" ] && echo " ~/.openclaw/agents/main/sessions/ (tar) [$(du -sh "$OC_HOME/agents/main/sessions" | cut -f1)]"
|
|
87
96
|
[ -f "$OC_HOME/openclaw.json" ] && echo " ~/.openclaw/openclaw.json (cp)"
|
|
88
97
|
[ -f "$CLAUDE_HOME/CLAUDE.md" ] && echo " ~/.claude/CLAUDE.md (cp)"
|
|
89
98
|
[ -f "$CLAUDE_HOME/settings.json" ] && echo " ~/.claude/settings.json (cp)"
|
|
90
|
-
[ -d "$CLAUDE_HOME/projects" ] && echo " ~/.claude/projects/ (tar)"
|
|
91
|
-
[ -n "$WORKSPACE" ] &&
|
|
99
|
+
[ -d "$CLAUDE_HOME/projects" ] && echo " ~/.claude/projects/ (tar) [$(du -sh "$CLAUDE_HOME/projects" | cut -f1)]"
|
|
100
|
+
if [ -n "$WORKSPACE" ] && [ -d "$WORKSPACE" ]; then
|
|
101
|
+
# macOS du uses -I for exclusions (not --exclude)
|
|
102
|
+
WS_KB=$(du -sk -I "node_modules" -I ".git" -I "_temp" -I "_trash" "$WORKSPACE" 2>/dev/null | cut -f1 || echo "?")
|
|
103
|
+
WS_MB=$((WS_KB / 1024))
|
|
104
|
+
echo " $WORKSPACE/ (tar, excludes node_modules/.git/objects/_temp/_trash)"
|
|
105
|
+
echo " estimated size: ${WS_MB}MB (${WS_KB}KB)"
|
|
106
|
+
if [ "$WS_KB" -gt 10000000 ] 2>/dev/null; then
|
|
107
|
+
echo " WARNING: exceeds 10GB limit. Backup would abort."
|
|
108
|
+
fi
|
|
109
|
+
fi
|
|
92
110
|
[ "$INCLUDE_SECRETS" = true ] && echo " ~/.ldm/secrets/ (cp -a)"
|
|
93
111
|
echo ""
|
|
94
112
|
echo "[DRY RUN] No files modified."
|
|
@@ -195,13 +213,14 @@ if [ -n "$WORKSPACE" ] && [ -d "$WORKSPACE" ]; then
|
|
|
195
213
|
echo "--- $WORKSPACE/ ---"
|
|
196
214
|
|
|
197
215
|
# Size guard: estimate workspace size before tarring
|
|
198
|
-
|
|
216
|
+
# macOS du uses -I for exclusions (not --exclude)
|
|
217
|
+
ESTIMATED_KB=$(du -sk -I "node_modules" -I ".git" -I "_temp" -I "_trash" "$WORKSPACE" 2>/dev/null | cut -f1 || echo "0")
|
|
199
218
|
MAX_KB=10000000 # 10GB
|
|
200
219
|
if [ "$ESTIMATED_KB" -gt "$MAX_KB" ] 2>/dev/null; then
|
|
201
220
|
echo " ERROR: Workspace estimated at ${ESTIMATED_KB}KB (>10GB). Aborting tar to prevent disk fill."
|
|
202
221
|
echo " Check for large directories: du -sh $WORKSPACE/*/"
|
|
203
222
|
else
|
|
204
|
-
tar -cf "$DEST
|
|
223
|
+
tar -cf "$DEST/$ORG.tar" \
|
|
205
224
|
--exclude "node_modules" \
|
|
206
225
|
--exclude ".git/objects" \
|
|
207
226
|
--exclude ".DS_Store" \
|
|
@@ -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
|
|
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
|
|
20
|
+
<string>{{HOME}}/.ldm/logs/backup.log</string>
|
|
21
21
|
<key>StandardErrorPath</key>
|
|
22
|
-
<string
|
|
22
|
+
<string>{{HOME}}/.ldm/logs/backup.log</string>
|
|
23
23
|
<key>EnvironmentVariables</key>
|
|
24
24
|
<dict>
|
|
25
25
|
<key>HOME</key>
|
|
26
|
-
<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>
|