total-recall-brain 3.21.0 → 3.21.2

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.2",
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,11 +48,28 @@ 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';
54
61
  import path from 'node:path';
55
62
 
63
+ // Reads a secret value from stdin (fd 0) so it never has to appear as a CLI
64
+ // argument — CLI args land in shell history, `ps aux`, and any transcript
65
+ // that echoes the command. Trims exactly one trailing newline (what a
66
+ // pasted/piped value normally carries), not all whitespace, so a value that
67
+ // legitimately starts or ends with a space round-trips intact.
68
+ function readStdinValue() {
69
+ const raw = fs.readFileSync(0, 'utf8');
70
+ return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
71
+ }
72
+
56
73
  function printHelp() {
57
74
  console.log(`
58
75
  total-recall secret — Secrets store (separate from memory vault)
@@ -60,11 +77,13 @@ function printHelp() {
60
77
  Usage: total-recall secret <command> [options]
61
78
 
62
79
  Commands:
63
- set <key> <value> Store a secret (0600 file; optional AES via TR_SECRETS_PASSWORD)
80
+ set <key> [value] Store a secret (0600 file; optional AES via TR_SECRETS_PASSWORD)
81
+ --stdin reads the value from fd0 instead of argv,
82
+ so it never lands in shell history/ps/a transcript
64
83
  generate <key> Generate a random secret for outside integrations
65
84
  get <key> Print secret value (audited)
66
85
  list List keys + metadata only (no values)
67
- rotate <key> <value> Replace value and mark rotated
86
+ rotate <key> [value] Replace value and mark rotated (also supports --stdin)
68
87
  delete <key> Remove a secret
69
88
  audit [--limit N] Recent set/get/rotate events (no values)
70
89
  usage [--days N] Sum usage.jsonl for last N days (default 1)
@@ -77,7 +96,18 @@ function printHelp() {
77
96
  --no-example Skip .env.example
78
97
  --no-global Only keys bound to this repo (skip unbound)
79
98
  --replace-all Overwrite entire .env (default: merge TR block only)
99
+ --remote <name> Also push the SSOT projection to a configured remote
100
+ target over SSH (see 'secret remote') instead of
101
+ (or in addition to) a local file
80
102
  --dry-run Show keys that would be written
103
+ remote <cmd> Manage remote (production) deploy targets — generic,
104
+ per-repo config, no host/path ever hardcoded in TR
105
+ add <name> --host <h> --path </remote/dir> [--user root]
106
+ [--port 22] [--filename .env] [--restart-cmd "..."]
107
+ list Show configured targets (no secret values)
108
+ remove <name> Delete a target
109
+ deploy <name> Push the SSOT projection to that target now
110
+ [--dry-run] [--keys k1,k2]
81
111
  import-env One-time migrate: scan local .env → store (not the steady-state path)
82
112
  --all / --file / --overwrite / --dry-run
83
113
  catalog Full catalog: keys, providers, usage, rotation, cost, tracking
@@ -116,6 +146,8 @@ function printHelp() {
116
146
  rotate options:
117
147
  --export-env After rotate, write .env to bound repos (and cwd if unbound)
118
148
  --export-all After rotate, export-env to all project-registry paths
149
+ --remote <name> After rotate, also push to a configured remote target
150
+ (production, etc.) over SSH — see 'secret remote'
119
151
 
120
152
  set / rotate options:
121
153
  --provider <name> Optional provider tag (e.g. openai, anthropic)
@@ -186,6 +218,13 @@ function parseArgs(args) {
186
218
  exempt: false,
187
219
  tracking_exempt: null,
188
220
  shared_ok: null,
221
+ subcommand: null,
222
+ remote: null,
223
+ remoteHost: null,
224
+ remoteUser: 'root',
225
+ remotePort: 22,
226
+ restartCmd: null,
227
+ stdin: false,
189
228
  };
190
229
  if (!args.length || args[0] === '--help' || args[0] === '-h') {
191
230
  out.help = true;
@@ -193,6 +232,12 @@ function parseArgs(args) {
193
232
  }
194
233
  out.command = args[0];
195
234
  let i = 1;
235
+ if (out.command === 'remote') {
236
+ if (args[i] && !args[i].startsWith('--')) out.subcommand = args[i++];
237
+ if (['add', 'remove', 'rm', 'deploy'].includes(out.subcommand) && args[i] && !args[i].startsWith('--')) {
238
+ out.key = args[i++]; // reused as the remote target name
239
+ }
240
+ }
196
241
  if (
197
242
  ['set', 'get', 'rotate', 'delete', 'meta', 'rotate-browser', 'rm', 'generate', 'account-sync'].includes(
198
243
  out.command,
@@ -208,6 +253,21 @@ function parseArgs(args) {
208
253
  while (i < args.length) {
209
254
  const a = args[i];
210
255
  switch (a) {
256
+ case '--remote':
257
+ out.remote = args[++i];
258
+ break;
259
+ case '--host':
260
+ out.remoteHost = args[++i];
261
+ break;
262
+ case '--user':
263
+ out.remoteUser = args[++i] || 'root';
264
+ break;
265
+ case '--port':
266
+ out.remotePort = parseInt(args[++i], 10) || 22;
267
+ break;
268
+ case '--restart-cmd':
269
+ out.restartCmd = args[++i];
270
+ break;
211
271
  case '--provider':
212
272
  out.provider = args[++i];
213
273
  break;
@@ -346,6 +406,9 @@ function parseArgs(args) {
346
406
  case '--no-shared-ok':
347
407
  out.shared_ok = false;
348
408
  break;
409
+ case '--stdin':
410
+ out.stdin = true;
411
+ break;
349
412
  case '--help':
350
413
  case '-h':
351
414
  out.help = true;
@@ -377,6 +440,22 @@ export default async function secretCli(argv) {
377
440
  process.exit(1);
378
441
  }
379
442
 
443
+ if (opts.stdin) {
444
+ if (!['set', 'rotate'].includes(opts.command)) {
445
+ console.error('❌ --stdin is only valid with `set` or `rotate`');
446
+ process.exit(1);
447
+ }
448
+ if (!opts.key) {
449
+ console.error(`Usage: total-recall secret ${opts.command} <key> --stdin`);
450
+ process.exit(1);
451
+ }
452
+ opts.value = readStdinValue();
453
+ if (!opts.value) {
454
+ console.error('❌ --stdin read an empty value');
455
+ process.exit(1);
456
+ }
457
+ }
458
+
380
459
  try {
381
460
  if (opts.command === 'set') {
382
461
  if (!opts.key || opts.value == null) {
@@ -724,11 +803,121 @@ export default async function secretCli(argv) {
724
803
  return;
725
804
  }
726
805
 
806
+ if (opts.command === 'remote') {
807
+ if (!opts.subcommand) {
808
+ console.error(
809
+ 'Usage: total-recall secret remote <add|list|remove|deploy> [...]\n' +
810
+ ' add <name> --host <h> --path </remote/dir> [--user root] [--port 22] [--filename .env] [--restart-cmd "..."]\n' +
811
+ ' list\n' +
812
+ ' remove <name>\n' +
813
+ ' deploy <name> [--dry-run] [--keys k1,k2]',
814
+ );
815
+ process.exit(1);
816
+ }
817
+
818
+ if (opts.subcommand === 'add') {
819
+ if (!opts.key || !opts.remoteHost || !opts.path) {
820
+ console.error(
821
+ 'Usage: total-recall secret remote add <name> --host <host> --path </remote/dir> [--user root] [--port 22] [--filename .env] [--restart-cmd "..."]',
822
+ );
823
+ process.exit(1);
824
+ }
825
+ const { path: targetsPath } = addRemoteTarget(brainDir, {
826
+ name: opts.key,
827
+ host: opts.remoteHost,
828
+ user: opts.remoteUser,
829
+ port: opts.remotePort,
830
+ remotePath: opts.path,
831
+ filename: opts.filename || '.env',
832
+ restartCommand: opts.restartCmd || null,
833
+ keys: opts.keys.length ? opts.keys : undefined,
834
+ });
835
+ console.log(`\n ✅ Remote target added: ${opts.key}`);
836
+ console.log(` ${opts.remoteUser}@${opts.remoteHost}:${opts.path}/${opts.filename || '.env'}`);
837
+ if (opts.restartCmd) console.log(` restart: ${opts.restartCmd}`);
838
+ console.log(` config: ${targetsPath}\n`);
839
+ return;
840
+ }
841
+
842
+ if (opts.subcommand === 'list') {
843
+ const targets = loadRemoteTargets(brainDir);
844
+ if (!targets.length) {
845
+ console.log(`\n No remote targets configured for this repo.\n`);
846
+ return;
847
+ }
848
+ console.log(`\n Remote deploy targets\n`);
849
+ for (const t of targets) {
850
+ console.log(
851
+ ` • ${t.name.padEnd(20)} ${t.user}@${t.host}:${t.port} ${t.remotePath}/${t.filename}`,
852
+ );
853
+ if (t.restartCommand) console.log(` restart: ${t.restartCommand}`);
854
+ if (t.keys?.length) console.log(` keys: ${t.keys.join(', ')}`);
855
+ }
856
+ console.log('');
857
+ return;
858
+ }
859
+
860
+ if (opts.subcommand === 'remove' || opts.subcommand === 'rm') {
861
+ if (!opts.key) {
862
+ console.error('Usage: total-recall secret remote remove <name>');
863
+ process.exit(1);
864
+ }
865
+ const r = removeRemoteTarget(brainDir, opts.key);
866
+ console.log(r.removed ? ` ✅ Removed remote target: ${opts.key}` : ` Remote target not found: ${opts.key}`);
867
+ return;
868
+ }
869
+
870
+ if (opts.subcommand === 'deploy') {
871
+ if (!opts.key) {
872
+ console.error('Usage: total-recall secret remote deploy <name> [--dry-run] [--keys k1,k2]');
873
+ process.exit(1);
874
+ }
875
+ const r = await deployEnvToRemote(brainDir, opts.key, {
876
+ dryRun: opts.dryRun,
877
+ keys: opts.keys.length ? opts.keys : undefined,
878
+ });
879
+ if (r.dryRun) {
880
+ console.log(`\n 📤 dry-run remote deploy → ${r.target} (${r.host})`);
881
+ console.log(` would write ${r.count} keys → ${r.remoteFile}\n`);
882
+ return;
883
+ }
884
+ console.log(`\n ✅ Deployed ${r.count} secret(s) → ${r.target} (${r.host}:${r.remoteFile})`);
885
+ if (r.restarted) console.log(` restart: ok`);
886
+ else if (r.restartOutput) console.log(` restart: FAILED — ${r.restartOutput.slice(0, 300)}`);
887
+ console.log(` (values written with mode 0600 remotely; not printed)\n`);
888
+ return;
889
+ }
890
+
891
+ console.error(`Unknown remote subcommand: ${opts.subcommand}`);
892
+ process.exit(1);
893
+ }
894
+
727
895
  if (opts.command === 'rotate') {
728
896
  if (!opts.key || !opts.value) {
729
- console.error('Usage: total-recall secret rotate <key> <new-value> [--export-env|--export-all]');
897
+ console.error('Usage: total-recall secret rotate <key> <new-value> [--export-env|--export-all|--remote <name>]');
730
898
  process.exit(1);
731
899
  }
900
+ if (opts.remote) {
901
+ await rotateSecret(brainDir, opts.key, opts.value, { provider: opts.provider, scope: opts.scope });
902
+ console.log(` ✅ Rotated: ${opts.key}`);
903
+ const r = await deployEnvToRemote(brainDir, opts.remote, {});
904
+ console.log(` ✅ Deployed to remote target: ${r.target} (${r.host}:${r.remoteFile}, ${r.count} keys)`);
905
+ if (r.restarted) console.log(` restart: ok`);
906
+ else if (r.restartOutput) console.log(` restart: FAILED — ${r.restartOutput.slice(0, 300)}`);
907
+ if (opts.exportEnv || opts.exportAll) {
908
+ const localR = await rotateSecretAndExport(brainDir, opts.key, opts.value, {
909
+ provider: opts.provider,
910
+ actor: 'cli',
911
+ exportEnv: true,
912
+ exportAllProjects: opts.exportAll,
913
+ exportCwd: true,
914
+ includeGlobal: true,
915
+ });
916
+ const ok = (localR.exports || []).filter((e) => e.ok !== false);
917
+ console.log(` local export-env: ${ok.length} target(s)`);
918
+ }
919
+ return;
920
+ }
732
921
  if (opts.exportEnv || opts.exportAll) {
733
922
  const r = await rotateSecretAndExport(brainDir, opts.key, opts.value, {
734
923
  provider: opts.provider,
@@ -859,6 +1048,23 @@ export default async function secretCli(argv) {
859
1048
  keys: opts.keys.length ? opts.keys : undefined,
860
1049
  };
861
1050
 
1051
+ if (opts.remote) {
1052
+ const r = await deployEnvToRemote(brainDir, opts.remote, {
1053
+ dryRun: opts.dryRun,
1054
+ keys: opts.keys.length ? opts.keys : undefined,
1055
+ });
1056
+ if (r.dryRun) {
1057
+ console.log(`\n 📤 dry-run remote export → ${r.target} (${r.host})`);
1058
+ console.log(` would write ${r.count} keys → ${r.remoteFile}\n`);
1059
+ } else {
1060
+ console.log(`\n ✅ Exported ${r.count} secret(s) → ${r.target} (${r.host}:${r.remoteFile})`);
1061
+ if (r.restarted) console.log(` restart: ok`);
1062
+ else if (r.restartOutput) console.log(` restart: FAILED — ${r.restartOutput.slice(0, 300)}`);
1063
+ console.log(` (values written with mode 0600 remotely; not printed)\n`);
1064
+ }
1065
+ if (!opts.path && !opts.allProjects) return; // --remote alone doesn't also touch local disk
1066
+ }
1067
+
862
1068
  if (opts.allProjects) {
863
1069
  const results = await exportEnvToRegistry(brainDir, exportOpts);
864
1070
  console.log(`\n 📤 export-env → ${results.length} project(s) from ${resolveSecretsPath(brainDir)}\n`);
@@ -0,0 +1,258 @@
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, projectSlugFromPath } 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
+ // Match the same projection local `export-env --path .` builds: repo-bound
180
+ // secrets (set with --repo <name>) only resolve when the caller's project
181
+ // slug is passed through. Without this, deployEnvToRemote silently dropped
182
+ // every repo-scoped key and pushed only unbound/global ones — the opposite
183
+ // of what --repo scoping is for. cwd is the right default since this CLI
184
+ // is always invoked from the repo root (same assumption --path . makes).
185
+ const projectPath = opts.projectPath || process.cwd();
186
+ const projection = await buildEnvProjection(brainDir, {
187
+ includeGlobal: target.includeGlobal !== false,
188
+ keys: opts.keys?.length ? opts.keys : target.keys,
189
+ projectPath,
190
+ projectSlug: opts.projectSlug || projectSlugFromPath(projectPath),
191
+ });
192
+
193
+ const remoteFile = path.posix.join(target.remotePath, target.filename || '.env');
194
+
195
+ if (opts.dryRun) {
196
+ return {
197
+ dryRun: true,
198
+ target: target.name,
199
+ host: target.host,
200
+ remoteFile,
201
+ keys: projection.keys,
202
+ count: projection.count,
203
+ };
204
+ }
205
+
206
+ if (!projection.count) {
207
+ return {
208
+ dryRun: false,
209
+ target: target.name,
210
+ host: target.host,
211
+ remoteFile,
212
+ keys: [],
213
+ count: 0,
214
+ skipped: 'no matching keys',
215
+ };
216
+ }
217
+
218
+ const existing = fetchRemoteFile(target, remoteFile);
219
+ const merged = mergeEnvManagedBlock(existing, projection.body);
220
+ writeRemoteFile(target, remoteFile, merged);
221
+
222
+ let restarted = false;
223
+ let restartOutput = null;
224
+ if (target.restartCommand && !opts.skipRestart) {
225
+ const r = runSsh(target, target.restartCommand);
226
+ restarted = r.status === 0;
227
+ restartOutput = (r.stdout || '') + (r.stderr || '');
228
+ }
229
+
230
+ return {
231
+ dryRun: false,
232
+ target: target.name,
233
+ host: target.host,
234
+ remoteFile,
235
+ keys: projection.keys,
236
+ count: projection.count,
237
+ restarted,
238
+ restartOutput: restartOutput ? restartOutput.slice(0, 2000) : null,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Deploy to every remote target that has this key bound via `keys`, or to
244
+ * every target when the key list is unset (target opts into "all keys").
245
+ */
246
+ export async function deployKeyToRemotes(brainDir, key, opts = {}) {
247
+ const targets = loadRemoteTargets(brainDir);
248
+ const matching = targets.filter((t) => !t.keys?.length || t.keys.includes(key));
249
+ const results = [];
250
+ for (const t of matching) {
251
+ try {
252
+ results.push(await deployEnvToRemote(brainDir, t.name, opts));
253
+ } catch (err) {
254
+ results.push({ target: t.name, host: t.host, ok: false, error: err.message });
255
+ }
256
+ }
257
+ return results;
258
+ }