instabox 0.4.3 → 0.5.1

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 {
@@ -14,13 +14,13 @@ import {
14
14
  readdirSync,
15
15
  } from 'node:fs';
16
16
  import http from 'node:http';
17
+ import { createInterface } from 'node:readline/promises';
17
18
  import { tmpdir, homedir } from 'node:os';
18
19
  import { join, dirname } from 'node:path';
19
- import { createInterface } from 'node:readline/promises';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  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';
22
+ import { orderBoxes } from '../lib/boxes.mjs';
23
+ import { checkCli, findCli, installCli, installCommand, isOnboarding, real, runCli } from '../lib/cli.mjs';
24
24
  import { callbackHandler } from '../lib/login.mjs';
25
25
  import { loadCredentials, saveCredentials } from '../lib/credentials.mjs';
26
26
 
@@ -34,10 +34,9 @@ const API = (
34
34
  const IMAGE = process.env.INSTABOX_IMAGE || 'universal';
35
35
  // SSH certificate lifetime for a box with no timeout of its own.
36
36
  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';
37
+ // With no provider selected, the browser confirms its existing Instabox account
38
+ // or offers sign-in. Explicit flags still start the chosen provider directly.
39
+ const PROVIDER = process.argv.includes('--google') ? 'google' : process.argv.includes('--github') ? 'github' : '';
41
40
  const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
42
41
  const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
43
42
  const green = (s) => (process.stdout.isTTY ? `\x1b[32m${s}\x1b[0m` : s);
@@ -53,12 +52,10 @@ function usage() {
53
52
  console.log(`
54
53
  ${bold('instabox')}: a cloud box in seconds
55
54
 
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)
55
+ npx instabox@latest sign in (first time), install the instabox CLI and agent skills
56
+ npx instabox@latest sh the same, then start a new box and SSH into it
57
+ --github, --google sign in directly with that provider instead of confirming
58
+ the browser's current Instabox account
62
59
  --no-cli do not install or update the instabox CLI
63
60
  --no-skills do not install the instabox agent skills
64
61
  npx instabox@latest <command> run an instabox CLI command (instabox help lists them)
@@ -130,7 +127,38 @@ function signInPage(ok, title, text, who) {
130
127
 
131
128
  // Browser sign-in, the same loopback flow as instabox's own CLI (RFC 8252 + PKCE): listen on
132
129
  // 127.0.0.1, open /auth/cli/start, receive the code at /callback, exchange it with the verifier.
133
- async function browserLogin() {
130
+ // askInvite asks for an invite code on the terminal; '' without one (a pipe, CI) or when left empty.
131
+ async function askInvite(again) {
132
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return '';
133
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
134
+ try {
135
+ const q = again
136
+ ? ` That code is invalid or used up. ${bold('Invite code')} ${dim('(Enter to stop)')}: `
137
+ : ` You're on the waitlist; an invite code lets you in now.\n ${bold('Invite code')} ${dim('(Enter to stop)')}: `;
138
+ return (await rl.question(q)).trim();
139
+ } finally {
140
+ rl.close();
141
+ }
142
+ }
143
+
144
+ // signInWithInvite is browserLogin, asking for an invite code when the account is on the waitlist.
145
+ async function signInWithInvite() {
146
+ let invite = '';
147
+ for (;;) {
148
+ const r = await browserLogin(invite);
149
+ if (r.tok) return r.tok;
150
+ const waitlisted = r.code === 'invite_required' || r.code === 'invite_invalid';
151
+ if (waitlisted) invite = await askInvite(r.code === 'invite_invalid');
152
+ if (waitlisted && invite) continue;
153
+ if (waitlisted)
154
+ die(
155
+ "this account is on the waitlist; we'll email you an invite code.\n Have one? Run: npx instabox@latest login --invite-code <code>",
156
+ );
157
+ die(`sign-in failed: ${r.err}`);
158
+ }
159
+ }
160
+
161
+ async function browserLogin(invite) {
134
162
  const verifier = randomBytes(32).toString('base64url');
135
163
  const state = randomBytes(24).toString('base64url');
136
164
  const challenge = createHash('sha256').update(verifier).digest('base64url');
@@ -151,13 +179,17 @@ async function browserLogin() {
151
179
  stale: () => signInPage(false, 'This sign-in link is stale', 'Run the command in your terminal again.'),
152
180
  again: () => signInPage(true, 'Already signing you in', 'Head back to your terminal.'),
153
181
  ok: (r) => signInPage(true, "You're in", 'Head back to your terminal.', r.me && (r.me.name || r.me.login)),
154
- failed: (err) => signInPage(false, 'Sign-in failed', `${err}. Return to your terminal and run the command again.`),
182
+ failed: (err, code) =>
183
+ code === 'invite_required' || code === 'invite_invalid'
184
+ ? signInPage(false, "You're on the waitlist", 'Return to your terminal to enter an invite code.')
185
+ : signInPage(false, 'Sign-in failed', `${err}. Return to your terminal and run the command again.`),
155
186
  };
156
187
  const server = http.createServer(callbackHandler({ state, exchange, finish, page }));
157
188
  await new Promise((r) => server.listen(0, '127.0.0.1', r));
158
189
  const redirect = `http://127.0.0.1:${server.address().port}/callback`;
159
190
  const q = { redirect_uri: redirect, state, code_challenge: challenge, code_challenge_method: 'S256' };
160
191
  if (PROVIDER) q.provider = PROVIDER;
192
+ if (invite) q.invite = invite;
161
193
  const start = `${API}/auth/cli/start?` + new URLSearchParams(q);
162
194
  console.log(` ${bold('Sign in to instabox')} in your browser ${dim('(opening it now)')}`);
163
195
  console.log(` ${dim('If it did not open:')} ${start}\n`);
@@ -166,8 +198,7 @@ async function browserLogin() {
166
198
  const result = await done;
167
199
  clearTimeout(timer);
168
200
  server.close();
169
- if (result.err) die(`sign-in failed: ${result.err}`);
170
- return result.tok;
201
+ return result.err ? { err: result.err, code: result.code } : { tok: result.tok };
171
202
  }
172
203
 
173
204
  // The CLI's saved token, a refreshed one, or a browser sign-in; saved where the CLI reads it.
@@ -181,7 +212,7 @@ async function signedIn() {
181
212
  return t;
182
213
  } catch {}
183
214
  }
184
- const t = await browserLogin();
215
+ const t = await signInWithInvite();
185
216
  saveCredentials(API, t);
186
217
  return t;
187
218
  }
@@ -199,51 +230,17 @@ async function waitOp(op) {
199
230
  return op;
200
231
  }
201
232
 
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}`));
233
+ // At the box limit: say so, and show the boxes there are to remove or open instead.
234
+ async function quotaFull(err) {
235
+ const { machines = [] } = await client.call('GET', '/v1/machines?limit=100').catch(() => ({}));
208
236
  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();
230
- }
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 };
237
+ console.log(`\n ${bold('Box limit reached')} ${dim(`(${err.message})`)}`);
238
+ if (boxes.length) {
239
+ const w = Math.max(...boxes.map((b) => b.name.length));
240
+ for (const b of boxes) console.log(` ${b.name.padEnd(w)} ${b.state}`);
244
241
  }
245
- if (m.state !== 'running') throw new Error(`box ${m.name} is ${m.state}`);
246
- return { id: m.id, reused: true };
242
+ console.log(`\n Remove one with ${bold('instabox rm <box>')}, or open one with ${bold('instabox ssh <box>')}.\n`);
243
+ process.exit(1);
247
244
  }
248
245
 
249
246
  async function newBox() {
@@ -257,7 +254,7 @@ async function newBox() {
257
254
  if (img) body.imageId = img.id;
258
255
  else body.templateId = IMAGE;
259
256
  const op = await waitOp(await client.call('POST', '/v1/machines', body));
260
- return { id: op.machineId, reused: false };
257
+ return { id: op.machineId, name: body.name };
261
258
  }
262
259
 
263
260
  // Runs while the box starts; never fatal. Returns a line to print, or null.
@@ -307,6 +304,26 @@ function skillFiles(dir, rel = '') {
307
304
  });
308
305
  }
309
306
 
307
+ // Skills this package used to ship under another name. A copy we installed (marked
308
+ // `installed-by: instabox`) is removed, so the agent does not see both; the user's own is kept.
309
+ const RETIRED_SKILLS = [
310
+ 'instabox-migrate', // now instabox-move-repo
311
+ 'instabox-move-all', // now instabox-basic-setup
312
+ ];
313
+
314
+ function removeRetiredSkills(roots) {
315
+ const removed = new Set();
316
+ for (const root of roots)
317
+ for (const n of RETIRED_SKILLS) {
318
+ try {
319
+ if (!readFileSync(join(root, n, 'SKILL.md'), 'utf8').includes('installed-by: instabox')) continue;
320
+ rmSync(join(root, n), { recursive: true, force: true });
321
+ removed.add(n);
322
+ } catch {}
323
+ }
324
+ return [...removed];
325
+ }
326
+
310
327
  // Agent skills shipped in this package (skills/<name>/SKILL.md, with any scripts beside it) into
311
328
  // the folders coding agents read. Overwrites only copies we installed earlier (marked
312
329
  // `installed-by: instabox`).
@@ -356,6 +373,8 @@ function installSkills() {
356
373
  if (of('installed').length) parts.push(`installed ${of('installed').join(', ')}`);
357
374
  if (of('unchanged').length) parts.push(`${of('unchanged').join(', ')} already up to date`);
358
375
  if (kept.size) parts.push(`kept your own ${[...kept].join(', ')}`);
376
+ const retired = removeRetiredSkills(roots);
377
+ if (retired.length) parts.push(`removed the old ${retired.join(', ')}`);
359
378
  return parts.length ? `agent skills: ${parts.join('; ')} ${dim('(~/.claude/skills, ~/.agents/skills)')}` : null;
360
379
  }
361
380
 
@@ -367,7 +386,16 @@ async function reportInstalls(cli) {
367
386
  if (skillsLine) console.log(` ${green('✓')} ${skillsLine}`);
368
387
  }
369
388
 
370
- async function sh() {
389
+ // The commands to run next, one per line, descriptions lined up.
390
+ function nextSteps(rows) {
391
+ const w = Math.max(...rows.map(([cmd]) => cmd.length));
392
+ console.log(`\n ${bold('Next steps')}`);
393
+ for (const [cmd, what] of rows) console.log(` ${cmd.padEnd(w)} ${dim(what)}`);
394
+ console.log();
395
+ }
396
+
397
+ // Sign-in, the CLI and the skills; with `sh` (wantBox) also a new box and an SSH session in it.
398
+ async function onboard(wantBox) {
371
399
  console.log(`\n ${bold('instabox')} ${dim('· a cloud box in seconds')}\n`);
372
400
 
373
401
  client.use(await signedIn());
@@ -377,22 +405,40 @@ async function sh() {
377
405
  );
378
406
 
379
407
  const cli = ensureCli();
380
- const picked = await chooseBox();
381
- if (picked?.listed) {
408
+ if (!wantBox) {
382
409
  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;
410
+ return nextSteps([
411
+ ['npx instabox@latest sh', 'start a new box and SSH into it'],
412
+ ['instabox ls', 'list your boxes'],
413
+ ['instabox ssh <box>', 'open a shell in one; add -- <command> to run just that'],
414
+ ]);
386
415
  }
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.`);
416
+ // An agent (no terminal) cannot use an interactive shell, so it gets the commands instead.
417
+ const tty = process.stdin.isTTY && process.stdout.isTTY;
418
+ if (tty) {
419
+ for (const bin of ['ssh', 'ssh-keygen']) {
420
+ if (spawnSync(bin, ['-V'], { stdio: 'ignore' }).error)
421
+ die(`${bin} was not found. Install OpenSSH and try again.`);
422
+ }
390
423
  }
391
424
  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
- );
425
+ process.stdout.write(`\n ${dim('starting a new box…')}`);
426
+ const box = await newBox().catch(async (e) => {
427
+ if (e.code !== 'quota_exceeded') die(`could not start your box: ${e.message}`);
428
+ // The way out of the limit is `instabox rm`, so the CLI and the skills land before the exit.
429
+ console.log();
430
+ await reportInstalls(cli);
431
+ return quotaFull(e);
432
+ });
433
+ const ready = `ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`;
434
+ if (!tty) {
435
+ console.log(`\n ${green('✓')} box ${bold(box.name)} ${ready}`);
436
+ await reportInstalls(cli);
437
+ return nextSteps([
438
+ [`instabox ssh ${box.name} -- <command>`, 'run a command in it'],
439
+ [`instabox ssh ${box.name}`, 'open a shell in it, from a terminal'],
440
+ ]);
441
+ }
396
442
 
397
443
  // A throwaway key for this session; only its public half leaves this machine.
398
444
  const dir = mkdtempSync(join(tmpdir(), 'instabox-'));
@@ -419,12 +465,7 @@ async function sh() {
419
465
  writeFileSync(join(dir, 'known_hosts'), `@cert-authority [${c.host}]:${c.port} ${c.hostTrust.publicKey}\n`);
420
466
 
421
467
  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`);
468
+ process.stdout.write(`\r ${green('✓')} box ${bold(m.name)} ${ready}${pauses}\n`);
428
469
  await reportInstalls(cli);
429
470
  console.log(dim(` type ${'exit'} to leave · stuck? press Enter, then ~.`));
430
471
  // The box's prompt is ~37 columns; narrower terminals make bash scroll the input line sideways.
@@ -452,7 +493,7 @@ async function sh() {
452
493
  ];
453
494
  const child = spawn('ssh', args, { stdio: 'inherit' });
454
495
  child.on('exit', (code) => {
455
- console.log(`\n ${dim(`left ${m.name}. Run`)} npx instabox@latest ${dim('again to get back in.')}\n`);
496
+ console.log(`\n ${dim(`left ${m.name}. Back in:`)} instabox ssh ${m.name}\n`);
456
497
  process.exit(code ?? 0);
457
498
  });
458
499
  }
@@ -460,7 +501,7 @@ async function sh() {
460
501
  // Any other command line is the CLI's, so `npm i -g instabox` still gives a working `instabox`.
461
502
  const args = process.argv.slice(2);
462
503
  if (args.length === 1 && (args[0] === '-h' || args[0] === '--help')) usage();
463
- else if (isOnboarding(args)) await sh();
504
+ else if (isOnboarding(args)) await onboard(args.includes('sh'));
464
505
  else {
465
506
  let path = findCli();
466
507
  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/lib/login.mjs CHANGED
@@ -20,7 +20,10 @@ export function callbackHandler({ state, exchange, finish, page }) {
20
20
  taken = true;
21
21
  let result;
22
22
  if (u.searchParams.get('error')) {
23
- result = { err: u.searchParams.get('error_description') || u.searchParams.get('error') };
23
+ result = {
24
+ err: u.searchParams.get('error_description') || u.searchParams.get('error'),
25
+ code: u.searchParams.get('error'),
26
+ };
24
27
  } else {
25
28
  try {
26
29
  result = await exchange(u.searchParams.get('code'));
@@ -29,7 +32,7 @@ export function callbackHandler({ state, exchange, finish, page }) {
29
32
  }
30
33
  }
31
34
  res.writeHead(200, html);
32
- res.end(result.tok ? page.ok(result) : page.failed(result.err));
35
+ res.end(result.tok ? page.ok(result) : page.failed(result.err, result.code));
33
36
  finish(result);
34
37
  };
35
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instabox",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
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
 
@@ -34,12 +34,13 @@ For CI or an agent without a browser, use an API key instead: `export INSTABOX_A
34
34
 
35
35
  ```sh
36
36
  instabox templates # base systems to start from
37
- instabox create --name work --timeout 2h # new box; waits until it is running
37
+ instabox create --name work # new box; waits until it is running
38
38
  instabox ls # your boxes and their states
39
39
  instabox ssh work # interactive shell (leave with: exit)
40
40
  instabox exec work -- uname -a # one command, no SSH client needed
41
41
  instabox exec work --cwd /data/home -- ls -la # exec runs `bash -lc` as dev
42
42
  instabox files work /data/home # list a directory (or print a text file)
43
+ instabox auto-suspend work 10m # pause after 10 idle minutes (default 30m; 0: never)
43
44
  instabox timeout work 2h --on-timeout pause # pause (keeps memory) or kill at the deadline
44
45
  instabox stop work && instabox start work # stop ends processes, keeps disks
45
46
  instabox suspend work && instabox resume work # pause/resume keeps running processes
@@ -47,7 +48,8 @@ instabox rm work # delete the box
47
48
  ```
48
49
 
49
50
  - Names or IDs (`m_...`) both work wherever `<id-or-name>` is accepted.
50
- - `create` options: `--template T` or `--snapshot S`, `--timeout D [--on-timeout pause|kill]`, `--meta K=V`, `--no-vault`, `--no-wait`. Every box is the same size (`instabox usage` shows it); there are no size flags.
51
+ - `create` options: `--template T` or `--snapshot S`, `--auto-suspend D|0`, `--timeout D [--on-timeout pause|kill]`, `--meta K=V`, `--no-vault`, `--no-wait`. Every box is the same size (`instabox usage` shows it); there are no size flags.
52
+ - A new box pauses itself after 30 idle minutes (no agent working, no terminal use, little CPU) and wakes on the next `exec`, `ssh` or `files`; a paused box keeps its memory and processes. `--auto-suspend 0` keeps a box always on (a server that must keep answering); `--auto-suspend 2h` changes the idle time.
51
53
  - Use `exec` to run a command and read its output, and `ssh` for an interactive shell.
52
54
  - `exec` returns the command's exit code and takes `--stdin` to pipe input. It stops the command after `--timeout` seconds: 60 by default, 600 at most. Run anything longer (installs, builds, test suites) over SSH, which has no time limit and also returns the exit code: `instabox ssh work -- 'cd /data/workspace/app && npm ci'`.
53
55
  - The box's HOME has `MACHINE.md` (which box it is, its size, what survives a stop, the installed tools) and `AGENTS.md`. Both are worth reading before working in the box; they appear a moment after it starts.
@@ -72,8 +74,12 @@ instabox create --name tenant --no-vault # a box for someone else: never gets th
72
74
  - An item's selector (`--selector K=V`) limits it to boxes whose metadata has those labels: create the box with the `--meta` the user's items expect.
73
75
  - Managing items needs a sign-in; an API key cannot read, create or change them. Never ask for a secret's value to put it somewhere else.
74
76
 
77
+ ## Moving work onto a box
78
+
79
+ To move the repo the user is working in (with its worktrees, uncommitted work and agent conversations) onto a new box and install its dependencies there, follow the `instabox-move-repo` skill.
80
+
75
81
  ## Habits
76
82
 
77
- - Give boxes a `--timeout` so forgotten ones pause or delete themselves.
83
+ - Forgotten boxes pause themselves. Give a box a `--timeout` when it must stop at a deadline, or `--timeout D --on-timeout kill` when it should be deleted then.
78
84
  - When the user asks for a new box, create one, with a name `instabox ls` does not already show. Offer an existing box only when they want to continue earlier work in it.
79
85
  - `instabox usage` shows storage use and limits. A quota error means delete or stop something; do not retry the same create in a loop.