total-recall-brain 3.21.0 → 3.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.21.0",
3
+ "version": "3.21.1",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -48,6 +48,13 @@ import {
48
48
  enqueueRotationDueTasks,
49
49
  getBrowserRotateAssist,
50
50
  } from '../core/secrets-rotate.mjs';
51
+ import {
52
+ loadRemoteTargets,
53
+ addRemoteTarget,
54
+ removeRemoteTarget,
55
+ deployEnvToRemote,
56
+ deployKeyToRemotes,
57
+ } from '../core/secrets-remote-deploy.mjs';
51
58
  import { listProviders, getProvider } from '../core/provider-catalog.mjs';
52
59
  import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
53
60
  import fs from 'node:fs';
@@ -77,7 +84,18 @@ function printHelp() {
77
84
  --no-example Skip .env.example
78
85
  --no-global Only keys bound to this repo (skip unbound)
79
86
  --replace-all Overwrite entire .env (default: merge TR block only)
87
+ --remote <name> Also push the SSOT projection to a configured remote
88
+ target over SSH (see 'secret remote') instead of
89
+ (or in addition to) a local file
80
90
  --dry-run Show keys that would be written
91
+ remote <cmd> Manage remote (production) deploy targets — generic,
92
+ per-repo config, no host/path ever hardcoded in TR
93
+ add <name> --host <h> --path </remote/dir> [--user root]
94
+ [--port 22] [--filename .env] [--restart-cmd "..."]
95
+ list Show configured targets (no secret values)
96
+ remove <name> Delete a target
97
+ deploy <name> Push the SSOT projection to that target now
98
+ [--dry-run] [--keys k1,k2]
81
99
  import-env One-time migrate: scan local .env → store (not the steady-state path)
82
100
  --all / --file / --overwrite / --dry-run
83
101
  catalog Full catalog: keys, providers, usage, rotation, cost, tracking
@@ -116,6 +134,8 @@ function printHelp() {
116
134
  rotate options:
117
135
  --export-env After rotate, write .env to bound repos (and cwd if unbound)
118
136
  --export-all After rotate, export-env to all project-registry paths
137
+ --remote <name> After rotate, also push to a configured remote target
138
+ (production, etc.) over SSH — see 'secret remote'
119
139
 
120
140
  set / rotate options:
121
141
  --provider <name> Optional provider tag (e.g. openai, anthropic)
@@ -186,6 +206,12 @@ function parseArgs(args) {
186
206
  exempt: false,
187
207
  tracking_exempt: null,
188
208
  shared_ok: null,
209
+ subcommand: null,
210
+ remote: null,
211
+ remoteHost: null,
212
+ remoteUser: 'root',
213
+ remotePort: 22,
214
+ restartCmd: null,
189
215
  };
190
216
  if (!args.length || args[0] === '--help' || args[0] === '-h') {
191
217
  out.help = true;
@@ -193,6 +219,12 @@ function parseArgs(args) {
193
219
  }
194
220
  out.command = args[0];
195
221
  let i = 1;
222
+ if (out.command === 'remote') {
223
+ if (args[i] && !args[i].startsWith('--')) out.subcommand = args[i++];
224
+ if (['add', 'remove', 'rm', 'deploy'].includes(out.subcommand) && args[i] && !args[i].startsWith('--')) {
225
+ out.key = args[i++]; // reused as the remote target name
226
+ }
227
+ }
196
228
  if (
197
229
  ['set', 'get', 'rotate', 'delete', 'meta', 'rotate-browser', 'rm', 'generate', 'account-sync'].includes(
198
230
  out.command,
@@ -208,6 +240,21 @@ function parseArgs(args) {
208
240
  while (i < args.length) {
209
241
  const a = args[i];
210
242
  switch (a) {
243
+ case '--remote':
244
+ out.remote = args[++i];
245
+ break;
246
+ case '--host':
247
+ out.remoteHost = args[++i];
248
+ break;
249
+ case '--user':
250
+ out.remoteUser = args[++i] || 'root';
251
+ break;
252
+ case '--port':
253
+ out.remotePort = parseInt(args[++i], 10) || 22;
254
+ break;
255
+ case '--restart-cmd':
256
+ out.restartCmd = args[++i];
257
+ break;
211
258
  case '--provider':
212
259
  out.provider = args[++i];
213
260
  break;
@@ -724,11 +771,121 @@ export default async function secretCli(argv) {
724
771
  return;
725
772
  }
726
773
 
774
+ if (opts.command === 'remote') {
775
+ if (!opts.subcommand) {
776
+ console.error(
777
+ 'Usage: total-recall secret remote <add|list|remove|deploy> [...]\n' +
778
+ ' add <name> --host <h> --path </remote/dir> [--user root] [--port 22] [--filename .env] [--restart-cmd "..."]\n' +
779
+ ' list\n' +
780
+ ' remove <name>\n' +
781
+ ' deploy <name> [--dry-run] [--keys k1,k2]',
782
+ );
783
+ process.exit(1);
784
+ }
785
+
786
+ if (opts.subcommand === 'add') {
787
+ if (!opts.key || !opts.remoteHost || !opts.path) {
788
+ console.error(
789
+ 'Usage: total-recall secret remote add <name> --host <host> --path </remote/dir> [--user root] [--port 22] [--filename .env] [--restart-cmd "..."]',
790
+ );
791
+ process.exit(1);
792
+ }
793
+ const { path: targetsPath } = addRemoteTarget(brainDir, {
794
+ name: opts.key,
795
+ host: opts.remoteHost,
796
+ user: opts.remoteUser,
797
+ port: opts.remotePort,
798
+ remotePath: opts.path,
799
+ filename: opts.filename || '.env',
800
+ restartCommand: opts.restartCmd || null,
801
+ keys: opts.keys.length ? opts.keys : undefined,
802
+ });
803
+ console.log(`\n ✅ Remote target added: ${opts.key}`);
804
+ console.log(` ${opts.remoteUser}@${opts.remoteHost}:${opts.path}/${opts.filename || '.env'}`);
805
+ if (opts.restartCmd) console.log(` restart: ${opts.restartCmd}`);
806
+ console.log(` config: ${targetsPath}\n`);
807
+ return;
808
+ }
809
+
810
+ if (opts.subcommand === 'list') {
811
+ const targets = loadRemoteTargets(brainDir);
812
+ if (!targets.length) {
813
+ console.log(`\n No remote targets configured for this repo.\n`);
814
+ return;
815
+ }
816
+ console.log(`\n Remote deploy targets\n`);
817
+ for (const t of targets) {
818
+ console.log(
819
+ ` • ${t.name.padEnd(20)} ${t.user}@${t.host}:${t.port} ${t.remotePath}/${t.filename}`,
820
+ );
821
+ if (t.restartCommand) console.log(` restart: ${t.restartCommand}`);
822
+ if (t.keys?.length) console.log(` keys: ${t.keys.join(', ')}`);
823
+ }
824
+ console.log('');
825
+ return;
826
+ }
827
+
828
+ if (opts.subcommand === 'remove' || opts.subcommand === 'rm') {
829
+ if (!opts.key) {
830
+ console.error('Usage: total-recall secret remote remove <name>');
831
+ process.exit(1);
832
+ }
833
+ const r = removeRemoteTarget(brainDir, opts.key);
834
+ console.log(r.removed ? ` ✅ Removed remote target: ${opts.key}` : ` Remote target not found: ${opts.key}`);
835
+ return;
836
+ }
837
+
838
+ if (opts.subcommand === 'deploy') {
839
+ if (!opts.key) {
840
+ console.error('Usage: total-recall secret remote deploy <name> [--dry-run] [--keys k1,k2]');
841
+ process.exit(1);
842
+ }
843
+ const r = await deployEnvToRemote(brainDir, opts.key, {
844
+ dryRun: opts.dryRun,
845
+ keys: opts.keys.length ? opts.keys : undefined,
846
+ });
847
+ if (r.dryRun) {
848
+ console.log(`\n 📤 dry-run remote deploy → ${r.target} (${r.host})`);
849
+ console.log(` would write ${r.count} keys → ${r.remoteFile}\n`);
850
+ return;
851
+ }
852
+ console.log(`\n ✅ Deployed ${r.count} secret(s) → ${r.target} (${r.host}:${r.remoteFile})`);
853
+ if (r.restarted) console.log(` restart: ok`);
854
+ else if (r.restartOutput) console.log(` restart: FAILED — ${r.restartOutput.slice(0, 300)}`);
855
+ console.log(` (values written with mode 0600 remotely; not printed)\n`);
856
+ return;
857
+ }
858
+
859
+ console.error(`Unknown remote subcommand: ${opts.subcommand}`);
860
+ process.exit(1);
861
+ }
862
+
727
863
  if (opts.command === 'rotate') {
728
864
  if (!opts.key || !opts.value) {
729
- console.error('Usage: total-recall secret rotate <key> <new-value> [--export-env|--export-all]');
865
+ console.error('Usage: total-recall secret rotate <key> <new-value> [--export-env|--export-all|--remote <name>]');
730
866
  process.exit(1);
731
867
  }
868
+ if (opts.remote) {
869
+ await rotateSecret(brainDir, opts.key, opts.value, { provider: opts.provider, scope: opts.scope });
870
+ console.log(` ✅ Rotated: ${opts.key}`);
871
+ const r = await deployEnvToRemote(brainDir, opts.remote, {});
872
+ console.log(` ✅ Deployed to remote target: ${r.target} (${r.host}:${r.remoteFile}, ${r.count} keys)`);
873
+ if (r.restarted) console.log(` restart: ok`);
874
+ else if (r.restartOutput) console.log(` restart: FAILED — ${r.restartOutput.slice(0, 300)}`);
875
+ if (opts.exportEnv || opts.exportAll) {
876
+ const localR = await rotateSecretAndExport(brainDir, opts.key, opts.value, {
877
+ provider: opts.provider,
878
+ actor: 'cli',
879
+ exportEnv: true,
880
+ exportAllProjects: opts.exportAll,
881
+ exportCwd: true,
882
+ includeGlobal: true,
883
+ });
884
+ const ok = (localR.exports || []).filter((e) => e.ok !== false);
885
+ console.log(` local export-env: ${ok.length} target(s)`);
886
+ }
887
+ return;
888
+ }
732
889
  if (opts.exportEnv || opts.exportAll) {
733
890
  const r = await rotateSecretAndExport(brainDir, opts.key, opts.value, {
734
891
  provider: opts.provider,
@@ -859,6 +1016,23 @@ export default async function secretCli(argv) {
859
1016
  keys: opts.keys.length ? opts.keys : undefined,
860
1017
  };
861
1018
 
1019
+ if (opts.remote) {
1020
+ const r = await deployEnvToRemote(brainDir, opts.remote, {
1021
+ dryRun: opts.dryRun,
1022
+ keys: opts.keys.length ? opts.keys : undefined,
1023
+ });
1024
+ if (r.dryRun) {
1025
+ console.log(`\n 📤 dry-run remote export → ${r.target} (${r.host})`);
1026
+ console.log(` would write ${r.count} keys → ${r.remoteFile}\n`);
1027
+ } else {
1028
+ console.log(`\n ✅ Exported ${r.count} secret(s) → ${r.target} (${r.host}:${r.remoteFile})`);
1029
+ if (r.restarted) console.log(` restart: ok`);
1030
+ else if (r.restartOutput) console.log(` restart: FAILED — ${r.restartOutput.slice(0, 300)}`);
1031
+ console.log(` (values written with mode 0600 remotely; not printed)\n`);
1032
+ }
1033
+ if (!opts.path && !opts.allProjects) return; // --remote alone doesn't also touch local disk
1034
+ }
1035
+
862
1036
  if (opts.allProjects) {
863
1037
  const results = await exportEnvToRegistry(brainDir, exportOpts);
864
1038
  console.log(`\n 📤 export-env → ${results.length} project(s) from ${resolveSecretsPath(brainDir)}\n`);
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Push the Total Recall secrets-store SSOT to a remote deploy target over SSH.
3
+ *
4
+ * Generic by design — Total Recall core never hardcodes a hostname, path, or
5
+ * product name. Every target (host, remote path, restart command) is
6
+ * declared by the user in this repo's own remote-targets.json, the same way
7
+ * repos/scopes are declared for export-env. This module only knows how to:
8
+ * 1. build the same dotenv projection export-env already builds,
9
+ * 2. fetch + merge it into a REMOTE file over SSH (never local disk),
10
+ * 3. optionally run a user-declared restart command over SSH.
11
+ *
12
+ * Secret values never touch argv, shell history, or process listings on
13
+ * either end — they're piped over stdin to a remote `cat > file` and never
14
+ * echoed back. Callers must not log the returned `dotenv`/`map` fields.
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { buildEnvProjection, mergeEnvManagedBlock } from './secrets-env-export.mjs';
21
+ import { resolveSecretsPath } from './secrets-store.mjs';
22
+
23
+ /**
24
+ * @param {string} brainDir
25
+ */
26
+ export function resolveRemoteTargetsPath(brainDir) {
27
+ return path.join(path.dirname(resolveSecretsPath(brainDir)), 'remote-targets.json');
28
+ }
29
+
30
+ /**
31
+ * @typedef {{
32
+ * name: string,
33
+ * host: string,
34
+ * user?: string,
35
+ * port?: number,
36
+ * remotePath: string,
37
+ * filename?: string,
38
+ * restartCommand?: string,
39
+ * sshOpts?: string[],
40
+ * keys?: string[],
41
+ * includeGlobal?: boolean,
42
+ * }} RemoteTarget
43
+ */
44
+
45
+ /** @param {string} brainDir @returns {RemoteTarget[]} */
46
+ export function loadRemoteTargets(brainDir) {
47
+ const p = resolveRemoteTargetsPath(brainDir);
48
+ if (!fs.existsSync(p)) return [];
49
+ try {
50
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
51
+ return Array.isArray(data) ? data : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /** @param {string} brainDir @param {RemoteTarget[]} targets */
58
+ export function saveRemoteTargets(brainDir, targets) {
59
+ const p = resolveRemoteTargetsPath(brainDir);
60
+ fs.mkdirSync(path.dirname(p), { recursive: true });
61
+ fs.writeFileSync(p, `${JSON.stringify(targets, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
62
+ try {
63
+ fs.chmodSync(p, 0o600);
64
+ } catch {
65
+ /* windows / fs without chmod */
66
+ }
67
+ return p;
68
+ }
69
+
70
+ /** @param {string} brainDir @param {RemoteTarget} target */
71
+ export function addRemoteTarget(brainDir, target) {
72
+ if (!target?.name) throw new Error('Remote target requires a name');
73
+ if (!target?.host) throw new Error('Remote target requires a host');
74
+ if (!target?.remotePath) throw new Error('Remote target requires remotePath (remote directory)');
75
+ const targets = loadRemoteTargets(brainDir).filter((t) => t.name !== target.name);
76
+ targets.push({
77
+ name: target.name,
78
+ host: target.host,
79
+ user: target.user || 'root',
80
+ port: target.port || 22,
81
+ remotePath: target.remotePath,
82
+ filename: target.filename || '.env',
83
+ restartCommand: target.restartCommand || null,
84
+ sshOpts: target.sshOpts || [],
85
+ keys: target.keys || undefined,
86
+ includeGlobal: target.includeGlobal !== false,
87
+ });
88
+ const p = saveRemoteTargets(brainDir, targets);
89
+ return { path: p, targets };
90
+ }
91
+
92
+ /** @param {string} brainDir @param {string} name */
93
+ export function removeRemoteTarget(brainDir, name) {
94
+ const targets = loadRemoteTargets(brainDir);
95
+ const next = targets.filter((t) => t.name !== name);
96
+ const removed = next.length !== targets.length;
97
+ if (removed) saveRemoteTargets(brainDir, next);
98
+ return { removed, targets: next };
99
+ }
100
+
101
+ function sshBaseArgs(target) {
102
+ return [
103
+ '-o', 'BatchMode=yes',
104
+ '-o', 'ConnectTimeout=8',
105
+ '-p', String(target.port || 22),
106
+ ...(target.sshOpts || []),
107
+ ];
108
+ }
109
+
110
+ function sshDestination(target) {
111
+ return `${target.user || 'root'}@${target.host}`;
112
+ }
113
+
114
+ /**
115
+ * Run a remote command over SSH, optionally piping stdin. Never uses a
116
+ * shell-interpolated string built from secret values — the remote command
117
+ * itself is a fixed, non-secret string; the secret body travels over stdin.
118
+ */
119
+ function runSsh(target, remoteCommand, { input } = {}) {
120
+ const args = [...sshBaseArgs(target), sshDestination(target), remoteCommand];
121
+ const result = spawnSync('ssh', args, {
122
+ input: input != null ? input : undefined,
123
+ encoding: 'utf8',
124
+ timeout: 20000,
125
+ });
126
+ return result;
127
+ }
128
+
129
+ function quoteRemotePath(p) {
130
+ // POSIX single-quote escaping — safe for arbitrary remote paths.
131
+ return `'${String(p).replace(/'/g, `'\\''`)}'`;
132
+ }
133
+
134
+ /**
135
+ * Fetch the current remote file body (empty string if absent/unreachable-file).
136
+ */
137
+ function fetchRemoteFile(target, remoteFile) {
138
+ const r = runSsh(target, `cat ${quoteRemotePath(remoteFile)} 2>/dev/null || true`);
139
+ if (r.error) throw new Error(`SSH connect failed for target "${target.name}": ${r.error.message}`);
140
+ return r.stdout || '';
141
+ }
142
+
143
+ /**
144
+ * Write body to the remote file via stdin (never as an argv/shell string),
145
+ * then chmod 600. Uses a temp file + atomic rename on the remote side so a
146
+ * dropped connection mid-write can't leave a truncated secrets file live.
147
+ */
148
+ function writeRemoteFile(target, remoteFile, body) {
149
+ const tmp = `${remoteFile}.tr-tmp.$$`;
150
+ const cmd =
151
+ `set -e; TMP="${remoteFile}.tr-tmp.$$"; ` +
152
+ `cat > "$TMP" && chmod 600 "$TMP" && mv "$TMP" ${quoteRemotePath(remoteFile)}`;
153
+ const r = runSsh(target, cmd, { input: body });
154
+ if (r.error) throw new Error(`SSH write failed for target "${target.name}": ${r.error.message}`);
155
+ if (r.status !== 0) {
156
+ throw new Error(
157
+ `Remote write failed for target "${target.name}" (exit ${r.status}): ${(r.stderr || '').slice(0, 500)}`,
158
+ );
159
+ }
160
+ return true;
161
+ }
162
+
163
+ /**
164
+ * Deploy the SSOT projection to one configured remote target.
165
+ *
166
+ * @param {string} brainDir
167
+ * @param {string} targetName
168
+ * @param {{ dryRun?: boolean, keys?: string[], skipRestart?: boolean }} opts
169
+ */
170
+ export async function deployEnvToRemote(brainDir, targetName, opts = {}) {
171
+ const targets = loadRemoteTargets(brainDir);
172
+ const target = targets.find((t) => t.name === targetName);
173
+ if (!target) {
174
+ throw new Error(
175
+ `Unknown remote target "${targetName}". Configure one first: npx total-recall secret remote add ${targetName} --host <host> --path </remote/dir>`,
176
+ );
177
+ }
178
+
179
+ const projection = await buildEnvProjection(brainDir, {
180
+ includeGlobal: target.includeGlobal !== false,
181
+ keys: opts.keys?.length ? opts.keys : target.keys,
182
+ });
183
+
184
+ const remoteFile = path.posix.join(target.remotePath, target.filename || '.env');
185
+
186
+ if (opts.dryRun) {
187
+ return {
188
+ dryRun: true,
189
+ target: target.name,
190
+ host: target.host,
191
+ remoteFile,
192
+ keys: projection.keys,
193
+ count: projection.count,
194
+ };
195
+ }
196
+
197
+ if (!projection.count) {
198
+ return {
199
+ dryRun: false,
200
+ target: target.name,
201
+ host: target.host,
202
+ remoteFile,
203
+ keys: [],
204
+ count: 0,
205
+ skipped: 'no matching keys',
206
+ };
207
+ }
208
+
209
+ const existing = fetchRemoteFile(target, remoteFile);
210
+ const merged = mergeEnvManagedBlock(existing, projection.body);
211
+ writeRemoteFile(target, remoteFile, merged);
212
+
213
+ let restarted = false;
214
+ let restartOutput = null;
215
+ if (target.restartCommand && !opts.skipRestart) {
216
+ const r = runSsh(target, target.restartCommand);
217
+ restarted = r.status === 0;
218
+ restartOutput = (r.stdout || '') + (r.stderr || '');
219
+ }
220
+
221
+ return {
222
+ dryRun: false,
223
+ target: target.name,
224
+ host: target.host,
225
+ remoteFile,
226
+ keys: projection.keys,
227
+ count: projection.count,
228
+ restarted,
229
+ restartOutput: restartOutput ? restartOutput.slice(0, 2000) : null,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Deploy to every remote target that has this key bound via `keys`, or to
235
+ * every target when the key list is unset (target opts into "all keys").
236
+ */
237
+ export async function deployKeyToRemotes(brainDir, key, opts = {}) {
238
+ const targets = loadRemoteTargets(brainDir);
239
+ const matching = targets.filter((t) => !t.keys?.length || t.keys.includes(key));
240
+ const results = [];
241
+ for (const t of matching) {
242
+ try {
243
+ results.push(await deployEnvToRemote(brainDir, t.name, opts));
244
+ } catch (err) {
245
+ results.push({ target: t.name, host: t.host, ok: false, error: err.message });
246
+ }
247
+ }
248
+ return results;
249
+ }