@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 +1 -1
- package/bin/ldm.js +409 -43
- package/catalog.json +2 -1
- package/package.json +1 -1
- package/scripts/ldm-backup.sh +16 -6
- 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/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.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
|
-
|
|
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
|
|
754
|
+
// Unload old agent before overwriting
|
|
663
755
|
try { execSync(`launchctl unload "${dest}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
|
|
664
|
-
|
|
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
|
|
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].
|
|
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
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
package/scripts/ldm-backup.sh
CHANGED
|
@@ -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" ] &&
|
|
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
|
-
|
|
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
|
|
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>
|