instabox 0.4.1 → 0.4.3

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
@@ -4,7 +4,9 @@
4
4
  npx instabox@latest
5
5
  ```
6
6
 
7
- Signs you in to instabox in your browser (first run only; Google by default, `--github` for GitHub), 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 (macOS/Linux; skip with `--no-cli`), installs the agent skills from [`../skills`](../skills) into `~/.claude/skills` and `~/.agents/skills` (skip with `--no-skills`), and drops you into the box over SSH. `npx instabox@latest sh` does the same.
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
+
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`.
8
10
 
9
11
  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.
10
12
 
@@ -12,6 +14,8 @@ Any other command line is the instabox CLI's ([`cmd/instabox`](../cmd/instabox))
12
14
 
13
15
  - `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.
14
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.
17
+ - 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
+ - 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.
15
19
  - 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.
16
20
  - `prepack` copies `../skills` into the package; every skill there ships on the next publish.
17
21
 
@@ -22,7 +26,7 @@ npm ci
22
26
  npm test
23
27
  npm run format:check
24
28
  npm pack # copies ../skills, then builds instabox-<version>.tgz
25
- npx --package=./instabox-0.4.0.tgz instabox --help
29
+ npx --package=./instabox-0.4.3.tgz instabox --help
26
30
  ```
27
31
 
28
32
  `INSTABOX_API_URL` points it at another instabox server.
package/bin/instabox.mjs CHANGED
@@ -2,7 +2,17 @@
2
2
  // npx instabox@latest: box + CLI + skills + SSH in; any other command goes to the instabox CLI.
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createHash, randomBytes } from 'node:crypto';
5
- import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
5
+ import {
6
+ accessSync,
7
+ constants as fsConstants,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ rmSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ readdirSync,
15
+ } from 'node:fs';
6
16
  import http from 'node:http';
7
17
  import { tmpdir, homedir } from 'node:os';
8
18
  import { join, dirname } from 'node:path';
@@ -10,7 +20,7 @@ import { createInterface } from 'node:readline/promises';
10
20
  import { fileURLToPath } from 'node:url';
11
21
  import { createClient } from '../lib/api.mjs';
12
22
  import { orderBoxes, parseChoice } from '../lib/boxes.mjs';
13
- import { boxFlag, findCli, installCli, isOnboarding, runCli } from '../lib/cli.mjs';
23
+ import { boxFlag, checkCli, findCli, installCli, installCommand, isOnboarding, real, runCli } from '../lib/cli.mjs';
14
24
  import { callbackHandler } from '../lib/login.mjs';
15
25
  import { loadCredentials, saveCredentials } from '../lib/credentials.mjs';
16
26
 
@@ -24,7 +34,10 @@ const API = (
24
34
  const IMAGE = process.env.INSTABOX_IMAGE || 'universal';
25
35
  // SSH certificate lifetime for a box with no timeout of its own.
26
36
  const CERT_MIN = 120;
27
- const PROVIDER = process.argv.includes('--github') ? 'github' : 'google';
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';
28
41
  const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
29
42
  const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
30
43
  const green = (s) => (process.stdout.isTTY ? `\x1b[32m${s}\x1b[0m` : s);
@@ -43,8 +56,10 @@ function usage() {
43
56
  npx instabox@latest sign in (first time), pick one of your boxes or start a new
44
57
  one, SSH into it, and install the instabox CLI and agent skills
45
58
  --box NAME go straight to that box instead of asking
46
- --github sign in with GitHub instead of Google
47
- --no-cli do not install the instabox CLI
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)
62
+ --no-cli do not install or update the instabox CLI
48
63
  --no-skills do not install the instabox agent skills
49
64
  npx instabox@latest <command> run an instabox CLI command (instabox help lists them)
50
65
  `);
@@ -184,8 +199,8 @@ async function waitOp(op) {
184
199
  return op;
185
200
  }
186
201
 
187
- // The box to enter, null for a new one, or { listed: <name> } when there is no terminal to ask
188
- // which one; the name is the box the list puts first, so the hint below is ready to paste.
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.
189
204
  async function chooseBox() {
190
205
  const { machines = [] } = await client
191
206
  .call('GET', '/v1/machines?limit=100')
@@ -202,7 +217,7 @@ async function chooseBox() {
202
217
  ` ${ask ? `${i + 1}) ` : ''}${b.name.padEnd(w)} ${b.state.padEnd(9)} ${dim(`${b.template?.id || ''} created ${new Date(b.createdAt).toLocaleString()}`)}`,
203
218
  ),
204
219
  );
205
- if (!ask) return { listed: boxes[0].name };
220
+ if (!ask) return { listed: true };
206
221
  console.log(` ${boxes.length + 1}) ${green('+ new box')}`);
207
222
  const rl = createInterface({ input: process.stdin, output: process.stdout });
208
223
  try {
@@ -248,7 +263,31 @@ async function newBox() {
248
263
  // Runs while the box starts; never fatal. Returns a line to print, or null.
249
264
  async function ensureCli() {
250
265
  if (process.argv.includes('--no-cli')) return null;
251
- if (findCli()) return `instabox CLI already installed ${dim('· try')} instabox ls`;
266
+ const found = findCli();
267
+ // The installer is a sh script: on Windows an existing CLI is only reported, never checked.
268
+ if (found && process.platform === 'win32') return `instabox CLI already installed ${dim('· try')} instabox ls`;
269
+ if (found) {
270
+ const { state, version } = await checkCli(found, API);
271
+ const v = version ? ` ${version}` : '';
272
+ if (state === 'current') return `instabox CLI${v} is up to date ${dim('· try')} instabox ls`;
273
+ if (state === 'dev')
274
+ return `instabox CLI at ${found} is a development build (${version}), left as is ${dim('· for the release:')} ${installCommand(API)}`;
275
+ if (state === 'unknown')
276
+ return `instabox CLI${v} already installed, could not check for an update ${dim('· to update:')} ${installCommand(API)}`;
277
+ // stale: replace it where it is, the way a first install does. Through a symlink, replace
278
+ // the file it points to, so the link (and whatever manages it) stays as it was.
279
+ const dir = dirname(real(found));
280
+ try {
281
+ accessSync(dir, fsConstants.W_OK);
282
+ } catch {
283
+ // Installing anywhere else would leave the old copy first on PATH.
284
+ return `instabox CLI${v} at ${found} is out of date and ${dir} is not writable ${dim('· to update it there:')} ${installCommand(API, dir)}`;
285
+ }
286
+ const r = await installCli(API, { dir });
287
+ if (r.error)
288
+ return `instabox CLI${v} is out of date and could not be updated ${dim(`(${r.error}) · to update:`)} ${installCommand(API)}`;
289
+ return `instabox CLI updated${v ? ` from${v}` : ''}${r.version ? ` to ${r.version}` : ''} at ${found} ${dim('· try')} instabox ls`;
290
+ }
252
291
  if (process.platform === 'win32') return null;
253
292
  const r = await installCli(API);
254
293
  if (r.error) return `instabox CLI not installed ${dim(`(${r.error})`)}`;
@@ -258,8 +297,19 @@ async function ensureCli() {
258
297
  );
259
298
  }
260
299
 
261
- // Agent skills shipped in this package (skills/<name>/SKILL.md) into the folders coding agents
262
- // read. Overwrites only copies we installed earlier (marked `installed-by: instabox`).
300
+ // A skill directory's files, relative to it, sorted.
301
+ function skillFiles(dir, rel = '') {
302
+ return readdirSync(join(dir, rel), { withFileTypes: true })
303
+ .sort((a, b) => a.name.localeCompare(b.name))
304
+ .flatMap((e) => {
305
+ const r = rel ? `${rel}/${e.name}` : e.name;
306
+ return e.isDirectory() ? skillFiles(dir, r) : e.isFile() ? [r] : [];
307
+ });
308
+ }
309
+
310
+ // Agent skills shipped in this package (skills/<name>/SKILL.md, with any scripts beside it) into
311
+ // the folders coding agents read. Overwrites only copies we installed earlier (marked
312
+ // `installed-by: instabox`).
263
313
  function installSkills() {
264
314
  if (process.argv.includes('--no-skills')) return null;
265
315
  const src = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
@@ -270,21 +320,43 @@ function installSkills() {
270
320
  return null;
271
321
  }
272
322
  const roots = [join(homedir(), '.claude', 'skills'), join(homedir(), '.agents', 'skills')];
273
- const done = new Set();
323
+ // A skill counts as updated if any of its copies changed, else installed if one was new.
324
+ const outcome = new Map();
325
+ const rank = { unchanged: 0, installed: 1, updated: 2 };
326
+ const kept = new Set();
274
327
  for (const root of roots) {
275
328
  for (const n of names) {
276
329
  const dest = join(root, n, 'SKILL.md');
277
330
  try {
278
- if (existsSync(dest) && !readFileSync(dest, 'utf8').includes('installed-by: instabox')) continue;
279
- mkdirSync(join(root, n), { recursive: true });
280
- writeFileSync(dest, readFileSync(join(src, n, 'SKILL.md')));
281
- done.add(n);
331
+ let what = 'installed';
332
+ if (existsSync(dest)) {
333
+ const old = readFileSync(dest, 'utf8');
334
+ if (!old.includes('installed-by: instabox')) {
335
+ kept.add(n);
336
+ continue;
337
+ }
338
+ what = 'unchanged';
339
+ }
340
+ // SKILL.md and whatever else the skill ships (scripts/...).
341
+ for (const f of skillFiles(join(src, n))) {
342
+ const body = readFileSync(join(src, n, f));
343
+ const to = join(root, n, f);
344
+ if (existsSync(to) && readFileSync(to).equals(body)) continue;
345
+ if (what === 'unchanged') what = 'updated';
346
+ mkdirSync(dirname(to), { recursive: true });
347
+ writeFileSync(to, body);
348
+ }
349
+ if (rank[what] > (rank[outcome.get(n)] ?? -1)) outcome.set(n, what);
282
350
  } catch {}
283
351
  }
284
352
  }
285
- return done.size
286
- ? `agent skills installed: ${[...done].join(', ')} ${dim('(~/.claude/skills, ~/.agents/skills)')}`
287
- : null;
353
+ const of = (what) => names.filter((n) => outcome.get(n) === what);
354
+ const parts = [];
355
+ if (of('updated').length) parts.push(`updated ${of('updated').join(', ')}`);
356
+ if (of('installed').length) parts.push(`installed ${of('installed').join(', ')}`);
357
+ if (of('unchanged').length) parts.push(`${of('unchanged').join(', ')} already up to date`);
358
+ if (kept.size) parts.push(`kept your own ${[...kept].join(', ')}`);
359
+ return parts.length ? `agent skills: ${parts.join('; ')} ${dim('(~/.claude/skills, ~/.agents/skills)')}` : null;
288
360
  }
289
361
 
290
362
  // Waits for the CLI install started earlier, installs the skills, and prints a line for each.
@@ -308,9 +380,8 @@ async function sh() {
308
380
  const picked = await chooseBox();
309
381
  if (picked?.listed) {
310
382
  await reportInstalls(cli);
311
- console.log(
312
- `\n Which box? ${bold(`npx instabox@latest --box ${picked.listed}`)} enters it, ${bold('instabox create --name NAME')} starts a new one.\n`,
313
- );
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`);
314
385
  return;
315
386
  }
316
387
  // Only entering a box needs OpenSSH; the list-and-stop path above runs without it.
package/lib/cli.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  // The Go instabox CLI: find it, install it, hand it the command line.
2
- import { spawn, spawnSync } from 'node:child_process';
3
- import { realpathSync, statSync } from 'node:fs';
2
+ import { execFile, spawn, spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { createReadStream, realpathSync, statSync } from 'node:fs';
4
5
  import { homedir } from 'node:os';
5
6
  import { delimiter, join, sep } from 'node:path';
6
7
 
7
- const OUR_FLAGS = new Set(['--github', '--no-cli', '--no-skills']);
8
+ const OUR_FLAGS = new Set(['--github', '--google', '--no-cli', '--no-skills']);
8
9
 
9
10
  // Bare `instabox` (or the old `sh`) with only our flags; anything else is a CLI command.
10
11
  export function isOnboarding(args) {
@@ -24,7 +25,7 @@ export function boxFlag(args) {
24
25
  return eq ? eq.slice('--box='.length) : null;
25
26
  }
26
27
 
27
- const real = (p) => {
28
+ export const real = (p) => {
28
29
  try {
29
30
  return realpathSync(p);
30
31
  } catch {
@@ -65,11 +66,73 @@ export function findCli({ env = process.env, self = process.argv[1] } = {}) {
65
66
  // The URL is "$1", never script text; a failed download stops before sh runs anything.
66
67
  const INSTALL = 's=$(curl -fsSL "$1/install.sh") && printf \'%s\\n\' "$s" | sh';
67
68
 
68
- // path is null, never a bare `instabox` that could resolve back to this script.
69
- export function installCli(api) {
69
+ // The command a person runs to install or update the CLI themselves; with dir, one that puts it
70
+ // in that directory (for a directory they cannot write to without sudo).
71
+ export const installCommand = (api, dir) =>
72
+ dir ? `curl -fsSL ${api}/install.sh | sudo env INSTABOX_INSTALL_DIR=${dir} sh` : `curl -fsSL ${api}/install.sh | sh`;
73
+
74
+ // What `instabox --version` prints after "instabox ", or null when it does not run. Async, so a
75
+ // slow or hung binary does not hold up the rest of onboarding.
76
+ export function cliVersion(path) {
77
+ return new Promise((resolve) => {
78
+ execFile(path, ['--version'], { encoding: 'utf8', timeout: 10_000 }, (err, stdout) => {
79
+ if (err) return resolve(null);
80
+ const m = String(stdout)
81
+ .trim()
82
+ .match(/^instabox (\S+)$/);
83
+ resolve(m ? m[1] : null);
84
+ });
85
+ });
86
+ }
87
+
88
+ // The file's sha256, streamed: the CLI is tens of MB and this runs while the box starts.
89
+ function sha256File(path) {
90
+ return new Promise((resolve, reject) => {
91
+ const h = createHash('sha256');
92
+ createReadStream(path)
93
+ .on('error', reject)
94
+ .on('data', (d) => h.update(d))
95
+ .on('end', () => resolve(h.digest('hex')));
96
+ });
97
+ }
98
+
99
+ // Whether the CLI at path is the build the server's /install.sh would install. Release builds
100
+ // are named after a commit (scripts/build-release.sh), which says nothing about which is newer,
101
+ // so the check is by content: the binary's sha256 against the server's SHA256SUMS.
102
+ // current: one of the server's builds
103
+ // dev: a local build (`go build` leaves "dev"; build-cli.sh marks a dirty tree "-dirty"),
104
+ // which is not ours to replace
105
+ // stale: a release build the server no longer serves
106
+ // unknown: the CLI does not run, or SHA256SUMS could not be read
107
+ export async function checkCli(path, api, { fetch = globalThis.fetch } = {}) {
108
+ const version = await cliVersion(path);
109
+ if (!version) return { state: 'unknown', version };
110
+ if (version === 'dev' || version.endsWith('-dirty')) return { state: 'dev', version };
111
+ let sums;
112
+ try {
113
+ const res = await fetch(`${api}/dl/SHA256SUMS`, { signal: AbortSignal.timeout(10_000) });
114
+ if (!res.ok) return { state: 'unknown', version };
115
+ sums = new Set((await res.text()).match(/\b[0-9a-f]{64}\b/g) || []);
116
+ } catch {
117
+ return { state: 'unknown', version };
118
+ }
119
+ if (!sums.size) return { state: 'unknown', version };
120
+ let mine;
121
+ try {
122
+ mine = await sha256File(path);
123
+ } catch {
124
+ return { state: 'unknown', version };
125
+ }
126
+ return { state: sums.has(mine) ? 'current' : 'stale', version };
127
+ }
128
+
129
+ // path is null, never a bare `instabox` that could resolve back to this script. dir replaces the
130
+ // CLI in that directory (INSTABOX_INSTALL_DIR), so an update lands where the old one is found.
131
+ export function installCli(api, { dir } = {}) {
70
132
  return new Promise((resolve) => {
71
133
  let out = '';
72
- const p = spawn('sh', ['-c', INSTALL, 'sh', api], { stdio: ['ignore', 'pipe', 'pipe'] });
134
+ const env = dir ? { ...process.env, INSTABOX_INSTALL_DIR: dir } : process.env;
135
+ const p = spawn('sh', ['-c', INSTALL, 'sh', api], { stdio: ['ignore', 'pipe', 'pipe'], env });
73
136
  p.stdout.on('data', (d) => {
74
137
  out += d;
75
138
  });
@@ -79,8 +142,10 @@ export function installCli(api) {
79
142
  p.on('error', (e) => resolve({ error: e.message }));
80
143
  p.on('close', (code) => {
81
144
  if (code !== 0) return resolve({ error: out.trim().split('\n').pop() || 'installer failed' });
145
+ const installed = out.match(/^Installed (.+) \(([^()]*), sha256 [0-9a-f]+\)$/m) || [];
82
146
  resolve({
83
- path: (out.match(/^Installed (.+) \([^()]*, sha256 [0-9a-f]+\)$/m) || [])[1] || findCli(),
147
+ path: installed[1] || findCli(),
148
+ version: installed[2]?.replace(/^instabox /, '') || null,
84
149
  pathHint: (out.match(/export PATH="[^"]+"/) || [])[0] || null,
85
150
  });
86
151
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instabox",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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,11 +13,23 @@ 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 (GitHub); --provider google for Google
16
+ instabox login # browser sign-in with GitHub; --provider google for 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.
20
+
19
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.
20
22
 
23
+ ## Templates
24
+
25
+ `universal` is the default, so `create` needs no `--template` for ordinary work. It is Debian with git, gh, Node.js 22, Python (uv), Go, Rust, a C toolchain, Docker, Claude Code and Codex; a desktop (VNC, Chromium) installs itself the first time it is used. Pick another template only for its one purpose:
26
+
27
+ - `agent-browser`: universal with the desktop already installed, for browsing or driving a GUI from the start.
28
+ - `herdr`: for the herdr client.
29
+ - `orca`: for Orca desktop and mobile.
30
+
31
+ `instabox templates` lists the ones this server offers.
32
+
21
33
  ## Everyday commands
22
34
 
23
35
  ```sh
@@ -35,13 +47,33 @@ instabox rm work # delete the box
35
47
  ```
36
48
 
37
49
  - Names or IDs (`m_...`) both work wherever `<id-or-name>` is accepted.
38
- - `create` options: `--template T` or `--snapshot S`, `--vcpus N`, `--mem-mib N`, `--disk-gib N`, `--meta K=V`, `--no-wait`.
39
- - `exec` returns the command's exit code; add `--timeout S` for long jobs and `--stdin` to pipe input.
40
- - Prefer `exec` over `ssh` when you (an agent) need output; use `ssh` only for interactive work the user will drive.
41
- - A public URL for a dev server in the box: `instabox preview expose work --port 3000`.
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
+ - Use `exec` to run a command and read its output, and `ssh` for an interactive shell.
52
+ - `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
+ - 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.
54
+ - A public URL for a web server in the box: `instabox port publish work 3000` prints it (`instabox port ls`, `instabox port unpublish work 3000`). The URL works while the box runs.
55
+
56
+ ## Vault: the user's secrets in every box
57
+
58
+ The user's Vault holds their secrets (API keys for model providers, a `.env`, a service-account file) on the server and writes them into every box of theirs when it boots or resumes: `env` items as environment variables of every login shell (so of `instabox exec` too), `file` items as files under HOME.
59
+
60
+ ```sh
61
+ instabox vault show work # what the box gets (names, never values) and the last write
62
+ instabox vault push work # write the current items into a running box now
63
+ instabox vault set ANTHROPIC_API_KEY # value from stdin, or prompted without echo (sign-in only)
64
+ instabox vault set-file .config/svc.json --from ./svc.json
65
+ instabox vault ls; instabox vault rm NAME
66
+ instabox create --name tenant --no-vault # a box for someone else: never gets the user's items
67
+ ```
68
+
69
+ - Before asking the user for a credential, check names with `instabox vault show <box>`. Inside the box, use silent presence checks: `test "${NAME+x}" = x` for an environment variable (replace `NAME` with its name), or `test -f "$HOME/.config/svc.json"` for a credential file. Exit status 0 means present, 1 means absent. Never print credential values or read credential-file contents into agent output, chat or logs.
70
+ - A changed item reaches the box at its next boot or resume, or at once with `vault push` (a changed `env` item reaches new shells only).
71
+ - A box made for another person or tenant must be created with `--no-vault` (`vault: false` in the API): anything running in it can read what the Vault writes. This is for good; `instabox start|resume <box> --no-vault` does the same to an existing box.
72
+ - 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
+ - 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.
42
74
 
43
75
  ## Habits
44
76
 
45
77
  - Give boxes a `--timeout` so forgotten ones pause or delete themselves.
46
- - Check `instabox ls` before creating, and reuse a running box with the same purpose.
78
+ - 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.
47
79
  - `instabox usage` shows storage use and limits. A quota error means delete or stop something; do not retry the same create in a loop.
@@ -24,17 +24,17 @@ Before starting, tell the user plainly:
24
24
  ```bash
25
25
  instabox whoami # signed in? otherwise: instabox login
26
26
  git rev-parse --show-toplevel # must be inside a git repo
27
- du -sh "$(git rev-parse --show-toplevel)/.git" # large repos: add --disk-gib to create
27
+ du -sh "$(git rev-parse --show-toplevel)/.git" # must fit on /data (instabox usage shows the size)
28
28
  instabox ls # pick a box name that is not taken
29
29
  ```
30
30
 
31
31
  If `.git` is a file (a git worktree or submodule checkout), move the main checkout instead. Submodules: their history comes along inside `.git/modules`; run `git submodule update --init` on the box. Uncommitted changes inside a submodule are not copied.
32
32
 
33
33
  ```bash
34
- instabox create --name <box> --vcpus 4 --mem-mib 8192
34
+ instabox create --name <box>
35
35
  ```
36
36
 
37
- Box names are lowercase letters, digits and `-`. The default template (`universal`) has git, node/npm, python3/uv, go, cargo and docker. Do not add a kill timeout: this box is meant to stay (offer `instabox timeout <box> 8h --on-timeout pause` if the user wants it to pause itself).
37
+ Box names are lowercase letters, digits and `-`. Every box is the same size. The default template (`universal`) has git, node/npm, python3/uv, go, cargo and docker. Do not add a kill timeout: this box is meant to stay (offer `instabox timeout <box> 8h --on-timeout pause` if the user wants it to pause itself).
38
38
 
39
39
  ## 2. Copy the repo and history
40
40
 
@@ -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.