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 +1 -1
- package/src/cli/secret.mjs +209 -3
- package/src/core/secrets-remote-deploy.mjs +258 -0
package/package.json
CHANGED
package/src/cli/secret.mjs
CHANGED
|
@@ -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>
|
|
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>
|
|
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
|
+
}
|