instabox 0.5.4 → 0.6.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.
@@ -1,26 +1,29 @@
1
1
  #!/usr/bin/env node
2
- // Puts the checked items into the Vault, piping each value from where it
3
- // lives straight into `instabox vault set` / `vault set-file`.
4
- //
5
- // node push.mjs --selection selection.json --dry-run # names, sizes, what it would do
6
- // node push.mjs --selection selection.json
7
- //
8
- // A value is held in this process's memory between reading it and writing
9
- // it to instabox's stdin; it is never printed, logged or put on a command
10
- // line. Output: one line per Vault item with its name, kind, size and outcome.
11
- //
12
- // Every value is read and the totals checked against the Vault's limits
13
- // before anything is written: over them, nothing is written (exit 3).
14
- // An item that is in the Vault now but was not when the plan was written,
15
- // and was not set by this skill, is left alone. The manifest
16
- // (~/.config/instabox-basic-setup/pushed.json: names, kinds, paths; no
17
- // values) records what this skill set, so a later run replaces those.
2
+ // Save reviewed org Secrets, select them for named sandboxes, and copy ordinary
3
+ // config files separately. Credential values are held only in memory/stdin.
18
4
  import { spawnSync } from 'node:child_process';
5
+ import { randomBytes } from 'node:crypto';
19
6
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
- import { dirname } from 'node:path';
7
+ import { dirname, join } from 'node:path';
21
8
  import { fileContent } from './content.mjs';
22
- import { VAULT_MAX_ITEMS, VAULT_MAX_TOTAL, VAULT_MAX_VALUE, human, isMain, run, selectorString } from './lib.mjs';
9
+ import {
10
+ SECRET_MAX_ITEMS,
11
+ SECRET_MAX_TOTAL,
12
+ SECRET_MAX_VALUE,
13
+ FILE_MAX_VALUE,
14
+ FILE_MAX_TOTAL,
15
+ configPathOK,
16
+ deliveryPolicy,
17
+ human,
18
+ isMain,
19
+ run,
20
+ sameSecret,
21
+ secretIdentity,
22
+ secretNameOK,
23
+ validID,
24
+ } from './lib.mjs';
23
25
  import { MANIFEST, readManifest } from './plan.mjs';
26
+ import { SHELL_RC } from './scan.mjs';
24
27
 
25
28
  const shellFor = (file) => (/\/\.z[^/]*$/.test(file) && spawnSync('zsh', ['-c', 'true']).status === 0 ? 'zsh' : 'bash');
26
29
 
@@ -83,170 +86,334 @@ export function readValue(src, env = process.env, rc = {}) {
83
86
  return { error: `unknown source ${src.type}` };
84
87
  }
85
88
 
86
- // instabox's own output with the value masked, in case a message ever echoes it.
87
- const mask = (text, value) => (value ? text.split(value).join('***') : text).trim().split('\n').pop() || '';
89
+ const INSTALL = readFileSync(new URL('./install-files.py', import.meta.url), 'utf8');
90
+ const quote = (s) => "'" + s.replaceAll("'", "'\"'\"'") + "'";
91
+ const unique = (values) => [...new Set(values)].sort();
88
92
 
89
- function writeManifest(path, items) {
93
+ function saveManifest(path, manifest) {
90
94
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
91
- const tmp = `${path}.tmp`;
92
- writeFileSync(tmp, JSON.stringify({ version: 1, items }, null, 2) + '\n', { mode: 0o600 });
93
- renameSync(tmp, path);
95
+ const temporary = `${path}.${randomBytes(8).toString('hex')}.tmp`;
96
+ writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
97
+ renameSync(temporary, path);
94
98
  }
95
99
 
96
- export function push(sel, { instabox = 'instabox', env = process.env, dryRun = false, manifestPath = MANIFEST, log = (s) => console.log(s) } = {}) {
100
+ function validSource(it, home) {
101
+ const s = it.source;
102
+ if (!s) return false;
103
+ if (s.type === 'cmd')
104
+ return JSON.stringify(s.argv) === JSON.stringify(['gh', 'auth', 'token', '--hostname', 'github.com']);
105
+ if (s.name !== it.name) return false;
106
+ return s.type === 'env' || (s.type === 'rc' && SHELL_RC.some((f) => join(home, f) === s.file));
107
+ }
108
+
109
+ function checkSelection(sel) {
110
+ if (sel.version !== 3 || Object.hasOwn(sel, 'selector'))
111
+ throw new Error('legacy selection format is retired; scan and approve a new org Secret plan');
112
+ if (
113
+ !validID(sel.orgId) ||
114
+ !Array.isArray(sel.sandboxes) ||
115
+ sel.sandboxes.some((id) => !validID(id) || !id.startsWith('m_')) ||
116
+ unique(sel.sandboxes).length !== sel.sandboxes.length
117
+ )
118
+ throw new Error('an explicit org ID and distinct sandbox IDs are required');
119
+ if (!Array.isArray(sel.items) || typeof sel.home !== 'string') throw new Error('invalid selection');
120
+ const ids = new Set(),
121
+ names = new Set(),
122
+ paths = new Set();
123
+ for (const it of sel.items) {
124
+ if (ids.has(it.id)) throw new Error('duplicate selection ID');
125
+ ids.add(it.id);
126
+ if (it.kind === 'env') {
127
+ if (!secretNameOK(it.name) || names.has(it.name) || !validSource(it, sel.home) || !Object.hasOwn(it, 'expected'))
128
+ throw new Error('invalid Secret selection or source');
129
+ names.add(it.name);
130
+ const policy = deliveryPolicy(it.delivery, it.hosts);
131
+ if (JSON.stringify(policy.hosts) !== JSON.stringify(it.hosts))
132
+ throw new Error('hosts must use the normalized policy from the approved plan');
133
+ } else if (it.kind === 'file') {
134
+ if (!sel.sandboxes.length || !Array.isArray(it.files))
135
+ throw new Error('config copying needs explicit sandbox IDs');
136
+ for (const f of it.files) {
137
+ if (!configPathOK(f.path) || paths.has(f.path) || !['text', 'raw', 'gitconfig'].includes(f.transform))
138
+ throw new Error('invalid or duplicate config target');
139
+ paths.add(f.path);
140
+ }
141
+ } else throw new Error('unknown selection kind');
142
+ }
143
+ }
144
+
145
+ export function push(
146
+ sel,
147
+ {
148
+ instabox = 'instabox',
149
+ env = process.env,
150
+ dryRun = false,
151
+ manifestPath = MANIFEST,
152
+ log = (s) => console.log(s),
153
+ } = {},
154
+ ) {
97
155
  const results = [];
98
- const fail = (msg) => {
99
- log(msg);
100
- return { ok: false, code: 1, results };
156
+ const fail = (message, code = 1, totals) => {
157
+ log(message);
158
+ return { ok: false, code, results, totals };
101
159
  };
102
- const ls = run([instabox, 'vault', 'ls', '--json'], { env });
103
- if (!ls.ok) return fail(`instabox vault ls failed: ${ls.stderr.trim().split('\n').pop()} (signed in? \`instabox login\`; INSTABOX_API_KEY must be unset)`);
104
- let vault;
160
+ const json = (args) => {
161
+ const response = run([instabox, ...args, '--json'], { env });
162
+ if (!response.ok)
163
+ throw new Error(`${args.slice(0, 2).join(' ')} failed; check sign-in, permissions and the selected org/sandbox`);
164
+ try {
165
+ return JSON.parse(response.stdout);
166
+ } catch {
167
+ throw new Error('CLI returned invalid metadata; update the CLI and retry');
168
+ }
169
+ };
170
+ let secrets, targets;
105
171
  try {
106
- vault = JSON.parse(ls.stdout).items || [];
107
- } catch {
108
- return fail('instabox vault ls --json did not print JSON; is the instabox CLI up to date?');
172
+ checkSelection(sel);
173
+ if (env.INSTABOX_API_KEY && sel.items.some((it) => it.kind === 'env'))
174
+ throw new Error('saving Secret values requires a signed-in org owner/admin session, not an API key');
175
+ const org = json(['org', 'get', sel.orgId]);
176
+ if (org.id !== sel.orgId) throw new Error('use the stable org ID, not a name');
177
+ if (sel.items.some((it) => it.kind === 'env') && !['owner', 'admin'].includes(org.role))
178
+ throw new Error('saving Secrets requires an org owner/admin sign-in');
179
+ secrets = json(['secrets', 'ls', '--org', sel.orgId]).items;
180
+ if (!Array.isArray(secrets) || secrets.some((s) => s.orgId !== sel.orgId))
181
+ throw new Error('Secret catalog belongs to another org');
182
+ for (const it of sel.items.filter((it) => it.kind === 'env')) {
183
+ const current = secrets.find((s) => s.name === it.name);
184
+ if (it.expected ? !sameSecret(it.expected, current) : current)
185
+ throw new Error(`${it.name} changed since the plan; regenerate and review before replacing it`);
186
+ }
187
+ targets = sel.sandboxes.map((id) => {
188
+ const box = json(['get', id]);
189
+ if (box.id !== id || box.orgId !== sel.orgId)
190
+ throw new Error('target sandbox does not belong to the selected org');
191
+ if (sel.items.some((it) => it.kind === 'file') && box.state !== 'running')
192
+ throw new Error(`${id}: start the sandbox before copying config files`);
193
+ const selection = json(['secrets', 'show', id]);
194
+ if (!Array.isArray(selection.envSecrets) || (selection.secrets !== 'all' && !Array.isArray(selection.secrets)))
195
+ throw new Error('sandbox returned invalid Secret selections');
196
+ return box;
197
+ });
198
+ } catch (e) {
199
+ return fail(e.message);
109
200
  }
110
- let maxItems = VAULT_MAX_ITEMS;
111
- let maxBytes = VAULT_MAX_TOTAL;
112
- const usage = run([instabox, 'usage', '--json'], { env });
113
- try {
114
- const l = JSON.parse(usage.stdout).limits?.vault;
115
- if (l?.maxItems) maxItems = l.maxItems;
116
- if (l?.maxBytes) maxBytes = l.maxBytes;
117
- } catch {}
118
-
119
- const manifest = readManifest(manifestPath);
120
- const ours = new Set(manifest.items.map((m) => m.name));
121
- const byName = new Map(vault.map((v) => [v.name, v]));
122
- const byPath = new Map(vault.filter((v) => v.path).map((v) => [v.path, v]));
123
- const selector = sel.selector || {};
124
- // One --selector per label: the CLI's flag takes key=value and repeats.
125
- const selFlag = Object.entries(selector).flatMap(([k, v]) => ['--selector', `${k}=${v}`]);
126
201
 
127
- // 1. Every Vault item this selection makes, with its value, in memory only. Each shell
128
- // startup file is sourced once for all the tokens read from it, and only if one is needed.
129
- const rcNames = new Map();
130
- for (const it of sel.items) {
131
- if (it.kind !== 'env' || it.source?.type !== 'rc') continue;
132
- if (!rcNames.has(it.source.file)) rcNames.set(it.source.file, []);
133
- rcNames.get(it.source.file).push(it.source.name);
202
+ let maxItems = SECRET_MAX_ITEMS,
203
+ maxBytes = SECRET_MAX_TOTAL;
204
+ try {
205
+ const limits = json(['usage']).limits?.vault;
206
+ if (limits?.maxItems) maxItems = limits.maxItems;
207
+ if (limits?.maxBytes) maxBytes = limits.maxBytes;
208
+ } catch {
209
+ return fail('could not read Secret limits; nothing written');
134
210
  }
135
- const rcValues = {};
136
- const rcFor = (file) => (rcValues[file] ??= readRc(file, rcNames.get(file) || [], env));
137
- const rcOf = (src) => (src.type === 'rc' ? { [src.file]: rcFor(src.file) } : {});
138
- const plan = [];
211
+
212
+ const tokens = [],
213
+ files = [];
214
+ const ghToken = sel.items.some((it) => it.kind === 'env' && it.name === 'GH_TOKEN' && it.delivery === 'env');
215
+ const rcNames = new Map(),
216
+ rcValues = new Map();
217
+ for (const it of sel.items)
218
+ if (it.kind === 'env' && it.source.type === 'rc') {
219
+ rcNames.set(it.source.file, [...(rcNames.get(it.source.file) || []), it.name]);
220
+ }
139
221
  for (const it of sel.items) {
140
- const allowed = new Set(it.replace || []);
141
- const units =
142
- it.kind === 'env'
143
- ? [{ name: it.name, kind: 'env', get: () => readValue(it.source, env, rcOf(it.source)) }]
144
- : it.files.map((f) => ({ name: f.name, kind: 'file', path: f.path, get: () => fileContent(f, { home: sel.home, ghToken: sel.ghToken }) }));
145
- for (const u of units) {
146
- const r = { id: it.id, name: u.name, kind: u.kind, path: u.path };
147
- results.push(r);
148
- // A file's path first: the Vault holds one file item per path, so whoever writes it is the one replaced.
149
- const old = (u.path && byPath.get(u.path)) || byName.get(u.name);
150
- if (old && !ours.has(old.name) && !allowed.has(old.name)) {
151
- r.status = 'kept';
152
- r.detail = `already in your Vault as ${old.name} (${old.kind}${old.path ? ` ~/${old.path}` : ''}, selector ${selectorString(old.selector)}), set some other way since the plan was written: left unchanged`;
153
- log(`kept ${u.name}: ${r.detail}`);
154
- continue;
155
- }
156
- // A file item already writing this path under another name is replaced under its own name:
157
- // the Vault allows one file item per path, so a second name would be refused.
158
- if (old && u.path && old.path === u.path && old.name !== u.name) {
159
- u.name = old.name;
160
- r.name = old.name;
161
- }
162
- const got = u.get();
163
- const value = got.error ? null : got.value ?? got.text;
164
- if (got.error || value === '' || value == null) {
165
- r.status = 'failed';
166
- r.detail = got.error || 'empty';
167
- log(`failed ${u.name} (${u.kind}): ${r.detail}`);
168
- continue;
169
- }
170
- const sent = u.kind === 'env' ? value.replace(/\n$/, '') : value;
171
- r.bytes = Buffer.byteLength(sent);
172
- if (r.bytes > VAULT_MAX_VALUE) {
173
- r.status = 'failed';
174
- r.detail = `${r.bytes} bytes: over the Vault's 64 KiB per item`;
175
- log(`failed ${u.name} (${u.kind}): ${r.detail}`);
176
- continue;
222
+ if (it.kind === 'env') {
223
+ if (it.source.type === 'rc' && !rcValues.has(it.source.file))
224
+ rcValues.set(it.source.file, readRc(it.source.file, rcNames.get(it.source.file), env));
225
+ const got = readValue(it.source, env, Object.fromEntries(rcValues));
226
+ const value = got.value;
227
+ if (
228
+ got.error ||
229
+ typeof value !== 'string' ||
230
+ value.includes('\0') ||
231
+ Buffer.byteLength(value) > SECRET_MAX_VALUE ||
232
+ (it.delivery === 'proxy' && /[\x00-\x08\x0a-\x1f\x7f]/.test(value))
233
+ )
234
+ return fail(`${it.name}: could not prepare a valid ${it.delivery} value; nothing written`);
235
+ tokens.push({ item: it, value });
236
+ } else {
237
+ for (const f of it.files) {
238
+ const got = fileContent(f, { home: sel.home, ghToken });
239
+ if (got.error || Buffer.byteLength(got.text) > FILE_MAX_VALUE)
240
+ return fail(`~/${f.path}: config could not be prepared; nothing written`);
241
+ files.push({ path: f.path, text: got.text });
177
242
  }
178
- plan.push({ r, u, old, sent });
179
243
  }
180
244
  }
245
+ const replaced = new Set(tokens.map((t) => t.item.name));
246
+ const staying = secrets.filter((s) => !replaced.has(s.name));
247
+ const totals = {
248
+ items: staying.length + tokens.length,
249
+ maxItems,
250
+ bytes:
251
+ staying.reduce((sum, s) => sum + (s.size || 0), 0) +
252
+ tokens.reduce((sum, t) => sum + Buffer.byteLength(t.value), 0),
253
+ maxBytes,
254
+ };
255
+ if (totals.items > maxItems || totals.bytes > maxBytes)
256
+ return fail(
257
+ `over the org Secret limits, nothing written: ${totals.items} items of ${maxItems}, ${human(totals.bytes)} of ${human(maxBytes)}`,
258
+ 3,
259
+ totals,
260
+ );
261
+ const fileInput = JSON.stringify({ files });
262
+ if (
263
+ files.reduce((sum, f) => sum + Buffer.byteLength(f.text), 0) > FILE_MAX_TOTAL ||
264
+ Buffer.byteLength(fileInput) > 1024 * 1024
265
+ )
266
+ return fail('config copies exceed the transfer limit; nothing written', 3, totals);
181
267
 
182
- // 2. The quotas: what stays in the Vault plus what this run sets.
183
- const replacing = new Set(plan.filter((p) => p.old).map((p) => p.old.name));
184
- const staying = vault.filter((v) => !replacing.has(v.name));
185
- const nItems = staying.length + plan.length;
186
- const nBytes = staying.reduce((s, v) => s + (v.size || 0), 0) + plan.reduce((s, p) => s + p.r.bytes, 0);
187
- const totals = { items: nItems, maxItems, bytes: nBytes, maxBytes };
188
- if (nItems > maxItems || nBytes > maxBytes) {
189
- const big = [...plan].sort((a, b) => b.r.bytes - a.r.bytes).slice(0, 8);
268
+ const manifest = readManifest(manifestPath);
269
+ let ok = true;
270
+ for (const { item, value } of tokens) {
271
+ const r = {
272
+ name: item.name,
273
+ kind: 'secret',
274
+ delivery: item.delivery,
275
+ bytes: Buffer.byteLength(value),
276
+ status: dryRun ? 'would save' : 'failed',
277
+ };
278
+ results.push(r);
279
+ if (!dryRun) {
280
+ const args = [
281
+ instabox,
282
+ 'secrets',
283
+ 'set',
284
+ item.name,
285
+ '--org',
286
+ sel.orgId,
287
+ '--delivery',
288
+ item.delivery,
289
+ ...item.hosts.flatMap((h) => ['--host', h]),
290
+ '--json',
291
+ ];
292
+ const response = run(args, { env, input: value + '\n' });
293
+ try {
294
+ const saved = response.ok && JSON.parse(response.stdout);
295
+ if (
296
+ !saved ||
297
+ saved.name !== item.name ||
298
+ saved.orgId !== sel.orgId ||
299
+ !saved.id ||
300
+ !Number.isSafeInteger(saved.version) ||
301
+ saved.delivery !== item.delivery ||
302
+ JSON.stringify(deliveryPolicy(saved.delivery, saved.hosts || []).hosts) !== JSON.stringify(item.hosts)
303
+ )
304
+ throw new Error();
305
+ r.status = 'saved';
306
+ manifest.secrets = manifest.secrets.filter((m) => m.orgId !== sel.orgId || m.name !== item.name);
307
+ manifest.secrets.push(secretIdentity(saved));
308
+ } catch {
309
+ ok = false;
310
+ }
311
+ }
190
312
  log(
191
- `over the Vault's limits, nothing written: ${nItems} items of ${maxItems}, ${human(nBytes)} of ${human(maxBytes)}. ` +
192
- `Biggest: ${big.map((p) => `${p.u.name} ${human(p.r.bytes)}`).join(', ')}. Uncheck some in the plan, or remove items you no longer need (instabox vault rm NAME).`,
313
+ `${r.status} ${item.name} (${item.delivery}${item.hosts.length ? ', ' + item.hosts.join(',') : ''}, ${r.bytes} bytes) in ${sel.orgId}`,
193
314
  );
194
- return { ok: false, code: 3, results, totals };
195
315
  }
316
+ if (!dryRun && tokens.length) saveManifest(manifestPath, manifest);
317
+ if (!ok)
318
+ return fail(
319
+ 'some Secrets could not be saved; sandbox selections and config files were not changed; saved Secrets remain in the org',
320
+ 1,
321
+ totals,
322
+ );
196
323
 
197
- // 3. Write.
198
- let ok = !results.some((r) => r.status === 'failed');
199
- const where = Object.keys(selector).length ? `boxes with ${selectorString(selector)}` : 'every box';
200
- for (const { r, u, sent } of plan) {
201
- const what = `${u.name} (${u.kind}${u.path ? ` ~/${u.path}` : ''}, ${r.bytes} bytes) for ${where}`;
324
+ for (const box of targets) {
325
+ const result = { sandboxId: box.id, kind: 'sandbox', status: dryRun ? 'would configure' : 'failed' };
326
+ results.push(result);
202
327
  if (dryRun) {
203
- r.status = 'would set';
204
- log(`would set ${what}`);
328
+ log(
329
+ `would update explicit Secret selections and copy ${files.length} config files to ${box.id}; existing selections are preserved`,
330
+ );
205
331
  continue;
206
332
  }
207
- const argv =
208
- u.kind === 'env'
209
- ? [instabox, 'vault', 'set', u.name, ...selFlag]
210
- : [instabox, 'vault', 'set-file', u.path, '--name', u.name, ...selFlag];
211
- // env items: the CLI drops one trailing newline from stdin, so send exactly one.
212
- const p = run(argv, { env, input: u.kind === 'env' ? sent + '\n' : sent });
213
- if (p.ok) {
214
- r.status = 'set';
215
- log(`set ${what}`);
216
- } else {
333
+ try {
334
+ if (tokens.length) {
335
+ const current = json(['secrets', 'show', box.id]);
336
+ const envNames = tokens.filter((t) => t.item.delivery === 'env').map((t) => t.item.name);
337
+ const proxyNames = tokens.filter((t) => t.item.delivery === 'proxy').map((t) => t.item.name);
338
+ const flags = [];
339
+ if (envNames.length) flags.push('--env-secrets', JSON.stringify(unique([...current.envSecrets, ...envNames])));
340
+ if (proxyNames.length && current.secrets !== 'all')
341
+ flags.push('--secrets', JSON.stringify(unique([...current.secrets, ...proxyNames])));
342
+ if (flags.length) json(['secrets', 'select', box.id, ...flags]);
343
+ if (box.state === 'running') {
344
+ const refreshed = json(['secrets', 'push', box.id]);
345
+ if (refreshed.guest?.state !== 'applied')
346
+ throw new Error('guest delivery is pending or failed; inspect secrets show and retry');
347
+ }
348
+ }
349
+ if (files.length) {
350
+ const response = run([instabox, 'exec', box.id, '--stdin', '--', 'python3 -c ' + quote(INSTALL)], {
351
+ env,
352
+ input: fileInput,
353
+ });
354
+ const marker = 'INSTABOX-BASIC-SETUP ';
355
+ const at = response.stdout.lastIndexOf(marker);
356
+ let copied;
357
+ try {
358
+ copied = JSON.parse(response.stdout.slice(at + marker.length).split('\n')[0]).files;
359
+ } catch {}
360
+ if (
361
+ at < 0 ||
362
+ !Array.isArray(copied) ||
363
+ copied.length !== files.length ||
364
+ new Set(copied.map((f) => f.path)).size !== files.length ||
365
+ copied.some((f) => !files.some((want) => want.path === f.path) || !['written', 'failed'].includes(f.status))
366
+ )
367
+ throw new Error('config copy returned no valid outcome');
368
+ for (const f of copied) {
369
+ const r = { sandboxId: box.id, kind: 'file', path: f.path, status: f.status };
370
+ results.push(r);
371
+ log(`${r.status} ${box.id}: ~/${f.path}`);
372
+ if (f.status === 'written' && !manifest.files.some((m) => m.sandboxId === box.id && m.path === f.path))
373
+ manifest.files.push({ sandboxId: box.id, path: f.path });
374
+ }
375
+ if (!response.ok || copied.some((f) => f.status !== 'written'))
376
+ throw new Error('some config files could not be copied (check existing paths and symlinks)');
377
+ }
378
+ result.status = 'configured';
379
+ log(
380
+ `configured ${box.id}${box.state === 'running' ? '; existing application processes may need a restart' : '; Secret delivery waits for the next start'}`,
381
+ );
382
+ } catch (e) {
217
383
  ok = false;
218
- r.status = 'failed';
219
- r.detail = mask(p.stderr || p.stdout, sent);
220
- log(`failed ${u.name} (${u.kind}): ${r.detail}`);
384
+ log(`failed ${box.id}: ${e.message}`);
221
385
  }
222
386
  }
223
- if (!dryRun) {
224
- const set = new Map(manifest.items.map((m) => [m.name, m]));
225
- for (const { r } of plan) if (r.status === 'set') set.set(r.name, { name: r.name, kind: r.kind, ...(r.path ? { path: r.path } : {}) });
226
- // Forget names that are gone from the Vault (removed by hand since).
227
- const live = new Set([...vault.map((v) => v.name), ...plan.filter((p) => p.r.status === 'set').map((p) => p.r.name)]);
228
- writeManifest(manifestPath, [...set.values()].filter((m) => live.has(m.name)).sort((a, b) => (a.name < b.name ? -1 : 1)));
229
- }
387
+ if (!dryRun && targets.length) saveManifest(manifestPath, manifest);
230
388
  return { ok, code: ok ? 0 : 1, results, totals };
231
389
  }
232
390
 
233
391
  function main() {
234
- const args = process.argv.slice(2);
235
- const i = args.indexOf('--selection');
236
- if (i < 0 || !args[i + 1]) {
392
+ const args = process.argv.slice(2),
393
+ at = args.indexOf('--selection');
394
+ if (at < 0 || !args[at + 1]) {
237
395
  process.stderr.write('usage: push.mjs --selection selection.json [--dry-run]\n');
238
396
  process.exit(2);
239
397
  }
240
- const dryRun = args.includes('--dry-run');
241
- const sel = JSON.parse(readFileSync(args[i + 1], 'utf8'));
242
- const { code, results, totals } = push(sel, { dryRun, instabox: process.env.INSTABOX_BIN || 'instabox' });
243
- const n = (s) => results.filter((r) => r.status === s).length;
244
- console.log(
245
- `\n${dryRun ? `${n('would set')} would be set` : `${n('set')} set`}, ${n('kept')} left as they were, ${n('failed')} failed${dryRun ? ' (dry run)' : ''}.`,
246
- );
247
- if (totals) console.log(`Vault after this: ${totals.items} of ${totals.maxItems} items, ${human(totals.bytes)} of ${human(totals.maxBytes)}.`);
248
- if (sel.leftover?.length) console.log(`Still in your Vault from an earlier run, unchecked now: ${sel.leftover.join(' ')} (remove with instabox vault rm NAME).`);
249
- process.exit(code);
398
+ const file = args[at + 1];
399
+ args.splice(at, 2);
400
+ if (args.some((arg) => arg !== '--dry-run')) {
401
+ process.stderr.write('unknown push arguments\n');
402
+ process.exit(2);
403
+ }
404
+ const selection = JSON.parse(readFileSync(file, 'utf8'));
405
+ const result = push(selection, {
406
+ dryRun: args.includes('--dry-run'),
407
+ instabox: process.env.INSTABOX_BIN || 'instabox',
408
+ });
409
+ if (result.totals)
410
+ console.log(
411
+ `Org Secrets after this: ${result.totals.items} of ${result.totals.maxItems}, ${human(result.totals.bytes)} of ${human(result.totals.maxBytes)}. Config files use no Secret quota.`,
412
+ );
413
+ if (selection.leftover?.length)
414
+ console.log(
415
+ `Previously saved and left unchanged: ${selection.leftover.join(' ')}. Remove only if intended: instabox secrets rm NAME --org ORG.`,
416
+ );
417
+ process.exit(result.code);
250
418
  }
251
-
252
419
  if (isMain(import.meta)) main();