total-recall-brain 3.1.0
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/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/antigravity.mjs +212 -0
- package/bin/init.mjs +36 -0
- package/bin/oci-sniper.sh +69 -0
- package/bin/total-recall.mjs +229 -0
- package/docs/ARCHITECTURE.md +251 -0
- package/docs/IDE_INTEGRATION.md +112 -0
- package/package.json +71 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
- package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
- package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
- package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
- package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
- package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
- package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
- package/src/cli/agent-dir.mjs +105 -0
- package/src/cli/backup.mjs +313 -0
- package/src/cli/brain.mjs +224 -0
- package/src/cli/chat.mjs +74 -0
- package/src/cli/config.mjs +247 -0
- package/src/cli/connect.mjs +872 -0
- package/src/cli/daemon.mjs +228 -0
- package/src/cli/deploy-ui.mjs +1793 -0
- package/src/cli/deploy.mjs +1726 -0
- package/src/cli/doctor.mjs +254 -0
- package/src/cli/dream.mjs +80 -0
- package/src/cli/forget.mjs +92 -0
- package/src/cli/friction.mjs +21 -0
- package/src/cli/generate-pat.mjs +65 -0
- package/src/cli/hash-password.mjs +22 -0
- package/src/cli/help.mjs +153 -0
- package/src/cli/import-rules.mjs +102 -0
- package/src/cli/ingest/index.mjs +318 -0
- package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
- package/src/cli/ingest/parsers/google-keep.mjs +147 -0
- package/src/cli/ingest/parsers/search-history.mjs +142 -0
- package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
- package/src/cli/ingest/utils/dedup.mjs +69 -0
- package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
- package/src/cli/ingest.mjs +102 -0
- package/src/cli/init.mjs +736 -0
- package/src/cli/integration-dispatcher.mjs +128 -0
- package/src/cli/lint.mjs +166 -0
- package/src/cli/map.mjs +194 -0
- package/src/cli/migrate.mjs +87 -0
- package/src/cli/rebuild.mjs +88 -0
- package/src/cli/recall.mjs +219 -0
- package/src/cli/relay.mjs +518 -0
- package/src/cli/remember.mjs +306 -0
- package/src/cli/research.mjs +381 -0
- package/src/cli/reset-password.mjs +75 -0
- package/src/cli/restore.mjs +135 -0
- package/src/cli/setup.mjs +616 -0
- package/src/cli/share.mjs +180 -0
- package/src/cli/skill.mjs +313 -0
- package/src/cli/snapshot.mjs +108 -0
- package/src/cli/start.mjs +63 -0
- package/src/cli/status.mjs +296 -0
- package/src/cli/sync.mjs +383 -0
- package/src/cli/uninstall.mjs +613 -0
- package/src/cli/upgrade.mjs +141 -0
- package/src/cli/wizard.html +3682 -0
- package/src/core/blackboard.mjs +54 -0
- package/src/core/clarity-rewriter.mjs +770 -0
- package/src/core/conclusion-writer.mjs +259 -0
- package/src/core/config.mjs +277 -0
- package/src/core/conflict-detector.mjs +536 -0
- package/src/core/crypto.mjs +107 -0
- package/src/core/daemon-control.mjs +158 -0
- package/src/core/daemon-loop.mjs +364 -0
- package/src/core/dream.mjs +347 -0
- package/src/core/drift-detector.mjs +143 -0
- package/src/core/embeddings.mjs +632 -0
- package/src/core/emergency-alerts.mjs +137 -0
- package/src/core/evolution.mjs +107 -0
- package/src/core/fact-seeker.mjs +1602 -0
- package/src/core/friction.mjs +95 -0
- package/src/core/import-rules.mjs +308 -0
- package/src/core/inference-engine.mjs +367 -0
- package/src/core/logger.mjs +75 -0
- package/src/core/memory-layers.mjs +96 -0
- package/src/core/migrate.mjs +116 -0
- package/src/core/notifications.mjs +77 -0
- package/src/core/operation-validator.mjs +272 -0
- package/src/core/optimizer.mjs +330 -0
- package/src/core/pattern_detector.mjs +113 -0
- package/src/core/post-mortem.mjs +413 -0
- package/src/core/protect-instructions.mjs +148 -0
- package/src/core/quick-capture.mjs +145 -0
- package/src/core/research-queue.mjs +431 -0
- package/src/core/research.mjs +461 -0
- package/src/core/runtime.mjs +515 -0
- package/src/core/sandbox.mjs +163 -0
- package/src/core/scheduler.mjs +569 -0
- package/src/core/schema.mjs +333 -0
- package/src/core/search.mjs +219 -0
- package/src/core/semantic-index.mjs +276 -0
- package/src/core/session-watcher.mjs +850 -0
- package/src/core/snapshot.mjs +133 -0
- package/src/core/source-adapters.mjs +1038 -0
- package/src/core/steering.mjs +137 -0
- package/src/core/surface.mjs +444 -0
- package/src/core/task_runner.mjs +125 -0
- package/src/core/tts.mjs +142 -0
- package/src/core/usage-tracker.mjs +349 -0
- package/src/core/vault-cache.mjs +76 -0
- package/src/core/vault.mjs +273 -0
- package/src/core/watchdog.mjs +236 -0
- package/src/server/api.mjs +944 -0
- package/src/server/auth.mjs +410 -0
- package/src/server/index.mjs +868 -0
- package/src/server/keys.mjs +181 -0
- package/src/server/rest.mjs +1652 -0
- package/src/server/route-manifest.json +77 -0
- package/src/server/routes/_shared.mjs +93 -0
- package/src/server/routes/keys.mjs +55 -0
- package/src/server/routes/memory.mjs +249 -0
- package/src/server/routes/sessions.mjs +336 -0
- package/src/server/routes/share.mjs +138 -0
- package/src/server/tools.mjs +618 -0
- package/templates/Caddyfile +37 -0
- package/templates/com.totalrecall.backup.plist +42 -0
- package/templates/com.totalrecall.daemon.plist +40 -0
- package/templates/com.totalrecall.duckdns.plist +23 -0
- package/templates/com.totalrecall.server.plist +46 -0
- package/templates/default-config/budget.yml +6 -0
- package/templates/default-config/runtime.yml +11 -0
- package/templates/default-config/security.yml +53 -0
- package/templates/default-config/voice.yml +34 -0
- package/templates/obsidian-queries/conflicts.md +26 -0
- package/templates/obsidian-queries/daily.md +17 -0
- package/templates/obsidian-queries/index.md +38 -0
- package/templates/obsidian-queries/skills.md +14 -0
- package/templates/onboarding-interview.md +72 -0
- package/templates/skill_engineering.md +40 -0
- package/templates/total-recall-daemon.service +21 -0
- package/templates/total-recall-server.service +20 -0
|
@@ -0,0 +1,1726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* total-recall deploy
|
|
3
|
+
*
|
|
4
|
+
* Provision a target machine with the full Sovereign AI stack:
|
|
5
|
+
* 1. Detect host architecture (aarch64 / x86_64 / arm64)
|
|
6
|
+
* 2. Install Ollama (if not present)
|
|
7
|
+
* 3. Pull Gemma 4 26B model (~16GB)
|
|
8
|
+
* 4. Pull Kokoro-82M voice model (~200MB)
|
|
9
|
+
* 4.5. Install SearXNG (native Python, no Docker) for web search on port 8888
|
|
10
|
+
* 5.5. Install Playwright + computer use deps (xdotool, scrot, xvfb)
|
|
11
|
+
* 5. Scaffold VFS at ~/.agent/
|
|
12
|
+
* 6. Copy default config templates
|
|
13
|
+
* 7. Install Caddy reverse proxy (if not present)
|
|
14
|
+
* 8. Deploy Caddyfile to /etc/caddy/Caddyfile
|
|
15
|
+
* 9. Install systemd units (Linux only)
|
|
16
|
+
* 10. Start services
|
|
17
|
+
* 11. Run initial compile
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* npx total-recall deploy [options]
|
|
21
|
+
*
|
|
22
|
+
* Options:
|
|
23
|
+
* --skip-ollama Skip Ollama installation
|
|
24
|
+
* --skip-searxng Skip SearXNG installation
|
|
25
|
+
* --skip-models Skip model pulling
|
|
26
|
+
* --skip-caddy Skip Caddy installation
|
|
27
|
+
* --skip-systemd Skip systemd unit installation
|
|
28
|
+
* --skip-compile Skip initial compile
|
|
29
|
+
* --domain <domain> Set domain for Caddyfile (default: localhost)
|
|
30
|
+
* --duckdns-token <t> DuckDNS API token — installs IP-update cron job
|
|
31
|
+
* --ui Open install progress UI in browser (localhost:3001)
|
|
32
|
+
* --ui-port <port> Port for the install UI server (default: 3001)
|
|
33
|
+
* --dry-run Print what would be done without executing
|
|
34
|
+
* --help Show this help
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { execSync, execFileSync, spawnSync, spawn } from 'node:child_process';
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import os from 'node:os';
|
|
41
|
+
import { fileURLToPath } from 'node:url';
|
|
42
|
+
import yaml from 'yaml';
|
|
43
|
+
import bcrypt from 'bcrypt';
|
|
44
|
+
import crypto from 'node:crypto';
|
|
45
|
+
import { BCRYPT_COST } from '../server/auth.mjs';
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
|
|
50
|
+
// ─── Robust PATH-Expansion for LaunchAgents & background processes ─────────────
|
|
51
|
+
try {
|
|
52
|
+
const HOME = os.homedir();
|
|
53
|
+
const commonPaths = [
|
|
54
|
+
'/opt/homebrew/bin',
|
|
55
|
+
'/usr/local/bin',
|
|
56
|
+
path.join(HOME, '.local/bin'),
|
|
57
|
+
path.join(HOME, '.nvm/versions/node/v24.12.0/bin'),
|
|
58
|
+
path.join(HOME, '.npm-global/bin'),
|
|
59
|
+
path.join(HOME, 'bin'),
|
|
60
|
+
'/usr/bin',
|
|
61
|
+
'/bin',
|
|
62
|
+
'/usr/sbin',
|
|
63
|
+
'/sbin'
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const nvmVersionsDir = path.join(HOME, '.nvm', 'versions', 'node');
|
|
67
|
+
if (fs.existsSync(nvmVersionsDir)) {
|
|
68
|
+
const versions = fs.readdirSync(nvmVersionsDir);
|
|
69
|
+
for (const v of versions) {
|
|
70
|
+
commonPaths.unshift(path.join(nvmVersionsDir, v, 'bin'));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const activePaths = process.env.PATH ? process.env.PATH.split(path.delimiter) : [];
|
|
75
|
+
const uniquePaths = Array.from(new Set([...activePaths, ...commonPaths])).filter(p => fs.existsSync(p));
|
|
76
|
+
process.env.PATH = uniquePaths.join(path.delimiter);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// Silent fallback
|
|
79
|
+
}
|
|
80
|
+
const ROOT = path.join(__dirname, '..', '..');
|
|
81
|
+
const AGENT_DIR = path.join(os.homedir(), '.agent');
|
|
82
|
+
const BRAIN_DIR = path.join(AGENT_DIR, 'skills', 'total-recall');
|
|
83
|
+
|
|
84
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
// Deploy UI emitter — set by deploy() if --ui flag is used
|
|
87
|
+
let _uiEmit = null;
|
|
88
|
+
|
|
89
|
+
function log(msg) { console.error(` ${msg}`); }
|
|
90
|
+
function logStep(step, msg){ console.error(`\n [${step}] ${msg}`); _uiEmit?.('step', `[${step}] ${msg}`); }
|
|
91
|
+
function logOk(msg) { console.error(` ✅ ${msg}`); _uiEmit?.('ok', msg); }
|
|
92
|
+
function logSkip(msg) { console.error(` ⏭ ${msg}`); _uiEmit?.('info', msg); }
|
|
93
|
+
function logWarn(msg) { console.error(` ⚠️ ${msg}`); _uiEmit?.('warn', msg); }
|
|
94
|
+
|
|
95
|
+
function commandExists(cmd) {
|
|
96
|
+
if (!cmd || !/^[a-zA-Z0-9_-]+$/.test(cmd)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
execFileSync('which', [cmd], { stdio: 'ignore' });
|
|
101
|
+
return true;
|
|
102
|
+
} catch { return false; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function run(cmd, opts = {}) {
|
|
106
|
+
const result = spawnSync('sh', ['-c', cmd], {
|
|
107
|
+
stdio: opts.silent ? 'pipe' : 'inherit',
|
|
108
|
+
timeout: opts.timeout || 300_000, // 5 min default
|
|
109
|
+
...opts,
|
|
110
|
+
});
|
|
111
|
+
if (result.status !== 0 && !opts.ignoreErrors) {
|
|
112
|
+
const stderr = result.stderr?.toString().trim() || '';
|
|
113
|
+
throw new Error(`Command failed (exit ${result.status}): ${cmd}\n${stderr}`);
|
|
114
|
+
}
|
|
115
|
+
return result.stdout?.toString().trim() || '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function detectArch() {
|
|
119
|
+
const machine = os.arch(); // 'arm64', 'x64', etc
|
|
120
|
+
const map = { arm64: 'aarch64', x64: 'x86_64', ia32: 'x86' };
|
|
121
|
+
return map[machine] || machine;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function detectPlatform() {
|
|
125
|
+
const p = os.platform();
|
|
126
|
+
if (p === 'linux') return 'linux';
|
|
127
|
+
if (p === 'darwin') return 'macos';
|
|
128
|
+
return p;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hasSystemd() {
|
|
132
|
+
if (detectPlatform() !== 'linux') return false;
|
|
133
|
+
if (!commandExists('systemctl')) return false;
|
|
134
|
+
try {
|
|
135
|
+
return fs.existsSync('/run/systemd/system');
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasLaunchd() {
|
|
142
|
+
return detectPlatform() === 'macos';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function launchAgentsDir() {
|
|
146
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function installPlist(templateName, dryRun) {
|
|
150
|
+
const src = path.join(ROOT, 'templates', templateName);
|
|
151
|
+
const dest = path.join(launchAgentsDir(), templateName);
|
|
152
|
+
let content = fs.readFileSync(src, 'utf8');
|
|
153
|
+
content = content.replace(/__ROOT__/g, ROOT);
|
|
154
|
+
content = content.replace(/__NODE__/g, process.execPath);
|
|
155
|
+
content = content.replace(/__HOME__/g, os.homedir());
|
|
156
|
+
if (dryRun) {
|
|
157
|
+
log(` Would install ${templateName} → ${dest}`);
|
|
158
|
+
} else {
|
|
159
|
+
fs.mkdirSync(launchAgentsDir(), { recursive: true });
|
|
160
|
+
fs.writeFileSync(dest, content, 'utf8');
|
|
161
|
+
logOk(`Installed ${dest}`);
|
|
162
|
+
}
|
|
163
|
+
return dest;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseArgs(args) {
|
|
167
|
+
const opts = {
|
|
168
|
+
skipOllama: false,
|
|
169
|
+
skipSearxng: false,
|
|
170
|
+
skipModels: false,
|
|
171
|
+
skipCaddy: false,
|
|
172
|
+
skipSystemd: false,
|
|
173
|
+
skipCompile: false,
|
|
174
|
+
domain: 'localhost',
|
|
175
|
+
duckdnsToken: null,
|
|
176
|
+
brainRepo: null,
|
|
177
|
+
backupRepo: null,
|
|
178
|
+
backupObsidian: null,
|
|
179
|
+
cloudflareToken: null,
|
|
180
|
+
cloudflareQuick: false,
|
|
181
|
+
allowInsecureHttp: false,
|
|
182
|
+
ui: false,
|
|
183
|
+
uiPort: 3001,
|
|
184
|
+
dryRun: false,
|
|
185
|
+
help: false,
|
|
186
|
+
dashboardPassword: null,
|
|
187
|
+
};
|
|
188
|
+
for (let i = 0; i < args.length; i++) {
|
|
189
|
+
switch (args[i]) {
|
|
190
|
+
case '--skip-ollama': opts.skipOllama = true; break;
|
|
191
|
+
case '--skip-searxng': opts.skipSearxng = true; break;
|
|
192
|
+
case '--skip-models': opts.skipModels = true; break;
|
|
193
|
+
case '--skip-caddy': opts.skipCaddy = true; break;
|
|
194
|
+
case '--skip-systemd': opts.skipSystemd = true; break;
|
|
195
|
+
case '--skip-compile': opts.skipCompile = true; break;
|
|
196
|
+
case '--domain': opts.domain = args[++i]; break;
|
|
197
|
+
case '--duckdns-token': opts.duckdnsToken = args[++i]; break;
|
|
198
|
+
case '--brain-repo': opts.brainRepo = args[++i]; break;
|
|
199
|
+
case '--backup-repo': opts.backupRepo = args[++i]; break;
|
|
200
|
+
case '--backup-obsidian': opts.backupObsidian = args[++i]; break;
|
|
201
|
+
case '--cloudflare-token': opts.cloudflareToken = args[++i]; break;
|
|
202
|
+
case '--cloudflare-quick': opts.cloudflareQuick = true; break;
|
|
203
|
+
case '--allow-insecure-http': opts.allowInsecureHttp = true; break;
|
|
204
|
+
case '--dashboard-password': opts.dashboardPassword = args[++i]; break;
|
|
205
|
+
case '--ui': opts.ui = true; break;
|
|
206
|
+
case '--ui-port': opts.uiPort = parseInt(args[++i], 10) || 3001; break;
|
|
207
|
+
case '--dry-run': opts.dryRun = true; break;
|
|
208
|
+
case '--help': case '-h': opts.help = true; break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return opts;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function printHelp() {
|
|
215
|
+
console.log(`
|
|
216
|
+
total-recall deploy — Provision a target machine with the Sovereign AI stack
|
|
217
|
+
|
|
218
|
+
Usage: total-recall deploy [options]
|
|
219
|
+
|
|
220
|
+
Options:
|
|
221
|
+
--skip-ollama Skip Ollama installation
|
|
222
|
+
--skip-searxng Skip SearXNG installation
|
|
223
|
+
--skip-models Skip model pulling
|
|
224
|
+
--skip-caddy Skip Caddy installation
|
|
225
|
+
--skip-systemd Skip systemd unit installation
|
|
226
|
+
--skip-compile Skip initial compile
|
|
227
|
+
--domain <domain> Set domain for Caddyfile (default: localhost)
|
|
228
|
+
--duckdns-token <t> DuckDNS API token — installs IP-update cron (*.duckdns.org domains)
|
|
229
|
+
--brain-repo <url> Git repo URL to clone as the brain vault into ~/.agent/
|
|
230
|
+
e.g. https://github.com/you/my-brain.git
|
|
231
|
+
If the vault is inside the project repo (default), omit this.
|
|
232
|
+
--backup-repo <url> Git remote for automatic daily vault backups
|
|
233
|
+
e.g. git@github.com:you/brain-backup.git
|
|
234
|
+
Installs a cron job (Linux) or launchd job (macOS) that
|
|
235
|
+
runs "total-recall backup --push-git <url>" at 2:00 AM.
|
|
236
|
+
--backup-obsidian <path>
|
|
237
|
+
Obsidian vault path for daily rsync backup.
|
|
238
|
+
e.g. ~/Documents/Obsidian Vault
|
|
239
|
+
Installs a daily job (cron/launchd) that rsyncs the
|
|
240
|
+
memory vault into "<vault>/Total Recall/". Pair with
|
|
241
|
+
Obsidian Sync or iCloud for off-host backup.
|
|
242
|
+
--allow-insecure-http
|
|
243
|
+
Permit a local/test deploy without public HTTPS.
|
|
244
|
+
--ui Open a real-time install progress page in the browser.
|
|
245
|
+
Served at http://localhost:3001 during the deploy.
|
|
246
|
+
--ui-port <port> Port for the install UI (default: 3001).
|
|
247
|
+
--dry-run Print plan without executing
|
|
248
|
+
--help, -h Show this help
|
|
249
|
+
|
|
250
|
+
DuckDNS HTTPS setup:
|
|
251
|
+
1. Sign up at https://www.duckdns.org and claim a subdomain
|
|
252
|
+
2. Point the subdomain at your server IP
|
|
253
|
+
3. Run: npx total-recall deploy --domain yourname.duckdns.org --duckdns-token YOUR_TOKEN
|
|
254
|
+
4. Caddy auto-provisions a Let's Encrypt TLS cert (port 80 must be open)
|
|
255
|
+
`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Scaffold VFS ───────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
function scaffoldVfs(opts) {
|
|
261
|
+
const dryRun = opts.dryRun;
|
|
262
|
+
logStep('6/12', 'Scaffolding VFS at ~/.agent/');
|
|
263
|
+
|
|
264
|
+
const scaffoldSrc = path.join(ROOT, 'scaffold', '.agent');
|
|
265
|
+
if (!fs.existsSync(scaffoldSrc)) {
|
|
266
|
+
throw new Error(`Scaffold source missing: ${scaffoldSrc}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (fs.existsSync(AGENT_DIR)) {
|
|
270
|
+
logWarn('~/.agent/ already exists — merging missing directories only');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Recursively copy scaffold, skipping existing files
|
|
274
|
+
copyDirMerge(scaffoldSrc, AGENT_DIR, dryRun);
|
|
275
|
+
logOk('VFS scaffolded');
|
|
276
|
+
|
|
277
|
+
// ── Step 5.5: Pull brain from git repo if specified ──
|
|
278
|
+
// Also check if this project's .agent/skills/total-recall/memory-vault has nodes (in-repo brain)
|
|
279
|
+
const inRepoVault = path.join(ROOT, '.agent', 'skills', 'total-recall', 'memory-vault');
|
|
280
|
+
if (opts.brainRepo) {
|
|
281
|
+
logStep('6.5/12', `Pulling brain vault from ${opts.brainRepo}`);
|
|
282
|
+
if (dryRun) {
|
|
283
|
+
log(` Would clone ${opts.brainRepo} into ${AGENT_DIR}`);
|
|
284
|
+
} else {
|
|
285
|
+
try {
|
|
286
|
+
if (commandExists('git')) {
|
|
287
|
+
// Clone into a temp dir then merge into AGENT_DIR
|
|
288
|
+
const tmpBrain = path.join(os.tmpdir(), `brain-${Date.now()}`);
|
|
289
|
+
run(`git clone --depth=1 "${opts.brainRepo}" "${tmpBrain}"`);
|
|
290
|
+
copyDirMerge(tmpBrain, AGENT_DIR, false);
|
|
291
|
+
run(`rm -rf "${tmpBrain}"`);
|
|
292
|
+
logOk(`Brain vault restored from ${opts.brainRepo}`);
|
|
293
|
+
} else {
|
|
294
|
+
logWarn('git not found — cannot clone brain repo');
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
logWarn(`Brain clone failed: ${err.message}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} else if (fs.existsSync(inRepoVault)) {
|
|
301
|
+
logStep('6.5/12', 'Brain vault found in project repo — copying to ~/.agent/');
|
|
302
|
+
if (!dryRun) copyDirMerge(inRepoVault, path.join(BRAIN_DIR, 'memory-vault'), false);
|
|
303
|
+
logOk('In-repo brain vault merged into ~/.agent/');
|
|
304
|
+
} else {
|
|
305
|
+
logWarn('No brain repo specified and no in-repo vault found — starting with empty brain.');
|
|
306
|
+
log(' Tip: npx total-recall deploy --brain-repo https://github.com/you/my-brain.git');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function copyDirMerge(src, dest, dryRun) {
|
|
311
|
+
if (!fs.existsSync(src)) return;
|
|
312
|
+
|
|
313
|
+
if (!fs.existsSync(dest)) {
|
|
314
|
+
if (dryRun) { log(` mkdir ${dest}`); }
|
|
315
|
+
else { fs.mkdirSync(dest, { recursive: true }); }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
319
|
+
const srcPath = path.join(src, entry.name);
|
|
320
|
+
const destPath = path.join(dest, entry.name);
|
|
321
|
+
if (entry.isDirectory()) {
|
|
322
|
+
copyDirMerge(srcPath, destPath, dryRun);
|
|
323
|
+
} else if (!fs.existsSync(destPath)) {
|
|
324
|
+
if (dryRun) { log(` copy ${srcPath} → ${destPath}`); }
|
|
325
|
+
else { fs.copyFileSync(srcPath, destPath); }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─── Copy Default Config ────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
function copyDefaultConfig(dryRun) {
|
|
333
|
+
logStep('7/12', 'Copying default configuration');
|
|
334
|
+
|
|
335
|
+
const configSrc = path.join(ROOT, 'templates', 'default-config');
|
|
336
|
+
const configDest = path.join(BRAIN_DIR, 'config');
|
|
337
|
+
|
|
338
|
+
if (!fs.existsSync(configSrc)) {
|
|
339
|
+
logWarn(`Default config template directory not found: ${configSrc}`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!fs.existsSync(configDest)) {
|
|
344
|
+
if (!dryRun) fs.mkdirSync(configDest, { recursive: true });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
for (const file of fs.readdirSync(configSrc)) {
|
|
348
|
+
const src = path.join(configSrc, file);
|
|
349
|
+
const dest = path.join(configDest, file);
|
|
350
|
+
if (fs.existsSync(dest)) {
|
|
351
|
+
logSkip(`${file} already exists, skipping`);
|
|
352
|
+
} else {
|
|
353
|
+
if (dryRun) { log(` copy ${src} → ${dest}`); }
|
|
354
|
+
else { fs.copyFileSync(src, dest); }
|
|
355
|
+
logOk(`Installed ${file}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isIpAddress(value) {
|
|
361
|
+
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value || '') || /^\[[0-9a-f:]+\]$/i.test(value || '') || /^[0-9a-f:]{3,}$/i.test(value || '');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function hardenSecurityConfig(dryRun, dashboardPassword = null) {
|
|
365
|
+
logStep('7.5/12', 'Hardening security defaults');
|
|
366
|
+
const securityPath = path.join(BRAIN_DIR, 'config', 'security.yml');
|
|
367
|
+
if (dryRun) {
|
|
368
|
+
log(` Would enforce HTTPS, localhost bind, hashed PATs, and private health checks in ${securityPath}`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let config = {};
|
|
373
|
+
if (fs.existsSync(securityPath)) {
|
|
374
|
+
config = yaml.parse(fs.readFileSync(securityPath, 'utf8')) || {};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
config.api = {
|
|
378
|
+
...(config.api || {}),
|
|
379
|
+
allow_static_pats: config.api?.allow_static_pats === true,
|
|
380
|
+
pats: (config.api?.allow_static_pats === true)
|
|
381
|
+
? (config.api?.pats || []).filter((pat) => pat && pat !== 'local')
|
|
382
|
+
: []
|
|
383
|
+
};
|
|
384
|
+
config.bind = {
|
|
385
|
+
...(config.bind || {}),
|
|
386
|
+
host: '127.0.0.1',
|
|
387
|
+
port: config.bind?.port || 3000,
|
|
388
|
+
allow_public_bind: false
|
|
389
|
+
};
|
|
390
|
+
config.network = {
|
|
391
|
+
...(config.network || {}),
|
|
392
|
+
require_https: true,
|
|
393
|
+
public_health: false,
|
|
394
|
+
allowed_origins: config.network?.allowed_origins || []
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
if (!config.dashboard) {
|
|
398
|
+
config.dashboard = {};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (dashboardPassword) {
|
|
402
|
+
const isDefault = dashboardPassword === 'totalrecall';
|
|
403
|
+
log(` 🔑 Enforcing admin dashboard password (force reset: ${isDefault})`);
|
|
404
|
+
config.dashboard.password_hash = bcrypt.hashSync(dashboardPassword, BCRYPT_COST);
|
|
405
|
+
config.dashboard.force_password_reset = isDefault;
|
|
406
|
+
} else if (!config.dashboard.password_hash) {
|
|
407
|
+
log(' 🔑 Dashboard credentials left unconfigured. You will set your admin password on first dashboard access.');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
fs.writeFileSync(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
|
|
411
|
+
logOk('Security config hardened: HTTPS required, Express bound to localhost, no public health, no legacy local PAT');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ─── Main ───────────────────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
export default async function deploy(args) {
|
|
417
|
+
const opts = parseArgs(args);
|
|
418
|
+
if (opts.help) { printHelp(); return; }
|
|
419
|
+
|
|
420
|
+
const arch = detectArch();
|
|
421
|
+
const platform = detectPlatform();
|
|
422
|
+
|
|
423
|
+
// ── Start optional deploy UI ──
|
|
424
|
+
if (opts.ui) {
|
|
425
|
+
try {
|
|
426
|
+
const { startDeployUI, waitForInstallOptions, emitProgress, openBrowser } = await import('./deploy-ui.mjs');
|
|
427
|
+
const uiUrl = await startDeployUI(opts.uiPort);
|
|
428
|
+
_uiEmit = emitProgress;
|
|
429
|
+
console.error(`\n ┌─────────────────────────────────────────────┐`);
|
|
430
|
+
console.error(` │ Install UI: ${uiUrl.padEnd(34)}│`);
|
|
431
|
+
console.error(` │ Waiting for you to click Install... │`);
|
|
432
|
+
console.error(` └─────────────────────────────────────────────┘\n`);
|
|
433
|
+
openBrowser(uiUrl);
|
|
434
|
+
|
|
435
|
+
// Block until the wizard's Phase 1 form is submitted
|
|
436
|
+
const wizardOpts = await waitForInstallOptions();
|
|
437
|
+
|
|
438
|
+
// Merge wizard config into opts
|
|
439
|
+
if (wizardOpts.domain) opts.domain = wizardOpts.domain;
|
|
440
|
+
if (wizardOpts.duckdnsToken) opts.duckdnsToken = wizardOpts.duckdnsToken;
|
|
441
|
+
// Cloudflare: both Zero Trust tunnel token and quick-tunnel flag
|
|
442
|
+
if (wizardOpts.httpsMethod === 'cloudflare-tunnel') {
|
|
443
|
+
opts.cloudflareToken = wizardOpts.cloudflareToken;
|
|
444
|
+
} else if (wizardOpts.httpsMethod === 'cloudflare-quick') {
|
|
445
|
+
opts.cloudflareQuick = true;
|
|
446
|
+
} else if (wizardOpts.cloudflareToken) {
|
|
447
|
+
// Token present even without explicit method — treat as Zero Trust
|
|
448
|
+
opts.cloudflareToken = wizardOpts.cloudflareToken;
|
|
449
|
+
}
|
|
450
|
+
if (wizardOpts.httpsMethod === 'local') opts.allowInsecureHttp = true;
|
|
451
|
+
if (wizardOpts.skipSearxng) opts.skipSearxng = true;
|
|
452
|
+
if (wizardOpts.skipCaddy) opts.skipCaddy = true;
|
|
453
|
+
if (wizardOpts.skipCompile) opts.skipCompile = true;
|
|
454
|
+
if (wizardOpts.skipModels) opts.skipModels = true;
|
|
455
|
+
if (wizardOpts.dashboardPassword) opts.dashboardPassword = wizardOpts.dashboardPassword;
|
|
456
|
+
if (wizardOpts.installGlobal !== undefined) opts.installGlobal = wizardOpts.installGlobal;
|
|
457
|
+
if (wizardOpts.projectPaths) opts.projectPaths = wizardOpts.projectPaths;
|
|
458
|
+
|
|
459
|
+
console.error(` ✅ Wizard config received — domain: ${opts.domain}`);
|
|
460
|
+
|
|
461
|
+
if (wizardOpts.deployTarget === 'vastai' || wizardOpts.skipLocalInstall) {
|
|
462
|
+
console.error(`\n ✅ Vast.ai remote instance provisioned. Remote installation runs in background.`);
|
|
463
|
+
console.error(` 📍 SSH access: ssh -p ${wizardOpts.vastSshPort} root@${wizardOpts.vastSshHost}\n`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (wizardOpts.deployTarget === 'localnet') {
|
|
468
|
+
const ip = wizardOpts.localnetHost;
|
|
469
|
+
const user = wizardOpts.localnetUser || 'root';
|
|
470
|
+
logStep('1/3', `Connecting to remote host ${user}@${ip}...`);
|
|
471
|
+
|
|
472
|
+
const check = spawnSync('ssh', [
|
|
473
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
474
|
+
'-o', 'ConnectTimeout=20',
|
|
475
|
+
`${user}@${ip}`,
|
|
476
|
+
'echo "OK"'
|
|
477
|
+
], { encoding: 'utf8' });
|
|
478
|
+
|
|
479
|
+
if (check.status !== 0) {
|
|
480
|
+
logWarn(`Failed to connect to ${user}@${ip} via SSH.`);
|
|
481
|
+
throw new Error(`Failed to connect to ${user}@${ip} via SSH. Please ensure SSH / Remote Login is enabled on that computer and your SSH key is authorized.`);
|
|
482
|
+
}
|
|
483
|
+
logOk(`Connected to ${user}@${ip}`);
|
|
484
|
+
|
|
485
|
+
logStep('2/3', 'Installing Node.js and dependencies on remote host...');
|
|
486
|
+
const cmdPrefix = 'export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh";';
|
|
487
|
+
spawnSync('ssh', [
|
|
488
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
489
|
+
`${user}@${ip}`,
|
|
490
|
+
`${cmdPrefix} which node || (curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh -o nvm_install.sh && bash nvm_install.sh && rm nvm_install.sh && export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm install 20)`
|
|
491
|
+
], { stdio: 'inherit' });
|
|
492
|
+
|
|
493
|
+
// Copy API Keys/secrets.enc to remote VM
|
|
494
|
+
try {
|
|
495
|
+
log('🔒 Persisting Google/OpenAI credentials on remote host...');
|
|
496
|
+
const configDir = `~/.agent/skills/total-recall/config`;
|
|
497
|
+
const secretsContent = JSON.stringify({
|
|
498
|
+
google_api_key: wizardOpts.googleApiKey || '',
|
|
499
|
+
openai_api_key: wizardOpts.openaiApiKey || ''
|
|
500
|
+
});
|
|
501
|
+
spawnSync('ssh', [
|
|
502
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
503
|
+
`${user}@${ip}`,
|
|
504
|
+
`mkdir -p ${configDir} && echo '${secretsContent}' > ${configDir}/secrets.enc && chmod 600 ${configDir}/secrets.enc`
|
|
505
|
+
]);
|
|
506
|
+
logOk('Credentials written remotely.');
|
|
507
|
+
} catch (err) {
|
|
508
|
+
logWarn(`Could not save credentials remotely: ${err.message}`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
logStep('3/3', 'Deploying Total Recall remotely...');
|
|
512
|
+
let deployCmd = `npx --yes total-recall deploy --domain ${wizardOpts.domain || 'localhost'}`;
|
|
513
|
+
if (wizardOpts.dashboardPassword) {
|
|
514
|
+
deployCmd += ` --dashboard-password "${wizardOpts.dashboardPassword}"`;
|
|
515
|
+
}
|
|
516
|
+
if (wizardOpts.httpsMethod === 'cloudflare-quick') {
|
|
517
|
+
deployCmd += ` --cloudflare-quick`;
|
|
518
|
+
}
|
|
519
|
+
spawnSync('ssh', [
|
|
520
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
521
|
+
`${user}@${ip}`,
|
|
522
|
+
`${cmdPrefix} ${deployCmd}`
|
|
523
|
+
], { stdio: 'inherit' });
|
|
524
|
+
|
|
525
|
+
logStep('Done', 'Generating access token & discovering endpoints...');
|
|
526
|
+
const pat = spawnSync('ssh', [
|
|
527
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
528
|
+
`${user}@${ip}`,
|
|
529
|
+
`${cmdPrefix} npx total-recall generate-pat --quiet`
|
|
530
|
+
], { encoding: 'utf8' }).stdout?.toString().trim();
|
|
531
|
+
|
|
532
|
+
let publicUrl = `http://${ip}:3000`;
|
|
533
|
+
if (wizardOpts.httpsMethod === 'cloudflare-quick') {
|
|
534
|
+
log(' Fetching remote Cloudflare Quick Tunnel URL...');
|
|
535
|
+
// Let's poll remote cloudflared log file via SSH to extract the URL
|
|
536
|
+
for (let attempt = 1; attempt <= 12; attempt++) {
|
|
537
|
+
const urlOut = spawnSync('ssh', [
|
|
538
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
539
|
+
`${user}@${ip}`,
|
|
540
|
+
`${cmdPrefix} grep -oE "https://[a-zA-Z0-9-]+\\.trycloudflare\\.com" ~/.agent/skills/total-recall/logs/cloudflared.log 2>/dev/null | tail -n 1`
|
|
541
|
+
], { encoding: 'utf8' }).stdout?.toString().trim();
|
|
542
|
+
if (urlOut && urlOut.startsWith('https://')) {
|
|
543
|
+
publicUrl = urlOut;
|
|
544
|
+
logOk(`Remote Quick Tunnel URL discovered: ${publicUrl}`);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
log(` Waiting for remote quick tunnel to boot (attempt ${attempt}/6)...`);
|
|
548
|
+
spawnSync('sleep', ['2']);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
logOk(`Remote setup complete! Brain is running.`);
|
|
553
|
+
if (pat) {
|
|
554
|
+
logOk(`Access token generated successfully.`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const { finishDeployUI: finishUI } = await import('./deploy-ui.mjs');
|
|
558
|
+
finishUI({
|
|
559
|
+
apiUrl: publicUrl,
|
|
560
|
+
dashUrl: `${publicUrl}/dashboard`,
|
|
561
|
+
healthUrl: `${publicUrl}/health`
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (wizardOpts.deployTarget === 'agent-cloud') {
|
|
568
|
+
const rawProvider = (wizardOpts.cloudProvider || '').toLowerCase().trim();
|
|
569
|
+
const token = wizardOpts.cloudToken;
|
|
570
|
+
const rawRegion = (wizardOpts.cloudRegion || '').toLowerCase().trim();
|
|
571
|
+
|
|
572
|
+
logStep('1/6', 'Spawning local CLI provisioning agent...');
|
|
573
|
+
|
|
574
|
+
let provider = 'digitalocean';
|
|
575
|
+
if (rawProvider.includes('hetzner') || rawProvider === 'hcloud') {
|
|
576
|
+
provider = 'hetzner';
|
|
577
|
+
} else if (rawProvider.includes('linode') || rawProvider.includes('akamai')) {
|
|
578
|
+
provider = 'linode';
|
|
579
|
+
} else if (rawProvider.includes('vultr')) {
|
|
580
|
+
provider = 'vultr';
|
|
581
|
+
} else if (rawProvider.includes('digital') || rawProvider === 'do') {
|
|
582
|
+
provider = 'digitalocean';
|
|
583
|
+
} else {
|
|
584
|
+
// Unrecognized — perform dynamic detection or fallbacks
|
|
585
|
+
logWarn(`Unrecognized provider "${wizardOpts.cloudProvider}". Attempting token-based detection...`);
|
|
586
|
+
if (token.startsWith('dop_v1_')) {
|
|
587
|
+
provider = 'digitalocean';
|
|
588
|
+
log('🔎 Token matches DigitalOcean pattern.');
|
|
589
|
+
} else if (token.length === 64 && /^[0-9a-fA-F]+$/.test(token)) {
|
|
590
|
+
provider = 'hetzner';
|
|
591
|
+
log('🔎 Token matches Hetzner Cloud pattern.');
|
|
592
|
+
} else if (token.length === 64) {
|
|
593
|
+
provider = 'linode';
|
|
594
|
+
log('🔎 Token looks like a Linode Personal Access Token.');
|
|
595
|
+
} else {
|
|
596
|
+
provider = 'digitalocean';
|
|
597
|
+
log('🔎 Defaulting to DigitalOcean provisioning.');
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
logStep('2/6', 'Checking / generating secure SSH key pair...');
|
|
602
|
+
const sshDir = path.join(os.homedir(), '.ssh');
|
|
603
|
+
let privateKeyPath = path.join(sshDir, 'id_total_recall');
|
|
604
|
+
let publicKeyPath = path.join(sshDir, 'id_total_recall.pub');
|
|
605
|
+
|
|
606
|
+
if (!fs.existsSync(sshDir)) {
|
|
607
|
+
fs.mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (!fs.existsSync(privateKeyPath) || !fs.existsSync(publicKeyPath)) {
|
|
611
|
+
log('🔑 Generating Ed25519 SSH key pair at ~/.ssh/id_total_recall...');
|
|
612
|
+
const gen = spawnSync('ssh-keygen', ['-t', 'ed25519', '-f', privateKeyPath, '-N', ''], { encoding: 'utf8' });
|
|
613
|
+
if (gen.status !== 0) {
|
|
614
|
+
logWarn('Failed to generate Ed25519 key, falling back to RSA...');
|
|
615
|
+
privateKeyPath = path.join(sshDir, 'id_rsa');
|
|
616
|
+
publicKeyPath = path.join(sshDir, 'id_rsa.pub');
|
|
617
|
+
if (!fs.existsSync(privateKeyPath) || !fs.existsSync(publicKeyPath)) {
|
|
618
|
+
spawnSync('ssh-keygen', ['-t', 'rsa', '-b', '4096', '-f', privateKeyPath, '-N', ''], { encoding: 'utf8' });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const publicKeyContent = fs.readFileSync(publicKeyPath, 'utf8').trim();
|
|
624
|
+
logOk('SSH key pair ready');
|
|
625
|
+
|
|
626
|
+
logStep('3/6', `Uploading SSH key and provisioning VPS instance on ${provider === 'digitalocean' ? 'DigitalOcean' : provider === 'hetzner' ? 'Hetzner' : provider === 'linode' ? 'Linode' : 'Vultr'}...`);
|
|
627
|
+
|
|
628
|
+
let ip = null;
|
|
629
|
+
if (provider === 'digitalocean') {
|
|
630
|
+
log('☁️ Checking for existing SSH key on DigitalOcean...');
|
|
631
|
+
const listRes = await fetch('https://api.digitalocean.com/v2/account/keys', {
|
|
632
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
633
|
+
});
|
|
634
|
+
if (!listRes.ok) {
|
|
635
|
+
throw new Error(`DigitalOcean API access failed (status ${listRes.status}): ${await listRes.text()}`);
|
|
636
|
+
}
|
|
637
|
+
const keysData = await listRes.json();
|
|
638
|
+
let sshKeyId = (keysData.ssh_keys || []).find(k => k.public_key.trim() === publicKeyContent.trim())?.id;
|
|
639
|
+
|
|
640
|
+
if (!sshKeyId) {
|
|
641
|
+
log('☁️ Uploading public SSH key to DigitalOcean...');
|
|
642
|
+
const regRes = await fetch('https://api.digitalocean.com/v2/account/keys', {
|
|
643
|
+
method: 'POST',
|
|
644
|
+
headers: {
|
|
645
|
+
'Authorization': `Bearer ${token}`,
|
|
646
|
+
'Content-Type': 'application/json'
|
|
647
|
+
},
|
|
648
|
+
body: JSON.stringify({ name: `Total Recall Key (${os.hostname()})`, public_key: publicKeyContent })
|
|
649
|
+
});
|
|
650
|
+
if (!regRes.ok) {
|
|
651
|
+
throw new Error(`Failed to upload SSH key to DigitalOcean: ${await regRes.text()}`);
|
|
652
|
+
}
|
|
653
|
+
const regData = await regRes.json();
|
|
654
|
+
sshKeyId = regData.ssh_key.id;
|
|
655
|
+
}
|
|
656
|
+
logOk(`SSH key registered (ID: ${sshKeyId})`);
|
|
657
|
+
|
|
658
|
+
log('☁️ Creating Droplet (Ubuntu 24.04, $6/mo shared-CPU) on DigitalOcean...');
|
|
659
|
+
const createRes = await fetch('https://api.digitalocean.com/v2/droplets', {
|
|
660
|
+
method: 'POST',
|
|
661
|
+
headers: {
|
|
662
|
+
'Authorization': `Bearer ${token}`,
|
|
663
|
+
'Content-Type': 'application/json'
|
|
664
|
+
},
|
|
665
|
+
body: JSON.stringify({
|
|
666
|
+
name: 'total-recall-brain',
|
|
667
|
+
region: rawRegion || 'nyc3',
|
|
668
|
+
size: 's-1vcpu-1gb',
|
|
669
|
+
image: 'ubuntu-24-04-x64',
|
|
670
|
+
ssh_keys: [ sshKeyId ],
|
|
671
|
+
backups: false,
|
|
672
|
+
ipv6: false
|
|
673
|
+
})
|
|
674
|
+
});
|
|
675
|
+
if (!createRes.ok) {
|
|
676
|
+
throw new Error(`Failed to create DigitalOcean droplet: ${await createRes.text()}`);
|
|
677
|
+
}
|
|
678
|
+
const createData = await createRes.json();
|
|
679
|
+
const dropletId = createData.droplet.id;
|
|
680
|
+
logOk(`Droplet created successfully (ID: ${dropletId})`);
|
|
681
|
+
|
|
682
|
+
logStep('4/6', 'Waiting for droplet to boot and allocate public IP...');
|
|
683
|
+
for (let attempt = 1; attempt <= 40; attempt++) {
|
|
684
|
+
await new Promise(r => setTimeout(r, 6000));
|
|
685
|
+
const statusRes = await fetch(`https://api.digitalocean.com/v2/droplets/${dropletId}`, {
|
|
686
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
687
|
+
});
|
|
688
|
+
if (statusRes.ok) {
|
|
689
|
+
const statusData = await statusRes.json();
|
|
690
|
+
const droplet = statusData.droplet;
|
|
691
|
+
const state = droplet.status;
|
|
692
|
+
log(`⏳ Droplet status: ${state}...`);
|
|
693
|
+
if (state === 'active') {
|
|
694
|
+
const ipv4 = droplet.networks.v4.find(net => net.type === 'public')?.ip_address;
|
|
695
|
+
if (ipv4) {
|
|
696
|
+
ip = ipv4;
|
|
697
|
+
logOk(`Droplet public IP allocated: ${ip}`);
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} else if (provider === 'hetzner') {
|
|
704
|
+
log('☁️ Checking for existing SSH key on Hetzner Cloud...');
|
|
705
|
+
const listRes = await fetch('https://api.hetzner.cloud/v1/ssh_keys', {
|
|
706
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
707
|
+
});
|
|
708
|
+
if (!listRes.ok) {
|
|
709
|
+
throw new Error(`Hetzner Cloud API access failed (status ${listRes.status}): ${await listRes.text()}`);
|
|
710
|
+
}
|
|
711
|
+
const keysData = await listRes.json();
|
|
712
|
+
let sshKeyId = (keysData.ssh_keys || []).find(k => k.public_key.trim() === publicKeyContent.trim())?.id;
|
|
713
|
+
|
|
714
|
+
if (!sshKeyId) {
|
|
715
|
+
log('☁️ Uploading public SSH key to Hetzner Cloud...');
|
|
716
|
+
const regRes = await fetch('https://api.hetzner.cloud/v1/ssh_keys', {
|
|
717
|
+
method: 'POST',
|
|
718
|
+
headers: {
|
|
719
|
+
'Authorization': `Bearer ${token}`,
|
|
720
|
+
'Content-Type': 'application/json'
|
|
721
|
+
},
|
|
722
|
+
body: JSON.stringify({ name: `Total Recall Key (${os.hostname()})`, public_key: publicKeyContent })
|
|
723
|
+
});
|
|
724
|
+
if (!regRes.ok) {
|
|
725
|
+
throw new Error(`Failed to upload SSH key to Hetzner: ${await regRes.text()}`);
|
|
726
|
+
}
|
|
727
|
+
const regData = await regRes.json();
|
|
728
|
+
sshKeyId = regData.ssh_key.id;
|
|
729
|
+
}
|
|
730
|
+
logOk(`SSH key registered (ID: ${sshKeyId})`);
|
|
731
|
+
|
|
732
|
+
log('☁️ Creating Server (Ubuntu 24.04, low-cost cx22) on Hetzner...');
|
|
733
|
+
const createRes = await fetch('https://api.hetzner.cloud/v1/servers', {
|
|
734
|
+
method: 'POST',
|
|
735
|
+
headers: {
|
|
736
|
+
'Authorization': `Bearer ${token}`,
|
|
737
|
+
'Content-Type': 'application/json'
|
|
738
|
+
},
|
|
739
|
+
body: JSON.stringify({
|
|
740
|
+
name: 'total-recall-brain',
|
|
741
|
+
server_type: 'cx22',
|
|
742
|
+
image: 'ubuntu-24.04',
|
|
743
|
+
location: rawRegion || 'nbg1',
|
|
744
|
+
ssh_keys: [ sshKeyId ],
|
|
745
|
+
start_after_create: true
|
|
746
|
+
})
|
|
747
|
+
});
|
|
748
|
+
if (!createRes.ok) {
|
|
749
|
+
throw new Error(`Failed to create Hetzner server: ${await createRes.text()}`);
|
|
750
|
+
}
|
|
751
|
+
const createData = await createRes.json();
|
|
752
|
+
const serverId = createData.server.id;
|
|
753
|
+
logOk(`Server created successfully (ID: ${serverId})`);
|
|
754
|
+
|
|
755
|
+
logStep('4/6', 'Waiting for server to boot and allocate public IP...');
|
|
756
|
+
for (let attempt = 1; attempt <= 40; attempt++) {
|
|
757
|
+
await new Promise(r => setTimeout(r, 6000));
|
|
758
|
+
const statusRes = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}`, {
|
|
759
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
760
|
+
});
|
|
761
|
+
if (statusRes.ok) {
|
|
762
|
+
const statusData = await statusRes.json();
|
|
763
|
+
const server = statusData.server;
|
|
764
|
+
const state = server.status;
|
|
765
|
+
log(`⏳ Server status: ${state}...`);
|
|
766
|
+
if (state === 'running') {
|
|
767
|
+
const ipv4 = server.public_net?.ipv4?.ip;
|
|
768
|
+
if (ipv4) {
|
|
769
|
+
ip = ipv4;
|
|
770
|
+
logOk(`Server public IP allocated: ${ip}`);
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
} else if (provider === 'linode') {
|
|
777
|
+
log('☁️ Checking for existing SSH key on Linode...');
|
|
778
|
+
const listRes = await fetch('https://api.linode.com/v4/profile/sshkeys', {
|
|
779
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
780
|
+
});
|
|
781
|
+
if (!listRes.ok) {
|
|
782
|
+
throw new Error(`Linode API access failed (status ${listRes.status}): ${await listRes.text()}`);
|
|
783
|
+
}
|
|
784
|
+
const keysData = await listRes.json();
|
|
785
|
+
let sshKeyRegistered = (keysData.data || []).some(k => k.ssh_key.trim() === publicKeyContent.trim());
|
|
786
|
+
|
|
787
|
+
if (!sshKeyRegistered) {
|
|
788
|
+
log('☁️ Uploading public SSH key to Linode...');
|
|
789
|
+
const regRes = await fetch('https://api.linode.com/v4/profile/sshkeys', {
|
|
790
|
+
method: 'POST',
|
|
791
|
+
headers: {
|
|
792
|
+
'Authorization': `Bearer ${token}`,
|
|
793
|
+
'Content-Type': 'application/json'
|
|
794
|
+
},
|
|
795
|
+
body: JSON.stringify({ label: `Total Recall Key (${os.hostname()})`, ssh_key: publicKeyContent })
|
|
796
|
+
});
|
|
797
|
+
if (!regRes.ok) {
|
|
798
|
+
throw new Error(`Failed to upload SSH key to Linode: ${await regRes.text()}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
logOk('SSH key ready on Linode');
|
|
802
|
+
|
|
803
|
+
log('☁️ Creating Linode VPS (Ubuntu 24.04, low-cost Nanode) on Akamai/Linode...');
|
|
804
|
+
const createRes = await fetch('https://api.linode.com/v4/linode/instances', {
|
|
805
|
+
method: 'POST',
|
|
806
|
+
headers: {
|
|
807
|
+
'Authorization': `Bearer ${token}`,
|
|
808
|
+
'Content-Type': 'application/json'
|
|
809
|
+
},
|
|
810
|
+
body: JSON.stringify({
|
|
811
|
+
type: 'g6-nanode-1',
|
|
812
|
+
region: rawRegion || 'us-east',
|
|
813
|
+
image: 'linode/ubuntu24.04',
|
|
814
|
+
authorized_keys: [ publicKeyContent ],
|
|
815
|
+
booted: true
|
|
816
|
+
})
|
|
817
|
+
});
|
|
818
|
+
if (!createRes.ok) {
|
|
819
|
+
throw new Error(`Failed to create Linode instance: ${await createRes.text()}`);
|
|
820
|
+
}
|
|
821
|
+
const createData = await createRes.json();
|
|
822
|
+
const linodeId = createData.id;
|
|
823
|
+
logOk(`Linode instance created successfully (ID: ${linodeId})`);
|
|
824
|
+
|
|
825
|
+
logStep('4/6', 'Waiting for Linode to boot and allocate public IP...');
|
|
826
|
+
for (let attempt = 1; attempt <= 40; attempt++) {
|
|
827
|
+
await new Promise(r => setTimeout(r, 6000));
|
|
828
|
+
const statusRes = await fetch(`https://api.linode.com/v4/linode/instances/${linodeId}`, {
|
|
829
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
830
|
+
});
|
|
831
|
+
if (statusRes.ok) {
|
|
832
|
+
const linode = await statusRes.json();
|
|
833
|
+
const state = linode.status;
|
|
834
|
+
log(`⏳ Linode status: ${state}...`);
|
|
835
|
+
if (state === 'running') {
|
|
836
|
+
const ipv4 = linode.ipv4?.[0];
|
|
837
|
+
if (ipv4) {
|
|
838
|
+
ip = ipv4;
|
|
839
|
+
logOk(`Linode public IP allocated: ${ip}`);
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} else if (provider === 'vultr') {
|
|
846
|
+
log('☁️ Checking for existing SSH key on Vultr...');
|
|
847
|
+
const listRes = await fetch('https://api.vultr.com/v2/ssh-keys', {
|
|
848
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
849
|
+
});
|
|
850
|
+
if (!listRes.ok) {
|
|
851
|
+
throw new Error(`Vultr API access failed (status ${listRes.status}): ${await listRes.text()}`);
|
|
852
|
+
}
|
|
853
|
+
const keysData = await listRes.json();
|
|
854
|
+
let sshKeyId = (keysData.ssh_keys || []).find(k => k.ssh_key.trim() === publicKeyContent.trim())?.id;
|
|
855
|
+
|
|
856
|
+
if (!sshKeyId) {
|
|
857
|
+
log('☁️ Uploading public SSH key to Vultr...');
|
|
858
|
+
const regRes = await fetch('https://api.vultr.com/v2/ssh-keys', {
|
|
859
|
+
method: 'POST',
|
|
860
|
+
headers: {
|
|
861
|
+
'Authorization': `Bearer ${token}`,
|
|
862
|
+
'Content-Type': 'application/json'
|
|
863
|
+
},
|
|
864
|
+
body: JSON.stringify({ name: `Total Recall Key (${os.hostname()})`, ssh_key: publicKeyContent })
|
|
865
|
+
});
|
|
866
|
+
if (!regRes.ok) {
|
|
867
|
+
throw new Error(`Failed to upload SSH key to Vultr: ${await regRes.text()}`);
|
|
868
|
+
}
|
|
869
|
+
const regData = await regRes.json();
|
|
870
|
+
sshKeyId = regData.ssh_key.id;
|
|
871
|
+
}
|
|
872
|
+
logOk(`SSH key registered on Vultr (ID: ${sshKeyId})`);
|
|
873
|
+
|
|
874
|
+
log('☁️ Creating Instance (Ubuntu 24.04, low-cost vc2-1c-1gb) on Vultr...');
|
|
875
|
+
const createRes = await fetch('https://api.vultr.com/v2/instances', {
|
|
876
|
+
method: 'POST',
|
|
877
|
+
headers: {
|
|
878
|
+
'Authorization': `Bearer ${token}`,
|
|
879
|
+
'Content-Type': 'application/json'
|
|
880
|
+
},
|
|
881
|
+
body: JSON.stringify({
|
|
882
|
+
region: rawRegion || 'ewr',
|
|
883
|
+
plan: 'vc2-1c-1gb',
|
|
884
|
+
os_id: 2284,
|
|
885
|
+
sshkey_ids: [ sshKeyId ],
|
|
886
|
+
enable_ipv6: false
|
|
887
|
+
})
|
|
888
|
+
});
|
|
889
|
+
if (!createRes.ok) {
|
|
890
|
+
throw new Error(`Failed to create Vultr instance: ${await createRes.text()}`);
|
|
891
|
+
}
|
|
892
|
+
const createData = await createRes.json();
|
|
893
|
+
const instanceId = createData.instance.id;
|
|
894
|
+
logOk(`Vultr instance created successfully (ID: ${instanceId})`);
|
|
895
|
+
|
|
896
|
+
logStep('4/6', 'Waiting for server to boot and allocate public IP...');
|
|
897
|
+
for (let attempt = 1; attempt <= 40; attempt++) {
|
|
898
|
+
await new Promise(r => setTimeout(r, 6000));
|
|
899
|
+
const statusRes = await fetch(`https://api.vultr.com/v2/instances/${instanceId}`, {
|
|
900
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
901
|
+
});
|
|
902
|
+
if (statusRes.ok) {
|
|
903
|
+
const statusData = await statusRes.json();
|
|
904
|
+
const inst = statusData.instance;
|
|
905
|
+
const state = inst.status;
|
|
906
|
+
log(`⏳ Vultr status: ${state}...`);
|
|
907
|
+
if (state === 'active') {
|
|
908
|
+
const ipv4 = inst.main_ip;
|
|
909
|
+
if (ipv4 && ipv4 !== '0.0.0.0') {
|
|
910
|
+
ip = ipv4;
|
|
911
|
+
logOk(`Vultr public IP allocated: ${ip}`);
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (!ip) {
|
|
920
|
+
throw new Error('Timeout waiting for public IP allocation from cloud provider.');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
logStep('5/6', 'SSH Bootstrapping & deploying Total Recall remotely...');
|
|
924
|
+
log('⏳ Waiting for remote SSH port 22 to become reachable...');
|
|
925
|
+
|
|
926
|
+
const sshArgs = [
|
|
927
|
+
'-i', privateKeyPath,
|
|
928
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
929
|
+
'-o', 'ConnectTimeout=8',
|
|
930
|
+
`root@${ip}`
|
|
931
|
+
];
|
|
932
|
+
|
|
933
|
+
let sshConnected = false;
|
|
934
|
+
for (let attempt = 1; attempt <= 30; attempt++) {
|
|
935
|
+
const check = spawnSync('ssh', [...sshArgs, 'echo "OK"'], { encoding: 'utf8', timeout: 8000 });
|
|
936
|
+
if (check.status === 0 && check.stdout?.trim() === 'OK') {
|
|
937
|
+
sshConnected = true;
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
940
|
+
log(`⏳ Waiting for SSH service to start (attempt ${attempt}/30)...`);
|
|
941
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (!sshConnected) {
|
|
945
|
+
throw new Error(`Failed to establish SSH connection to root@${ip} after 2.5 minutes.`);
|
|
946
|
+
}
|
|
947
|
+
logOk('SSH connection established successfully');
|
|
948
|
+
|
|
949
|
+
log('🛠️ Installing Node.js & system dependencies on remote host...');
|
|
950
|
+
const installResult = spawnSync('ssh', [
|
|
951
|
+
...sshArgs,
|
|
952
|
+
'apt-get update && apt-get install -y -qq curl git sudo && ' +
|
|
953
|
+
'curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh -o nvm_install.sh && bash nvm_install.sh && rm nvm_install.sh && ' +
|
|
954
|
+
'export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh" && nvm install 20 2>/dev/null'
|
|
955
|
+
], { stdio: 'inherit' });
|
|
956
|
+
|
|
957
|
+
if (installResult.status !== 0) {
|
|
958
|
+
throw new Error('Failed to install Node.js / nvm on remote VM.');
|
|
959
|
+
}
|
|
960
|
+
logOk('Node.js and nvm installed remotely');
|
|
961
|
+
|
|
962
|
+
// Copy API Keys/secrets.enc to remote VM
|
|
963
|
+
try {
|
|
964
|
+
log('🔒 Persisting Google/OpenAI credentials on remote host...');
|
|
965
|
+
const configDir = `~/.agent/skills/total-recall/config`;
|
|
966
|
+
const secretsContent = JSON.stringify({
|
|
967
|
+
google_api_key: wizardOpts.googleApiKey || '',
|
|
968
|
+
openai_api_key: wizardOpts.openaiApiKey || ''
|
|
969
|
+
});
|
|
970
|
+
spawnSync('ssh', [
|
|
971
|
+
...sshArgs,
|
|
972
|
+
`mkdir -p ${configDir} && echo '${secretsContent}' > ${configDir}/secrets.enc && chmod 600 ${configDir}/secrets.enc`
|
|
973
|
+
]);
|
|
974
|
+
logOk('Credentials written remotely.');
|
|
975
|
+
} catch (err) {
|
|
976
|
+
logWarn(`Could not save credentials remotely: ${err.message}`);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
log('🧠 Running Total Recall deploy remotely...');
|
|
980
|
+
let deployCmd = `npx --yes total-recall deploy --domain ${wizardOpts.domain || 'localhost'}`;
|
|
981
|
+
if (wizardOpts.dashboardPassword) {
|
|
982
|
+
deployCmd += ` --dashboard-password "${wizardOpts.dashboardPassword}"`;
|
|
983
|
+
}
|
|
984
|
+
deployCmd += ` --cloudflare-quick`;
|
|
985
|
+
|
|
986
|
+
const remoteDeployResult = spawnSync('ssh', [
|
|
987
|
+
...sshArgs,
|
|
988
|
+
`export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh" && ${deployCmd}`
|
|
989
|
+
], { stdio: 'inherit' });
|
|
990
|
+
|
|
991
|
+
if (remoteDeployResult.status !== 0) {
|
|
992
|
+
throw new Error('Remote Total Recall deployment failed.');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
logStep('6/6', 'Activating Cloudflare Quick Tunnel and secure HTTPS links...');
|
|
996
|
+
|
|
997
|
+
let pat = spawnSync('ssh', [
|
|
998
|
+
...sshArgs,
|
|
999
|
+
'export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh" && npx total-recall generate-pat --quiet'
|
|
1000
|
+
], { encoding: 'utf8' }).stdout?.toString().trim();
|
|
1001
|
+
|
|
1002
|
+
let publicUrl = `http://${ip}:3000`;
|
|
1003
|
+
log('⏳ Fetching remote Cloudflare Quick Tunnel URL...');
|
|
1004
|
+
|
|
1005
|
+
for (let attempt = 1; attempt <= 12; attempt++) {
|
|
1006
|
+
const urlOut = spawnSync('ssh', [
|
|
1007
|
+
...sshArgs,
|
|
1008
|
+
'grep -oE "https://[a-zA-Z0-9-]+\\.trycloudflare\\.com" ~/.agent/skills/total-recall/logs/cloudflared.log 2>/dev/null | tail -n 1'
|
|
1009
|
+
], { encoding: 'utf8' }).stdout?.toString().trim();
|
|
1010
|
+
if (urlOut && urlOut.startsWith('https://')) {
|
|
1011
|
+
publicUrl = urlOut;
|
|
1012
|
+
logOk(`Remote Quick Tunnel URL discovered: ${publicUrl}`);
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
log(` Waiting for remote quick tunnel to boot (attempt ${attempt}/12)...`);
|
|
1016
|
+
await new Promise(r => setTimeout(r, 4000));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
logOk(`Cloud VM provisioning and deployment complete!`);
|
|
1020
|
+
if (pat) {
|
|
1021
|
+
logOk(`Access token generated successfully.`);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Persist to local wizard-config.json
|
|
1025
|
+
try {
|
|
1026
|
+
const configDir = path.join(_brainDir(), 'config');
|
|
1027
|
+
const configFile = path.join(configDir, 'wizard-config.json');
|
|
1028
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
1029
|
+
let current = {};
|
|
1030
|
+
if (fs.existsSync(configFile)) {
|
|
1031
|
+
try { current = JSON.parse(fs.readFileSync(configFile, 'utf8') || '{}'); } catch {}
|
|
1032
|
+
}
|
|
1033
|
+
current.deployTarget = 'agent-cloud';
|
|
1034
|
+
current['cfg-domain'] = publicUrl.replace('https://', '');
|
|
1035
|
+
current['cfg-api-url'] = publicUrl;
|
|
1036
|
+
current['cfg-dash-url'] = `${publicUrl}/dashboard`;
|
|
1037
|
+
current['cfg-health-url'] = `${publicUrl}/health`;
|
|
1038
|
+
if (wizardOpts.dashboardPassword) {
|
|
1039
|
+
current['cfg-dashboard-password'] = wizardOpts.dashboardPassword;
|
|
1040
|
+
}
|
|
1041
|
+
fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1042
|
+
} catch (e) {
|
|
1043
|
+
console.error('Failed to auto-persist cloud install options on disk:', e);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const { finishDeployUI: finishUI } = await import('./deploy-ui.mjs');
|
|
1047
|
+
finishUI({
|
|
1048
|
+
apiUrl: publicUrl,
|
|
1049
|
+
dashUrl: `${publicUrl}/dashboard`,
|
|
1050
|
+
healthUrl: `${publicUrl}/health`
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
} catch (e) {
|
|
1056
|
+
console.error(` ⚠️ Deployment failed: ${e.message}`);
|
|
1057
|
+
if (_uiEmit) {
|
|
1058
|
+
_uiEmit('error', e.message);
|
|
1059
|
+
}
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const bannerWidth = 49;
|
|
1065
|
+
const contentWidth = bannerWidth - 4; // account for "│ " and " │"
|
|
1066
|
+
const fmtLine = (label, value) => {
|
|
1067
|
+
const content = `${label}${value}`;
|
|
1068
|
+
return ` │ ${content.padEnd(contentWidth)}│`;
|
|
1069
|
+
};
|
|
1070
|
+
console.error(`
|
|
1071
|
+
┌${'─'.repeat(bannerWidth)}┐
|
|
1072
|
+
${fmtLine('Total Recall Deploy', '')}
|
|
1073
|
+
${fmtLine('Architecture: ', arch)}
|
|
1074
|
+
${fmtLine('Platform: ', platform)}
|
|
1075
|
+
${fmtLine('Target VFS: ', '~/.agent/')}
|
|
1076
|
+
└${'─'.repeat(bannerWidth)}┘
|
|
1077
|
+
`);
|
|
1078
|
+
|
|
1079
|
+
if (opts.dryRun) logWarn('DRY RUN — no changes will be made\n');
|
|
1080
|
+
|
|
1081
|
+
if (!opts.allowInsecureHttp && !opts.cloudflareToken && !opts.cloudflareQuick) {
|
|
1082
|
+
if (opts.skipCaddy) {
|
|
1083
|
+
throw new Error('Refusing insecure deploy: Caddy/Cloudflare is required unless --allow-insecure-http is set.');
|
|
1084
|
+
}
|
|
1085
|
+
if (opts.domain === 'localhost' || isIpAddress(opts.domain)) {
|
|
1086
|
+
throw new Error('Refusing insecure deploy: provide a real DNS domain for HTTPS, use Cloudflare tunnel, or pass --allow-insecure-http for local testing.');
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ── Step 1: Detect architecture ──
|
|
1091
|
+
logStep('1/12', `Architecture detected: ${arch} (${platform})`);
|
|
1092
|
+
|
|
1093
|
+
// ── Step 2: Install Ollama (DEPRECATED) ──
|
|
1094
|
+
logStep('2/12', 'Ollama installation (Deprecated)');
|
|
1095
|
+
logSkip('Skipped: Local GPU-heavy LLM model hosting via Ollama is fully deprecated in Total Recall.');
|
|
1096
|
+
|
|
1097
|
+
// ── Step 3: Pull Gemma 4 model (DEPRECATED) ──
|
|
1098
|
+
logStep('3/12', 'Pulling Gemma 4 26B model (Deprecated)');
|
|
1099
|
+
logSkip('Skipped: Cognitive processing has transitioned exclusively to specialized, headlessly dispatched CLI agents.');
|
|
1100
|
+
|
|
1101
|
+
// ── Step 4: Kokoro voice model (DEPRECATED) ──
|
|
1102
|
+
logStep('4/12', 'Kokoro-82M voice model (Deprecated)');
|
|
1103
|
+
logSkip('Skipped: Local text-to-speech rendering is deprecated.');
|
|
1104
|
+
|
|
1105
|
+
// ── Step 4.5: Install SearXNG (native Python, no Docker required) ──
|
|
1106
|
+
logStep('5/12', 'Deploying SearXNG');
|
|
1107
|
+
if (opts.skipSearxng) {
|
|
1108
|
+
logSkip('Skipped (--skip-searxng)');
|
|
1109
|
+
} else if (opts.dryRun) {
|
|
1110
|
+
log(' Would install SearXNG via pip and start on port 8888');
|
|
1111
|
+
} else {
|
|
1112
|
+
try {
|
|
1113
|
+
const searxngDir = '/opt/searxng';
|
|
1114
|
+
const venvPython = `${searxngDir}/venv/bin/python`;
|
|
1115
|
+
const venvPip = `${searxngDir}/venv/bin/pip`;
|
|
1116
|
+
|
|
1117
|
+
if (!commandExists('python3')) {
|
|
1118
|
+
run('apt-get install -y python3 python3-pip python3-venv');
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (!fs.existsSync(searxngDir)) {
|
|
1122
|
+
log(' Cloning SearXNG...');
|
|
1123
|
+
run(`git clone https://github.com/searxng/searxng.git ${searxngDir}`);
|
|
1124
|
+
} else {
|
|
1125
|
+
log(' SearXNG already cloned, pulling latest...');
|
|
1126
|
+
run(`git -C ${searxngDir} pull --ff-only`, { ignoreErrors: true });
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Create venv + install
|
|
1130
|
+
if (!fs.existsSync(venvPython)) {
|
|
1131
|
+
log(' Creating virtualenv...');
|
|
1132
|
+
run(`python3 -m venv ${searxngDir}/venv`);
|
|
1133
|
+
}
|
|
1134
|
+
log(' Installing SearXNG dependencies...');
|
|
1135
|
+
run(`${venvPip} install --quiet -e '${searxngDir}[test]'`);
|
|
1136
|
+
|
|
1137
|
+
// Write minimal settings.yml
|
|
1138
|
+
const settingsDir = '/etc/searxng';
|
|
1139
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
1140
|
+
const settingsPath = `${settingsDir}/settings.yml`;
|
|
1141
|
+
if (!fs.existsSync(settingsPath)) {
|
|
1142
|
+
fs.writeFileSync(settingsPath, [
|
|
1143
|
+
'use_default_settings: true',
|
|
1144
|
+
'server:',
|
|
1145
|
+
' bind_address: "127.0.0.1"',
|
|
1146
|
+
' port: 8888',
|
|
1147
|
+
' secret_key: "' + crypto.randomBytes(32).toString('hex') + '"',
|
|
1148
|
+
' base_url: "http://127.0.0.1:8888/"',
|
|
1149
|
+
'search:',
|
|
1150
|
+
' formats: [html, json]',
|
|
1151
|
+
].join('\n'));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Write systemd unit (Linux) or start directly
|
|
1155
|
+
if (detectPlatform() === 'linux' && !opts.skipSystemd) {
|
|
1156
|
+
const unit = [
|
|
1157
|
+
'[Unit]',
|
|
1158
|
+
'Description=SearXNG (Total Recall web search)',
|
|
1159
|
+
'After=network.target',
|
|
1160
|
+
'',
|
|
1161
|
+
'[Service]',
|
|
1162
|
+
'Type=simple',
|
|
1163
|
+
`ExecStart=${venvPython} -m searx.webapp`,
|
|
1164
|
+
'Environment=SEARXNG_SETTINGS_PATH=/etc/searxng/settings.yml',
|
|
1165
|
+
'Restart=on-failure',
|
|
1166
|
+
'RestartSec=5',
|
|
1167
|
+
'',
|
|
1168
|
+
'[Install]',
|
|
1169
|
+
'WantedBy=multi-user.target',
|
|
1170
|
+
].join('\n');
|
|
1171
|
+
fs.writeFileSync('/etc/systemd/system/searxng.service', unit);
|
|
1172
|
+
run('systemctl daemon-reload', { ignoreErrors: true });
|
|
1173
|
+
run('systemctl enable --now searxng', { ignoreErrors: true });
|
|
1174
|
+
logOk('SearXNG systemd service installed and started');
|
|
1175
|
+
} else {
|
|
1176
|
+
// No systemd (container/macOS) — start via nohup
|
|
1177
|
+
run(
|
|
1178
|
+
`SEARXNG_SETTINGS_PATH=/etc/searxng/settings.yml ` +
|
|
1179
|
+
`nohup ${venvPython} -m searx.webapp > /var/log/searxng.log 2>&1 &`,
|
|
1180
|
+
{ ignoreErrors: true }
|
|
1181
|
+
);
|
|
1182
|
+
logOk('SearXNG started on port 8888 (nohup)');
|
|
1183
|
+
}
|
|
1184
|
+
} catch (e) {
|
|
1185
|
+
logWarn(`SearXNG install failed: ${e.message}`);
|
|
1186
|
+
logWarn('Web search will be unavailable. Re-run deploy to retry.');
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// ── Step 5.5: Install Playwright + Computer Use deps ──
|
|
1191
|
+
logStep('5.5/12', 'Installing Playwright + computer use dependencies');
|
|
1192
|
+
if (opts.dryRun) {
|
|
1193
|
+
log(' Would install: playwright chromium, xdotool, scrot, xvfb');
|
|
1194
|
+
} else {
|
|
1195
|
+
try {
|
|
1196
|
+
// Install Node.js Playwright package
|
|
1197
|
+
log(' Installing playwright npm package...');
|
|
1198
|
+
run(`cd "${ROOT}" && npm install --no-save playwright`, { timeout: 120_000, ignoreErrors: true });
|
|
1199
|
+
|
|
1200
|
+
// Install Chromium browser via playwright
|
|
1201
|
+
log(' Installing Chromium via playwright...');
|
|
1202
|
+
run(`cd "${ROOT}" && npx playwright install chromium`, { timeout: 300_000, ignoreErrors: true });
|
|
1203
|
+
|
|
1204
|
+
// Install X11 / desktop control tools (Linux only)
|
|
1205
|
+
if (platform === 'linux') {
|
|
1206
|
+
log(' Installing xdotool, scrot, xvfb...');
|
|
1207
|
+
run('apt-get install -y -qq xdotool scrot xvfb', { ignoreErrors: true });
|
|
1208
|
+
logOk('Computer use deps installed (Playwright + xdotool + scrot + xvfb)');
|
|
1209
|
+
} else if (platform === 'macos') {
|
|
1210
|
+
if (commandExists('brew')) {
|
|
1211
|
+
run('brew install xdotool', { ignoreErrors: true });
|
|
1212
|
+
}
|
|
1213
|
+
logOk('Playwright installed (macOS computer use uses system accessibility APIs)');
|
|
1214
|
+
} else {
|
|
1215
|
+
logOk('Playwright installed');
|
|
1216
|
+
}
|
|
1217
|
+
} catch (e) {
|
|
1218
|
+
logWarn(`Playwright/computer use install failed: ${e.message}`);
|
|
1219
|
+
logWarn('Browser + computer use tools may be unavailable. Re-run deploy to retry.');
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ── Step 6: Scaffold VFS ──
|
|
1224
|
+
scaffoldVfs(opts);
|
|
1225
|
+
|
|
1226
|
+
// ── Step 7: Copy default config ──
|
|
1227
|
+
copyDefaultConfig(opts.dryRun);
|
|
1228
|
+
hardenSecurityConfig(opts.dryRun, opts.dashboardPassword);
|
|
1229
|
+
|
|
1230
|
+
// ── Step 7: Install Reverse Proxy / Tunnel ──
|
|
1231
|
+
if (opts.cloudflareToken || opts.cloudflareQuick) {
|
|
1232
|
+
logStep('8/12', 'Cloudflare Zero Trust Tunnel installation');
|
|
1233
|
+
let cloudflaredBin = 'cloudflared';
|
|
1234
|
+
if (commandExists('cloudflared')) {
|
|
1235
|
+
logOk('cloudflared already installed');
|
|
1236
|
+
} else {
|
|
1237
|
+
const localBin = path.join(BRAIN_DIR, 'bin', 'cloudflared');
|
|
1238
|
+
if (fs.existsSync(localBin)) {
|
|
1239
|
+
cloudflaredBin = localBin;
|
|
1240
|
+
logOk(`cloudflared found in VFS: ${localBin}`);
|
|
1241
|
+
} else if (opts.dryRun) {
|
|
1242
|
+
log(' Would install cloudflared and route traffic via Zero Trust');
|
|
1243
|
+
} else {
|
|
1244
|
+
if (platform === 'linux') {
|
|
1245
|
+
run('curl -L -s --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb');
|
|
1246
|
+
run('sudo dpkg -i cloudflared.deb > /dev/null');
|
|
1247
|
+
run('rm cloudflared.deb');
|
|
1248
|
+
logOk('cloudflared binary installed');
|
|
1249
|
+
} else if (platform === 'macos') {
|
|
1250
|
+
try {
|
|
1251
|
+
log(' Installing cloudflared via Homebrew...');
|
|
1252
|
+
run('brew install cloudflare/cloudflare/cloudflared');
|
|
1253
|
+
} catch (brewErr) {
|
|
1254
|
+
logWarn(`Homebrew install failed: ${brewErr.message}`);
|
|
1255
|
+
log(' Downloading standalone cloudflared binary as fallback...');
|
|
1256
|
+
run(`mkdir -p ${BRAIN_DIR}/bin`);
|
|
1257
|
+
const cloudflaredArch = (arch === 'aarch64' || arch === 'arm64' || os.arch() === 'arm64') ? 'arm64' : 'amd64';
|
|
1258
|
+
run(`curl -L -s https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${cloudflaredArch}.tgz -o ${BRAIN_DIR}/bin/cloudflared.tgz`);
|
|
1259
|
+
run(`tar -xzf ${BRAIN_DIR}/bin/cloudflared.tgz -C ${BRAIN_DIR}/bin/`);
|
|
1260
|
+
run(`chmod +x ${localBin}`);
|
|
1261
|
+
run(`rm ${BRAIN_DIR}/bin/cloudflared.tgz`);
|
|
1262
|
+
cloudflaredBin = localBin;
|
|
1263
|
+
logOk(`Standalone cloudflared binary (${cloudflaredArch}) downloaded and configured!`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if (!opts.dryRun) {
|
|
1270
|
+
if (opts.cloudflareToken && platform === 'linux') {
|
|
1271
|
+
spawnSync('sudo', ['cloudflared', 'service', 'install', opts.cloudflareToken], { stdio: 'inherit' });
|
|
1272
|
+
logOk('Cloudflare tunnel running securely via token');
|
|
1273
|
+
} else if (opts.cloudflareQuick) {
|
|
1274
|
+
log(' Starting Zero-Config Quick Tunnel (trycloudflare.com)...');
|
|
1275
|
+
run(`mkdir -p ${BRAIN_DIR}/logs`);
|
|
1276
|
+
run(`nohup ${cloudflaredBin} tunnel --url http://localhost:3000 > ${BRAIN_DIR}/logs/cloudflared.log 2>&1 &`);
|
|
1277
|
+
run('sleep 4');
|
|
1278
|
+
try {
|
|
1279
|
+
const url = run(`grep -o "https://.*\\.trycloudflare\\.com" ${BRAIN_DIR}/logs/cloudflared.log | head -1`);
|
|
1280
|
+
logOk(`Quick Tunnel Active: ${url}`);
|
|
1281
|
+
} catch (e) {
|
|
1282
|
+
logWarn(`Could not extract Quick Tunnel URL. Check ${BRAIN_DIR}/logs/cloudflared.log`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
opts.skipCaddy = true; // Tunnel replaces Caddy
|
|
1287
|
+
} else {
|
|
1288
|
+
logStep('8/12', 'Caddy reverse proxy installation');
|
|
1289
|
+
if (opts.skipCaddy) {
|
|
1290
|
+
logSkip('Skipped (--skip-caddy)');
|
|
1291
|
+
} else if (commandExists('caddy')) {
|
|
1292
|
+
logOk('Caddy already installed');
|
|
1293
|
+
} else {
|
|
1294
|
+
if (opts.dryRun) {
|
|
1295
|
+
log(' Would install Caddy for the target platform');
|
|
1296
|
+
} else {
|
|
1297
|
+
if (platform === 'linux') {
|
|
1298
|
+
run('sudo apt-get update -qq && sudo apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl', { ignoreErrors: true });
|
|
1299
|
+
run('curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" -o /tmp/caddy.gpg.key && sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg /tmp/caddy.gpg.key && rm /tmp/caddy.gpg.key', { ignoreErrors: true });
|
|
1300
|
+
run('curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | sudo tee /etc/apt/sources.list.d/caddy-stable.list', { ignoreErrors: true });
|
|
1301
|
+
run('sudo apt-get update -qq && sudo apt-get install -y caddy');
|
|
1302
|
+
} else if (platform === 'macos') {
|
|
1303
|
+
if (commandExists('brew')) {
|
|
1304
|
+
run('brew install caddy');
|
|
1305
|
+
} else {
|
|
1306
|
+
logWarn('Homebrew not found — install Caddy manually: https://caddyserver.com/docs/install');
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
logOk('Caddy installed');
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// ── Step 8: Deploy Caddyfile ──
|
|
1315
|
+
logStep('9/12', 'Deploying Caddyfile');
|
|
1316
|
+
if (opts.skipCaddy) {
|
|
1317
|
+
logSkip('Skipped (--skip-caddy)');
|
|
1318
|
+
} else {
|
|
1319
|
+
const caddyfileSrc = path.join(ROOT, 'templates', 'Caddyfile');
|
|
1320
|
+
let caddyContent = fs.readFileSync(caddyfileSrc, 'utf8');
|
|
1321
|
+
caddyContent = caddyContent.replace(/YOUR_DOMAIN/g, opts.domain);
|
|
1322
|
+
|
|
1323
|
+
if (platform === 'linux') {
|
|
1324
|
+
const caddyDest = '/etc/caddy/Caddyfile';
|
|
1325
|
+
if (opts.dryRun) {
|
|
1326
|
+
log(` Would write Caddyfile to ${caddyDest} (domain: ${opts.domain})`);
|
|
1327
|
+
} else {
|
|
1328
|
+
const tmpCaddy = path.join(os.tmpdir(), 'Caddyfile.total-recall');
|
|
1329
|
+
fs.writeFileSync(tmpCaddy, caddyContent);
|
|
1330
|
+
run(`sudo cp ${tmpCaddy} ${caddyDest}`);
|
|
1331
|
+
fs.unlinkSync(tmpCaddy);
|
|
1332
|
+
logOk(`Caddyfile deployed to ${caddyDest} (domain: ${opts.domain})`);
|
|
1333
|
+
}
|
|
1334
|
+
} else {
|
|
1335
|
+
// macOS / other — write to ~/.agent/config/Caddyfile
|
|
1336
|
+
const caddyDest = path.join(BRAIN_DIR, 'config', 'Caddyfile');
|
|
1337
|
+
if (opts.dryRun) {
|
|
1338
|
+
log(` Would write Caddyfile to ${caddyDest} (domain: ${opts.domain})`);
|
|
1339
|
+
} else {
|
|
1340
|
+
fs.writeFileSync(caddyDest, caddyContent);
|
|
1341
|
+
logOk(`Caddyfile written to ${caddyDest} (domain: ${opts.domain})`);
|
|
1342
|
+
log(' Run Caddy manually: caddy run --config ~/.agent/skills/total-recall/config/Caddyfile');
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// ── Step 8.5: DuckDNS IP-update job ──
|
|
1348
|
+
if (opts.duckdnsToken && opts.domain.endsWith('.duckdns.org')) {
|
|
1349
|
+
logStep('9.5/12', 'Installing DuckDNS IP-update job');
|
|
1350
|
+
const subdomain = opts.domain.replace('.duckdns.org', '');
|
|
1351
|
+
|
|
1352
|
+
// Immediately update DNS record on both platforms
|
|
1353
|
+
if (!opts.dryRun) {
|
|
1354
|
+
try {
|
|
1355
|
+
const tmpLog = path.join(os.tmpdir(), 'duckdns-update.log');
|
|
1356
|
+
run(`curl -s "https://www.duckdns.org/update?domains=${subdomain}&token=${opts.duckdnsToken}&ip=" -o "${tmpLog}"`);
|
|
1357
|
+
const result = fs.readFileSync(tmpLog, 'utf8').trim();
|
|
1358
|
+
if (result === 'OK') {
|
|
1359
|
+
logOk(`DuckDNS record updated for ${opts.domain}`);
|
|
1360
|
+
} else {
|
|
1361
|
+
logWarn(`DuckDNS update response: ${result} — check your token`);
|
|
1362
|
+
}
|
|
1363
|
+
} catch (e) {
|
|
1364
|
+
logWarn(`DuckDNS update failed: ${e.message}`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (platform === 'linux') {
|
|
1369
|
+
const cronLine = `*/5 * * * * root curl -s "https://www.duckdns.org/update?domains=${subdomain}&token=${opts.duckdnsToken}&ip=" -o /var/log/duckdns.log`;
|
|
1370
|
+
const cronFile = '/etc/cron.d/total-recall-duckdns';
|
|
1371
|
+
if (opts.dryRun) {
|
|
1372
|
+
log(` Would write cron job to ${cronFile}`);
|
|
1373
|
+
} else {
|
|
1374
|
+
fs.writeFileSync('/tmp/total-recall-duckdns', cronLine + '\n');
|
|
1375
|
+
run('sudo cp /tmp/total-recall-duckdns ' + cronFile);
|
|
1376
|
+
run('sudo chmod 644 ' + cronFile);
|
|
1377
|
+
logOk(`DuckDNS cron installed at ${cronFile} (updates every 5 minutes)`);
|
|
1378
|
+
}
|
|
1379
|
+
} else if (platform === 'macos') {
|
|
1380
|
+
const plistName = 'com.totalrecall.duckdns.plist';
|
|
1381
|
+
const src = path.join(ROOT, 'templates', plistName);
|
|
1382
|
+
const dest = path.join(launchAgentsDir(), plistName);
|
|
1383
|
+
if (opts.dryRun) {
|
|
1384
|
+
log(` Would install launchd interval job → ${dest}`);
|
|
1385
|
+
} else {
|
|
1386
|
+
let content = fs.readFileSync(src, 'utf8');
|
|
1387
|
+
content = content.replace(/__SUBDOMAIN__/g, subdomain);
|
|
1388
|
+
content = content.replace(/__TOKEN__/g, opts.duckdnsToken);
|
|
1389
|
+
content = content.replace(/__HOME__/g, os.homedir());
|
|
1390
|
+
fs.mkdirSync(launchAgentsDir(), { recursive: true });
|
|
1391
|
+
fs.writeFileSync(dest, content, 'utf8');
|
|
1392
|
+
run(`launchctl load -w "${dest}"`, { ignoreErrors: true });
|
|
1393
|
+
logOk(`DuckDNS launchd job installed at ${dest} (updates every 5 minutes)`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// ── Step 9.6: Automatic backup job ──
|
|
1399
|
+
if (opts.backupRepo) {
|
|
1400
|
+
logStep('9.6/12', `Installing automatic daily backup → ${opts.backupRepo}`);
|
|
1401
|
+
|
|
1402
|
+
if (platform === 'linux') {
|
|
1403
|
+
const cronLine = `0 2 * * * root ${process.execPath} ${path.join(ROOT, 'bin', 'total-recall.mjs')} backup --push-git ${opts.backupRepo}`;
|
|
1404
|
+
const cronFile = '/etc/cron.d/total-recall-backup';
|
|
1405
|
+
if (opts.dryRun) {
|
|
1406
|
+
log(` Would write daily backup cron to ${cronFile}`);
|
|
1407
|
+
} else {
|
|
1408
|
+
fs.writeFileSync('/tmp/total-recall-backup', cronLine + '\n');
|
|
1409
|
+
run('sudo cp /tmp/total-recall-backup ' + cronFile);
|
|
1410
|
+
run('sudo chmod 644 ' + cronFile);
|
|
1411
|
+
logOk(`Backup cron installed at ${cronFile} (runs daily at 2:00 AM)`);
|
|
1412
|
+
log(` Remote: ${opts.backupRepo}`);
|
|
1413
|
+
log(` Restore: npx total-recall deploy --brain-repo ${opts.backupRepo}`);
|
|
1414
|
+
}
|
|
1415
|
+
} else if (platform === 'macos') {
|
|
1416
|
+
const plistName = 'com.totalrecall.backup.plist';
|
|
1417
|
+
const src = path.join(ROOT, 'templates', plistName);
|
|
1418
|
+
const dest = path.join(launchAgentsDir(), plistName);
|
|
1419
|
+
if (opts.dryRun) {
|
|
1420
|
+
log(` Would install launchd daily backup job → ${dest}`);
|
|
1421
|
+
} else {
|
|
1422
|
+
let content = fs.readFileSync(src, 'utf8');
|
|
1423
|
+
content = content.replace(/__ROOT__/g, ROOT);
|
|
1424
|
+
content = content.replace(/__NODE__/g, process.execPath);
|
|
1425
|
+
content = content.replace(/__HOME__/g, os.homedir());
|
|
1426
|
+
content = content.replace(/__BACKUP_REPO__/g, opts.backupRepo);
|
|
1427
|
+
fs.mkdirSync(launchAgentsDir(), { recursive: true });
|
|
1428
|
+
fs.writeFileSync(dest, content, 'utf8');
|
|
1429
|
+
run(`launchctl load -w "${dest}"`, { ignoreErrors: true });
|
|
1430
|
+
logOk(`Backup launchd job installed at ${dest} (runs daily at 2:00 AM)`);
|
|
1431
|
+
log(` Remote: ${opts.backupRepo}`);
|
|
1432
|
+
log(` Restore: npx total-recall deploy --brain-repo ${opts.backupRepo}`);
|
|
1433
|
+
}
|
|
1434
|
+
} else {
|
|
1435
|
+
logWarn(`Auto-backup scheduling not supported on ${platform} — run manually:`);
|
|
1436
|
+
log(` npx total-recall backup --push-git ${opts.backupRepo}`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ── Step 9.7: Obsidian backup job ──
|
|
1441
|
+
if (opts.backupObsidian) {
|
|
1442
|
+
const obsidianPath = opts.backupObsidian.startsWith('~')
|
|
1443
|
+
? path.join(os.homedir(), opts.backupObsidian.slice(1))
|
|
1444
|
+
: opts.backupObsidian;
|
|
1445
|
+
logStep('9.7/12', `Installing daily Obsidian rsync backup → ${obsidianPath}`);
|
|
1446
|
+
|
|
1447
|
+
const backupCmd = `${process.execPath} ${path.join(ROOT, 'bin', 'total-recall.mjs')} backup --obsidian "${obsidianPath}"`;
|
|
1448
|
+
|
|
1449
|
+
if (platform === 'linux') {
|
|
1450
|
+
const cronLine = `0 3 * * * root ${backupCmd}`;
|
|
1451
|
+
const cronFile = '/etc/cron.d/total-recall-obsidian-backup';
|
|
1452
|
+
if (opts.dryRun) {
|
|
1453
|
+
log(` Would write Obsidian backup cron to ${cronFile}`);
|
|
1454
|
+
} else {
|
|
1455
|
+
fs.writeFileSync('/tmp/total-recall-obsidian-backup', cronLine + '\n');
|
|
1456
|
+
run('sudo cp /tmp/total-recall-obsidian-backup ' + cronFile);
|
|
1457
|
+
run('sudo chmod 644 ' + cronFile);
|
|
1458
|
+
logOk(`Obsidian backup cron installed at ${cronFile} (runs daily at 3:00 AM)`);
|
|
1459
|
+
}
|
|
1460
|
+
} else if (platform === 'macos') {
|
|
1461
|
+
// Inline plist for Obsidian rsync — no separate template needed
|
|
1462
|
+
const plistName = 'com.totalrecall.obsidian-backup.plist';
|
|
1463
|
+
const dest = path.join(launchAgentsDir(), plistName);
|
|
1464
|
+
if (opts.dryRun) {
|
|
1465
|
+
log(` Would install launchd Obsidian backup job → ${dest}`);
|
|
1466
|
+
} else {
|
|
1467
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1468
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1469
|
+
<plist version="1.0">
|
|
1470
|
+
<dict>
|
|
1471
|
+
<key>Label</key>
|
|
1472
|
+
<string>com.totalrecall.obsidian-backup</string>
|
|
1473
|
+
<key>ProgramArguments</key>
|
|
1474
|
+
<array>
|
|
1475
|
+
<string>${process.execPath}</string>
|
|
1476
|
+
<string>${path.join(ROOT, 'bin', 'total-recall.mjs')}</string>
|
|
1477
|
+
<string>backup</string>
|
|
1478
|
+
<string>--obsidian</string>
|
|
1479
|
+
<string>${obsidianPath}</string>
|
|
1480
|
+
</array>
|
|
1481
|
+
<key>StartCalendarInterval</key>
|
|
1482
|
+
<dict>
|
|
1483
|
+
<key>Hour</key>
|
|
1484
|
+
<integer>3</integer>
|
|
1485
|
+
<key>Minute</key>
|
|
1486
|
+
<integer>0</integer>
|
|
1487
|
+
</dict>
|
|
1488
|
+
<key>EnvironmentVariables</key>
|
|
1489
|
+
<dict>
|
|
1490
|
+
<key>HOME</key>
|
|
1491
|
+
<string>${os.homedir()}</string>
|
|
1492
|
+
<key>PATH</key>
|
|
1493
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
|
1494
|
+
</dict>
|
|
1495
|
+
<key>StandardOutPath</key>
|
|
1496
|
+
<string>${os.homedir()}/.agent/skills/total-recall/logs/obsidian-backup.log</string>
|
|
1497
|
+
<key>StandardErrorPath</key>
|
|
1498
|
+
<string>${os.homedir()}/.agent/skills/total-recall/logs/obsidian-backup.log</string>
|
|
1499
|
+
<key>RunAtLoad</key>
|
|
1500
|
+
<false/>
|
|
1501
|
+
</dict>
|
|
1502
|
+
</plist>`;
|
|
1503
|
+
fs.mkdirSync(launchAgentsDir(), { recursive: true });
|
|
1504
|
+
fs.writeFileSync(dest, plistContent, 'utf8');
|
|
1505
|
+
run(`launchctl load -w "${dest}"`, { ignoreErrors: true });
|
|
1506
|
+
logOk(`Obsidian backup launchd job installed at ${dest} (runs daily at 3:00 AM)`);
|
|
1507
|
+
log(` Vault: ${obsidianPath}/Total Recall/`);
|
|
1508
|
+
}
|
|
1509
|
+
} else {
|
|
1510
|
+
logWarn(`Obsidian backup scheduling not supported on ${platform} — run manually:`);
|
|
1511
|
+
log(` npx total-recall backup --obsidian "${obsidianPath}"`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
logStep('10/12', `Installing service units (${platform})`);
|
|
1516
|
+
if (opts.skipSystemd) {
|
|
1517
|
+
logSkip('Skipped (--skip-systemd)');
|
|
1518
|
+
} else if (hasSystemd()) {
|
|
1519
|
+
const user = os.userInfo().username;
|
|
1520
|
+
const units = ['total-recall-server.service', 'total-recall-daemon.service'];
|
|
1521
|
+
|
|
1522
|
+
for (const unit of units) {
|
|
1523
|
+
const srcUnit = path.join(ROOT, 'templates', unit);
|
|
1524
|
+
let content = fs.readFileSync(srcUnit, 'utf8');
|
|
1525
|
+
content = content.replace(/%i/g, user);
|
|
1526
|
+
content = content.replace(/\/opt\/total-recall/g, ROOT);
|
|
1527
|
+
content = content.replace(/\/usr\/bin\/node/g, process.execPath);
|
|
1528
|
+
|
|
1529
|
+
const dest = `/etc/systemd/system/${unit}`;
|
|
1530
|
+
if (opts.dryRun) {
|
|
1531
|
+
log(` Would install ${unit} → ${dest}`);
|
|
1532
|
+
} else {
|
|
1533
|
+
const tmpUnit = path.join(os.tmpdir(), unit);
|
|
1534
|
+
fs.writeFileSync(tmpUnit, content);
|
|
1535
|
+
run(`sudo cp ${tmpUnit} ${dest}`);
|
|
1536
|
+
fs.unlinkSync(tmpUnit);
|
|
1537
|
+
logOk(`Installed ${unit}`);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (!opts.dryRun) {
|
|
1542
|
+
run('sudo systemctl daemon-reload');
|
|
1543
|
+
logOk('systemd daemon reloaded');
|
|
1544
|
+
}
|
|
1545
|
+
} else if (hasLaunchd()) {
|
|
1546
|
+
const plists = ['com.totalrecall.server.plist', 'com.totalrecall.daemon.plist'];
|
|
1547
|
+
for (const plist of plists) {
|
|
1548
|
+
installPlist(plist, opts.dryRun);
|
|
1549
|
+
}
|
|
1550
|
+
} else {
|
|
1551
|
+
logWarn(`No systemd or launchd on ${platform} — skipping service install`);
|
|
1552
|
+
log(' Use "total-recall daemon start" for manual process management');
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// ── Step 10: Start services ──
|
|
1556
|
+
logStep('11/12', 'Starting services');
|
|
1557
|
+
if (hasSystemd() && !opts.skipSystemd) {
|
|
1558
|
+
if (opts.dryRun) {
|
|
1559
|
+
log(' Would enable and start total-recall-server + total-recall-daemon');
|
|
1560
|
+
} else {
|
|
1561
|
+
run('sudo systemctl enable --now total-recall-server', { ignoreErrors: true });
|
|
1562
|
+
run('sudo systemctl enable --now total-recall-daemon', { ignoreErrors: true });
|
|
1563
|
+
if (commandExists('caddy')) {
|
|
1564
|
+
run('sudo systemctl enable --now caddy', { ignoreErrors: true });
|
|
1565
|
+
}
|
|
1566
|
+
logOk('Services started');
|
|
1567
|
+
}
|
|
1568
|
+
} else if (hasLaunchd() && !opts.skipSystemd) {
|
|
1569
|
+
if (opts.dryRun) {
|
|
1570
|
+
log(' Would launchctl load com.totalrecall.server + com.totalrecall.daemon');
|
|
1571
|
+
} else {
|
|
1572
|
+
const agentsDir = launchAgentsDir();
|
|
1573
|
+
run(`launchctl load -w "${path.join(agentsDir, 'com.totalrecall.server.plist')}"`, { ignoreErrors: true });
|
|
1574
|
+
run(`launchctl load -w "${path.join(agentsDir, 'com.totalrecall.daemon.plist')}"`, { ignoreErrors: true });
|
|
1575
|
+
logOk('LaunchAgent services loaded (auto-start on login)');
|
|
1576
|
+
}
|
|
1577
|
+
} else if (platform === 'linux') {
|
|
1578
|
+
if (opts.dryRun) {
|
|
1579
|
+
log(' Would fall back to nohup starting server and cognitive daemon in background');
|
|
1580
|
+
} else {
|
|
1581
|
+
logStep('11/12 (fallback)', 'Starting server and daemon in background via nohup (no systemd)');
|
|
1582
|
+
try {
|
|
1583
|
+
fs.mkdirSync(path.join(BRAIN_DIR, 'logs'), { recursive: true });
|
|
1584
|
+
const serverLog = path.join(BRAIN_DIR, 'logs', 'server.log');
|
|
1585
|
+
const daemonLog = path.join(BRAIN_DIR, 'logs', 'daemon.log');
|
|
1586
|
+
const serverScript = path.join(ROOT, 'bin', 'total-recall.mjs');
|
|
1587
|
+
const daemonScript = path.join(ROOT, 'src', 'core', 'daemon-loop.mjs');
|
|
1588
|
+
|
|
1589
|
+
const serverOut = fs.openSync(serverLog, 'a');
|
|
1590
|
+
const daemonOut = fs.openSync(daemonLog, 'a');
|
|
1591
|
+
|
|
1592
|
+
const serverProc = spawn('node', [serverScript, 'start', '--port', '3000', '--host', '127.0.0.1'], {
|
|
1593
|
+
detached: true,
|
|
1594
|
+
stdio: ['ignore', serverOut, serverOut]
|
|
1595
|
+
});
|
|
1596
|
+
serverProc.unref();
|
|
1597
|
+
|
|
1598
|
+
const daemonProc = spawn('node', [daemonScript], {
|
|
1599
|
+
detached: true,
|
|
1600
|
+
stdio: ['ignore', daemonOut, daemonOut]
|
|
1601
|
+
});
|
|
1602
|
+
daemonProc.unref();
|
|
1603
|
+
|
|
1604
|
+
logOk('Server and daemon-loop started in background (safe spawn fallback)');
|
|
1605
|
+
} catch (err) {
|
|
1606
|
+
logWarn(`Background startup fallback failed: ${err.message}`);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
} else {
|
|
1610
|
+
logWarn('Services not auto-started');
|
|
1611
|
+
log(' Start manually:');
|
|
1612
|
+
log(' node src/server/index.mjs # HTTP server');
|
|
1613
|
+
log(' total-recall daemon start # Background daemon');
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// ── Step 11: Initial compile + cron setup ──
|
|
1617
|
+
logStep('12/12', 'Running initial compile + installing agent cron');
|
|
1618
|
+
if (opts.skipCompile) {
|
|
1619
|
+
logSkip('Skipped (--skip-compile)');
|
|
1620
|
+
} else {
|
|
1621
|
+
if (opts.dryRun) {
|
|
1622
|
+
log(' Would compile vault surface (INSTRUCTIONS.md)');
|
|
1623
|
+
log(' Would install agent scheduler cron via scripts/setup-cron.sh');
|
|
1624
|
+
} else {
|
|
1625
|
+
try {
|
|
1626
|
+
// Compile vault surface directly (compile.mjs no longer exists — SSSS migration)
|
|
1627
|
+
const { compileSurface } = await import('../core/surface.mjs');
|
|
1628
|
+
await compileSurface({
|
|
1629
|
+
vaultDir: path.join(BRAIN_DIR, 'memory-vault'),
|
|
1630
|
+
skillsDir: path.join(AGENT_DIR, 'skills'),
|
|
1631
|
+
derivedDir: path.join(BRAIN_DIR, 'memory-derived'),
|
|
1632
|
+
instructionsFile: path.join(AGENT_DIR, 'INSTRUCTIONS.md'),
|
|
1633
|
+
});
|
|
1634
|
+
logOk('Initial compile complete');
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
logWarn(`Compile failed: ${err.message}`);
|
|
1637
|
+
}
|
|
1638
|
+
// Install the agent scheduler cron
|
|
1639
|
+
try {
|
|
1640
|
+
const setupCron = path.join(ROOT, 'scripts', 'setup-cron.sh');
|
|
1641
|
+
if (fs.existsSync(setupCron)) {
|
|
1642
|
+
run(`bash "${setupCron}"`, { silent: true });
|
|
1643
|
+
logOk('Agent scheduler cron installed (fires every 5 minutes)');
|
|
1644
|
+
} else {
|
|
1645
|
+
logWarn('scripts/setup-cron.sh not found — cron not installed');
|
|
1646
|
+
}
|
|
1647
|
+
} catch (err) {
|
|
1648
|
+
logWarn(`Cron setup failed: ${err.message}`);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Globally link total-recall package so the CLI is immediately available
|
|
1652
|
+
try {
|
|
1653
|
+
log('Registering total-recall CLI command globally...');
|
|
1654
|
+
run('npm link', { cwd: ROOT, silent: true });
|
|
1655
|
+
logOk('total-recall CLI registered globally! Run "total-recall" anywhere in your terminal.');
|
|
1656
|
+
} catch (err) {
|
|
1657
|
+
logWarn(`Could not link CLI globally: ${err.message}`);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// ── Step 12.5: Provision selected project brains ──
|
|
1663
|
+
if (opts.projectPaths && opts.projectPaths.length > 0) {
|
|
1664
|
+
logStep('12.5/12', `Provisioning ${opts.projectPaths.length} project brain(s)...`);
|
|
1665
|
+
const initMjs = path.join(ROOT, 'bin', 'total-recall.mjs');
|
|
1666
|
+
for (const projPath of opts.projectPaths) {
|
|
1667
|
+
if (!fs.existsSync(projPath)) {
|
|
1668
|
+
logWarn(`Skipping ${projPath} — directory does not exist`);
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
const projBrainDir = path.join(projPath, '.agent', 'skills', 'total-recall');
|
|
1672
|
+
if (fs.existsSync(path.join(projBrainDir, 'memory-vault'))) {
|
|
1673
|
+
logSkip(`${path.basename(projPath)} (brain already exists)`);
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
try {
|
|
1677
|
+
log(` 📂 Initializing project brain in ${projPath}...`);
|
|
1678
|
+
const r = spawnSync(process.execPath, [initMjs, 'init', '--project'], {
|
|
1679
|
+
cwd: projPath,
|
|
1680
|
+
encoding: 'utf8',
|
|
1681
|
+
timeout: 30000,
|
|
1682
|
+
env: { ...process.env, FORCE_COLOR: '0' }
|
|
1683
|
+
});
|
|
1684
|
+
if (r.status === 0) {
|
|
1685
|
+
logOk(`${path.basename(projPath)} — project brain provisioned`);
|
|
1686
|
+
} else {
|
|
1687
|
+
logWarn(`${path.basename(projPath)} — init failed: ${(r.stderr || '').slice(0, 120)}`);
|
|
1688
|
+
}
|
|
1689
|
+
} catch (err) {
|
|
1690
|
+
logWarn(`${path.basename(projPath)} — init error: ${err.message}`);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// ── Done ──
|
|
1696
|
+
const apiUrl = `https://${opts.domain}/v1/chat/completions`;
|
|
1697
|
+
const dashUrl = `https://${opts.domain}/`;
|
|
1698
|
+
const healthUrl = `https://${opts.domain}/health`;
|
|
1699
|
+
|
|
1700
|
+
console.error(`
|
|
1701
|
+
┌─────────────────────────────────────────────────┐
|
|
1702
|
+
│ ✅ Deploy complete! │
|
|
1703
|
+
│ │
|
|
1704
|
+
│ VFS: ~/.agent/ │
|
|
1705
|
+
│ Config: ~/.agent/config/ │
|
|
1706
|
+
│ API: ${apiUrl.padEnd(35)}│
|
|
1707
|
+
│ Memory: https://${opts.domain}/api/memory │
|
|
1708
|
+
│ Dashboard: ${dashUrl.padEnd(35)}│
|
|
1709
|
+
│ Cron: Every 5 min — agent processes queue │
|
|
1710
|
+
│ Backup: Daily 2 AM — git push to backup repo│
|
|
1711
|
+
│ │
|
|
1712
|
+
│ Next steps: │
|
|
1713
|
+
│ npx total-recall daemon status │
|
|
1714
|
+
└─────────────────────────────────────────────────┘
|
|
1715
|
+
`);
|
|
1716
|
+
|
|
1717
|
+
// Signal the deploy UI (if running) that install is done
|
|
1718
|
+
if (opts.ui && _uiEmit) {
|
|
1719
|
+
try {
|
|
1720
|
+
const { finishDeployUI } = await import('./deploy-ui.mjs');
|
|
1721
|
+
finishDeployUI({ apiUrl, dashUrl, healthUrl });
|
|
1722
|
+
// Give the browser time to receive the done event before exiting
|
|
1723
|
+
await new Promise(r => setTimeout(r, 4500));
|
|
1724
|
+
} catch {}
|
|
1725
|
+
}
|
|
1726
|
+
}
|