instabox 0.6.0 → 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,295 +1,313 @@
1
1
  #!/usr/bin/env node
2
- // The checkbox plan between the scan and the push.
3
- //
4
- // node plan.mjs write --scan scan.json --vault vault.json [--selector k=v,...] > plan.md
5
- // node plan.mjs parse --scan scan.json --vault vault.json plan.md > selection.json
6
- //
7
- // vault.json is `instabox vault ls --json`: items already in the Vault. One
8
- // set there by an earlier run of this skill (listed in the manifest, see
9
- // push.mjs) is replaced; one set any other way is unchecked, and checking it
10
- // replaces it.
11
- //
12
- // `parse` reads the plan back strictly: every id exactly once, nothing
13
- // unknown, no malformed checkbox. Errors go to stderr, all at once, exit 1.
14
- // Over the Vault's limits: exit 3, with the sizes.
2
+ // Names-only approval plan for org Secrets and explicit sandbox config copies.
15
3
  import { createHash } from 'node:crypto';
16
4
  import { readFileSync } from 'node:fs';
17
5
  import { homedir } from 'node:os';
18
6
  import { join } from 'node:path';
19
7
  import { fileContent } from './content.mjs';
20
8
  import {
21
- VAULT_MAX_ITEMS,
22
- VAULT_MAX_TOTAL,
23
- VAULT_MAX_VALUE,
9
+ SECRET_MAX_ITEMS,
10
+ SECRET_MAX_TOTAL,
11
+ FILE_MAX_VALUE,
12
+ FILE_MAX_TOTAL,
13
+ configPathOK,
14
+ deliveryPolicy,
24
15
  human,
25
16
  isMain,
26
- parseSelector,
27
- selectorString,
17
+ sameSecret,
18
+ secretIdentity,
19
+ validID,
28
20
  } from './lib.mjs';
29
21
 
30
22
  export const MANIFEST = join(homedir(), '.config', 'instabox-basic-setup', 'pushed.json');
31
-
32
23
  export function readManifest(path = MANIFEST) {
33
24
  try {
34
25
  const m = JSON.parse(readFileSync(path, 'utf8'));
35
- return Array.isArray(m.items) ? m : { items: [] };
36
- } catch {
37
- return { items: [] };
38
- }
26
+ if (m.version === 3 && Array.isArray(m.secrets) && Array.isArray(m.files)) return m;
27
+ } catch {}
28
+ // Legacy names and file paths confer no ownership of an org Secret.
29
+ return { version: 3, secrets: [], files: [] };
39
30
  }
40
31
 
41
- const SECTIONS = [
42
- ['tokens', 'Tokens', 'Environment variables, on tmpfs in the box (gone at a stop, back at the next start; never in a snapshot). Values go from this machine straight into the Vault and are never shown.'],
43
- ['git', 'Git', 'Files in the box’s home, written at every start and resume.'],
44
- ['editor', 'Editor and terminal', 'Files in the box’s home, written at every start and resume.'],
45
- ];
46
-
47
- // Vault names an item writes: env name, or each file's item name.
48
- const vaultNames = (it) => (it.kind === 'env' ? [it.name] : it.files.map((f) => f.name));
32
+ export function existingFor(it, secrets, manifest) {
33
+ if (it.kind !== 'env') return [];
34
+ return secrets
35
+ .filter((s) => s.name === it.name)
36
+ .map((item) => ({
37
+ item,
38
+ ours: (manifest.secrets || []).some((m) => sameSecret(m, item)),
39
+ }));
40
+ }
49
41
 
50
- // What is in the Vault already under one of this item's names or paths: [{vault item, ours}].
51
- export function existingFor(it, vault, manifest) {
52
- const ours = new Set((manifest?.items || []).map((m) => m.name));
53
- const found = [];
54
- const names = new Set(vaultNames(it));
55
- const paths = new Set(it.kind === 'file' ? it.files.map((f) => f.path) : []);
56
- for (const v of vault || []) {
57
- if (names.has(v.name) || (v.path && paths.has(v.path))) found.push({ item: v, ours: ours.has(v.name) });
58
- }
59
- return found;
42
+ export function planDigest(scan, secrets, manifest, orgId) {
43
+ const rows = secrets.map(secretIdentity).sort((a, b) => a.name.localeCompare(b.name));
44
+ const own = (manifest.secrets || [])
45
+ .filter((s) => s.orgId === orgId)
46
+ .map(secretIdentity)
47
+ .sort((a, b) => a.name.localeCompare(b.name));
48
+ return createHash('sha256')
49
+ .update(JSON.stringify([scan, rows, own, orgId]))
50
+ .digest('hex')
51
+ .slice(0, 16);
60
52
  }
61
53
 
62
- export function planDigest(scan, vault, manifest) {
63
- const v = (vault || []).map((i) => [i.name, i.kind, i.path || '', selectorString(i.selector)]).sort();
64
- const m = (manifest?.items || []).map((i) => i.name).sort();
65
- return createHash('sha256').update(JSON.stringify([scan, v, m])).digest('hex').slice(0, 16);
54
+ export class PlanError extends Error {
55
+ constructor(errors, code = 1) {
56
+ super(errors.join('\n'));
57
+ this.errors = errors;
58
+ this.code = code;
59
+ }
66
60
  }
67
61
 
68
- // The default checkbox: the scan's, except that something set in the Vault some other way stays unless checked.
69
- function defaults(scan, vault, manifest) {
70
- return scan.items.map((it) => {
71
- const ex = existingFor(it, vault, manifest);
72
- const theirs = ex.filter((e) => !e.ours);
73
- return { it, ex, theirs, checked: it.checked && !theirs.length };
74
- });
62
+ function checkContext(scan, secrets, orgId) {
63
+ if (scan.version !== 3 || !Array.isArray(scan.items))
64
+ throw new PlanError(['scan format changed; run scan.mjs again']);
65
+ if (!validID(orgId)) throw new PlanError(['an explicit org ID is required']);
66
+ if (!Array.isArray(secrets) || secrets.some((s) => s.orgId !== orgId || !s.id || !Number.isSafeInteger(s.version)))
67
+ throw new PlanError(['Secret metadata must belong to the selected org and include IDs and versions']);
75
68
  }
76
69
 
77
- export function writePlan(scan, { vault = [], manifest = { items: [] }, selector = {} } = {}) {
78
- const h = scan.home;
79
- const out = [];
80
- out.push('# instabox basic setup: plan', '');
81
- out.push(
82
- 'Check `[x]` what goes into your instabox Vault and uncheck `[ ]` what stays out.',
83
- 'Do not edit the ids in backticks or delete lines: the agent reads this file back and stops on anything it does not recognise.',
70
+ export function writePlan(
71
+ scan,
72
+ { secrets = [], manifest = { secrets: [] }, orgId, sandboxes = [], delivery = 'env', hosts = [] } = {},
73
+ ) {
74
+ checkContext(scan, secrets, orgId);
75
+ if (sandboxes.some((id) => !validID(id) || !id.startsWith('m_')))
76
+ throw new PlanError(['sandboxes must use stable m_ IDs']);
77
+ const policy = deliveryPolicy(delivery, hosts);
78
+ const out = [
79
+ '# instabox basic setup: plan',
84
80
  '',
85
- 'The selector says which of your boxes get these items: `*` is every box, the ones you create later included;',
86
- '`key=value` (comma-separated for more) only boxes with that metadata (`instabox create --meta key=value`).',
81
+ 'Check `[x]` the items to save or copy. Do not change IDs or delete lines.',
82
+ 'Tokens become encrypted org Secrets. Each Delivery line explicitly chooses env (real values) or proxy (HTTPS-header placeholders with allowed hosts).',
83
+ 'This run adds env grants only to the sandbox IDs below; an empty list adds no grants. Existing grants remain in force.',
84
+ 'Proxy Secrets also become available to other org sandboxes whose proxy selection is all, including future sandboxes.',
85
+ 'Replacing an existing Secret affects its other authorized sandboxes. Config files are copied once to the named running sandboxes and may overwrite those paths; they are not Secrets.',
86
+ 'Env values can be copied into files, processes or snapshots; removing a selection cannot recover those copies.',
87
87
  '',
88
- `Selector: \`${selectorString(selector)}\``,
88
+ `Org: \`${orgId}\``,
89
+ `Sandboxes: \`${[...new Set(sandboxes)].join(',') || '-'}\``,
89
90
  '',
90
- `<!-- plan ${planDigest(scan, vault, manifest)}: written from this scan and Vault; scan again and write a new plan if either changed -->`,
91
- );
92
- const rows = defaults(scan, vault, manifest);
93
- for (const [key, title, blurb] of SECTIONS) {
94
- out.push('', `## ${title}`, '', blurb, '');
95
- const mine = rows.filter((r) => r.it.section === key);
96
- if (!mine.length) out.push('_Nothing found._');
97
- for (const { it, ex, theirs, checked } of mine) {
98
- let text;
99
- if (it.kind === 'env') text = `${it.name} from ${it.from}`;
100
- else if (it.files.length === 1) text = `~/${it.path} · ${human(it.bytes)}`;
101
- else text = `~/${it.path}/: ${it.files.length} files · ${human(it.bytes)}`;
102
- if (it.reason) text += ` · off by default: ${it.reason}`;
103
- if (it.note) text += ` · ${it.note}`;
104
- if (theirs.length)
105
- text += ` · already in your Vault as ${theirs.map((e) => e.item.name).join(', ')}, set some other way: checking this replaces it`;
106
- else if (ex.length) text += ' · replaces the copy from your last run';
107
- out.push(`- [${checked ? 'x' : ' '}] \`${it.id}\` ${text}`);
91
+ `<!-- plan ${planDigest(scan, secrets, manifest, orgId)}: scan and org metadata; regenerate if either changes -->`,
92
+ ];
93
+ for (const [section, title] of [
94
+ ['tokens', 'Tokens'],
95
+ ['git', 'Git config copies'],
96
+ ['editor', 'Editor and terminal config copies'],
97
+ ]) {
98
+ out.push('', `## ${title}`, '');
99
+ const rows = scan.items.filter((it) => it.section === section);
100
+ if (!rows.length) out.push('_Nothing found._');
101
+ for (const it of rows) {
102
+ const ex = existingFor(it, secrets, manifest);
103
+ const other = ex.some((e) => !e.ours);
104
+ const checked = it.checked && !other && (it.kind === 'env' || sandboxes.length > 0);
105
+ let description =
106
+ it.kind === 'env'
107
+ ? `${it.name} from ${it.from}`
108
+ : `~/${it.path}: ${it.files.length} file(s), ${human(it.bytes)}`;
109
+ if (it.reason) description += `; off by default: ${it.reason}`;
110
+ if (it.note) description += `; ${it.note}`;
111
+ if (other) description += '; already in this org, set or changed elsewhere: checking replaces it';
112
+ else if (ex.length) description += '; replaces the unchanged Secret from your previous run';
113
+ if (it.kind === 'file' && !sandboxes.length) description += '; select running sandbox IDs before checking this';
114
+ out.push(`- [${checked ? 'x' : ' '}] \`${it.id}\` ${description}`);
115
+ if (it.kind === 'env') {
116
+ // Preserve an existing mode as a starting point; never silently change
117
+ // a proxy credential into a real-value env credential on a repeat run.
118
+ const p = ex.length ? deliveryPolicy(ex[0].item.delivery, ex[0].item.hosts || []) : policy;
119
+ out.push(`Delivery \`${it.id}\`: \`${p.delivery}\` hosts \`${p.hosts.join(',') || '-'}\``);
120
+ }
108
121
  }
109
122
  }
110
- out.push('', '## Can’t move: do these yourself', '');
111
- out.push('No checkbox: the agent repeats these at the end, with the command for each.', '');
112
- if (!scan.manual.length) out.push('_Nothing._');
123
+ out.push('', '## Manual setup', '', 'These entries have no checkbox; no credentials are read for them.', '');
113
124
  for (const m of scan.manual) out.push(`- ${m.what}: ${m.why}. ${m.todo}`);
114
- out.push('');
115
- return out.join('\n');
116
- }
117
-
118
- export class PlanError extends Error {
119
- constructor(errors, code = 1) {
120
- super(errors.join('\n'));
121
- this.errors = errors;
122
- this.code = code;
123
- }
125
+ return out.join('\n') + '\n';
124
126
  }
125
127
 
126
- // Reads the plan back. Throws PlanError listing every problem (code 1), or the limits (code 3).
127
- export function parsePlan(scan, text, { vault = [], manifest = { items: [] }, limits = {} } = {}) {
128
- const maxItems = limits.maxItems || VAULT_MAX_ITEMS;
129
- const maxBytes = limits.maxBytes || VAULT_MAX_TOTAL;
128
+ export function parsePlan(scan, text, { secrets = [], manifest = { secrets: [] }, orgId, limits = {} } = {}) {
129
+ checkContext(scan, secrets, orgId);
130
130
  const errors = [];
131
- const byId = new Map(scan.items.map((i) => [i.id, i]));
132
- const state = new Map();
133
- let selector = null;
134
- let sawSelector = false;
135
- let digest = null;
136
- text.split('\n').forEach((raw, i) => {
137
- const n = i + 1;
138
- const l = raw.replace(/\r$/, '');
139
- const s = l.match(/^Selector:\s*(.*)$/);
140
- if (s && /^`/.test(s[1].trim())) {
141
- if (sawSelector) errors.push(`line ${n}: a second Selector: line`);
142
- sawSelector = true;
143
- const m = s[1].trim().match(/^`([^`]*)`$/);
144
- if (!m) errors.push(`line ${n}: write the selector in backticks: Selector: \`*\``);
145
- else
146
- try {
147
- selector = parseSelector(m[1]);
148
- } catch (e) {
149
- errors.push(`line ${n}: ${e.message}`);
150
- }
151
- return;
131
+ const byId = new Map(scan.items.map((it) => [it.id, it]));
132
+ const states = new Map(),
133
+ policies = new Map();
134
+ let planOrg, sandboxes, digest;
135
+ for (const [index, raw] of text.split('\n').entries()) {
136
+ const line = raw.replace(/\r$/, ''),
137
+ n = index + 1;
138
+ if (/^(Org|Sandboxes):/.test(line)) {
139
+ const m = line.match(/^(Org|Sandboxes): `([^`]*)`$/);
140
+ if (!m) {
141
+ errors.push(`line ${n}: invalid Org or Sandboxes line`);
142
+ continue;
143
+ }
144
+ if (m[1] === 'Org') {
145
+ if (planOrg !== undefined) errors.push(`line ${n}: duplicate Org line`);
146
+ planOrg = m[2];
147
+ } else {
148
+ if (sandboxes !== undefined) errors.push(`line ${n}: duplicate Sandboxes line`);
149
+ sandboxes = m[2] === '-' ? [] : m[2].split(',').map((s) => s.trim());
150
+ if (
151
+ sandboxes.some((id) => !validID(id) || !id.startsWith('m_')) ||
152
+ new Set(sandboxes).size !== sandboxes.length
153
+ )
154
+ errors.push(`line ${n}: use distinct stable sandbox IDs, comma-separated, or -`);
155
+ }
156
+ continue;
157
+ }
158
+ if (/^Delivery\b/.test(line)) {
159
+ const m = line.match(/^Delivery `([^`]+)`: `([^`]+)` hosts `([^`]*)`$/);
160
+ if (!m || byId.get(m[1])?.kind !== 'env') {
161
+ errors.push(`line ${n}: invalid Delivery line`);
162
+ continue;
163
+ }
164
+ if (policies.has(m[1])) errors.push(`line ${n}: duplicate delivery for ${m[1]}`);
165
+ try {
166
+ policies.set(m[1], deliveryPolicy(m[2], m[3] === '-' ? [] : m[3].split(',').map((h) => h.trim())));
167
+ } catch (e) {
168
+ errors.push(`line ${n}: ${e.message}`);
169
+ }
170
+ continue;
171
+ }
172
+ if (/^Selector:/.test(line)) errors.push(`line ${n}: legacy selectors are retired`);
173
+ const d = line.match(/^<!-- plan ([0-9a-f]+):/);
174
+ if (d) {
175
+ if (digest) errors.push(`line ${n}: duplicate plan fingerprint`);
176
+ digest = d[1];
152
177
  }
153
- const d = l.match(/<!-- plan ([0-9a-f]+):/);
154
- if (d) digest = d[1];
155
- // Anything that looks like a list item with a checkbox must be exactly one of ours.
156
- if (!/^\s*(?:[-*+]|\d+[.)])\s*\[/.test(l) && !/^\s*[-*+]\s*`(?:env|file|files):/.test(l)) return;
157
- const m = l.match(/^- \[([ xX])\] `([^`]+)`(?: .*)?$/);
178
+ if (!/^\s*(?:[-*+]|\d+[.)])\s*\[/.test(line) && !/^\s*[-*+]\s*`(?:env|file|files):/.test(line)) continue;
179
+ const m = line.match(/^- \[([ xX])\] `([^`]+)`(?: .*)?$/);
158
180
  if (!m) {
159
- errors.push(`line ${n}: not a checkbox line this plan understands: ${JSON.stringify(l.slice(0, 80))} (write "- [x] \`id\` ..." or "- [ ] \`id\` ...")`);
160
- return;
181
+ errors.push(`line ${n}: not a checkbox line; use - [x] or - [ ] with the original ID`);
182
+ continue;
161
183
  }
162
- const [, mark, id] = m;
163
- if (!byId.has(id)) return errors.push(`line ${n}: unknown id \`${id}\``);
164
- if (state.has(id)) return errors.push(`line ${n}: \`${id}\` appears twice`);
165
- state.set(id, mark !== ' ');
166
- });
167
- if (!sawSelector) errors.push('no "Selector: `*`" line (write `*` for every box)');
168
- if (digest === null) errors.push('the "<!-- plan ... -->" line is missing: write the plan again');
169
- else if (digest !== planDigest(scan, vault, manifest))
170
- errors.push('this plan was written from a different scan or Vault: scan, list the Vault and write the plan again');
171
- for (const id of byId.keys()) if (!state.has(id)) errors.push(`\`${id}\` is missing from the plan (uncheck a line instead of deleting it)`);
184
+ if (!byId.has(m[2])) {
185
+ errors.push(`line ${n}: unknown id \`${m[2]}\``);
186
+ continue;
187
+ }
188
+ if (states.has(m[2])) errors.push(`line ${n}: \`${m[2]}\` appears twice`);
189
+ states.set(m[2], m[1] !== ' ');
190
+ }
191
+ if (planOrg !== orgId) errors.push('Org must match the metadata used to write this plan; regenerate for another org');
192
+ if (sandboxes === undefined)
193
+ errors.push('Sandboxes line is required; use - to save Secrets without adding sandbox grants');
194
+ if (digest !== planDigest(scan, secrets, manifest, orgId))
195
+ errors.push('different scan or org Secret state; scan, list Secrets and write a new plan');
196
+ for (const it of scan.items) {
197
+ if (!states.has(it.id)) errors.push(`\`${it.id}\` is missing from the plan`);
198
+ if (it.kind === 'env' && !policies.has(it.id))
199
+ errors.push(`\`${it.id}\` needs an explicit delivery mode and hosts line`);
200
+ }
172
201
  if (errors.length) throw new PlanError(errors);
173
202
 
174
- const picked = scan.items.filter((i) => state.get(i.id));
175
- // GH_TOKEN from this plan, or already in the Vault: either way gh's credential helper works.
176
- const ghToken =
177
- picked.some((i) => i.kind === 'env' && i.name === 'GH_TOKEN') || vault.some((v) => v.kind === 'env' && v.name === 'GH_TOKEN');
178
- const items = [];
179
- const sizes = [];
180
- for (const it of picked) {
181
- const ex = existingFor(it, vault, manifest);
182
- const out = { ...it, replace: ex.map((e) => e.item.name) };
183
- delete out.checked;
184
- if (it.kind === 'file') {
185
- out.files = it.files.map((f) => {
203
+ const picked = scan.items.filter((it) => states.get(it.id));
204
+ const ghToken = picked.some(
205
+ (it) => it.kind === 'env' && it.name === 'GH_TOKEN' && policies.get(it.id).delivery === 'env',
206
+ );
207
+ const items = picked.map((it) => {
208
+ const item = { ...it };
209
+ delete item.checked;
210
+ if (it.kind === 'env') {
211
+ Object.assign(item, policies.get(it.id));
212
+ item.expected = secrets.find((s) => s.name === it.name);
213
+ item.expected = item.expected ? secretIdentity(item.expected) : null;
214
+ } else {
215
+ if (!sandboxes.length) errors.push(`\`${it.id}\`: config copying requires explicit running sandbox IDs`);
216
+ item.files = it.files.map((f) => {
217
+ if (!configPathOK(f.path)) errors.push(`\`${it.id}\`: invalid config path`);
186
218
  const c = fileContent(f, { home: scan.home, ghToken });
187
219
  if (c.error) errors.push(`\`${it.id}\`: ${c.error}`);
188
220
  const bytes = c.error ? 0 : Buffer.byteLength(c.text);
189
- if (bytes > VAULT_MAX_VALUE) errors.push(`\`${it.id}\`: ~/${f.path} is ${human(bytes)}, over the Vault's 64 KiB per item: uncheck it`);
190
- if (!c.error && !bytes) errors.push(`\`${it.id}\`: ~/${f.path} is empty, and the Vault takes no empty file: uncheck it`);
221
+ if (bytes > FILE_MAX_VALUE) errors.push(`\`${it.id}\`: file exceeds the config-copy limit of 64 KiB`);
191
222
  return { ...f, bytes };
192
223
  });
193
- out.bytes = out.files.reduce((s, f) => s + f.bytes, 0);
224
+ item.bytes = item.files.reduce((sum, f) => sum + f.bytes, 0);
194
225
  }
195
- sizes.push({ id: it.id, items: vaultNames(it).length, bytes: out.bytes || 0 });
196
- items.push(out);
197
- }
226
+ return item;
227
+ });
198
228
  if (errors.length) throw new PlanError(errors);
199
-
200
- // The Vault's quotas count every item of the user, so what stays there counts too.
201
- const replaced = new Set(items.flatMap((i) => i.replace));
202
- const staying = vault.filter((v) => !replaced.has(v.name));
203
- const stayBytes = staying.reduce((s, v) => s + (v.size || 0), 0);
204
- const nItems = staying.length + sizes.reduce((s, x) => s + x.items, 0);
205
- const nBytes = stayBytes + sizes.reduce((s, x) => s + x.bytes, 0);
206
- const vaultUse = { items: nItems, maxItems, fileBytes: nBytes, maxBytes, staying: staying.length, stayingBytes: stayBytes };
207
- if (nItems > maxItems || nBytes > maxBytes) {
208
- const big = [...sizes].sort((a, b) => b.bytes - a.bytes || b.items - a.items).slice(0, 8);
229
+ const tokens = items.filter((it) => it.kind === 'env');
230
+ const names = new Set(tokens.map((it) => it.name));
231
+ const staying = secrets.filter((it) => !names.has(it.name));
232
+ const maxItems = limits.maxItems || SECRET_MAX_ITEMS,
233
+ maxBytes = limits.maxBytes || SECRET_MAX_TOTAL;
234
+ const totals = {
235
+ items: staying.length + tokens.length,
236
+ maxItems,
237
+ stayingBytes: staying.reduce((sum, s) => sum + (s.size || 0), 0),
238
+ maxBytes,
239
+ };
240
+ if (totals.items > maxItems || totals.stayingBytes > maxBytes)
209
241
  throw new PlanError(
210
242
  [
211
- `over the Vault's limits: ${nItems} items of ${maxItems}, ${human(nBytes)} of ${human(maxBytes)} (tokens not counted yet), ${staying.length} items (${human(stayBytes)}) of them already in your Vault and staying.`,
212
- `the biggest checked lines: ${big.map((b) => `\`${b.id}\` ${b.items} item${b.items === 1 ? '' : 's'}, ${human(b.bytes)}`).join('; ')}`,
213
- 'uncheck some, or remove Vault items you no longer need (`instabox vault ls`, `instabox vault rm NAME`)',
243
+ `over the org Secret limits: ${totals.items} items of ${maxItems}; ${human(totals.stayingBytes)} of ${human(maxBytes)} already staying (selected values measured before push)`,
244
+ 'Uncheck tokens or remove unused org Secrets; config files do not consume Secret quota.',
214
245
  ],
215
246
  3,
216
247
  );
217
- }
218
-
219
- const ours = new Set((manifest.items || []).map((m) => m.name));
220
- const pickedNames = new Set(items.flatMap(vaultNames));
248
+ const fileBytes = items.filter((it) => it.kind === 'file').reduce((sum, it) => sum + it.bytes, 0);
249
+ if (fileBytes > FILE_MAX_TOTAL)
250
+ throw new PlanError(['config copies exceed 512 KiB; uncheck files or transfer them separately'], 3);
221
251
  return {
222
- version: 2,
252
+ version: 3,
223
253
  home: scan.home,
224
- selector,
254
+ orgId,
255
+ sandboxes,
225
256
  ghToken,
226
257
  items,
227
258
  manual: scan.manual,
228
- skipped: scan.items.filter((i) => !state.get(i.id)).map((i) => i.id),
229
- // From an earlier run and not in this plan any more: left in the Vault, listed in the report.
230
- leftover: (manifest.items || []).filter((m) => !pickedNames.has(m.name) && vault.some((v) => v.name === m.name)).map((m) => m.name),
231
- vault: vaultUse,
232
- ours: [...ours],
259
+ skipped: scan.items.filter((it) => !states.get(it.id)).map((it) => it.id),
260
+ leftover: (manifest.secrets || [])
261
+ .filter((m) => m.orgId === orgId && !names.has(m.name) && secrets.some((s) => sameSecret(m, s)))
262
+ .map((m) => m.name),
263
+ secrets: totals,
264
+ fileBytes,
233
265
  };
234
266
  }
235
267
 
236
- function readVault(file) {
237
- const v = JSON.parse(readFileSync(file, 'utf8'));
238
- if (!Array.isArray(v.items)) throw new Error(`${file}: not \`instabox vault ls --json\` output`);
239
- return v.items;
240
- }
241
-
242
268
  function main() {
243
- const [cmd, ...rest] = process.argv.slice(2);
269
+ const [command, ...args] = process.argv.slice(2);
244
270
  const opt = (name) => {
245
- const i = rest.indexOf(name);
271
+ const i = args.indexOf(name);
246
272
  if (i < 0) return undefined;
247
- const v = rest[i + 1] ?? '';
248
- rest.splice(i, 2);
249
- return v;
250
- };
251
- const usage = () => {
252
- process.stderr.write(
253
- 'usage: plan.mjs write --scan scan.json --vault vault.json [--selector k=v,...] [--manifest FILE]\n' +
254
- ' plan.mjs parse --scan scan.json --vault vault.json [--limits limits.json] [--manifest FILE] plan.md\n',
255
- );
256
- process.exit(2);
273
+ const value = args[i + 1];
274
+ args.splice(i, 2);
275
+ return value;
257
276
  };
258
- const scanFile = opt('--scan');
259
- const vaultFile = opt('--vault');
277
+ const scanFile = opt('--scan'),
278
+ secretsFile = opt('--secrets'),
279
+ orgId = opt('--org');
260
280
  const manifest = readManifest(opt('--manifest') || MANIFEST);
261
- if (!scanFile || !vaultFile) usage();
262
- const scan = JSON.parse(readFileSync(scanFile, 'utf8'));
263
- const vault = readVault(vaultFile);
264
- if (cmd === 'write') {
265
- let selector = {};
266
- const s = opt('--selector');
267
- try {
268
- if (s !== undefined) selector = parseSelector(s);
269
- } catch (e) {
270
- process.stderr.write(e.message + '\n');
271
- process.exit(2);
272
- }
273
- if (rest.length) usage();
274
- process.stdout.write(writePlan(scan, { vault, manifest, selector }));
275
- } else if (cmd === 'parse') {
276
- const lf = opt('--limits');
277
- if (rest.length !== 1) usage();
278
- let limits = {};
279
- if (lf) {
280
- try {
281
- limits = JSON.parse(readFileSync(lf, 'utf8')) || {};
282
- } catch {}
283
- }
284
- try {
285
- const sel = parsePlan(scan, readFileSync(rest[0], 'utf8'), { vault, manifest, limits });
286
- process.stdout.write(JSON.stringify(sel, null, 2) + '\n');
287
- } catch (e) {
288
- if (!(e instanceof PlanError)) throw e;
289
- process.stderr.write(`${rest[0]}: ${e.code === 3 ? 'too big' : `${e.errors.length} problem(s)`}:\n${e.errors.map((x) => ` ${x}`).join('\n')}\n`);
290
- process.exit(e.code);
291
- }
292
- } else usage();
281
+ try {
282
+ if (!scanFile || !secretsFile || !orgId)
283
+ throw new Error('use --scan scan.json --secrets secrets.json --org ORG_ID');
284
+ const scan = JSON.parse(readFileSync(scanFile, 'utf8'));
285
+ const secrets = JSON.parse(readFileSync(secretsFile, 'utf8')).items;
286
+ if (!Array.isArray(secrets)) throw new Error('metadata must be instabox secrets ls --json output');
287
+ if (command === 'write') {
288
+ const sandboxes = [],
289
+ hosts = [];
290
+ let value;
291
+ while ((value = opt('--sandbox')) !== undefined) sandboxes.push(value);
292
+ while ((value = opt('--host')) !== undefined) hosts.push(value);
293
+ const delivery = opt('--delivery') || 'env';
294
+ if (args.length) throw new Error('unknown write arguments');
295
+ process.stdout.write(writePlan(scan, { secrets, orgId, manifest, sandboxes, delivery, hosts }));
296
+ } else if (command === 'parse') {
297
+ const limitFile = opt('--limits');
298
+ const limits = limitFile ? JSON.parse(readFileSync(limitFile, 'utf8')) : {};
299
+ if (args.length !== 1) throw new Error('parse requires one plan.md path');
300
+ const selection = parsePlan(scan, readFileSync(args[0], 'utf8'), {
301
+ secrets,
302
+ orgId,
303
+ manifest,
304
+ limits: limits || {},
305
+ });
306
+ process.stdout.write(JSON.stringify(selection, null, 2) + '\n');
307
+ } else throw new Error('command must be write or parse');
308
+ } catch (e) {
309
+ process.stderr.write(e.message + '\n');
310
+ process.exit(e instanceof PlanError ? e.code : 1);
311
+ }
293
312
  }
294
-
295
313
  if (isMain(import.meta)) main();