instabox 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/instabox.mjs +62 -5
- package/lib/login.mjs +5 -2
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +28 -4
- 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
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// The checkbox plan between the scan and the move.
|
|
3
|
-
//
|
|
4
|
-
// node plan.mjs write --scan scan.json --box NAME > migrate-plan.md
|
|
5
|
-
// node plan.mjs parse --scan scan.json [--disk-gib N] migrate-plan.md > selection.json
|
|
6
|
-
//
|
|
7
|
-
// `write` renders every scanned item as a `- [x]` / `- [ ]` line with its id
|
|
8
|
-
// in backticks. The user edits the checkboxes (and the box name); `parse`
|
|
9
|
-
// reads them back strictly: every id exactly once, nothing unknown, no
|
|
10
|
-
// malformed checkbox, nothing checked that cannot move. Errors go to stderr,
|
|
11
|
-
// all of them at once, with exit status 1. --disk-gib N: exit 3 when the
|
|
12
|
-
// selection does not fit the box.
|
|
13
|
-
import { createHash } from 'node:crypto';
|
|
14
|
-
import { readFileSync } from 'node:fs';
|
|
15
|
-
import { BOX_NAME, VAULT_MAX_ITEMS, VAULT_MAX_TOTAL, human, isMain } from './lib.mjs';
|
|
16
|
-
import { describeTools, mapTools } from './tools.mjs';
|
|
17
|
-
|
|
18
|
-
export function scanDigest(scan) {
|
|
19
|
-
return createHash('sha256').update(JSON.stringify(scan)).digest('hex').slice(0, 16);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const tilde = (home, p) => (p && p.startsWith(home + '/') ? '~' + p.slice(home.length) : p);
|
|
23
|
-
|
|
24
|
-
function allItems(scan) {
|
|
25
|
-
return [...scan.work, ...scan.history, ...scan.credentials, ...scan.environment];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function line(item, text) {
|
|
29
|
-
return `- [${item.checked ? 'x' : ' '}] \`${item.id}\` ${text}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function writePlan(scan, box) {
|
|
33
|
-
if (!BOX_NAME.test(box)) throw new Error(`box name ${JSON.stringify(box)}: use lowercase letters, digits and -`);
|
|
34
|
-
const h = scan.home;
|
|
35
|
-
const out = [];
|
|
36
|
-
out.push('# Move to instabox: plan');
|
|
37
|
-
out.push('');
|
|
38
|
-
out.push(
|
|
39
|
-
'Check `[x]` what moves to the new box and uncheck `[ ]` what stays. Change the box name if you like.',
|
|
40
|
-
'Do not edit the ids in backticks or delete lines: the agent reads this file back and stops on anything it does not recognise.',
|
|
41
|
-
);
|
|
42
|
-
out.push('');
|
|
43
|
-
out.push(`Box: \`${box}\``);
|
|
44
|
-
out.push('');
|
|
45
|
-
out.push(`<!-- scan ${scanDigest(scan)}: this plan belongs to that scan; scan again and write a new plan if anything changed -->`);
|
|
46
|
-
|
|
47
|
-
out.push('', '## Work', '');
|
|
48
|
-
out.push(
|
|
49
|
-
'Each repo goes to /data/workspace on the box with its uncommitted and untracked work (files git ignores stay here).',
|
|
50
|
-
'Checked by default: repos with work that exists only on this machine.',
|
|
51
|
-
'',
|
|
52
|
-
);
|
|
53
|
-
if (!scan.work.length) out.push('_No git repos found._');
|
|
54
|
-
for (const r of scan.work) {
|
|
55
|
-
const state = [r.branch, r.why].join(' · ');
|
|
56
|
-
out.push(line(r, `${tilde(h, r.path)} · ${state} · ${human(r.bytes)} → ${r.dest}`));
|
|
57
|
-
const hist = scan.history.find((x) => x.repo === r.id);
|
|
58
|
-
if (hist) {
|
|
59
|
-
const parts = [];
|
|
60
|
-
const n = (k) => `${k} session${k === 1 ? '' : 's'}`;
|
|
61
|
-
if (hist.claude) parts.push(`Claude Code ${n(hist.claude.sessions)}`);
|
|
62
|
-
if (hist.codex) parts.push(`Codex ${n(hist.codex.sessions)}`);
|
|
63
|
-
out.push(
|
|
64
|
-
' ' +
|
|
65
|
-
line(
|
|
66
|
-
hist,
|
|
67
|
-
`its agent history: ${parts.join(', ')} · ${human(hist.bytes)} (may contain secrets you pasted into a chat)`,
|
|
68
|
-
),
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
out.push('', '## Credentials', '');
|
|
74
|
-
out.push(
|
|
75
|
-
`Checked items go into your instabox Vault for this box only (selector migrated=${box}): values go straight from here to the Vault and are never shown.`,
|
|
76
|
-
'Lines marked "can\'t move" are listed for the final report: check them and the plan is refused.',
|
|
77
|
-
'',
|
|
78
|
-
);
|
|
79
|
-
if (!scan.credentials.length) out.push('_No credentials found._');
|
|
80
|
-
for (const c of scan.credentials) {
|
|
81
|
-
let text;
|
|
82
|
-
if (c.kind === 'env') text = `${c.name} from ${c.from} → Vault env`;
|
|
83
|
-
else if (c.kind === 'file') text = `${c.from}${c.bytes ? ` (${human(c.bytes)})` : ''} → Vault file ~/${c.path}`;
|
|
84
|
-
else text = `${c.name}: can't move: ${c.reason}. ${c.manual}`;
|
|
85
|
-
if (c.kind !== 'manual' && c.reason) text += ` · off by default: ${c.reason}`;
|
|
86
|
-
if (c.note) text += ` · ${c.note}`;
|
|
87
|
-
out.push(line(c, text));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
out.push('', '## Environment', '');
|
|
91
|
-
out.push('Dotfiles and agent config are copied with secrets and macOS-only lines left out; tools are installed fresh for Linux.', '');
|
|
92
|
-
if (!scan.environment.length) out.push('_Nothing found._');
|
|
93
|
-
for (const e of scan.environment) {
|
|
94
|
-
let text;
|
|
95
|
-
if (e.kind === 'tools') text = `${e.manager}: ${e.names.length + (e.unknown?.length || 0)} tools → ${describeTools(e)}`;
|
|
96
|
-
else if (e.names) text = `~/${e.path}: ${e.names.join(', ')}${e.bytes ? ` · ${human(e.bytes)}` : ''}`;
|
|
97
|
-
else text = `~/${e.path}${e.dir ? '/' : ''} · ${human(e.bytes)}`;
|
|
98
|
-
if (e.note) text += ` · ${e.note}`;
|
|
99
|
-
out.push(line(e, text));
|
|
100
|
-
}
|
|
101
|
-
out.push('');
|
|
102
|
-
return out.join('\n');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export class PlanError extends Error {
|
|
106
|
-
constructor(errors) {
|
|
107
|
-
super(errors.join('\n'));
|
|
108
|
-
this.errors = errors;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Reads the plan back. Throws PlanError listing every problem.
|
|
113
|
-
export function parsePlan(scan, text, { diskGiB } = {}) {
|
|
114
|
-
const errors = [];
|
|
115
|
-
const items = new Map(allItems(scan).map((i) => [i.id, i]));
|
|
116
|
-
const state = new Map();
|
|
117
|
-
let box = null;
|
|
118
|
-
let digest = null;
|
|
119
|
-
text.split('\n').forEach((raw, i) => {
|
|
120
|
-
const n = i + 1;
|
|
121
|
-
const l = raw.replace(/\r$/, '');
|
|
122
|
-
const b = l.match(/^Box:\s*(.*)$/);
|
|
123
|
-
if (b) {
|
|
124
|
-
if (box !== null) errors.push(`line ${n}: a second Box: line`);
|
|
125
|
-
const m = b[1].trim().match(/^`([^`]*)`$/);
|
|
126
|
-
if (!m) errors.push(`line ${n}: write the box name in backticks: Box: \`name\``);
|
|
127
|
-
else if (!BOX_NAME.test(m[1])) errors.push(`line ${n}: box name ${JSON.stringify(m[1])}: use lowercase letters, digits and -, 63 at most, not starting with -`);
|
|
128
|
-
else box = m[1];
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
const s = l.match(/<!-- scan ([0-9a-f]+):/);
|
|
132
|
-
if (s) digest = s[1];
|
|
133
|
-
// Anything that looks like a list item with a checkbox must be exactly one of ours.
|
|
134
|
-
if (!/^\s*(?:[-*+]|\d+[.)])\s*\[/.test(l) && !/^\s*[-*+]\s*`[a-z]+:/.test(l)) return;
|
|
135
|
-
const m = l.match(/^(?: {2})?- \[([ xX])\] `([^`]+)`(?: .*)?$/);
|
|
136
|
-
if (!m) {
|
|
137
|
-
errors.push(`line ${n}: not a checkbox line this plan understands: ${JSON.stringify(l.slice(0, 80))} (write "- [x] \`id\` ..." or "- [ ] \`id\` ...")`);
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
const [, mark, id] = m;
|
|
141
|
-
if (!items.has(id)) {
|
|
142
|
-
errors.push(`line ${n}: unknown id \`${id}\``);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
if (state.has(id)) {
|
|
146
|
-
errors.push(`line ${n}: \`${id}\` appears twice`);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
state.set(id, mark !== ' ');
|
|
150
|
-
});
|
|
151
|
-
if (box === null && !errors.some((e) => e.includes('box name') || e.includes('Box:'))) errors.push('no "Box: `name`" line');
|
|
152
|
-
if (digest === null) errors.push('the "<!-- scan ... -->" line is missing: write the plan again from the scan');
|
|
153
|
-
else if (digest !== scanDigest(scan)) errors.push(`this plan was written from a different scan (${digest}, the scan file is ${scanDigest(scan)}): write the plan again`);
|
|
154
|
-
for (const id of items.keys()) if (!state.has(id)) errors.push(`\`${id}\` is missing from the plan (uncheck a line instead of deleting it)`);
|
|
155
|
-
|
|
156
|
-
const on = (id) => state.get(id) === true;
|
|
157
|
-
for (const [id, checked] of state) {
|
|
158
|
-
if (!checked) continue;
|
|
159
|
-
const it = items.get(id);
|
|
160
|
-
if (it.kind === 'manual') errors.push(`\`${id}\` can't move (${it.reason}); uncheck it: it is listed in the final report with what to do instead`);
|
|
161
|
-
if (it.id.startsWith('history:') && !on(it.repo)) errors.push(`\`${id}\` is checked but its repo \`${it.repo}\` is not: check both or neither`);
|
|
162
|
-
if (it.repo && it.id.startsWith('cred:') && !on(it.repo)) errors.push(`\`${id}\` is a .env of \`${it.repo}\`, which is not checked: check both or neither`);
|
|
163
|
-
}
|
|
164
|
-
if (errors.length) throw new PlanError(errors);
|
|
165
|
-
|
|
166
|
-
const pick = (list) => list.filter((i) => on(i.id));
|
|
167
|
-
const work = pick(scan.work);
|
|
168
|
-
const history = pick(scan.history);
|
|
169
|
-
const credentials = pick(scan.credentials);
|
|
170
|
-
const environment = pick(scan.environment);
|
|
171
|
-
const tools = mapTools(environment.filter((e) => e.kind === 'tools'));
|
|
172
|
-
const manual = [
|
|
173
|
-
...scan.credentials.filter((c) => c.kind === 'manual').map((c) => ({ what: c.name, why: c.reason, todo: c.manual.replaceAll('<box>', box) })),
|
|
174
|
-
...tools.manual.map((m) => ({ what: m.split(':')[0], why: 'no automatic Linux install', todo: m.split(': ').slice(1).join(': ') })),
|
|
175
|
-
];
|
|
176
|
-
const bytes = {
|
|
177
|
-
work: work.reduce((s, r) => s + r.bytes, 0),
|
|
178
|
-
history: history.reduce((s, x) => s + x.bytes, 0),
|
|
179
|
-
environment: environment.reduce((s, e) => s + (e.bytes || 0), 0),
|
|
180
|
-
};
|
|
181
|
-
bytes.total = bytes.work + bytes.history + bytes.environment;
|
|
182
|
-
const vaultBytes = credentials.reduce((s, c) => s + (c.bytes || 0), 0);
|
|
183
|
-
const warnings = [];
|
|
184
|
-
if (credentials.length > VAULT_MAX_ITEMS) warnings.push(`${credentials.length} credentials: the Vault holds ${VAULT_MAX_ITEMS} items at most`);
|
|
185
|
-
if (vaultBytes > VAULT_MAX_TOTAL) warnings.push(`credential files total ${human(vaultBytes)}: the Vault holds ${human(VAULT_MAX_TOTAL)} at most`);
|
|
186
|
-
const result = {
|
|
187
|
-
box,
|
|
188
|
-
home: scan.home,
|
|
189
|
-
selector: { migrated: box },
|
|
190
|
-
work,
|
|
191
|
-
history,
|
|
192
|
-
credentials,
|
|
193
|
-
environment,
|
|
194
|
-
tools,
|
|
195
|
-
manual,
|
|
196
|
-
skipped: [...state].filter(([, v]) => !v).map(([id]) => id),
|
|
197
|
-
bytes,
|
|
198
|
-
vault: { items: credentials.length, fileBytes: vaultBytes },
|
|
199
|
-
warnings,
|
|
200
|
-
};
|
|
201
|
-
if (diskGiB) {
|
|
202
|
-
const disk = diskGiB * 1024 ** 3;
|
|
203
|
-
// Half the disk stays free for dependencies, builds and tools installed on the box.
|
|
204
|
-
result.disk = { gib: diskGiB, budgetBytes: disk / 2, fits: bytes.total <= disk / 2 };
|
|
205
|
-
}
|
|
206
|
-
return result;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function main() {
|
|
210
|
-
const [cmd, ...rest] = process.argv.slice(2);
|
|
211
|
-
const opt = (name) => {
|
|
212
|
-
const i = rest.indexOf(name);
|
|
213
|
-
if (i < 0) return undefined;
|
|
214
|
-
const v = rest[i + 1] ?? ''; // a flag with no value is an empty one, not an absent one
|
|
215
|
-
rest.splice(i, 2);
|
|
216
|
-
return v;
|
|
217
|
-
};
|
|
218
|
-
const usage = () => {
|
|
219
|
-
process.stderr.write(
|
|
220
|
-
'usage: plan.mjs write --scan scan.json --box NAME\n plan.mjs parse --scan scan.json [--disk-gib N] migrate-plan.md\n',
|
|
221
|
-
);
|
|
222
|
-
process.exit(2);
|
|
223
|
-
};
|
|
224
|
-
const scanFile = opt('--scan');
|
|
225
|
-
if (!scanFile) usage();
|
|
226
|
-
const scan = JSON.parse(readFileSync(scanFile, 'utf8'));
|
|
227
|
-
if (cmd === 'write') {
|
|
228
|
-
const box = opt('--box');
|
|
229
|
-
if (!box || rest.length) usage();
|
|
230
|
-
try {
|
|
231
|
-
process.stdout.write(writePlan(scan, box));
|
|
232
|
-
} catch (e) {
|
|
233
|
-
process.stderr.write(e.message + '\n');
|
|
234
|
-
process.exit(1);
|
|
235
|
-
}
|
|
236
|
-
} else if (cmd === 'parse') {
|
|
237
|
-
const disk = opt('--disk-gib');
|
|
238
|
-
if (rest.length !== 1) usage();
|
|
239
|
-
// An empty or non-numeric value (say `instabox usage` failed) must not switch the disk check off.
|
|
240
|
-
if (disk !== undefined && !(/^\d+(?:\.\d+)?$/.test(disk) && Number(disk) > 0)) {
|
|
241
|
-
process.stderr.write(`--disk-gib must be a positive number of GiB, got ${JSON.stringify(disk ?? '')}\n`);
|
|
242
|
-
usage();
|
|
243
|
-
}
|
|
244
|
-
let sel;
|
|
245
|
-
try {
|
|
246
|
-
sel = parsePlan(scan, readFileSync(rest[0], 'utf8'), { diskGiB: disk === undefined ? undefined : Number(disk) });
|
|
247
|
-
} catch (e) {
|
|
248
|
-
if (!(e instanceof PlanError)) throw e;
|
|
249
|
-
process.stderr.write(`${rest[0]}: ${e.errors.length} problem(s):\n${e.errors.map((x) => ` ${x}`).join('\n')}\n`);
|
|
250
|
-
process.exit(1);
|
|
251
|
-
}
|
|
252
|
-
process.stdout.write(JSON.stringify(sel, null, 2) + '\n');
|
|
253
|
-
for (const w of sel.warnings) process.stderr.write(`warning: ${w}\n`);
|
|
254
|
-
if (sel.disk && !sel.disk.fits) {
|
|
255
|
-
process.stderr.write(
|
|
256
|
-
`too big: the selection is ${human(sel.bytes.total)}; the box's /data disk is ${sel.disk.gib} GiB and half of it is kept for dependencies and builds (${human(sel.disk.budgetBytes)}). Uncheck some repos or history.\n`,
|
|
257
|
-
);
|
|
258
|
-
process.exit(3);
|
|
259
|
-
}
|
|
260
|
-
} else usage();
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (isMain(import.meta)) main();
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Puts the checked credentials into the Vault for one box only, piping each
|
|
3
|
-
// value from where it lives straight into `instabox vault set` / `set-file`.
|
|
4
|
-
//
|
|
5
|
-
// node push-creds.mjs --selection selection.json [--dry-run]
|
|
6
|
-
//
|
|
7
|
-
// A value is held in this process's memory between reading it and writing it
|
|
8
|
-
// to instabox's stdin; it is never printed, logged or put on a command line.
|
|
9
|
-
// What is printed: one line per item with its name, kind and outcome.
|
|
10
|
-
//
|
|
11
|
-
// Items already in the Vault are left alone unless they carry exactly this
|
|
12
|
-
// box's selector (a re-run): replacing them would change what other boxes get.
|
|
13
|
-
// Afterwards, run `instabox vault push <box>` to write them into the running box.
|
|
14
|
-
import { spawnSync } from 'node:child_process';
|
|
15
|
-
import { readFileSync } from 'node:fs';
|
|
16
|
-
import { VAULT_MAX_VALUE, isMain, run } from './lib.mjs';
|
|
17
|
-
|
|
18
|
-
const shellFor = (file) => (/\/\.z[^/]*$/.test(file) && spawnSync('zsh', ['-c', 'true']).status === 0 ? 'zsh' : 'bash');
|
|
19
|
-
|
|
20
|
-
// The value of one credential, or {error}. Never logged.
|
|
21
|
-
export function readValue(src, env = process.env) {
|
|
22
|
-
try {
|
|
23
|
-
switch (src.type) {
|
|
24
|
-
case 'env':
|
|
25
|
-
return env[src.name] ? { value: env[src.name] } : { error: `${src.name} is not set in this environment` };
|
|
26
|
-
case 'rc': {
|
|
27
|
-
// Source the startup file the way a shell does, with its output discarded, then print only the variable.
|
|
28
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(src.name)) return { error: 'bad name' };
|
|
29
|
-
const r = spawnSync(
|
|
30
|
-
shellFor(src.file),
|
|
31
|
-
['-c', `. "$1" >/dev/null 2>&1 </dev/null; printf %s "\${${src.name}-}"`, 'instabox-move-all', src.file],
|
|
32
|
-
{ env, encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
33
|
-
);
|
|
34
|
-
return r.stdout ? { value: r.stdout } : { error: `${src.name} is empty after sourcing ${src.file}` };
|
|
35
|
-
}
|
|
36
|
-
case 'cmd': {
|
|
37
|
-
const r = run(src.argv, { env });
|
|
38
|
-
const v = r.stdout.replace(/\n$/, '');
|
|
39
|
-
return r.ok && v ? { value: v } : { error: `\`${src.argv.join(' ')}\` failed (exit ${r.status})` };
|
|
40
|
-
}
|
|
41
|
-
case 'file': {
|
|
42
|
-
const buf = readFileSync(src.path);
|
|
43
|
-
if (buf.includes(0)) return { error: 'a binary file: the Vault holds text only' };
|
|
44
|
-
return { value: buf.toString('utf8') };
|
|
45
|
-
}
|
|
46
|
-
case 'json': {
|
|
47
|
-
let v = JSON.parse(readFileSync(src.path, 'utf8'));
|
|
48
|
-
for (const k of src.keys) v = v?.[k];
|
|
49
|
-
return typeof v === 'string' && v ? { value: v } : { error: `no ${src.keys.join('.')} in ${src.path}` };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
} catch (e) {
|
|
53
|
-
return { error: e.code || 'unreadable' };
|
|
54
|
-
}
|
|
55
|
-
return { error: `unknown source ${src.type}` };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// instabox's own output with the value masked, in case a message ever echoes it.
|
|
59
|
-
const mask = (text, value) => (value ? text.split(value).join('***') : text).trim().split('\n').pop() || '';
|
|
60
|
-
|
|
61
|
-
function sameSelector(a, b) {
|
|
62
|
-
const ka = Object.keys(a || {}).sort();
|
|
63
|
-
const kb = Object.keys(b || {}).sort();
|
|
64
|
-
return ka.length === kb.length && ka.every((k, i) => k === kb[i] && a[k] === b[k]);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function pushCreds(sel, { instabox = 'instabox', env = process.env, dryRun = false, log = (s) => console.log(s) } = {}) {
|
|
68
|
-
const selector = `migrated=${sel.box}`;
|
|
69
|
-
const results = [];
|
|
70
|
-
const ls = run([instabox, 'vault', 'ls', '--json'], { env });
|
|
71
|
-
if (!ls.ok) {
|
|
72
|
-
log(`instabox vault ls failed: ${ls.stderr.trim().split('\n').pop()} (signed in? \`instabox login\`; INSTABOX_API_KEY must be unset)`);
|
|
73
|
-
return { ok: false, results };
|
|
74
|
-
}
|
|
75
|
-
let existing;
|
|
76
|
-
try {
|
|
77
|
-
existing = JSON.parse(ls.stdout).items || [];
|
|
78
|
-
} catch {
|
|
79
|
-
log('instabox vault ls --json did not print JSON; is the instabox CLI up to date?');
|
|
80
|
-
return { ok: false, results };
|
|
81
|
-
}
|
|
82
|
-
const byName = new Map(existing.map((i) => [i.name, i]));
|
|
83
|
-
const byPath = new Map(existing.filter((i) => i.path).map((i) => [i.path, i]));
|
|
84
|
-
let ok = true;
|
|
85
|
-
for (const c of sel.credentials) {
|
|
86
|
-
const r = { id: c.id, name: c.name, kind: c.kind };
|
|
87
|
-
results.push(r);
|
|
88
|
-
const old = byName.get(c.name) || (c.path && byPath.get(c.path));
|
|
89
|
-
if (old && !sameSelector(old.selector, sel.selector)) {
|
|
90
|
-
r.status = 'kept';
|
|
91
|
-
const sel0 = Object.keys(old.selector || {}).length;
|
|
92
|
-
r.detail = sel0
|
|
93
|
-
? `already in your Vault as ${old.name} for sandboxes with ${Object.entries(old.selector).map(([k, v]) => `${k}=${v}`).join(',')}; left unchanged, so it does not reach this box unless you add that metadata`
|
|
94
|
-
: `already in your Vault as ${old.name} for every box, this one included; left unchanged`;
|
|
95
|
-
log(`kept ${c.name} (${c.kind}): ${r.detail}`);
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
const got = readValue(c.source, env);
|
|
99
|
-
if (got.error) {
|
|
100
|
-
ok = false;
|
|
101
|
-
r.status = 'failed';
|
|
102
|
-
r.detail = got.error;
|
|
103
|
-
log(`failed ${c.name} (${c.kind}): ${got.error}`);
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
const bytes = Buffer.byteLength(got.value);
|
|
107
|
-
if (bytes > VAULT_MAX_VALUE) {
|
|
108
|
-
ok = false;
|
|
109
|
-
r.status = 'failed';
|
|
110
|
-
r.detail = `${bytes} bytes: over the Vault's 64 KiB per item`;
|
|
111
|
-
log(`failed ${c.name} (${c.kind}): ${r.detail}`);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
const argv =
|
|
115
|
-
c.kind === 'env'
|
|
116
|
-
? [instabox, 'vault', 'set', c.name, '--selector', selector]
|
|
117
|
-
: [instabox, 'vault', 'set-file', c.path, '--name', c.name, '--selector', selector];
|
|
118
|
-
if (dryRun) {
|
|
119
|
-
r.status = 'would set';
|
|
120
|
-
log(`would set ${c.name} (${c.kind}${c.path ? ` ~/${c.path}` : ''}, ${bytes} bytes) for ${selector}`);
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
// env items: the CLI drops one trailing newline from stdin, so send exactly one.
|
|
124
|
-
const input = c.kind === 'env' ? got.value.replace(/\n$/, '') + '\n' : got.value;
|
|
125
|
-
const p = run(argv, { env, input });
|
|
126
|
-
if (p.ok) {
|
|
127
|
-
r.status = 'set';
|
|
128
|
-
log(`set ${c.name} (${c.kind}${c.path ? ` ~/${c.path}` : ''}, ${bytes} bytes) for ${selector}`);
|
|
129
|
-
} else {
|
|
130
|
-
ok = false;
|
|
131
|
-
r.status = 'failed';
|
|
132
|
-
r.detail = mask(p.stderr || p.stdout, got.value);
|
|
133
|
-
log(`failed ${c.name} (${c.kind}): ${r.detail}`);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return { ok, results };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function main() {
|
|
140
|
-
const args = process.argv.slice(2);
|
|
141
|
-
const i = args.indexOf('--selection');
|
|
142
|
-
if (i < 0 || !args[i + 1]) {
|
|
143
|
-
process.stderr.write('usage: push-creds.mjs --selection selection.json [--dry-run]\n');
|
|
144
|
-
process.exit(2);
|
|
145
|
-
}
|
|
146
|
-
const sel = JSON.parse(readFileSync(args[i + 1], 'utf8'));
|
|
147
|
-
const { ok, results } = pushCreds(sel, { dryRun: args.includes('--dry-run'), instabox: process.env.INSTABOX_BIN || 'instabox' });
|
|
148
|
-
const n = (s) => results.filter((r) => r.status === s).length;
|
|
149
|
-
console.log(`\n${n('set')} set, ${n('kept')} already there, ${n('failed')} failed${args.includes('--dry-run') ? ' (dry run)' : ''}.`);
|
|
150
|
-
if (!args.includes('--dry-run') && n('set')) console.log(`Next: instabox vault push ${sel.box}`);
|
|
151
|
-
process.exit(ok ? 0 : 1);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (isMain(import.meta)) main();
|