instabox 0.4.2 → 0.5.0
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 +8 -5
- package/bin/instabox.mjs +85 -82
- package/lib/api.mjs +4 -2
- package/lib/boxes.mjs +1 -10
- package/lib/cli.mjs +3 -16
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +2 -2
- package/skills/instabox-move-all/SKILL.md +158 -0
- package/skills/instabox-move-all/scripts/lib.mjs +226 -0
- package/skills/instabox-move-all/scripts/plan.mjs +263 -0
- package/skills/instabox-move-all/scripts/push-creds.mjs +154 -0
- package/skills/instabox-move-all/scripts/scan.mjs +811 -0
- package/skills/instabox-move-all/scripts/stage-env.mjs +313 -0
- package/skills/instabox-move-all/scripts/tools.mjs +173 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Shared by the instabox-move-all scripts. Node >= 18, no dependencies.
|
|
2
|
+
//
|
|
3
|
+
// The one rule every function here keeps: a credential's value is never
|
|
4
|
+
// returned, stored or printed. Code that looks at a secret-bearing file
|
|
5
|
+
// returns booleans, names and sizes only.
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { lstatSync, readdirSync, readFileSync, openSync, readSync, closeSync, realpathSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// Environment variable names that hold a credential, also with a rotation or environment
|
|
12
|
+
// qualifier after it (OPENAI_API_KEY_2, GITHUB_TOKEN_OLD, DB_PASSWORD_PROD). The qualifier
|
|
13
|
+
// needs its own `_`, so PATH is not PAT plus H.
|
|
14
|
+
export const SECRET_NAME =
|
|
15
|
+
/(?:^|_)(?:API_?KEY|APIKEY|KEY_?SECRET|TOKEN|ACCESS_?TOKEN|AUTH_?TOKEN|REFRESH_?TOKEN|SECRET|SECRET_?KEY|CLIENT_?SECRET|ACCESS_?KEY|ACCESS_?KEY_?ID|PASSWORD|PASSWD|PASS|PAT|PRIVATE_?KEY|CREDENTIALS?)(?:_(?:\d+|V\d+|OLD|NEW|PREV|PREVIOUS|BAK|BACKUP|ALT|PROD|PRODUCTION|DEV|STAGING|STAGE|TEST|LOCAL|CI))?$/i;
|
|
16
|
+
|
|
17
|
+
// Values that are credentials whatever the variable is called: token
|
|
18
|
+
// prefixes of the common services, and URLs with a password in them.
|
|
19
|
+
export const SECRET_VALUE = new RegExp(
|
|
20
|
+
[
|
|
21
|
+
String.raw`\bgh[pousr]_[A-Za-z0-9]{20,}`,
|
|
22
|
+
String.raw`\bgithub_pat_[A-Za-z0-9_]{20,}`,
|
|
23
|
+
String.raw`\bsk-[A-Za-z0-9_-]{20,}`,
|
|
24
|
+
String.raw`\bAKIA[0-9A-Z]{16}\b`,
|
|
25
|
+
String.raw`\bASIA[0-9A-Z]{16}\b`,
|
|
26
|
+
String.raw`\bxox[abposr]-[A-Za-z0-9-]{10,}`,
|
|
27
|
+
String.raw`\bglpat-[A-Za-z0-9_-]{20,}`,
|
|
28
|
+
String.raw`\bnpm_[A-Za-z0-9]{30,}`,
|
|
29
|
+
String.raw`\bAIza[0-9A-Za-z_-]{35}`,
|
|
30
|
+
String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----`,
|
|
31
|
+
String.raw`[a-z][a-z0-9+.-]*://[^/\s:@'"]+:[^@\s'"/]+@`,
|
|
32
|
+
].join('|'),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// key = value / key: value pairs whose key says it is a secret (config files), at the start
|
|
36
|
+
// of a line or inside an inline table; numbers and booleans are not secrets (max_tokens = 100).
|
|
37
|
+
export const SECRET_KEY_LINE =
|
|
38
|
+
/(?:^|[\s{,;])["']?[\w.-]*(?:token|password|passwd|secret|api[_-]?key|apikey|_auth|authorization|private[_-]?key)[\w.-]*["']?\s*[=:]\s*["']?(?![0-9.]+\b|true\b|false\b)[^\s"',}]/i;
|
|
39
|
+
|
|
40
|
+
// Names the Vault refuses for env items (docs/vault.md), plus our own.
|
|
41
|
+
const RESERVED = new Set(['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS']);
|
|
42
|
+
export function vaultEnvNameOK(name) {
|
|
43
|
+
return (
|
|
44
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
|
45
|
+
name.length <= 128 &&
|
|
46
|
+
!RESERVED.has(name) &&
|
|
47
|
+
!name.startsWith('INSTABOX_') &&
|
|
48
|
+
!name.startsWith('SSH_')
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Whether a variable's name says it holds a credential. This is the scrubber's question; whether
|
|
53
|
+
// the Vault can store it under that name is a separate one (vaultEnvNameOK).
|
|
54
|
+
export function isSecretName(name) {
|
|
55
|
+
return SECRET_NAME.test(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Lines in macOS shells and configs that mean nothing on the Linux box.
|
|
59
|
+
export const MACOS_ONLY =
|
|
60
|
+
/\/opt\/homebrew|\/usr\/local\/Cellar|\/usr\/local\/Homebrew|\/Applications\/|\/Library\/|\bosxkeychain\b|\bUseKeychain\b|\bpbcopy\b|\bpbpaste\b|\bosascript\b|\bbrew shellenv\b|\bterminal-notifier\b|\bafplay\b|\/System\/|\.orbstack\/|1password.*agent\.sock|\bsecurity (?:find|add)-[a-z-]*password\b/i;
|
|
61
|
+
|
|
62
|
+
// The Vault's limits (docs/vault.md).
|
|
63
|
+
export const VAULT_MAX_VALUE = 64 * 1024;
|
|
64
|
+
export const VAULT_MAX_TOTAL = 256 * 1024;
|
|
65
|
+
export const VAULT_MAX_ITEMS = 100;
|
|
66
|
+
|
|
67
|
+
export const BOX_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
68
|
+
export const BOX_HOME = '/data/home';
|
|
69
|
+
export const BOX_WORKSPACE = '/data/workspace';
|
|
70
|
+
// Staged files that are merged into the box's own copies rather than replacing them.
|
|
71
|
+
export const STAGE_DIR = '.config/instabox-move';
|
|
72
|
+
|
|
73
|
+
// Runs a command without a shell; never throws. stdout is returned to the
|
|
74
|
+
// caller only: callers that run credential tools (gh auth token) must not
|
|
75
|
+
// keep or print it.
|
|
76
|
+
export function run(argv, { cwd, env, input, timeout = 30_000 } = {}) {
|
|
77
|
+
const r = spawnSync(argv[0], argv.slice(1), {
|
|
78
|
+
cwd,
|
|
79
|
+
env,
|
|
80
|
+
input,
|
|
81
|
+
timeout,
|
|
82
|
+
encoding: 'utf8',
|
|
83
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
84
|
+
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
85
|
+
});
|
|
86
|
+
return { ok: r.status === 0 && !r.error, status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function hasCommand(name, env) {
|
|
90
|
+
for (const dir of (env?.PATH || '').split(':')) {
|
|
91
|
+
if (!dir) continue;
|
|
92
|
+
try {
|
|
93
|
+
const st = lstatSync(join(dir, name));
|
|
94
|
+
if (st.isFile() || st.isSymbolicLink()) return true;
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function lstatOrNull(p) {
|
|
101
|
+
try {
|
|
102
|
+
return lstatSync(p);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Bytes under a path, symlinks counted as links, not followed.
|
|
109
|
+
export function treeBytes(p, skip = () => false) {
|
|
110
|
+
const st = lstatOrNull(p);
|
|
111
|
+
if (!st) return 0;
|
|
112
|
+
if (!st.isDirectory()) return st.size;
|
|
113
|
+
let total = 0;
|
|
114
|
+
let names = [];
|
|
115
|
+
try {
|
|
116
|
+
names = readdirSync(p);
|
|
117
|
+
} catch {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
for (const n of names) if (!skip(n)) total += treeBytes(join(p, n), skip);
|
|
121
|
+
return total;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Every regular file and symlink under dir, as paths relative to it, sorted.
|
|
125
|
+
export function listFiles(dir, skip = () => false, rel = '') {
|
|
126
|
+
const out = [];
|
|
127
|
+
let names = [];
|
|
128
|
+
try {
|
|
129
|
+
names = readdirSync(join(dir, rel)).sort();
|
|
130
|
+
} catch {
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
for (const n of names) {
|
|
134
|
+
if (skip(n)) continue;
|
|
135
|
+
const r = rel ? `${rel}/${n}` : n;
|
|
136
|
+
const st = lstatOrNull(join(dir, r));
|
|
137
|
+
if (!st) continue;
|
|
138
|
+
if (st.isDirectory()) out.push(...listFiles(dir, skip, r));
|
|
139
|
+
else if (st.isFile() || st.isSymbolicLink()) out.push(r);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function readText(p) {
|
|
145
|
+
try {
|
|
146
|
+
return readFileSync(p, 'utf8');
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function readJSON(p) {
|
|
153
|
+
const t = readText(p);
|
|
154
|
+
if (t === null) return null;
|
|
155
|
+
try {
|
|
156
|
+
return JSON.parse(t);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The first n bytes of a file, for sniffing a header.
|
|
163
|
+
export function readHead(p, n = 4096) {
|
|
164
|
+
let fd;
|
|
165
|
+
try {
|
|
166
|
+
fd = openSync(p, 'r');
|
|
167
|
+
const buf = Buffer.alloc(n);
|
|
168
|
+
const len = readSync(fd, buf, 0, n, 0);
|
|
169
|
+
return buf.subarray(0, len).toString('utf8');
|
|
170
|
+
} catch {
|
|
171
|
+
return '';
|
|
172
|
+
} finally {
|
|
173
|
+
if (fd !== undefined) closeSync(fd);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function isBinary(buf) {
|
|
178
|
+
return buf.includes(0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function human(bytes) {
|
|
182
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
183
|
+
const u = ['KB', 'MB', 'GB', 'TB'];
|
|
184
|
+
let v = bytes / 1024;
|
|
185
|
+
let i = 0;
|
|
186
|
+
while (v >= 1024 && i < u.length - 1) {
|
|
187
|
+
v /= 1024;
|
|
188
|
+
i++;
|
|
189
|
+
}
|
|
190
|
+
return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Claude Code's name for a project directory: every non-alphanumeric character becomes '-'.
|
|
194
|
+
export function claudeProjectName(path) {
|
|
195
|
+
return path.replace(/[^A-Za-z0-9]/g, '-');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A name that is safe in a path and a quoted shell word (as instabox-migrate does).
|
|
199
|
+
export function safeName(s) {
|
|
200
|
+
return s.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The Vault item name for a file item: its path with / as -, as `instabox vault set-file` does.
|
|
204
|
+
export function vaultFileName(path) {
|
|
205
|
+
return safeName(path.replace(/^\./, '').replaceAll('/', '-')).replace(/^-+|-+$/g, '');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A Vault file path must be relative, clean, without .. components (docs/vault.md).
|
|
209
|
+
export function vaultPathOK(p) {
|
|
210
|
+
if (!p || p.startsWith('/') || p.length > 255 || /[\0\\\n]/.test(p)) return false;
|
|
211
|
+
const parts = p.split('/');
|
|
212
|
+
return parts.every((c) => c !== '' && c !== '.' && !c.startsWith('..'));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function homeRel(home, p) {
|
|
216
|
+
return p.startsWith(home + '/') ? '~/' + p.slice(home.length + 1) : p;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// True when the module is the script node was started with (not imported by a test).
|
|
220
|
+
export function isMain(meta) {
|
|
221
|
+
try {
|
|
222
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(meta.url));
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
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();
|
|
@@ -0,0 +1,154 @@
|
|
|
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();
|