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