@wipcomputer/wip-ldm-os 0.4.71 → 0.4.73-alpha.1
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 +203 -89
- package/docs/backup/README.md +108 -0
- package/docs/backup/TECHNICAL.md +112 -0
- package/package.json +1 -1
- package/shared/docs/how-backup-works.md.tmpl +14 -11
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.72"
|
|
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
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* ldm --version Show version
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync } from 'node:fs';
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync, statSync } from 'node:fs';
|
|
24
24
|
import { join, basename, resolve, dirname } from 'node:path';
|
|
25
25
|
import { execSync } from 'node:child_process';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
@@ -148,6 +148,8 @@ const NONE_FLAG = args.includes('--none');
|
|
|
148
148
|
const FIX_FLAG = args.includes('--fix');
|
|
149
149
|
const CLEANUP_FLAG = args.includes('--cleanup');
|
|
150
150
|
const CHECK_FLAG = args.includes('--check');
|
|
151
|
+
const ALPHA_FLAG = args.includes('--alpha');
|
|
152
|
+
const BETA_FLAG = args.includes('--beta');
|
|
151
153
|
|
|
152
154
|
function readJSON(path) {
|
|
153
155
|
try {
|
|
@@ -411,6 +413,174 @@ async function installCatalogComponent(c) {
|
|
|
411
413
|
}
|
|
412
414
|
|
|
413
415
|
// ── Bridge deploy (#245) ──
|
|
416
|
+
// Deploy all scripts from scripts/ to ~/.ldm/bin/
|
|
417
|
+
// Called from both cmdInit() and cmdInstallCatalog() so script fixes land on every update.
|
|
418
|
+
function deployScripts() {
|
|
419
|
+
const scriptsSrc = join(__dirname, '..', 'scripts');
|
|
420
|
+
if (!existsSync(scriptsSrc)) return 0;
|
|
421
|
+
mkdirSync(join(LDM_ROOT, 'bin'), { recursive: true });
|
|
422
|
+
let count = 0;
|
|
423
|
+
for (const file of readdirSync(scriptsSrc)) {
|
|
424
|
+
if (!file.endsWith('.sh')) continue;
|
|
425
|
+
const src = join(scriptsSrc, file);
|
|
426
|
+
const dest = join(LDM_ROOT, 'bin', file);
|
|
427
|
+
cpSync(src, dest);
|
|
428
|
+
chmodSync(dest, 0o755);
|
|
429
|
+
count++;
|
|
430
|
+
}
|
|
431
|
+
if (count > 0) {
|
|
432
|
+
console.log(` + ${count} script(s) deployed to ~/.ldm/bin/`);
|
|
433
|
+
}
|
|
434
|
+
return count;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Deploy personalized docs to both settings/docs/ and library/documentation/
|
|
438
|
+
// Called from both cmdInit() and cmdInstallCatalog() so doc fixes land on every update.
|
|
439
|
+
function deployDocs() {
|
|
440
|
+
const docsSrc = join(__dirname, '..', 'shared', 'docs');
|
|
441
|
+
if (!existsSync(docsSrc)) return 0;
|
|
442
|
+
|
|
443
|
+
let workspacePath = '';
|
|
444
|
+
try {
|
|
445
|
+
const ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
|
|
446
|
+
workspacePath = (ldmConfig.workspace || '').replace('~', HOME);
|
|
447
|
+
} catch { return 0; }
|
|
448
|
+
if (!workspacePath || !existsSync(workspacePath)) return 0;
|
|
449
|
+
|
|
450
|
+
// Read config for template vars
|
|
451
|
+
let ldmConfig;
|
|
452
|
+
try {
|
|
453
|
+
ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
|
|
454
|
+
} catch { return 0; }
|
|
455
|
+
|
|
456
|
+
const sc = ldmConfig;
|
|
457
|
+
|
|
458
|
+
// Agents from config (rich objects with harness/machine/prefix)
|
|
459
|
+
const agentsObj = sc.agents || {};
|
|
460
|
+
const agentsList = Object.entries(agentsObj).map(([id, a]) => `${id} (${a.harness} on ${a.machine})`).join(', ');
|
|
461
|
+
const agentsDetail = Object.entries(agentsObj).map(([id, a]) => `- **${id}**: ${a.harness} on ${a.machine}, branch prefix \`${a.prefix}/\``).join('\n');
|
|
462
|
+
|
|
463
|
+
// Harnesses from config
|
|
464
|
+
const harnessConfig = sc.harnesses || {};
|
|
465
|
+
const harnessesDetected = Object.entries(harnessConfig).filter(([,h]) => h.detected).map(([name]) => name);
|
|
466
|
+
const harnessesList = harnessesDetected.length > 0 ? harnessesDetected.join(', ') : 'run ldm install to detect';
|
|
467
|
+
|
|
468
|
+
const templateVars = {
|
|
469
|
+
'name': sc.name || '',
|
|
470
|
+
'org': sc.org || '',
|
|
471
|
+
'timezone': sc.timezone || '',
|
|
472
|
+
'paths.workspace': (sc.paths?.workspace || '').replace('~', HOME),
|
|
473
|
+
'paths.ldm': (sc.paths?.ldm || '').replace('~', HOME),
|
|
474
|
+
'paths.openclaw': (sc.paths?.openclaw || '').replace('~', HOME),
|
|
475
|
+
'paths.icloud': (sc.paths?.icloud || '').replace('~', HOME),
|
|
476
|
+
'memory.local': (sc.memory?.local || '').replace('~', HOME),
|
|
477
|
+
'deploy.website': sc.deploy?.website || '',
|
|
478
|
+
'backup.keep': String(sc.backup?.keep || 7),
|
|
479
|
+
'agents_list': agentsList,
|
|
480
|
+
'agents_detail': agentsDetail,
|
|
481
|
+
'harnesses_list': harnessesList,
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
function renderTemplates(destDir) {
|
|
485
|
+
mkdirSync(destDir, { recursive: true });
|
|
486
|
+
let count = 0;
|
|
487
|
+
for (const file of readdirSync(docsSrc)) {
|
|
488
|
+
if (!file.endsWith('.tmpl')) continue;
|
|
489
|
+
let content = readFileSync(join(docsSrc, file), 'utf8');
|
|
490
|
+
content = content.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
|
491
|
+
return templateVars[key.trim()] || match;
|
|
492
|
+
});
|
|
493
|
+
const outName = file.replace('.tmpl', '');
|
|
494
|
+
writeFileSync(join(destDir, outName), content);
|
|
495
|
+
count++;
|
|
496
|
+
}
|
|
497
|
+
return count;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Deploy to settings/docs/ (agent reference)
|
|
501
|
+
const docsDest = join(workspacePath, 'settings', 'docs');
|
|
502
|
+
const docsCount = renderTemplates(docsDest);
|
|
503
|
+
if (docsCount > 0) {
|
|
504
|
+
console.log(` + ${docsCount} personalized doc(s) deployed to ${docsDest.replace(HOME, '~')}/`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Deploy to library/documentation/ (human-readable library copy)
|
|
508
|
+
const libraryDest = join(workspacePath, 'library', 'documentation');
|
|
509
|
+
if (existsSync(join(workspacePath, 'library'))) {
|
|
510
|
+
const libCount = renderTemplates(libraryDest);
|
|
511
|
+
if (libCount > 0) {
|
|
512
|
+
console.log(` + ${libCount} doc(s) deployed to ${libraryDest.replace(HOME, '~')}/`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return docsCount;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Check backup health: is a trigger configured, did it run recently, is iCloud set up?
|
|
520
|
+
// Called from cmdInstallCatalog() on every install.
|
|
521
|
+
function checkBackupHealth() {
|
|
522
|
+
const config = readJSON(join(LDM_ROOT, 'config.json'));
|
|
523
|
+
if (!config) return;
|
|
524
|
+
|
|
525
|
+
const backup = config.backup || {};
|
|
526
|
+
const issues = [];
|
|
527
|
+
|
|
528
|
+
// Check iCloud offsite
|
|
529
|
+
const icloudPath = config.paths?.icloudBackup || backup.icloudPath;
|
|
530
|
+
if (!icloudPath) {
|
|
531
|
+
issues.push('iCloud offsite not configured. Add paths.icloudBackup to ~/.ldm/config.json');
|
|
532
|
+
} else {
|
|
533
|
+
const expandedPath = icloudPath.replace(/^~/, HOME);
|
|
534
|
+
if (!existsSync(expandedPath)) {
|
|
535
|
+
try { mkdirSync(expandedPath, { recursive: true }); } catch {}
|
|
536
|
+
if (!existsSync(expandedPath)) {
|
|
537
|
+
issues.push(`iCloud path does not exist: ${icloudPath}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Check LaunchAgent
|
|
543
|
+
try {
|
|
544
|
+
const label = backup.triggerLabel || 'ai.openclaw.ldm-backup';
|
|
545
|
+
const result = execSync(`launchctl list ${label} 2>/dev/null`, { encoding: 'utf8', timeout: 3000 });
|
|
546
|
+
if (!result) issues.push(`LaunchAgent ${label} not loaded`);
|
|
547
|
+
} catch {
|
|
548
|
+
issues.push('Backup LaunchAgent not loaded. Backups may not run automatically.');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Check last backup age
|
|
552
|
+
const backupRoot = join(LDM_ROOT, 'backups');
|
|
553
|
+
if (existsSync(backupRoot)) {
|
|
554
|
+
const dirs = readdirSync(backupRoot)
|
|
555
|
+
.filter(d => d.match(/^20\d{2}-\d{2}-\d{2}--/) && statSync(join(backupRoot, d)).isDirectory())
|
|
556
|
+
.sort()
|
|
557
|
+
.reverse();
|
|
558
|
+
if (dirs.length > 0) {
|
|
559
|
+
const latest = dirs[0];
|
|
560
|
+
const latestDate = latest.replace(/--.*/, '').replace(/-/g, '/');
|
|
561
|
+
const age = Date.now() - new Date(latestDate).getTime();
|
|
562
|
+
const hours = Math.round(age / (1000 * 60 * 60));
|
|
563
|
+
if (hours > 36) {
|
|
564
|
+
issues.push(`Last backup is ${hours} hours old (${latest}). Expected within 24 hours.`);
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
issues.push('No backups found. Run: ldm backup');
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Check backup script exists
|
|
572
|
+
const scriptPath = join(LDM_ROOT, 'bin', 'ldm-backup.sh');
|
|
573
|
+
if (!existsSync(scriptPath)) {
|
|
574
|
+
issues.push('Backup script missing at ~/.ldm/bin/ldm-backup.sh. Run: ldm init');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (issues.length > 0) {
|
|
578
|
+
for (const issue of issues) {
|
|
579
|
+
console.log(` ! Backup: ${issue}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
414
584
|
// The bridge (src/bridge/) builds to dist/bridge/ and ships in the npm package.
|
|
415
585
|
// After `npm install -g`, the updated files live at the npm package location but
|
|
416
586
|
// never get copied to ~/.ldm/extensions/lesa-bridge/dist/. This function fixes that.
|
|
@@ -724,29 +894,8 @@ async function cmdInit() {
|
|
|
724
894
|
}
|
|
725
895
|
}
|
|
726
896
|
|
|
727
|
-
// Deploy
|
|
728
|
-
|
|
729
|
-
const backupDest = join(LDM_ROOT, 'bin', 'ldm-backup.sh');
|
|
730
|
-
if (existsSync(backupSrc)) {
|
|
731
|
-
mkdirSync(join(LDM_ROOT, 'bin'), { recursive: true });
|
|
732
|
-
cpSync(backupSrc, backupDest);
|
|
733
|
-
chmodSync(backupDest, 0o755);
|
|
734
|
-
console.log(` + ldm-backup.sh deployed to ~/.ldm/bin/`);
|
|
735
|
-
}
|
|
736
|
-
const restoreSrc = join(__dirname, '..', 'scripts', 'ldm-restore.sh');
|
|
737
|
-
const restoreDest = join(LDM_ROOT, 'bin', 'ldm-restore.sh');
|
|
738
|
-
if (existsSync(restoreSrc)) {
|
|
739
|
-
cpSync(restoreSrc, restoreDest);
|
|
740
|
-
chmodSync(restoreDest, 0o755);
|
|
741
|
-
console.log(` + ldm-restore.sh deployed to ~/.ldm/bin/`);
|
|
742
|
-
}
|
|
743
|
-
const summarySrc = join(__dirname, '..', 'scripts', 'ldm-summary.sh');
|
|
744
|
-
const summaryDest = join(LDM_ROOT, 'bin', 'ldm-summary.sh');
|
|
745
|
-
if (existsSync(summarySrc)) {
|
|
746
|
-
cpSync(summarySrc, summaryDest);
|
|
747
|
-
chmodSync(summaryDest, 0o755);
|
|
748
|
-
console.log(` + ldm-summary.sh deployed to ~/.ldm/bin/`);
|
|
749
|
-
}
|
|
897
|
+
// Deploy all scripts from scripts/ to ~/.ldm/bin/ (#119)
|
|
898
|
+
deployScripts();
|
|
750
899
|
|
|
751
900
|
// Deploy shared rules to ~/.ldm/shared/rules/ and to harnesses
|
|
752
901
|
const rulesSrc = join(__dirname, '..', 'shared', 'rules');
|
|
@@ -859,67 +1008,8 @@ async function cmdInit() {
|
|
|
859
1008
|
}
|
|
860
1009
|
} catch {}
|
|
861
1010
|
|
|
862
|
-
// Deploy personalized docs to settings/docs/
|
|
863
|
-
|
|
864
|
-
if (existsSync(docsSrc)) {
|
|
865
|
-
let workspacePath = '';
|
|
866
|
-
try {
|
|
867
|
-
const ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
|
|
868
|
-
workspacePath = (ldmConfig.workspace || '').replace('~', HOME);
|
|
869
|
-
|
|
870
|
-
if (workspacePath && existsSync(workspacePath)) {
|
|
871
|
-
const docsDest = join(workspacePath, 'settings', 'docs');
|
|
872
|
-
mkdirSync(docsDest, { recursive: true });
|
|
873
|
-
let docsCount = 0;
|
|
874
|
-
|
|
875
|
-
// Build template values from ~/.ldm/config.json (unified config)
|
|
876
|
-
// Legacy: settings/config.json was a separate file, now merged into config.json
|
|
877
|
-
const sc = ldmConfig;
|
|
878
|
-
const lc = ldmConfig;
|
|
879
|
-
|
|
880
|
-
// Agents from settings config (rich objects with harness/machine/prefix)
|
|
881
|
-
const agentsObj = sc.agents || {};
|
|
882
|
-
const agentsList = Object.entries(agentsObj).map(([id, a]) => `${id} (${a.harness} on ${a.machine})`).join(', ');
|
|
883
|
-
const agentsDetail = Object.entries(agentsObj).map(([id, a]) => `- **${id}**: ${a.harness} on ${a.machine}, branch prefix \`${a.prefix}/\``).join('\n');
|
|
884
|
-
|
|
885
|
-
// Harnesses from ldm config
|
|
886
|
-
const harnessConfig = lc.harnesses || {};
|
|
887
|
-
const harnessesDetected = Object.entries(harnessConfig).filter(([,h]) => h.detected).map(([name]) => name);
|
|
888
|
-
const harnessesList = harnessesDetected.length > 0 ? harnessesDetected.join(', ') : 'run ldm install to detect';
|
|
889
|
-
|
|
890
|
-
const templateVars = {
|
|
891
|
-
'name': sc.name || '',
|
|
892
|
-
'org': sc.org || '',
|
|
893
|
-
'timezone': sc.timezone || '',
|
|
894
|
-
'paths.workspace': (sc.paths?.workspace || '').replace('~', HOME),
|
|
895
|
-
'paths.ldm': (sc.paths?.ldm || '').replace('~', HOME),
|
|
896
|
-
'paths.openclaw': (sc.paths?.openclaw || '').replace('~', HOME),
|
|
897
|
-
'paths.icloud': (sc.paths?.icloud || '').replace('~', HOME),
|
|
898
|
-
'memory.local': (sc.memory?.local || '').replace('~', HOME),
|
|
899
|
-
'deploy.website': sc.deploy?.website || '',
|
|
900
|
-
'backup.keep': String(sc.backup?.keep || 7),
|
|
901
|
-
'agents_list': agentsList,
|
|
902
|
-
'agents_detail': agentsDetail,
|
|
903
|
-
'harnesses_list': harnessesList,
|
|
904
|
-
};
|
|
905
|
-
|
|
906
|
-
for (const file of readdirSync(docsSrc)) {
|
|
907
|
-
if (!file.endsWith('.tmpl')) continue;
|
|
908
|
-
let content = readFileSync(join(docsSrc, file), 'utf8');
|
|
909
|
-
// Replace template vars
|
|
910
|
-
content = content.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
|
911
|
-
return templateVars[key.trim()] || match;
|
|
912
|
-
});
|
|
913
|
-
const outName = file.replace('.tmpl', '');
|
|
914
|
-
writeFileSync(join(docsDest, outName), content);
|
|
915
|
-
docsCount++;
|
|
916
|
-
}
|
|
917
|
-
if (docsCount > 0) {
|
|
918
|
-
console.log(` + ${docsCount} personalized doc(s) deployed to ${docsDest.replace(HOME, '~')}/`);
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
} catch {}
|
|
922
|
-
}
|
|
1011
|
+
// Deploy personalized docs to settings/docs/ and library/documentation/
|
|
1012
|
+
deployDocs();
|
|
923
1013
|
|
|
924
1014
|
// Deploy LaunchAgents to ~/Library/LaunchAgents/
|
|
925
1015
|
// Templates use {{HOME}} and {{OPENCLAW_GATEWAY_TOKEN}} placeholders, replaced at deploy time.
|
|
@@ -1103,6 +1193,8 @@ async function cmdInstall() {
|
|
|
1103
1193
|
--json JSON output
|
|
1104
1194
|
--yes Auto-accept catalog prompts
|
|
1105
1195
|
--none Skip catalog prompts
|
|
1196
|
+
--alpha Check @alpha npm tag for updates (prerelease track)
|
|
1197
|
+
--beta Check @beta npm tag for updates (prerelease track)
|
|
1106
1198
|
`);
|
|
1107
1199
|
process.exit(0);
|
|
1108
1200
|
}
|
|
@@ -1410,13 +1502,19 @@ async function cmdInstallCatalog() {
|
|
|
1410
1502
|
// Self-update: check if CLI itself is outdated. Update first, then re-exec.
|
|
1411
1503
|
// This breaks the chicken-and-egg: new features in ldm install are always
|
|
1412
1504
|
// available because the installer upgrades itself before doing anything else.
|
|
1505
|
+
// --alpha and --beta flags check the corresponding npm dist-tag instead of @latest.
|
|
1413
1506
|
if (!DRY_RUN && !process.env.LDM_SELF_UPDATED) {
|
|
1414
1507
|
try {
|
|
1415
|
-
const
|
|
1508
|
+
const npmTag = ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
|
|
1509
|
+
const trackLabel = npmTag === 'latest' ? '' : ` (${npmTag} track)`;
|
|
1510
|
+
const npmViewCmd = npmTag === 'latest'
|
|
1511
|
+
? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
|
|
1512
|
+
: `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
|
|
1513
|
+
const latest = execSync(npmViewCmd, {
|
|
1416
1514
|
encoding: 'utf8', timeout: 15000,
|
|
1417
1515
|
}).trim();
|
|
1418
1516
|
if (latest && latest !== PKG_VERSION) {
|
|
1419
|
-
console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}. Updating first...`);
|
|
1517
|
+
console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
|
|
1420
1518
|
try {
|
|
1421
1519
|
execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
|
|
1422
1520
|
console.log(` CLI updated to v${latest}. Re-running with new code...`);
|
|
@@ -1451,6 +1549,13 @@ async function cmdInstallCatalog() {
|
|
|
1451
1549
|
// in the extension directories. This copies them to both LDM and OpenClaw targets.
|
|
1452
1550
|
deployBridge();
|
|
1453
1551
|
|
|
1552
|
+
// Deploy scripts and docs on every install so fixes land without re-init
|
|
1553
|
+
deployScripts();
|
|
1554
|
+
deployDocs();
|
|
1555
|
+
|
|
1556
|
+
// Check backup configuration
|
|
1557
|
+
checkBackupHealth();
|
|
1558
|
+
|
|
1454
1559
|
const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
|
|
1455
1560
|
const state = detectSystemState();
|
|
1456
1561
|
const reconciled = reconcileState(state);
|
|
@@ -1724,9 +1829,14 @@ async function cmdInstallCatalog() {
|
|
|
1724
1829
|
}
|
|
1725
1830
|
|
|
1726
1831
|
// Check npm for updates (fast, one HTTP call)
|
|
1832
|
+
// --alpha and --beta flags check the corresponding npm dist-tag
|
|
1727
1833
|
if (npmPkg) {
|
|
1728
1834
|
try {
|
|
1729
|
-
const
|
|
1835
|
+
const npmTag = ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
|
|
1836
|
+
const npmViewCmd = npmTag === 'latest'
|
|
1837
|
+
? `npm view ${npmPkg} version 2>/dev/null`
|
|
1838
|
+
: `npm view ${npmPkg} dist-tags.${npmTag} 2>/dev/null`;
|
|
1839
|
+
const latestVersion = execSync(npmViewCmd, {
|
|
1730
1840
|
encoding: 'utf8', timeout: 10000,
|
|
1731
1841
|
}).trim();
|
|
1732
1842
|
|
|
@@ -1790,7 +1900,11 @@ async function cmdInstallCatalog() {
|
|
|
1790
1900
|
if (!currentVersion) continue;
|
|
1791
1901
|
|
|
1792
1902
|
try {
|
|
1793
|
-
const
|
|
1903
|
+
const npmTag = ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
|
|
1904
|
+
const npmViewCmd = npmTag === 'latest'
|
|
1905
|
+
? `npm view ${catalogComp.npm} version 2>/dev/null`
|
|
1906
|
+
: `npm view ${catalogComp.npm} dist-tags.${npmTag} 2>/dev/null`;
|
|
1907
|
+
const latestVersion = execSync(npmViewCmd, {
|
|
1794
1908
|
encoding: 'utf8', timeout: 10000,
|
|
1795
1909
|
}).trim();
|
|
1796
1910
|
if (latestVersion && latestVersion !== currentVersion) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Backup
|
|
2
|
+
|
|
3
|
+
## One Script, One Place
|
|
4
|
+
|
|
5
|
+
`~/.ldm/bin/ldm-backup.sh` runs daily at 3:00 AM via LaunchAgent `ai.openclaw.ldm-backup`. It backs up everything to `~/.ldm/backups/`, then tars it to iCloud for offsite.
|
|
6
|
+
|
|
7
|
+
## What Gets Backed Up
|
|
8
|
+
|
|
9
|
+
| Source | Method | What's in it |
|
|
10
|
+
|--------|--------|-------------|
|
|
11
|
+
| `~/.ldm/memory/crystal.db` | sqlite3 .backup | Irreplaceable memory (all agents) |
|
|
12
|
+
| `~/.ldm/agents/` | cp -a | Identity files, journals, daily logs |
|
|
13
|
+
| `~/.ldm/state/` | cp -a | Config, version, registry |
|
|
14
|
+
| `~/.ldm/config.json` | cp | Workspace pointer, org |
|
|
15
|
+
| `~/.openclaw/memory/main.sqlite` | sqlite3 .backup | OC conversations |
|
|
16
|
+
| `~/.openclaw/memory/context-embeddings.sqlite` | sqlite3 .backup | Embeddings |
|
|
17
|
+
| `~/.openclaw/workspace/` | tar | Shared context, daily logs |
|
|
18
|
+
| `~/.openclaw/agents/main/sessions/` | tar | OC session JSONL |
|
|
19
|
+
| `~/.openclaw/openclaw.json` | cp | OC config |
|
|
20
|
+
| `~/.claude/CLAUDE.md` | cp | CC instructions |
|
|
21
|
+
| `~/.claude/settings.json` | cp | CC settings |
|
|
22
|
+
| `~/.claude/projects/` | tar | CC auto-memory + transcripts |
|
|
23
|
+
| Workspace directory | tar (excludes node_modules, .git/objects, old backups, _trash) | Entire workspace |
|
|
24
|
+
|
|
25
|
+
**NOT backed up:** node_modules/, .git/objects/ (reconstructable), extensions (reinstallable), ~/.claude/cache.
|
|
26
|
+
|
|
27
|
+
## Backup Structure
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
~/.ldm/backups/2026-03-24--09-50-22/
|
|
31
|
+
ldm/
|
|
32
|
+
memory/crystal.db
|
|
33
|
+
agents/
|
|
34
|
+
state/
|
|
35
|
+
config.json
|
|
36
|
+
openclaw/
|
|
37
|
+
memory/main.sqlite
|
|
38
|
+
memory/context-embeddings.sqlite
|
|
39
|
+
workspace.tar
|
|
40
|
+
sessions.tar
|
|
41
|
+
openclaw.json
|
|
42
|
+
claude/
|
|
43
|
+
CLAUDE.md
|
|
44
|
+
settings.json
|
|
45
|
+
projects.tar
|
|
46
|
+
<workspace>.tar
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## iCloud Offsite
|
|
50
|
+
|
|
51
|
+
After local backup, the entire dated folder is compressed and copied to iCloud. The destination path is read from `~/.ldm/config.json` at `paths.icloudBackup`.
|
|
52
|
+
|
|
53
|
+
One file per backup. iCloud syncs it across devices. Rotation matches the local retention setting.
|
|
54
|
+
|
|
55
|
+
## How to Run
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
~/.ldm/bin/ldm-backup.sh # run backup now
|
|
59
|
+
~/.ldm/bin/ldm-backup.sh --dry-run # preview what would be backed up
|
|
60
|
+
~/.ldm/bin/ldm-backup.sh --keep 14 # keep 14 days instead of 7
|
|
61
|
+
~/.ldm/bin/ldm-backup.sh --include-secrets # include ~/.ldm/secrets/
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
You can also run via the CLI:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
ldm backup # run backup now
|
|
68
|
+
ldm backup --dry-run # preview with sizes
|
|
69
|
+
ldm backup --pin "before upgrade" # pin latest backup so rotation skips it
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How to Restore
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
~/.ldm/bin/ldm-restore.sh # list available backups
|
|
76
|
+
~/.ldm/bin/ldm-restore.sh 2026-03-24--09-50-22 # restore everything
|
|
77
|
+
~/.ldm/bin/ldm-restore.sh --only ldm <backup> # restore only crystal.db + agents
|
|
78
|
+
~/.ldm/bin/ldm-restore.sh --only openclaw <backup> # restore only OC data
|
|
79
|
+
~/.ldm/bin/ldm-restore.sh --from-icloud <file> # restore from iCloud tar
|
|
80
|
+
~/.ldm/bin/ldm-restore.sh --dry-run <backup> # preview
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
After restore: `openclaw gateway restart` then `crystal status` to verify.
|
|
84
|
+
|
|
85
|
+
## Schedule
|
|
86
|
+
|
|
87
|
+
| What | When | How |
|
|
88
|
+
|------|------|-----|
|
|
89
|
+
| Backup | 3:00 AM | LaunchAgent `ai.openclaw.ldm-backup` |
|
|
90
|
+
|
|
91
|
+
One LaunchAgent. One script. No Full Disk Access currently (target: midnight via LDMDevTools.app once PID error is fixed). Verify is built into the script (exit code + log).
|
|
92
|
+
|
|
93
|
+
## Config
|
|
94
|
+
|
|
95
|
+
All backup settings live in `~/.ldm/config.json`:
|
|
96
|
+
- `paths.workspace` ... workspace path
|
|
97
|
+
- `paths.icloudBackup` ... iCloud offsite destination
|
|
98
|
+
- `backup.keep` ... retention days (default: 7)
|
|
99
|
+
- `backup.includeSecrets` ... whether to include `~/.ldm/secrets/`
|
|
100
|
+
- `org` ... used for tar filename prefix
|
|
101
|
+
|
|
102
|
+
## Logs
|
|
103
|
+
|
|
104
|
+
`~/.ldm/logs/backup.log` (LaunchAgent stdout/stderr)
|
|
105
|
+
|
|
106
|
+
## Technical Details
|
|
107
|
+
|
|
108
|
+
See [TECHNICAL.md](./TECHNICAL.md) for config schema, LaunchAgent plist, rotation logic, and script internals.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Backup: Technical Details
|
|
2
|
+
|
|
3
|
+
## Config Schema
|
|
4
|
+
|
|
5
|
+
All backup settings are in `~/.ldm/config.json`. The backup script reads these at runtime.
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"org": "wipcomputerinc",
|
|
10
|
+
"paths": {
|
|
11
|
+
"workspace": "~/wipcomputerinc",
|
|
12
|
+
"ldm": "~/.ldm",
|
|
13
|
+
"claude": "~/.claude",
|
|
14
|
+
"openclaw": "~/.openclaw",
|
|
15
|
+
"icloudBackup": "~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups"
|
|
16
|
+
},
|
|
17
|
+
"backup": {
|
|
18
|
+
"keep": 7,
|
|
19
|
+
"includeSecrets": false
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Key | Type | Default | Description |
|
|
25
|
+
|-----|------|---------|-------------|
|
|
26
|
+
| `paths.workspace` | string | required | Root workspace directory to back up |
|
|
27
|
+
| `paths.icloudBackup` | string | optional | iCloud destination for offsite copies |
|
|
28
|
+
| `backup.keep` | number | 7 | Days of backups to keep before rotation |
|
|
29
|
+
| `backup.includeSecrets` | boolean | false | Whether to include `~/.ldm/secrets/` |
|
|
30
|
+
| `org` | string | required | Used as prefix in iCloud tar filenames |
|
|
31
|
+
|
|
32
|
+
## Script Location
|
|
33
|
+
|
|
34
|
+
- **Source:** `scripts/ldm-backup.sh` in the wip-ldm-os-private repo
|
|
35
|
+
- **Deployed to:** `~/.ldm/bin/ldm-backup.sh`
|
|
36
|
+
- **Deployed by:** `deployScripts()` in `bin/ldm.js`, called during both `ldm init` and `ldm install`
|
|
37
|
+
- **Restore script:** `scripts/ldm-restore.sh` deployed to `~/.ldm/bin/ldm-restore.sh`
|
|
38
|
+
|
|
39
|
+
All `.sh` files in the repo's `scripts/` directory are deployed to `~/.ldm/bin/` on every `ldm install`. This means script fixes land automatically on the next update without requiring a full `ldm init`.
|
|
40
|
+
|
|
41
|
+
## LaunchAgent
|
|
42
|
+
|
|
43
|
+
**Label:** `ai.openclaw.ldm-backup`
|
|
44
|
+
**Plist source:** `shared/launchagents/ai.openclaw.ldm-backup.plist`
|
|
45
|
+
**Deployed to:** `~/Library/LaunchAgents/ai.openclaw.ldm-backup.plist`
|
|
46
|
+
|
|
47
|
+
```xml
|
|
48
|
+
<key>StartCalendarInterval</key>
|
|
49
|
+
<dict>
|
|
50
|
+
<key>Hour</key>
|
|
51
|
+
<integer>3</integer>
|
|
52
|
+
<key>Minute</key>
|
|
53
|
+
<integer>0</integer>
|
|
54
|
+
</dict>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The plist uses `{{HOME}}` placeholders that are replaced at deploy time by `ldm init`.
|
|
58
|
+
|
|
59
|
+
**Logs:** stdout and stderr both go to `~/.ldm/logs/backup.log`.
|
|
60
|
+
|
|
61
|
+
**No Full Disk Access (FDA):** The LaunchAgent runs at 3:00 AM without FDA. Some paths (like `~/Library/Messages/`) are inaccessible without FDA. The target is to move the trigger to midnight via LDMDevTools.app (which has FDA) once the PID error is resolved.
|
|
62
|
+
|
|
63
|
+
### Dead Triggers (Cleaned Automatically)
|
|
64
|
+
|
|
65
|
+
The `cleanDeadBackupTriggers()` function in `ldm.js` removes old competing triggers on every `ldm init`:
|
|
66
|
+
- Old cron entries referencing `LDMDevTools.app`
|
|
67
|
+
- `com.wipcomputer.daily-backup` LaunchAgent
|
|
68
|
+
- OpenClaw `backup-verify` cron entries
|
|
69
|
+
|
|
70
|
+
Only `ai.openclaw.ldm-backup` should exist.
|
|
71
|
+
|
|
72
|
+
## Rotation Logic
|
|
73
|
+
|
|
74
|
+
The backup script handles rotation after a successful backup:
|
|
75
|
+
|
|
76
|
+
1. List all dated directories in `~/.ldm/backups/` (format: `YYYY-MM-DD--HH-MM-SS`)
|
|
77
|
+
2. Sort by name (which sorts chronologically)
|
|
78
|
+
3. Skip any directory containing a `.pinned` marker file
|
|
79
|
+
4. Delete directories beyond the `keep` count (oldest first)
|
|
80
|
+
5. Same rotation logic applies to iCloud tars at `paths.icloudBackup`
|
|
81
|
+
|
|
82
|
+
**Pinning:** `ldm backup --pin "reason"` creates a `.pinned` file in the latest backup directory. Pinned backups are never rotated.
|
|
83
|
+
|
|
84
|
+
## iCloud Offsite Details
|
|
85
|
+
|
|
86
|
+
After the local backup completes:
|
|
87
|
+
|
|
88
|
+
1. Tar + gzip the entire dated backup directory
|
|
89
|
+
2. Filename format: `<org>-<machine>-<timestamp>.tar.gz`
|
|
90
|
+
3. Copy to `paths.icloudBackup` (from config.json)
|
|
91
|
+
4. Apply the same rotation (keep N, skip pinned)
|
|
92
|
+
5. iCloud syncs the file to all devices automatically
|
|
93
|
+
|
|
94
|
+
The iCloud path must exist. The script does not create it. `ldm init` does not create it either. Create it manually if it does not exist.
|
|
95
|
+
|
|
96
|
+
## SQLite Safety
|
|
97
|
+
|
|
98
|
+
SQLite files are backed up using `sqlite3 .backup`, not `cp`. This ensures a consistent snapshot even if the database is being written to. The script checks for the `sqlite3` binary and skips database backup with a warning if it is not found.
|
|
99
|
+
|
|
100
|
+
Files backed up this way:
|
|
101
|
+
- `~/.ldm/memory/crystal.db`
|
|
102
|
+
- `~/.openclaw/memory/main.sqlite`
|
|
103
|
+
- `~/.openclaw/memory/context-embeddings.sqlite`
|
|
104
|
+
|
|
105
|
+
## Excludes
|
|
106
|
+
|
|
107
|
+
The workspace tar excludes:
|
|
108
|
+
- `node_modules/` ... reconstructable via npm install
|
|
109
|
+
- `.git/objects/` ... reconstructable via git fetch
|
|
110
|
+
- `backups/` ... avoids recursive backup
|
|
111
|
+
- `_trash/` ... already deleted content
|
|
112
|
+
- `*.tar.gz` ... avoids backing up old backup archives
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## One Script, One Place
|
|
4
4
|
|
|
5
|
-
`~/.ldm/bin/ldm-backup.sh` runs daily at
|
|
5
|
+
`~/.ldm/bin/ldm-backup.sh` runs daily at 3:00 AM via LaunchAgent `ai.openclaw.ldm-backup`. It backs up everything to `~/.ldm/backups/`, then tars it to iCloud for offsite.
|
|
6
6
|
|
|
7
7
|
## What Gets Backed Up
|
|
8
8
|
|
|
@@ -48,14 +48,14 @@
|
|
|
48
48
|
|
|
49
49
|
## iCloud Offsite
|
|
50
50
|
|
|
51
|
-
After local backup, the entire dated folder is compressed and copied to iCloud
|
|
51
|
+
After local backup, the entire dated folder is compressed and copied to iCloud. The iCloud path is read from `~/.ldm/config.json` at `paths.icloudBackup`.
|
|
52
52
|
|
|
53
53
|
```
|
|
54
54
|
~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/
|
|
55
55
|
wipcomputerinc-lesa-2026-03-24--09-50-22.tar.gz
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
One file per backup. iCloud syncs it across devices. Rotates to
|
|
58
|
+
One file per backup. iCloud syncs it across devices. Rotates to {{backup.keep}} days.
|
|
59
59
|
|
|
60
60
|
## How to Run
|
|
61
61
|
|
|
@@ -83,19 +83,22 @@ After restore: `openclaw gateway restart` then `crystal status` to verify.
|
|
|
83
83
|
|
|
84
84
|
| What | When | How |
|
|
85
85
|
|------|------|-----|
|
|
86
|
-
| Backup |
|
|
86
|
+
| Backup | 3:00 AM | LaunchAgent `ai.openclaw.ldm-backup` runs `~/.ldm/bin/ldm-backup.sh` |
|
|
87
87
|
|
|
88
|
-
One
|
|
88
|
+
One LaunchAgent. One script. No Full Disk Access currently (target: midnight via LDMDevTools.app once PID error is fixed). Verify is built into the script (exit code + log).
|
|
89
89
|
|
|
90
90
|
## Config
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
92
|
+
All backup settings live in `~/.ldm/config.json`:
|
|
93
|
+
- `paths.workspace` ... workspace path
|
|
94
|
+
- `paths.icloudBackup` ... iCloud offsite destination
|
|
95
|
+
- `backup.keep` ... retention days (default: 7)
|
|
96
|
+
- `backup.includeSecrets` ... whether to include `~/.ldm/secrets/`
|
|
97
|
+
- `org` ... used for tar filename prefix
|
|
95
98
|
|
|
96
99
|
## Logs
|
|
97
100
|
|
|
98
|
-
`~/.ldm/logs/
|
|
101
|
+
`~/.ldm/logs/backup.log` (LaunchAgent stdout/stderr)
|
|
99
102
|
|
|
100
103
|
---
|
|
101
104
|
|
|
@@ -103,6 +106,6 @@ Backup reads from two config files:
|
|
|
103
106
|
|
|
104
107
|
**Local backups:** `~/.ldm/backups/`
|
|
105
108
|
**iCloud offsite:** `~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/`
|
|
106
|
-
**Schedule:**
|
|
107
|
-
**Retention:**
|
|
109
|
+
**Schedule:** 3:00 AM via LaunchAgent `ai.openclaw.ldm-backup`
|
|
110
|
+
**Retention:** {{backup.keep}} days local, {{backup.keep}} days iCloud
|
|
108
111
|
**Script:** `~/.ldm/bin/ldm-backup.sh` (deployed by `ldm install`)
|