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 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
- Signs you in to instabox in your browser (first run only; GitHub by default, `--google` for Google), lets you pick one of your boxes or start a new one (no boxes yet: a new one, no question; `--box NAME` skips the question), 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 drops you into the box over SSH. `npx instabox@latest sh` does the same.
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
- A GitHub and a Google sign-in are separate instabox accounts, each with its own boxes, so this and the CLI's own `instabox login` default to the same provider, GitHub. With `--google`, the account is the Google one, and the CLI reaches it with `instabox login --provider google`.
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
- - The box list is `instabox ls`: running boxes first, then newest. A suspended or stopped box is resumed or started; its settings (timeout, auto-suspend) are left alone. Without a terminal to ask (an agent running it), it lists the boxes, installs the CLI and skills, and stops; `--box NAME` or `instabox create` takes it from there.
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: box + CLI + skills + SSH in; any other command goes to the instabox CLI.
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, parseChoice } from '../lib/boxes.mjs';
23
- import { boxFlag, checkCli, findCli, installCli, installCommand, isOnboarding, real, runCli } from '../lib/cli.mjs';
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
- // GitHub, the provider the instabox CLI itself signs in with: a GitHub and a
38
- // Google sign-in are separate accounts, and the two entry points disagreeing
39
- // put one person in both. --google is still here for a Google account.
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), pick one of your boxes or start a new
57
- one, SSH into it, and install the instabox CLI and agent skills
58
- --box NAME go straight to that box instead of asking
59
- --google sign in with Google instead of GitHub (a separate account
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
- // The box to enter, null for a new one, or { listed: true } when there is no terminal to ask
203
- // which one (an agent running it): the boxes are listed and nothing is picked for it.
204
- async function chooseBox() {
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
- const wanted = boxFlag(process.argv.slice(2));
210
- if (wanted) return boxes.find((b) => b.name === wanted || b.id === wanted) || die(`you have no box named ${wanted}`);
211
- if (!boxes.length) return null;
212
- const ask = process.stdin.isTTY && process.stdout.isTTY;
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, reused: false };
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
- async function sh() {
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
- const picked = await chooseBox();
381
- if (picked?.listed) {
351
+ if (!wantBox) {
382
352
  await reportInstalls(cli);
383
- console.log(`\n New box: ${bold('instabox create --name NAME')}`);
384
- console.log(` Or enter one listed above: ${bold('npx instabox@latest --box NAME')}\n`);
385
- return;
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
- // Only entering a box needs OpenSSH; the list-and-stop path above runs without it.
388
- for (const bin of ['ssh', 'ssh-keygen']) {
389
- if (spawnSync(bin, ['-V'], { stdio: 'ignore' }).error) die(`${bin} was not found. Install OpenSSH and try again.`);
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(picked ? `opening ${picked.name}…` : 'starting a new box…')}`);
393
- const box = await (picked ? enterBox(picked.id) : newBox()).catch((e) =>
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
- const took = box.resumed
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}. Run`)} npx instabox@latest ${dim('again to get back in.')}\n`);
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) throw new ApiError(res.status, data?.error?.message || `instabox HTTP ${res.status}`);
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
- // Which of your boxes to offer, in order: live ones only, running first, then newest.
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` (or the old `sh`) with only our flags; anything else is a CLI command.
10
+ // Bare `instabox` or `sh`, with only our flags; anything else is a CLI command.
11
11
  export function isOnboarding(args) {
12
- for (let i = 0; i < args.length; i++) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instabox",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "A cloud box for your coding agent, the instabox CLI and its agent skills in one command: npx instabox@latest",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 sign-in with GitHub; --provider google for Google
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, so sign in the way the user did. Both `instabox login` and `npx instabox@latest` sign in with GitHub unless given `--provider google` or `--google`, and the CLI shares the sign-in `npx` made.
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