instabox 0.4.3 → 0.5.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/README.md +7 -4
- package/bin/instabox.mjs +125 -84
- package/lib/api.mjs +4 -2
- package/lib/boxes.mjs +1 -10
- package/lib/cli.mjs +2 -15
- package/lib/login.mjs +5 -2
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +11 -5
- package/skills/instabox-basic-setup/SKILL.md +108 -0
- package/skills/instabox-basic-setup/scripts/content.mjs +98 -0
- package/skills/instabox-basic-setup/scripts/lib.mjs +233 -0
- package/skills/instabox-basic-setup/scripts/plan.mjs +295 -0
- package/skills/instabox-basic-setup/scripts/push.mjs +252 -0
- package/skills/instabox-basic-setup/scripts/scan.mjs +246 -0
- package/skills/instabox-move-repo/SKILL.md +193 -0
- package/skills/instabox-move-repo/scripts/deps.sh +370 -0
- package/skills/instabox-move-repo/scripts/history.mjs +340 -0
- package/skills/instabox-move-repo/scripts/repo-files.mjs +552 -0
- package/skills/instabox-snapshots/SKILL.md +2 -2
- package/skills/instabox-migrate/SKILL.md +0 -132
- package/skills/instabox-move-all/SKILL.md +0 -158
- package/skills/instabox-move-all/scripts/lib.mjs +0 -226
- package/skills/instabox-move-all/scripts/plan.mjs +0 -263
- package/skills/instabox-move-all/scripts/push-creds.mjs +0 -154
- package/skills/instabox-move-all/scripts/scan.mjs +0 -811
- package/skills/instabox-move-all/scripts/stage-env.mjs +0 -313
- package/skills/instabox-move-all/scripts/tools.mjs +0 -173
|
@@ -0,0 +1,295 @@
|
|
|
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.
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { fileContent } from './content.mjs';
|
|
20
|
+
import {
|
|
21
|
+
VAULT_MAX_ITEMS,
|
|
22
|
+
VAULT_MAX_TOTAL,
|
|
23
|
+
VAULT_MAX_VALUE,
|
|
24
|
+
human,
|
|
25
|
+
isMain,
|
|
26
|
+
parseSelector,
|
|
27
|
+
selectorString,
|
|
28
|
+
} from './lib.mjs';
|
|
29
|
+
|
|
30
|
+
export const MANIFEST = join(homedir(), '.config', 'instabox-basic-setup', 'pushed.json');
|
|
31
|
+
|
|
32
|
+
export function readManifest(path = MANIFEST) {
|
|
33
|
+
try {
|
|
34
|
+
const m = JSON.parse(readFileSync(path, 'utf8'));
|
|
35
|
+
return Array.isArray(m.items) ? m : { items: [] };
|
|
36
|
+
} catch {
|
|
37
|
+
return { items: [] };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
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));
|
|
49
|
+
|
|
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;
|
|
60
|
+
}
|
|
61
|
+
|
|
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);
|
|
66
|
+
}
|
|
67
|
+
|
|
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
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
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.',
|
|
84
|
+
'',
|
|
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`).',
|
|
87
|
+
'',
|
|
88
|
+
`Selector: \`${selectorString(selector)}\``,
|
|
89
|
+
'',
|
|
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}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
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._');
|
|
113
|
+
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
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
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;
|
|
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;
|
|
152
|
+
}
|
|
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])\] `([^`]+)`(?: .*)?$/);
|
|
158
|
+
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;
|
|
161
|
+
}
|
|
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)`);
|
|
172
|
+
if (errors.length) throw new PlanError(errors);
|
|
173
|
+
|
|
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) => {
|
|
186
|
+
const c = fileContent(f, { home: scan.home, ghToken });
|
|
187
|
+
if (c.error) errors.push(`\`${it.id}\`: ${c.error}`);
|
|
188
|
+
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`);
|
|
191
|
+
return { ...f, bytes };
|
|
192
|
+
});
|
|
193
|
+
out.bytes = out.files.reduce((s, f) => s + f.bytes, 0);
|
|
194
|
+
}
|
|
195
|
+
sizes.push({ id: it.id, items: vaultNames(it).length, bytes: out.bytes || 0 });
|
|
196
|
+
items.push(out);
|
|
197
|
+
}
|
|
198
|
+
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);
|
|
209
|
+
throw new PlanError(
|
|
210
|
+
[
|
|
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`)',
|
|
214
|
+
],
|
|
215
|
+
3,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const ours = new Set((manifest.items || []).map((m) => m.name));
|
|
220
|
+
const pickedNames = new Set(items.flatMap(vaultNames));
|
|
221
|
+
return {
|
|
222
|
+
version: 2,
|
|
223
|
+
home: scan.home,
|
|
224
|
+
selector,
|
|
225
|
+
ghToken,
|
|
226
|
+
items,
|
|
227
|
+
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],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
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
|
+
function main() {
|
|
243
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
244
|
+
const opt = (name) => {
|
|
245
|
+
const i = rest.indexOf(name);
|
|
246
|
+
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);
|
|
257
|
+
};
|
|
258
|
+
const scanFile = opt('--scan');
|
|
259
|
+
const vaultFile = opt('--vault');
|
|
260
|
+
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();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (isMain(import.meta)) main();
|
|
@@ -0,0 +1,252 @@
|
|
|
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.
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname } from 'node:path';
|
|
21
|
+
import { fileContent } from './content.mjs';
|
|
22
|
+
import { VAULT_MAX_ITEMS, VAULT_MAX_TOTAL, VAULT_MAX_VALUE, human, isMain, run, selectorString } from './lib.mjs';
|
|
23
|
+
import { MANIFEST, readManifest } from './plan.mjs';
|
|
24
|
+
|
|
25
|
+
const shellFor = (file) => (/\/\.z[^/]*$/.test(file) && spawnSync('zsh', ['-c', 'true']).status === 0 ? 'zsh' : 'bash');
|
|
26
|
+
|
|
27
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
28
|
+
|
|
29
|
+
// The values of some variables a shell startup file exports, sourcing it once: {name: value}.
|
|
30
|
+
// Never logged. The file is sourced the way a shell does, its output discarded, and only the
|
|
31
|
+
// variables are printed (NUL-separated: a shell variable cannot hold a NUL). Names still unset
|
|
32
|
+
// after that get one more try in an interactive shell, for rc files that start with the usual
|
|
33
|
+
// `case $- in *i*)` / `[[ -o interactive ]] || return` guard; that shell reads no startup file
|
|
34
|
+
// of its own, so nothing it prints reaches the output.
|
|
35
|
+
export function readRc(file, names, env = process.env) {
|
|
36
|
+
const got = {};
|
|
37
|
+
const zsh = shellFor(file) === 'zsh';
|
|
38
|
+
for (const interactive of [false, true]) {
|
|
39
|
+
const want = names.filter((n) => NAME.test(n) && !got[n]);
|
|
40
|
+
if (!want.length) break;
|
|
41
|
+
const flags = interactive ? (zsh ? ['-f', '-i'] : ['--norc', '--noprofile', '-i']) : [];
|
|
42
|
+
const script = `. "$1" >/dev/null 2>&1 </dev/null; printf '%s\\0' ${want.map((n) => `"\${${n}-}"`).join(' ')}`;
|
|
43
|
+
const r = spawnSync(zsh ? 'zsh' : 'bash', [...flags, '-c', script, 'instabox-basic-setup', file], {
|
|
44
|
+
env,
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
timeout: 30_000,
|
|
47
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
+
});
|
|
49
|
+
const vals = (r.stdout || '').split('\0');
|
|
50
|
+
want.forEach((n, i) => {
|
|
51
|
+
if (vals[i]) got[n] = vals[i];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return got;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The value of one token, or {error}. Never logged. rc: {file: {name: value}} already read by
|
|
58
|
+
// readRc, so a file holding several tokens is sourced once.
|
|
59
|
+
export function readValue(src, env = process.env, rc = {}) {
|
|
60
|
+
try {
|
|
61
|
+
switch (src.type) {
|
|
62
|
+
case 'env':
|
|
63
|
+
return env[src.name] ? { value: env[src.name] } : { error: `${src.name} is not set in this environment` };
|
|
64
|
+
case 'rc': {
|
|
65
|
+
if (!NAME.test(src.name)) return { error: 'bad name' };
|
|
66
|
+
const v = (rc[src.file] ?? readRc(src.file, [src.name], env))[src.name];
|
|
67
|
+
if (v) return { value: v };
|
|
68
|
+
// The shell this runs from may have it already (an rc that returned early, or set it some other way).
|
|
69
|
+
if (env[src.name]) return { value: env[src.name] };
|
|
70
|
+
return {
|
|
71
|
+
error: `${src.name} is not set after sourcing ${src.file} (also tried as an interactive shell), nor in this environment`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
case 'cmd': {
|
|
75
|
+
const r = run(src.argv, { env });
|
|
76
|
+
const v = r.stdout.replace(/\n$/, '');
|
|
77
|
+
return r.ok && v ? { value: v } : { error: `\`${src.argv.join(' ')}\` failed (exit ${r.status})` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return { error: e.code || 'unreadable' };
|
|
82
|
+
}
|
|
83
|
+
return { error: `unknown source ${src.type}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
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() || '';
|
|
88
|
+
|
|
89
|
+
function writeManifest(path, items) {
|
|
90
|
+
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);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function push(sel, { instabox = 'instabox', env = process.env, dryRun = false, manifestPath = MANIFEST, log = (s) => console.log(s) } = {}) {
|
|
97
|
+
const results = [];
|
|
98
|
+
const fail = (msg) => {
|
|
99
|
+
log(msg);
|
|
100
|
+
return { ok: false, code: 1, results };
|
|
101
|
+
};
|
|
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;
|
|
105
|
+
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?');
|
|
109
|
+
}
|
|
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
|
+
|
|
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);
|
|
134
|
+
}
|
|
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 = [];
|
|
139
|
+
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;
|
|
177
|
+
}
|
|
178
|
+
plan.push({ r, u, old, sent });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
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);
|
|
190
|
+
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).`,
|
|
193
|
+
);
|
|
194
|
+
return { ok: false, code: 3, results, totals };
|
|
195
|
+
}
|
|
196
|
+
|
|
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}`;
|
|
202
|
+
if (dryRun) {
|
|
203
|
+
r.status = 'would set';
|
|
204
|
+
log(`would set ${what}`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
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 {
|
|
217
|
+
ok = false;
|
|
218
|
+
r.status = 'failed';
|
|
219
|
+
r.detail = mask(p.stderr || p.stdout, sent);
|
|
220
|
+
log(`failed ${u.name} (${u.kind}): ${r.detail}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
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
|
+
}
|
|
230
|
+
return { ok, code: ok ? 0 : 1, results, totals };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function main() {
|
|
234
|
+
const args = process.argv.slice(2);
|
|
235
|
+
const i = args.indexOf('--selection');
|
|
236
|
+
if (i < 0 || !args[i + 1]) {
|
|
237
|
+
process.stderr.write('usage: push.mjs --selection selection.json [--dry-run]\n');
|
|
238
|
+
process.exit(2);
|
|
239
|
+
}
|
|
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);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (isMain(import.meta)) main();
|