@wipcomputer/wip-ldm-os 0.4.71 → 0.4.72
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 +180 -85
- 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';
|
|
@@ -411,6 +411,174 @@ async function installCatalogComponent(c) {
|
|
|
411
411
|
}
|
|
412
412
|
|
|
413
413
|
// ── Bridge deploy (#245) ──
|
|
414
|
+
// Deploy all scripts from scripts/ to ~/.ldm/bin/
|
|
415
|
+
// Called from both cmdInit() and cmdInstallCatalog() so script fixes land on every update.
|
|
416
|
+
function deployScripts() {
|
|
417
|
+
const scriptsSrc = join(__dirname, '..', 'scripts');
|
|
418
|
+
if (!existsSync(scriptsSrc)) return 0;
|
|
419
|
+
mkdirSync(join(LDM_ROOT, 'bin'), { recursive: true });
|
|
420
|
+
let count = 0;
|
|
421
|
+
for (const file of readdirSync(scriptsSrc)) {
|
|
422
|
+
if (!file.endsWith('.sh')) continue;
|
|
423
|
+
const src = join(scriptsSrc, file);
|
|
424
|
+
const dest = join(LDM_ROOT, 'bin', file);
|
|
425
|
+
cpSync(src, dest);
|
|
426
|
+
chmodSync(dest, 0o755);
|
|
427
|
+
count++;
|
|
428
|
+
}
|
|
429
|
+
if (count > 0) {
|
|
430
|
+
console.log(` + ${count} script(s) deployed to ~/.ldm/bin/`);
|
|
431
|
+
}
|
|
432
|
+
return count;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Deploy personalized docs to both settings/docs/ and library/documentation/
|
|
436
|
+
// Called from both cmdInit() and cmdInstallCatalog() so doc fixes land on every update.
|
|
437
|
+
function deployDocs() {
|
|
438
|
+
const docsSrc = join(__dirname, '..', 'shared', 'docs');
|
|
439
|
+
if (!existsSync(docsSrc)) return 0;
|
|
440
|
+
|
|
441
|
+
let workspacePath = '';
|
|
442
|
+
try {
|
|
443
|
+
const ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
|
|
444
|
+
workspacePath = (ldmConfig.workspace || '').replace('~', HOME);
|
|
445
|
+
} catch { return 0; }
|
|
446
|
+
if (!workspacePath || !existsSync(workspacePath)) return 0;
|
|
447
|
+
|
|
448
|
+
// Read config for template vars
|
|
449
|
+
let ldmConfig;
|
|
450
|
+
try {
|
|
451
|
+
ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
|
|
452
|
+
} catch { return 0; }
|
|
453
|
+
|
|
454
|
+
const sc = ldmConfig;
|
|
455
|
+
|
|
456
|
+
// Agents from config (rich objects with harness/machine/prefix)
|
|
457
|
+
const agentsObj = sc.agents || {};
|
|
458
|
+
const agentsList = Object.entries(agentsObj).map(([id, a]) => `${id} (${a.harness} on ${a.machine})`).join(', ');
|
|
459
|
+
const agentsDetail = Object.entries(agentsObj).map(([id, a]) => `- **${id}**: ${a.harness} on ${a.machine}, branch prefix \`${a.prefix}/\``).join('\n');
|
|
460
|
+
|
|
461
|
+
// Harnesses from config
|
|
462
|
+
const harnessConfig = sc.harnesses || {};
|
|
463
|
+
const harnessesDetected = Object.entries(harnessConfig).filter(([,h]) => h.detected).map(([name]) => name);
|
|
464
|
+
const harnessesList = harnessesDetected.length > 0 ? harnessesDetected.join(', ') : 'run ldm install to detect';
|
|
465
|
+
|
|
466
|
+
const templateVars = {
|
|
467
|
+
'name': sc.name || '',
|
|
468
|
+
'org': sc.org || '',
|
|
469
|
+
'timezone': sc.timezone || '',
|
|
470
|
+
'paths.workspace': (sc.paths?.workspace || '').replace('~', HOME),
|
|
471
|
+
'paths.ldm': (sc.paths?.ldm || '').replace('~', HOME),
|
|
472
|
+
'paths.openclaw': (sc.paths?.openclaw || '').replace('~', HOME),
|
|
473
|
+
'paths.icloud': (sc.paths?.icloud || '').replace('~', HOME),
|
|
474
|
+
'memory.local': (sc.memory?.local || '').replace('~', HOME),
|
|
475
|
+
'deploy.website': sc.deploy?.website || '',
|
|
476
|
+
'backup.keep': String(sc.backup?.keep || 7),
|
|
477
|
+
'agents_list': agentsList,
|
|
478
|
+
'agents_detail': agentsDetail,
|
|
479
|
+
'harnesses_list': harnessesList,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
function renderTemplates(destDir) {
|
|
483
|
+
mkdirSync(destDir, { recursive: true });
|
|
484
|
+
let count = 0;
|
|
485
|
+
for (const file of readdirSync(docsSrc)) {
|
|
486
|
+
if (!file.endsWith('.tmpl')) continue;
|
|
487
|
+
let content = readFileSync(join(docsSrc, file), 'utf8');
|
|
488
|
+
content = content.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
|
489
|
+
return templateVars[key.trim()] || match;
|
|
490
|
+
});
|
|
491
|
+
const outName = file.replace('.tmpl', '');
|
|
492
|
+
writeFileSync(join(destDir, outName), content);
|
|
493
|
+
count++;
|
|
494
|
+
}
|
|
495
|
+
return count;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Deploy to settings/docs/ (agent reference)
|
|
499
|
+
const docsDest = join(workspacePath, 'settings', 'docs');
|
|
500
|
+
const docsCount = renderTemplates(docsDest);
|
|
501
|
+
if (docsCount > 0) {
|
|
502
|
+
console.log(` + ${docsCount} personalized doc(s) deployed to ${docsDest.replace(HOME, '~')}/`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Deploy to library/documentation/ (human-readable library copy)
|
|
506
|
+
const libraryDest = join(workspacePath, 'library', 'documentation');
|
|
507
|
+
if (existsSync(join(workspacePath, 'library'))) {
|
|
508
|
+
const libCount = renderTemplates(libraryDest);
|
|
509
|
+
if (libCount > 0) {
|
|
510
|
+
console.log(` + ${libCount} doc(s) deployed to ${libraryDest.replace(HOME, '~')}/`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return docsCount;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Check backup health: is a trigger configured, did it run recently, is iCloud set up?
|
|
518
|
+
// Called from cmdInstallCatalog() on every install.
|
|
519
|
+
function checkBackupHealth() {
|
|
520
|
+
const config = readJSON(join(LDM_ROOT, 'config.json'));
|
|
521
|
+
if (!config) return;
|
|
522
|
+
|
|
523
|
+
const backup = config.backup || {};
|
|
524
|
+
const issues = [];
|
|
525
|
+
|
|
526
|
+
// Check iCloud offsite
|
|
527
|
+
const icloudPath = config.paths?.icloudBackup || backup.icloudPath;
|
|
528
|
+
if (!icloudPath) {
|
|
529
|
+
issues.push('iCloud offsite not configured. Add paths.icloudBackup to ~/.ldm/config.json');
|
|
530
|
+
} else {
|
|
531
|
+
const expandedPath = icloudPath.replace(/^~/, HOME);
|
|
532
|
+
if (!existsSync(expandedPath)) {
|
|
533
|
+
try { mkdirSync(expandedPath, { recursive: true }); } catch {}
|
|
534
|
+
if (!existsSync(expandedPath)) {
|
|
535
|
+
issues.push(`iCloud path does not exist: ${icloudPath}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Check LaunchAgent
|
|
541
|
+
try {
|
|
542
|
+
const label = backup.triggerLabel || 'ai.openclaw.ldm-backup';
|
|
543
|
+
const result = execSync(`launchctl list ${label} 2>/dev/null`, { encoding: 'utf8', timeout: 3000 });
|
|
544
|
+
if (!result) issues.push(`LaunchAgent ${label} not loaded`);
|
|
545
|
+
} catch {
|
|
546
|
+
issues.push('Backup LaunchAgent not loaded. Backups may not run automatically.');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Check last backup age
|
|
550
|
+
const backupRoot = join(LDM_ROOT, 'backups');
|
|
551
|
+
if (existsSync(backupRoot)) {
|
|
552
|
+
const dirs = readdirSync(backupRoot)
|
|
553
|
+
.filter(d => d.match(/^20\d{2}-\d{2}-\d{2}--/) && statSync(join(backupRoot, d)).isDirectory())
|
|
554
|
+
.sort()
|
|
555
|
+
.reverse();
|
|
556
|
+
if (dirs.length > 0) {
|
|
557
|
+
const latest = dirs[0];
|
|
558
|
+
const latestDate = latest.replace(/--.*/, '').replace(/-/g, '/');
|
|
559
|
+
const age = Date.now() - new Date(latestDate).getTime();
|
|
560
|
+
const hours = Math.round(age / (1000 * 60 * 60));
|
|
561
|
+
if (hours > 36) {
|
|
562
|
+
issues.push(`Last backup is ${hours} hours old (${latest}). Expected within 24 hours.`);
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
issues.push('No backups found. Run: ldm backup');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Check backup script exists
|
|
570
|
+
const scriptPath = join(LDM_ROOT, 'bin', 'ldm-backup.sh');
|
|
571
|
+
if (!existsSync(scriptPath)) {
|
|
572
|
+
issues.push('Backup script missing at ~/.ldm/bin/ldm-backup.sh. Run: ldm init');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (issues.length > 0) {
|
|
576
|
+
for (const issue of issues) {
|
|
577
|
+
console.log(` ! Backup: ${issue}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
414
582
|
// The bridge (src/bridge/) builds to dist/bridge/ and ships in the npm package.
|
|
415
583
|
// After `npm install -g`, the updated files live at the npm package location but
|
|
416
584
|
// never get copied to ~/.ldm/extensions/lesa-bridge/dist/. This function fixes that.
|
|
@@ -724,29 +892,8 @@ async function cmdInit() {
|
|
|
724
892
|
}
|
|
725
893
|
}
|
|
726
894
|
|
|
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
|
-
}
|
|
895
|
+
// Deploy all scripts from scripts/ to ~/.ldm/bin/ (#119)
|
|
896
|
+
deployScripts();
|
|
750
897
|
|
|
751
898
|
// Deploy shared rules to ~/.ldm/shared/rules/ and to harnesses
|
|
752
899
|
const rulesSrc = join(__dirname, '..', 'shared', 'rules');
|
|
@@ -859,67 +1006,8 @@ async function cmdInit() {
|
|
|
859
1006
|
}
|
|
860
1007
|
} catch {}
|
|
861
1008
|
|
|
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
|
-
}
|
|
1009
|
+
// Deploy personalized docs to settings/docs/ and library/documentation/
|
|
1010
|
+
deployDocs();
|
|
923
1011
|
|
|
924
1012
|
// Deploy LaunchAgents to ~/Library/LaunchAgents/
|
|
925
1013
|
// Templates use {{HOME}} and {{OPENCLAW_GATEWAY_TOKEN}} placeholders, replaced at deploy time.
|
|
@@ -1451,6 +1539,13 @@ async function cmdInstallCatalog() {
|
|
|
1451
1539
|
// in the extension directories. This copies them to both LDM and OpenClaw targets.
|
|
1452
1540
|
deployBridge();
|
|
1453
1541
|
|
|
1542
|
+
// Deploy scripts and docs on every install so fixes land without re-init
|
|
1543
|
+
deployScripts();
|
|
1544
|
+
deployDocs();
|
|
1545
|
+
|
|
1546
|
+
// Check backup configuration
|
|
1547
|
+
checkBackupHealth();
|
|
1548
|
+
|
|
1454
1549
|
const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
|
|
1455
1550
|
const state = detectSystemState();
|
|
1456
1551
|
const reconciled = reconcileState(state);
|
|
@@ -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`)
|