instabox 0.4.3 → 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 +7 -4
- package/bin/instabox.mjs +63 -79
- package/lib/api.mjs +4 -2
- package/lib/boxes.mjs +1 -10
- package/lib/cli.mjs +2 -15
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +2 -2
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.
|
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,10 +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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
const PROVIDER = process.argv.includes('--google') ? 'google' : 'github';
|
|
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' : '';
|
|
41
39
|
const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
|
|
42
40
|
const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
43
41
|
const green = (s) => (process.stdout.isTTY ? `\x1b[32m${s}\x1b[0m` : s);
|
|
@@ -53,12 +51,10 @@ function usage() {
|
|
|
53
51
|
console.log(`
|
|
54
52
|
${bold('instabox')}: a cloud box in seconds
|
|
55
53
|
|
|
56
|
-
npx instabox@latest sign in (first time),
|
|
57
|
-
|
|
58
|
-
--
|
|
59
|
-
|
|
60
|
-
with its own boxes; instabox login --provider google
|
|
61
|
-
signs the CLI in to the same one)
|
|
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
|
|
62
58
|
--no-cli do not install or update the instabox CLI
|
|
63
59
|
--no-skills do not install the instabox agent skills
|
|
64
60
|
npx instabox@latest <command> run an instabox CLI command (instabox help lists them)
|
|
@@ -199,51 +195,17 @@ async function waitOp(op) {
|
|
|
199
195
|
return op;
|
|
200
196
|
}
|
|
201
197
|
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const { machines = [] } = await client
|
|
206
|
-
.call('GET', '/v1/machines?limit=100')
|
|
207
|
-
.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(() => ({}));
|
|
208
201
|
const boxes = orderBoxes(machines);
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const w = Math.max(...boxes.map((b) => b.name.length));
|
|
214
|
-
console.log(`\n ${bold('Your boxes')}`);
|
|
215
|
-
boxes.forEach((b, i) =>
|
|
216
|
-
console.log(
|
|
217
|
-
` ${ask ? `${i + 1}) ` : ''}${b.name.padEnd(w)} ${b.state.padEnd(9)} ${dim(`${b.template?.id || ''} created ${new Date(b.createdAt).toLocaleString()}`)}`,
|
|
218
|
-
),
|
|
219
|
-
);
|
|
220
|
-
if (!ask) return { listed: true };
|
|
221
|
-
console.log(` ${boxes.length + 1}) ${green('+ new box')}`);
|
|
222
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
223
|
-
try {
|
|
224
|
-
for (;;) {
|
|
225
|
-
const i = parseChoice(await rl.question(' Enter a box [1]: '), boxes.length);
|
|
226
|
-
if (i !== null) return i === boxes.length ? null : boxes[i];
|
|
227
|
-
}
|
|
228
|
-
} finally {
|
|
229
|
-
rl.close();
|
|
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}`);
|
|
230
206
|
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Wakes a suspended or stopped box and leaves its settings (timeout, auto-suspend) alone.
|
|
234
|
-
async function enterBox(id) {
|
|
235
|
-
const path = '/v1/machines/' + enc(id);
|
|
236
|
-
let m = await client.call('GET', path);
|
|
237
|
-
if (m.operationId) {
|
|
238
|
-
await waitOp(await client.call('GET', '/v1/operations/' + enc(m.operationId)));
|
|
239
|
-
m = await client.call('GET', path);
|
|
240
|
-
}
|
|
241
|
-
if (m.state === 'suspended' || m.state === 'stopped') {
|
|
242
|
-
await waitOp(await client.call('POST', `${path}/${m.state === 'suspended' ? 'resume' : 'start'}`));
|
|
243
|
-
return { id: m.id, resumed: true };
|
|
244
|
-
}
|
|
245
|
-
if (m.state !== 'running') throw new Error(`box ${m.name} is ${m.state}`);
|
|
246
|
-
return { id: m.id, reused: true };
|
|
207
|
+
console.log(`\n Remove one with ${bold('instabox rm <box>')}, or open one with ${bold('instabox ssh <box>')}.\n`);
|
|
208
|
+
process.exit(1);
|
|
247
209
|
}
|
|
248
210
|
|
|
249
211
|
async function newBox() {
|
|
@@ -257,7 +219,7 @@ async function newBox() {
|
|
|
257
219
|
if (img) body.imageId = img.id;
|
|
258
220
|
else body.templateId = IMAGE;
|
|
259
221
|
const op = await waitOp(await client.call('POST', '/v1/machines', body));
|
|
260
|
-
return { id: op.machineId,
|
|
222
|
+
return { id: op.machineId, name: body.name };
|
|
261
223
|
}
|
|
262
224
|
|
|
263
225
|
// Runs while the box starts; never fatal. Returns a line to print, or null.
|
|
@@ -367,7 +329,16 @@ async function reportInstalls(cli) {
|
|
|
367
329
|
if (skillsLine) console.log(` ${green('✓')} ${skillsLine}`);
|
|
368
330
|
}
|
|
369
331
|
|
|
370
|
-
|
|
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) {
|
|
371
342
|
console.log(`\n ${bold('instabox')} ${dim('· a cloud box in seconds')}\n`);
|
|
372
343
|
|
|
373
344
|
client.use(await signedIn());
|
|
@@ -377,22 +348,40 @@ async function sh() {
|
|
|
377
348
|
);
|
|
378
349
|
|
|
379
350
|
const cli = ensureCli();
|
|
380
|
-
|
|
381
|
-
if (picked?.listed) {
|
|
351
|
+
if (!wantBox) {
|
|
382
352
|
await reportInstalls(cli);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
]);
|
|
386
358
|
}
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
}
|
|
390
366
|
}
|
|
391
367
|
const t0 = Date.now();
|
|
392
|
-
process.stdout.write(`\n ${dim(
|
|
393
|
-
const box = await
|
|
394
|
-
die(`could not start your box: ${e.message}`)
|
|
395
|
-
|
|
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
|
+
}
|
|
396
385
|
|
|
397
386
|
// A throwaway key for this session; only its public half leaves this machine.
|
|
398
387
|
const dir = mkdtempSync(join(tmpdir(), 'instabox-'));
|
|
@@ -419,12 +408,7 @@ async function sh() {
|
|
|
419
408
|
writeFileSync(join(dir, 'known_hosts'), `@cert-authority [${c.host}]:${c.port} ${c.hostTrust.publicKey}\n`);
|
|
420
409
|
|
|
421
410
|
const pauses = m.timeoutAt ? dim(` · pauses itself in ${Math.max(1, Math.round(ttlSeconds / 60))} min`) : '';
|
|
422
|
-
|
|
423
|
-
? `resumed in ${((Date.now() - t0) / 1000).toFixed(1)}s`
|
|
424
|
-
: box.reused
|
|
425
|
-
? 'back in'
|
|
426
|
-
: `ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
|
427
|
-
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`);
|
|
428
412
|
await reportInstalls(cli);
|
|
429
413
|
console.log(dim(` type ${'exit'} to leave · stuck? press Enter, then ~.`));
|
|
430
414
|
// The box's prompt is ~37 columns; narrower terminals make bash scroll the input line sideways.
|
|
@@ -452,7 +436,7 @@ async function sh() {
|
|
|
452
436
|
];
|
|
453
437
|
const child = spawn('ssh', args, { stdio: 'inherit' });
|
|
454
438
|
child.on('exit', (code) => {
|
|
455
|
-
console.log(`\n ${dim(`left ${m.name}.
|
|
439
|
+
console.log(`\n ${dim(`left ${m.name}. Back in:`)} instabox ssh ${m.name}\n`);
|
|
456
440
|
process.exit(code ?? 0);
|
|
457
441
|
});
|
|
458
442
|
}
|
|
@@ -460,7 +444,7 @@ async function sh() {
|
|
|
460
444
|
// Any other command line is the CLI's, so `npm i -g instabox` still gives a working `instabox`.
|
|
461
445
|
const args = process.argv.slice(2);
|
|
462
446
|
if (args.length === 1 && (args[0] === '-h' || args[0] === '--help')) usage();
|
|
463
|
-
else if (isOnboarding(args)) await sh
|
|
447
|
+
else if (isOnboarding(args)) await onboard(args.includes('sh'));
|
|
464
448
|
else {
|
|
465
449
|
let path = findCli();
|
|
466
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
|
@@ -7,22 +7,9 @@ import { delimiter, join, sep } from 'node:path';
|
|
|
7
7
|
|
|
8
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
|
|