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
package/README.md
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
# instabox (npm)
|
|
2
2
|
|
|
3
3
|
```
|
|
4
|
-
npx instabox@latest
|
|
4
|
+
npx instabox@latest # sign in, install the CLI and the agent skills
|
|
5
|
+
npx instabox@latest sh # the same, then start a new box and SSH into it
|
|
5
6
|
```
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
`npx instabox@latest` signs you in to instabox in your browser (first run only; confirm its current Instabox account or choose another), installs the `instabox` CLI already signed in or updates an out-of-date one (macOS/Linux; skip with `--no-cli`), installs or updates the agent skills from [`../skills`](../skills) in `~/.claude/skills` and `~/.agents/skills` (skip with `--no-skills`), and prints the next steps. It does not touch your boxes.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
`npx instabox@latest sh` does all that, then starts a new box (`box-<hex>`) and drops you into it over SSH, no questions. Run by an agent (no terminal), it starts the box and prints `instabox ssh <box> -- <command>` instead of opening a shell. At the box limit it starts nothing and lists your boxes, to remove one (`instabox rm <box>`) or open one (`instabox ssh <box>`).
|
|
11
|
+
|
|
12
|
+
A GitHub and a Google sign-in are separate instabox accounts, each with its own boxes. Both this entry point and `instabox login` let the browser confirm the account. Explicit `--github` or `--google` starts that provider directly, as does `instabox login --provider github|google`.
|
|
10
13
|
|
|
11
14
|
Any other command line is the instabox CLI's ([`cmd/instabox`](../cmd/instabox)): `npx instabox@latest ls` runs `instabox ls`, installing the CLI first if it is missing.
|
|
12
15
|
|
|
13
16
|
## How it works
|
|
14
17
|
|
|
15
18
|
- `bin/instabox.mjs` runs instabox's loopback sign-in (RFC 8252 + PKCE) and saves the tokens where the CLI keeps its own, `~/.config/instabox/credentials.json`, so both are signed in at once. There is one copy on purpose: the server rotates refresh tokens and ends the session when a rotated one comes back.
|
|
16
|
-
-
|
|
19
|
+
- `sh` always starts a new box, with no timeout and no idle auto-suspend of its own. An existing box is entered with the CLI: `instabox ssh <box>`.
|
|
17
20
|
- An installed CLI is checked against what the server's `/install.sh` would install: the binary's sha256 against the server's `/dl/SHA256SUMS` (release builds are named after a commit, so their versions do not sort). One the server no longer serves is replaced in place through the same installer; a development build (`instabox dev`, or `-dirty`) is left alone with a one-line notice of the install command.
|
|
18
21
|
- Skills are written only over copies marked `installed-by: instabox`; the output says which were installed, updated, already up to date, or kept because they are the user's own.
|
|
19
22
|
- The bin is also called `instabox`, so `lib/cli.mjs` finds the real CLI the way the insta CLI does: PATH without `node_modules` dirs and without links back to this script, then the installer's own targets.
|
|
@@ -26,7 +29,7 @@ npm ci
|
|
|
26
29
|
npm test
|
|
27
30
|
npm run format:check
|
|
28
31
|
npm pack # copies ../skills, then builds instabox-<version>.tgz
|
|
29
|
-
npx --package=./instabox-0.4.
|
|
32
|
+
npx --package=./instabox-0.4.3.tgz instabox --help
|
|
30
33
|
```
|
|
31
34
|
|
|
32
35
|
`INSTABOX_API_URL` points it at another instabox server.
|
package/bin/instabox.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// npx instabox@latest:
|
|
2
|
+
// npx instabox@latest: sign in + CLI + skills; `sh` also starts a new box and SSHes in. Anything else goes to the CLI.
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto';
|
|
5
5
|
import {
|
|
@@ -16,11 +16,10 @@ import {
|
|
|
16
16
|
import http from 'node:http';
|
|
17
17
|
import { tmpdir, homedir } from 'node:os';
|
|
18
18
|
import { join, dirname } from 'node:path';
|
|
19
|
-
import { createInterface } from 'node:readline/promises';
|
|
20
19
|
import { fileURLToPath } from 'node:url';
|
|
21
20
|
import { createClient } from '../lib/api.mjs';
|
|
22
|
-
import { orderBoxes
|
|
23
|
-
import {
|
|
21
|
+
import { orderBoxes } from '../lib/boxes.mjs';
|
|
22
|
+
import { checkCli, findCli, installCli, installCommand, isOnboarding, real, runCli } from '../lib/cli.mjs';
|
|
24
23
|
import { callbackHandler } from '../lib/login.mjs';
|
|
25
24
|
import { loadCredentials, saveCredentials } from '../lib/credentials.mjs';
|
|
26
25
|
|
|
@@ -34,7 +33,9 @@ const API = (
|
|
|
34
33
|
const IMAGE = process.env.INSTABOX_IMAGE || 'universal';
|
|
35
34
|
// SSH certificate lifetime for a box with no timeout of its own.
|
|
36
35
|
const CERT_MIN = 120;
|
|
37
|
-
|
|
36
|
+
// With no provider selected, the browser confirms its existing Instabox account
|
|
37
|
+
// or offers sign-in. Explicit flags still start the chosen provider directly.
|
|
38
|
+
const PROVIDER = process.argv.includes('--google') ? 'google' : process.argv.includes('--github') ? 'github' : '';
|
|
38
39
|
const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
|
|
39
40
|
const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
40
41
|
const green = (s) => (process.stdout.isTTY ? `\x1b[32m${s}\x1b[0m` : s);
|
|
@@ -50,11 +51,10 @@ function usage() {
|
|
|
50
51
|
console.log(`
|
|
51
52
|
${bold('instabox')}: a cloud box in seconds
|
|
52
53
|
|
|
53
|
-
npx instabox@latest sign in (first time),
|
|
54
|
-
|
|
55
|
-
--
|
|
56
|
-
|
|
57
|
-
instabox login uses GitHub, --provider google for Google)
|
|
54
|
+
npx instabox@latest sign in (first time), install the instabox CLI and agent skills
|
|
55
|
+
npx instabox@latest sh the same, then start a new box and SSH into it
|
|
56
|
+
--github, --google sign in directly with that provider instead of confirming
|
|
57
|
+
the browser's current Instabox account
|
|
58
58
|
--no-cli do not install or update the instabox CLI
|
|
59
59
|
--no-skills do not install the instabox agent skills
|
|
60
60
|
npx instabox@latest <command> run an instabox CLI command (instabox help lists them)
|
|
@@ -195,51 +195,17 @@ async function waitOp(op) {
|
|
|
195
195
|
return op;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const { machines = [] } = await client
|
|
202
|
-
.call('GET', '/v1/machines?limit=100')
|
|
203
|
-
.catch((e) => die(`could not list your boxes: ${e.message}`));
|
|
198
|
+
// At the box limit: say so, and show the boxes there are to remove or open instead.
|
|
199
|
+
async function quotaFull(err) {
|
|
200
|
+
const { machines = [] } = await client.call('GET', '/v1/machines?limit=100').catch(() => ({}));
|
|
204
201
|
const boxes = orderBoxes(machines);
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const w = Math.max(...boxes.map((b) => b.name.length));
|
|
210
|
-
console.log(`\n ${bold('Your boxes')}`);
|
|
211
|
-
boxes.forEach((b, i) =>
|
|
212
|
-
console.log(
|
|
213
|
-
` ${ask ? `${i + 1}) ` : ''}${b.name.padEnd(w)} ${b.state.padEnd(9)} ${dim(`${b.template?.id || ''} created ${new Date(b.createdAt).toLocaleString()}`)}`,
|
|
214
|
-
),
|
|
215
|
-
);
|
|
216
|
-
if (!ask) return { listed: true };
|
|
217
|
-
console.log(` ${boxes.length + 1}) ${green('+ new box')}`);
|
|
218
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
219
|
-
try {
|
|
220
|
-
for (;;) {
|
|
221
|
-
const i = parseChoice(await rl.question(' Enter a box [1]: '), boxes.length);
|
|
222
|
-
if (i !== null) return i === boxes.length ? null : boxes[i];
|
|
223
|
-
}
|
|
224
|
-
} finally {
|
|
225
|
-
rl.close();
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Wakes a suspended or stopped box and leaves its settings (timeout, auto-suspend) alone.
|
|
230
|
-
async function enterBox(id) {
|
|
231
|
-
const path = '/v1/machines/' + enc(id);
|
|
232
|
-
let m = await client.call('GET', path);
|
|
233
|
-
if (m.operationId) {
|
|
234
|
-
await waitOp(await client.call('GET', '/v1/operations/' + enc(m.operationId)));
|
|
235
|
-
m = await client.call('GET', path);
|
|
236
|
-
}
|
|
237
|
-
if (m.state === 'suspended' || m.state === 'stopped') {
|
|
238
|
-
await waitOp(await client.call('POST', `${path}/${m.state === 'suspended' ? 'resume' : 'start'}`));
|
|
239
|
-
return { id: m.id, resumed: true };
|
|
202
|
+
console.log(`\n ${bold('Box limit reached')} ${dim(`(${err.message})`)}`);
|
|
203
|
+
if (boxes.length) {
|
|
204
|
+
const w = Math.max(...boxes.map((b) => b.name.length));
|
|
205
|
+
for (const b of boxes) console.log(` ${b.name.padEnd(w)} ${b.state}`);
|
|
240
206
|
}
|
|
241
|
-
|
|
242
|
-
|
|
207
|
+
console.log(`\n Remove one with ${bold('instabox rm <box>')}, or open one with ${bold('instabox ssh <box>')}.\n`);
|
|
208
|
+
process.exit(1);
|
|
243
209
|
}
|
|
244
210
|
|
|
245
211
|
async function newBox() {
|
|
@@ -253,7 +219,7 @@ async function newBox() {
|
|
|
253
219
|
if (img) body.imageId = img.id;
|
|
254
220
|
else body.templateId = IMAGE;
|
|
255
221
|
const op = await waitOp(await client.call('POST', '/v1/machines', body));
|
|
256
|
-
return { id: op.machineId,
|
|
222
|
+
return { id: op.machineId, name: body.name };
|
|
257
223
|
}
|
|
258
224
|
|
|
259
225
|
// Runs while the box starts; never fatal. Returns a line to print, or null.
|
|
@@ -293,8 +259,19 @@ async function ensureCli() {
|
|
|
293
259
|
);
|
|
294
260
|
}
|
|
295
261
|
|
|
296
|
-
//
|
|
297
|
-
|
|
262
|
+
// A skill directory's files, relative to it, sorted.
|
|
263
|
+
function skillFiles(dir, rel = '') {
|
|
264
|
+
return readdirSync(join(dir, rel), { withFileTypes: true })
|
|
265
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
266
|
+
.flatMap((e) => {
|
|
267
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
268
|
+
return e.isDirectory() ? skillFiles(dir, r) : e.isFile() ? [r] : [];
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Agent skills shipped in this package (skills/<name>/SKILL.md, with any scripts beside it) into
|
|
273
|
+
// the folders coding agents read. Overwrites only copies we installed earlier (marked
|
|
274
|
+
// `installed-by: instabox`).
|
|
298
275
|
function installSkills() {
|
|
299
276
|
if (process.argv.includes('--no-skills')) return null;
|
|
300
277
|
const src = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
@@ -313,7 +290,6 @@ function installSkills() {
|
|
|
313
290
|
for (const n of names) {
|
|
314
291
|
const dest = join(root, n, 'SKILL.md');
|
|
315
292
|
try {
|
|
316
|
-
const body = readFileSync(join(src, n, 'SKILL.md'), 'utf8');
|
|
317
293
|
let what = 'installed';
|
|
318
294
|
if (existsSync(dest)) {
|
|
319
295
|
const old = readFileSync(dest, 'utf8');
|
|
@@ -321,11 +297,16 @@ function installSkills() {
|
|
|
321
297
|
kept.add(n);
|
|
322
298
|
continue;
|
|
323
299
|
}
|
|
324
|
-
what =
|
|
300
|
+
what = 'unchanged';
|
|
325
301
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
302
|
+
// SKILL.md and whatever else the skill ships (scripts/...).
|
|
303
|
+
for (const f of skillFiles(join(src, n))) {
|
|
304
|
+
const body = readFileSync(join(src, n, f));
|
|
305
|
+
const to = join(root, n, f);
|
|
306
|
+
if (existsSync(to) && readFileSync(to).equals(body)) continue;
|
|
307
|
+
if (what === 'unchanged') what = 'updated';
|
|
308
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
309
|
+
writeFileSync(to, body);
|
|
329
310
|
}
|
|
330
311
|
if (rank[what] > (rank[outcome.get(n)] ?? -1)) outcome.set(n, what);
|
|
331
312
|
} catch {}
|
|
@@ -348,7 +329,16 @@ async function reportInstalls(cli) {
|
|
|
348
329
|
if (skillsLine) console.log(` ${green('✓')} ${skillsLine}`);
|
|
349
330
|
}
|
|
350
331
|
|
|
351
|
-
|
|
332
|
+
// The commands to run next, one per line, descriptions lined up.
|
|
333
|
+
function nextSteps(rows) {
|
|
334
|
+
const w = Math.max(...rows.map(([cmd]) => cmd.length));
|
|
335
|
+
console.log(`\n ${bold('Next steps')}`);
|
|
336
|
+
for (const [cmd, what] of rows) console.log(` ${cmd.padEnd(w)} ${dim(what)}`);
|
|
337
|
+
console.log();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Sign-in, the CLI and the skills; with `sh` (wantBox) also a new box and an SSH session in it.
|
|
341
|
+
async function onboard(wantBox) {
|
|
352
342
|
console.log(`\n ${bold('instabox')} ${dim('· a cloud box in seconds')}\n`);
|
|
353
343
|
|
|
354
344
|
client.use(await signedIn());
|
|
@@ -358,22 +348,40 @@ async function sh() {
|
|
|
358
348
|
);
|
|
359
349
|
|
|
360
350
|
const cli = ensureCli();
|
|
361
|
-
|
|
362
|
-
if (picked?.listed) {
|
|
351
|
+
if (!wantBox) {
|
|
363
352
|
await reportInstalls(cli);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
353
|
+
return nextSteps([
|
|
354
|
+
['npx instabox@latest sh', 'start a new box and SSH into it'],
|
|
355
|
+
['instabox ls', 'list your boxes'],
|
|
356
|
+
['instabox ssh <box>', 'open a shell in one; add -- <command> to run just that'],
|
|
357
|
+
]);
|
|
367
358
|
}
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
359
|
+
// An agent (no terminal) cannot use an interactive shell, so it gets the commands instead.
|
|
360
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
361
|
+
if (tty) {
|
|
362
|
+
for (const bin of ['ssh', 'ssh-keygen']) {
|
|
363
|
+
if (spawnSync(bin, ['-V'], { stdio: 'ignore' }).error)
|
|
364
|
+
die(`${bin} was not found. Install OpenSSH and try again.`);
|
|
365
|
+
}
|
|
371
366
|
}
|
|
372
367
|
const t0 = Date.now();
|
|
373
|
-
process.stdout.write(`\n ${dim(
|
|
374
|
-
const box = await
|
|
375
|
-
die(`could not start your box: ${e.message}`)
|
|
376
|
-
|
|
368
|
+
process.stdout.write(`\n ${dim('starting a new box…')}`);
|
|
369
|
+
const box = await newBox().catch(async (e) => {
|
|
370
|
+
if (e.code !== 'quota_exceeded') die(`could not start your box: ${e.message}`);
|
|
371
|
+
// The way out of the limit is `instabox rm`, so the CLI and the skills land before the exit.
|
|
372
|
+
console.log();
|
|
373
|
+
await reportInstalls(cli);
|
|
374
|
+
return quotaFull(e);
|
|
375
|
+
});
|
|
376
|
+
const ready = `ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
|
377
|
+
if (!tty) {
|
|
378
|
+
console.log(`\n ${green('✓')} box ${bold(box.name)} ${ready}`);
|
|
379
|
+
await reportInstalls(cli);
|
|
380
|
+
return nextSteps([
|
|
381
|
+
[`instabox ssh ${box.name} -- <command>`, 'run a command in it'],
|
|
382
|
+
[`instabox ssh ${box.name}`, 'open a shell in it, from a terminal'],
|
|
383
|
+
]);
|
|
384
|
+
}
|
|
377
385
|
|
|
378
386
|
// A throwaway key for this session; only its public half leaves this machine.
|
|
379
387
|
const dir = mkdtempSync(join(tmpdir(), 'instabox-'));
|
|
@@ -400,12 +408,7 @@ async function sh() {
|
|
|
400
408
|
writeFileSync(join(dir, 'known_hosts'), `@cert-authority [${c.host}]:${c.port} ${c.hostTrust.publicKey}\n`);
|
|
401
409
|
|
|
402
410
|
const pauses = m.timeoutAt ? dim(` · pauses itself in ${Math.max(1, Math.round(ttlSeconds / 60))} min`) : '';
|
|
403
|
-
|
|
404
|
-
? `resumed in ${((Date.now() - t0) / 1000).toFixed(1)}s`
|
|
405
|
-
: box.reused
|
|
406
|
-
? 'back in'
|
|
407
|
-
: `ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
|
408
|
-
process.stdout.write(`${process.stdout.isTTY ? '\r' : '\n'} ${green('✓')} box ${bold(m.name)} ${took}${pauses}\n`);
|
|
411
|
+
process.stdout.write(`\r ${green('✓')} box ${bold(m.name)} ${ready}${pauses}\n`);
|
|
409
412
|
await reportInstalls(cli);
|
|
410
413
|
console.log(dim(` type ${'exit'} to leave · stuck? press Enter, then ~.`));
|
|
411
414
|
// The box's prompt is ~37 columns; narrower terminals make bash scroll the input line sideways.
|
|
@@ -433,7 +436,7 @@ async function sh() {
|
|
|
433
436
|
];
|
|
434
437
|
const child = spawn('ssh', args, { stdio: 'inherit' });
|
|
435
438
|
child.on('exit', (code) => {
|
|
436
|
-
console.log(`\n ${dim(`left ${m.name}.
|
|
439
|
+
console.log(`\n ${dim(`left ${m.name}. Back in:`)} instabox ssh ${m.name}\n`);
|
|
437
440
|
process.exit(code ?? 0);
|
|
438
441
|
});
|
|
439
442
|
}
|
|
@@ -441,7 +444,7 @@ async function sh() {
|
|
|
441
444
|
// Any other command line is the CLI's, so `npm i -g instabox` still gives a working `instabox`.
|
|
442
445
|
const args = process.argv.slice(2);
|
|
443
446
|
if (args.length === 1 && (args[0] === '-h' || args[0] === '--help')) usage();
|
|
444
|
-
else if (isOnboarding(args)) await sh
|
|
447
|
+
else if (isOnboarding(args)) await onboard(args.includes('sh'));
|
|
445
448
|
else {
|
|
446
449
|
let path = findCli();
|
|
447
450
|
if (!path) {
|
package/lib/api.mjs
CHANGED
|
@@ -3,9 +3,10 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { loadCredentials, saveCredentials } from './credentials.mjs';
|
|
4
4
|
|
|
5
5
|
export class ApiError extends Error {
|
|
6
|
-
constructor(status, message) {
|
|
6
|
+
constructor(status, message, code) {
|
|
7
7
|
super(message);
|
|
8
8
|
this.status = status;
|
|
9
|
+
this.code = code;
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -25,7 +26,8 @@ export function createClient(
|
|
|
25
26
|
|
|
26
27
|
const parse = async (res) => {
|
|
27
28
|
const data = res.status === 204 ? null : await res.json().catch(() => null);
|
|
28
|
-
if (!res.ok)
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
throw new ApiError(res.status, data?.error?.message || `instabox HTTP ${res.status}`, data?.error?.code);
|
|
29
31
|
return data;
|
|
30
32
|
};
|
|
31
33
|
|
package/lib/boxes.mjs
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Your boxes in the order to show them: live ones only, running first, then newest.
|
|
2
2
|
export function orderBoxes(machines) {
|
|
3
3
|
return machines
|
|
4
4
|
.filter((m) => !['deleting', 'deleted', 'error'].includes(m.state))
|
|
5
5
|
.sort((a, b) => (b.state === 'running') - (a.state === 'running') || new Date(b.createdAt) - new Date(a.createdAt));
|
|
6
6
|
}
|
|
7
|
-
|
|
8
|
-
// The 0-based pick from a typed answer: Enter is the first box, count itself is "new box", else null.
|
|
9
|
-
export function parseChoice(answer, count) {
|
|
10
|
-
const s = answer.trim();
|
|
11
|
-
if (s === '') return 0;
|
|
12
|
-
if (!/^\d+$/.test(s)) return null;
|
|
13
|
-
const n = Number(s);
|
|
14
|
-
return n >= 1 && n <= count + 1 ? n - 1 : null;
|
|
15
|
-
}
|
package/lib/cli.mjs
CHANGED
|
@@ -5,24 +5,11 @@ import { createReadStream, realpathSync, statSync } from 'node:fs';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { delimiter, join, sep } from 'node:path';
|
|
7
7
|
|
|
8
|
-
const OUR_FLAGS = new Set(['--github', '--no-cli', '--no-skills']);
|
|
8
|
+
const OUR_FLAGS = new Set(['--github', '--google', '--no-cli', '--no-skills']);
|
|
9
9
|
|
|
10
|
-
// Bare `instabox`
|
|
10
|
+
// Bare `instabox` or `sh`, with only our flags; anything else is a CLI command.
|
|
11
11
|
export function isOnboarding(args) {
|
|
12
|
-
|
|
13
|
-
const a = args[i];
|
|
14
|
-
if (a === '--box' && i + 1 < args.length) i++;
|
|
15
|
-
else if (a !== 'sh' && !OUR_FLAGS.has(a) && !a.startsWith('--box=')) return false;
|
|
16
|
-
}
|
|
17
|
-
return true;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// The box named by --box NAME or --box=NAME, else null.
|
|
21
|
-
export function boxFlag(args) {
|
|
22
|
-
const i = args.indexOf('--box');
|
|
23
|
-
if (i !== -1) return args[i + 1] ?? null;
|
|
24
|
-
const eq = args.find((a) => a.startsWith('--box='));
|
|
25
|
-
return eq ? eq.slice('--box='.length) : null;
|
|
12
|
+
return args.every((a) => a === 'sh' || OUR_FLAGS.has(a));
|
|
26
13
|
}
|
|
27
14
|
|
|
28
15
|
export const real = (p) => {
|
package/package.json
CHANGED
package/skills/instabox/SKILL.md
CHANGED
|
@@ -13,10 +13,10 @@ An instabox sandbox is a Linux VM (Debian, user `dev`, sudo). It has a root disk
|
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
15
|
instabox whoami # who is signed in (fails if nobody)
|
|
16
|
-
instabox login # browser
|
|
16
|
+
instabox login # confirm the browser's account, or choose GitHub / Google
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
A GitHub sign-in and a Google sign-in are separate accounts with separate boxes
|
|
19
|
+
A GitHub sign-in and a Google sign-in are separate accounts with separate boxes. Both `instabox login` and `npx instabox@latest` open account confirmation in the browser: use its current Instabox account or choose another. To go directly to a provider, use `instabox login --provider github|google`, or `npx instabox@latest --github|--google`. The CLI shares the sign-in `npx` made. Device-code sign-in (`--device`) uses GitHub.
|
|
20
20
|
|
|
21
21
|
For CI or an agent without a browser, use an API key instead: `export INSTABOX_API_KEY=ibk_...` (the user creates it on the dashboard's API Keys page). Never print, commit or log a key.
|
|
22
22
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: instabox-move-all
|
|
3
|
+
description: Use when the user wants to move their whole local setup onto instabox, not just one repo — "move everything to instabox", "move my whole setup", "all my projects to a box", "set up a box like my laptop". Scans this machine (repos with unsaved work, agent history, credentials, dotfiles, agent config, global tools), lets the user pick from a checkbox plan, then moves the picks onto one new box. The bulk version of instabox-migrate.
|
|
4
|
+
metadata:
|
|
5
|
+
installed-by: instabox
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Move a whole local setup to instabox
|
|
9
|
+
|
|
10
|
+
The bulk version of the `instabox-migrate` skill: instead of the current repo, it moves everything the user picks onto **one new box**. Each repo goes over exactly as `instabox-migrate` moves one; this skill adds the scan, the plan, credentials and the environment around it. See the `instabox` skill for the CLI.
|
|
11
|
+
|
|
12
|
+
Four scripts in `scripts/` next to this file do the parts that must be exact (Node 18+, nothing to install):
|
|
13
|
+
|
|
14
|
+
| Script | Does |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `scan.mjs` | Read-only inventory, as JSON. Credentials by name and place only, never values. |
|
|
17
|
+
| `plan.mjs` | `write`: the checkbox plan from the scan. `parse`: reads the edited plan back, strictly, as JSON. |
|
|
18
|
+
| `stage-env.mjs` | Copies the picked dotfiles and agent config into a local directory, credentials and macOS-only lines removed, plus a setup script for the box. |
|
|
19
|
+
| `push-creds.mjs` | Puts the picked credentials into the Vault for this box only, piping each value from its source into `instabox vault set`. |
|
|
20
|
+
|
|
21
|
+
**Never read, print or paste a credential value yourself**: not `gh auth token`, not `cat ~/.aws/credentials`, not `echo $OPENAI_API_KEY`, not a `.env`. `push-creds.mjs` is the only thing that touches values. If a credential needs something the script cannot do, tell the user the command to run themselves.
|
|
22
|
+
|
|
23
|
+
Before starting, tell the user plainly:
|
|
24
|
+
|
|
25
|
+
- What happens: a scan of this machine (nothing changes here), a plan file they edit, then one new box with what they checked. Their local copies stay as they are, and nothing keeps the two in sync afterwards.
|
|
26
|
+
- **Agent conversation history can contain secrets they pasted into a chat.** It is a separate checkbox per repo.
|
|
27
|
+
- Some sign-ins cannot move (below); the final report says what to run on the box for each.
|
|
28
|
+
|
|
29
|
+
## 1. Scan
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
instabox whoami # signed in? otherwise: instabox login
|
|
33
|
+
S=~/.claude/skills/instabox-move-all/scripts # or ~/.agents/skills/instabox-move-all/scripts: wherever this file is
|
|
34
|
+
W=~/instabox-move && mkdir -p -m 700 "$W"
|
|
35
|
+
node "$S/scan.mjs" > "$W/scan.json"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
It looks for git repos under `~/Work ~/work ~/code ~/src ~/projects ~/dev ~/Developer ~/repos ~/git ~/github` (three levels down), the current directory's repo, and any repo the user's Claude Code or Codex history points at. If the user keeps code elsewhere, add `--root DIR` (repeatable); `--depth N` to look deeper; `--no-tools` to skip asking brew/npm/uv/cargo.
|
|
39
|
+
|
|
40
|
+
What it finds, in four lists:
|
|
41
|
+
|
|
42
|
+
- `work`: each repo with its branch, changed / untracked / stashed / unpushed counts, whether it has a remote, and its size the way `instabox-migrate` copies it (`.git` plus files git does not ignore). Its box directory is `/data/workspace/<name>`, `-2`, `-3`... when two repos share a name.
|
|
43
|
+
- `history`: Claude Code (`~/.claude/projects`) and Codex (`~/.codex/sessions`) sessions, per repo.
|
|
44
|
+
- `credentials`: each with where it comes from and what it becomes: a Vault `env` item, a Vault `file` item, or `manual` (cannot move, with the reason and the command to run on the box).
|
|
45
|
+
- `environment`: dotfiles, Claude Code and Codex config, skills, local MCP servers, and global tools (`brew leaves`, `npm -g`, `uv tool`, `cargo install`, `~/go/bin`).
|
|
46
|
+
|
|
47
|
+
## 2. Write the plan and let the user choose
|
|
48
|
+
|
|
49
|
+
Pick a box name `instabox ls` does not show (lowercase letters, digits, `-`), then:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
BOX=<box>
|
|
53
|
+
node "$S/plan.mjs" write --scan "$W/scan.json" --box "$BOX" > "$W/migrate-plan.md"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`migrate-plan.md` has three sections, Work, Credentials and Environment, one `- [x]` / `- [ ]` line per item with its size and where it goes. Defaults:
|
|
57
|
+
|
|
58
|
+
- Repos with work that exists only here (uncommitted, untracked, stashed, unpushed, or no remote) are checked, with their history. Clean, pushed repos are unchecked: they can be cloned again.
|
|
59
|
+
- Credentials that work as a token or a file are checked. SSH private keys are unchecked (prefer `GH_TOKEN` over HTTPS, or a new key made on the box). Sign-ins that cannot move are unchecked and marked "can't move".
|
|
60
|
+
- Dotfiles, agent config and tools are checked.
|
|
61
|
+
|
|
62
|
+
Show the user a short summary (how many repos and how big, which credentials, what cannot move) and the path of the file, and ask them to edit the checkboxes and the `Box:` line, then tell you to go on. In Claude Code you may first ask a few coarse questions with AskUserQuestion (multiSelect), such as "Move: repos with unsaved work / every repo / credentials / dotfiles and agent config / tools", and apply the answers to the plan by changing only `[x]` and `[ ]`; the user still gets the file for the details.
|
|
63
|
+
|
|
64
|
+
When they are done, read it back:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
GIB=$(instabox usage --json | jq -r '.limits.machineSpec.persistentDiskGiB // 20')
|
|
68
|
+
node "$S/plan.mjs" parse --scan "$W/scan.json" --disk-gib "$GIB" "$W/migrate-plan.md" > "$W/selection.json"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- Exit 1: the plan has problems (a malformed checkbox, a line deleted or duplicated, an unknown id, a "can't move" item checked, history checked without its repo, a bad box name, a plan from an older scan). All are listed; show them to the user, fix or let them fix, and parse again. Never fix a plan by editing ids or deleting lines.
|
|
72
|
+
- Exit 2: a usage error, e.g. `--disk-gib` is empty because `instabox usage` failed. Fix that (`instabox login`) and parse again; never drop `--disk-gib`.
|
|
73
|
+
- Exit 3: too big. Every box has the same `/data` disk, and half of it stays free for dependencies and builds. Ask the user what to uncheck (the message gives the sizes), then parse again.
|
|
74
|
+
- Warnings about the Vault's limits (100 items, 256 KiB in all): ask the user to uncheck some credential files.
|
|
75
|
+
|
|
76
|
+
From here on, `selection.json` is the plan: `.box`, `.work`, `.history`, `.credentials`, `.environment`, `.tools`, `.manual` (what the user must do by hand) and `.bytes`.
|
|
77
|
+
|
|
78
|
+
## 3. Create the box
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
BOX=$(jq -r .box "$W/selection.json")
|
|
82
|
+
instabox create --name "$BOX" --meta migrated="$BOX"
|
|
83
|
+
instabox ls --meta migrated="$BOX" # exactly this box
|
|
84
|
+
instabox exec "$BOX" -- df -h /data # room for what is coming
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The `migrated=<box>` label is what the credentials select on (next step), so they reach this box and no other. No `--timeout`: this box is meant to stay (offer `instabox timeout "$BOX" 8h --on-timeout pause` if the user wants it to pause itself).
|
|
88
|
+
|
|
89
|
+
## 4. Credentials into the Vault
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
node "$S/push-creds.mjs" --selection "$W/selection.json" --dry-run # names and sizes only
|
|
93
|
+
node "$S/push-creds.mjs" --selection "$W/selection.json"
|
|
94
|
+
instabox vault push "$BOX"
|
|
95
|
+
instabox vault show "$BOX"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Every item gets the selector `migrated=<box>`. Values go from their source (a variable sourced from the user's shell file, `gh auth token`, a key in `~/.codex/auth.json`, a file) straight into the CLI's stdin; the output is names, sizes and outcomes. An item already in the Vault under the same name or path is left alone (it would change what other boxes get), unless it is from an earlier run of this move; the output says which. `.env` files of repos go in as Vault files under `~/.config/instabox-move/env/<repo>/` and are linked into the repo in step 5. If an item fails, tell the user what failed and the command to set it themselves, for example `instabox vault set NAME --selector migrated=<box>` (it prompts without echo).
|
|
99
|
+
|
|
100
|
+
## 5. Environment
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
node "$S/stage-env.mjs" --selection "$W/selection.json" --out "$W/stage"
|
|
104
|
+
COPYFILE_DISABLE=1 tar -C "$W/stage" -czf - . |
|
|
105
|
+
instabox ssh "$BOX" -- 'tar -xzf - --skip-old-files --no-overwrite-dir --warning=no-unknown-keyword -C ~'
|
|
106
|
+
instabox ssh "$BOX" -- 'bash -l ~/.config/instabox-move/setup.sh'
|
|
107
|
+
rm -rf "$W/stage"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
What the staged copy and `setup.sh` do on the box:
|
|
111
|
+
|
|
112
|
+
- Lines that look like credentials are replaced by a comment; macOS-only lines (Homebrew paths, `osxkeychain`, `UseKeychain`, `pbcopy`, ...) are commented out; this machine's HOME becomes `/data/home`. The JSON summary printed by `stage-env.mjs` counts the lines changed per file.
|
|
113
|
+
- The box keeps its own startup files: `~/.bashrc` sources the user's bash files, `~/.gitconfig` includes theirs, Claude Code `settings.json` is merged (theirs wins), `CLAUDE.md` / `AGENTS.md` are appended. Files the box already has are not overwritten (`--skip-old-files`). zsh is installed when there is a `.zshrc`; bash stays the login shell.
|
|
114
|
+
- Skills, commands and agents go to `~/.claude/...` and `~/.agents/skills`; local MCP servers are added with `claude mcp add-json` (their secrets come from the Vault as environment variables).
|
|
115
|
+
- Tools are installed fresh: Homebrew formulae through a table of Debian packages (or `uv tool` / `npm -g`), npm globals, `uv tool`, `cargo install`, `go install`. No macOS binary is copied. Formulae without a mapping are listed in `.manual`.
|
|
116
|
+
|
|
117
|
+
`setup.sh` is safe to run again. Run it over `ssh` (installs can take minutes). It carries on past a failed step, then lists every failed step and exits 1: report those to the user, fix what you can on the box and run it again. Then check the shells still parse: `instabox exec "$BOX" -- 'bash -n ~/.bashrc; [ -f ~/.zshrc ] && zsh -n ~/.zshrc'`. Fix a broken line in the staged copy under `~/.config/instabox-move/shell/` on the box.
|
|
118
|
+
|
|
119
|
+
## 6. Work: each repo
|
|
120
|
+
|
|
121
|
+
For each repo in `selection.json`:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
jq -r '.work[] | [.path, .name] | @tsv' "$W/selection.json"
|
|
125
|
+
jq -r '.history[].repo' "$W/selection.json" # repos whose history goes too ("repo:<path>")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
follow **steps 2, 3 and 4 of the `instabox-migrate` skill** for that repo, with two changes:
|
|
129
|
+
|
|
130
|
+
- `cd` into the repo's path first, and set `NAME` to the repo's `name` from `selection.json` instead of the `basename` line, so two repos with the same name do not land in one directory.
|
|
131
|
+
- Copy the Claude Code and Codex history (the second and third parts of its step 2, and its step 3) only if the repo is in `.history`.
|
|
132
|
+
|
|
133
|
+
Its checks apply: the box must show the same `git status` and `HEAD`. Do the dependency installs and builds of its step 4 per repo; the tools and `.env` links from step 5 are already there.
|
|
134
|
+
|
|
135
|
+
## 7. Verify and report
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
instabox exec "$BOX" -- 'gh auth status; git config user.name; claude --version; codex --version'
|
|
139
|
+
instabox exec "$BOX" -- 'test "${GH_TOKEN+x}" = x && echo GH_TOKEN set' # presence only; same for other names
|
|
140
|
+
instabox vault show "$BOX"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Tell the user, in short sections:
|
|
144
|
+
|
|
145
|
+
- **Moved**: the box name (`instabox ssh <box>`), each repo with its directory and whether it builds and its tests pass, the credentials now in the Vault for this box, the environment and tools installed.
|
|
146
|
+
- **Skipped**: what they unchecked, and anything that failed (with the reason).
|
|
147
|
+
- **To do by hand**: every entry of `.manual`, with its exact command (for example `codex login --device-auth`, `claude` then `/login`, `gcloud auth login --no-launch-browser`, `docker login ghcr.io`, `aws sso login --use-device-code`), and how to pick up old conversations (`claude --continue` in a repo; `codex resume <id>`).
|
|
148
|
+
- The credentials reach only boxes with the label `migrated=<box>`; a box created later with `--meta migrated=<box>` gets them too. Remove one with `instabox vault rm NAME`.
|
|
149
|
+
- Ignored files stayed behind (except the `.env` files that went to the Vault), and secrets in committed files or in the copied history went over.
|
|
150
|
+
|
|
151
|
+
Delete `$W` when the user is done with it (it holds the scan and plan, no values).
|
|
152
|
+
|
|
153
|
+
## What cannot move, and why
|
|
154
|
+
|
|
155
|
+
- **Rotating OAuth sign-ins**: Claude Code's login, Codex's ChatGPT login (`~/.codex/auth.json` tokens), gcloud user credentials, remote MCP servers signed in with OAuth. Their refresh token changes on use, so a copy would sign one side out. Sign in again on the box.
|
|
156
|
+
- **macOS keychain-bound**: Docker's `credsStore`, Claude Code on macOS, AWS `credential_process` helpers. The keychain does not exist on the box. (`gh`'s keychain token can move: it is read with `gh auth token`.)
|
|
157
|
+
- **Short-lived**: AWS SSO sessions and `aws_session_token`. Sign in on the box.
|
|
158
|
+
- **Too big**: a credential file over 64 KiB; the plan says how to copy it.
|