instabox 0.5.0 → 0.5.2

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/bin/instabox.mjs CHANGED
@@ -14,6 +14,7 @@ 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
20
  import { fileURLToPath } from 'node:url';
@@ -126,7 +127,38 @@ function signInPage(ok, title, text, who) {
126
127
 
127
128
  // Browser sign-in, the same loopback flow as instabox's own CLI (RFC 8252 + PKCE): listen on
128
129
  // 127.0.0.1, open /auth/cli/start, receive the code at /callback, exchange it with the verifier.
129
- 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) {
130
162
  const verifier = randomBytes(32).toString('base64url');
131
163
  const state = randomBytes(24).toString('base64url');
132
164
  const challenge = createHash('sha256').update(verifier).digest('base64url');
@@ -147,13 +179,17 @@ async function browserLogin() {
147
179
  stale: () => signInPage(false, 'This sign-in link is stale', 'Run the command in your terminal again.'),
148
180
  again: () => signInPage(true, 'Already signing you in', 'Head back to your terminal.'),
149
181
  ok: (r) => signInPage(true, "You're in", 'Head back to your terminal.', r.me && (r.me.name || r.me.login)),
150
- 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.`),
151
186
  };
152
187
  const server = http.createServer(callbackHandler({ state, exchange, finish, page }));
153
188
  await new Promise((r) => server.listen(0, '127.0.0.1', r));
154
189
  const redirect = `http://127.0.0.1:${server.address().port}/callback`;
155
190
  const q = { redirect_uri: redirect, state, code_challenge: challenge, code_challenge_method: 'S256' };
156
191
  if (PROVIDER) q.provider = PROVIDER;
192
+ if (invite) q.invite = invite;
157
193
  const start = `${API}/auth/cli/start?` + new URLSearchParams(q);
158
194
  console.log(` ${bold('Sign in to instabox')} in your browser ${dim('(opening it now)')}`);
159
195
  console.log(` ${dim('If it did not open:')} ${start}\n`);
@@ -162,8 +198,7 @@ async function browserLogin() {
162
198
  const result = await done;
163
199
  clearTimeout(timer);
164
200
  server.close();
165
- if (result.err) die(`sign-in failed: ${result.err}`);
166
- return result.tok;
201
+ return result.err ? { err: result.err, code: result.code } : { tok: result.tok };
167
202
  }
168
203
 
169
204
  // The CLI's saved token, a refreshed one, or a browser sign-in; saved where the CLI reads it.
@@ -177,7 +212,7 @@ async function signedIn() {
177
212
  return t;
178
213
  } catch {}
179
214
  }
180
- const t = await browserLogin();
215
+ const t = await signInWithInvite();
181
216
  saveCredentials(API, t);
182
217
  return t;
183
218
  }
@@ -269,6 +304,26 @@ function skillFiles(dir, rel = '') {
269
304
  });
270
305
  }
271
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
+
272
327
  // Agent skills shipped in this package (skills/<name>/SKILL.md, with any scripts beside it) into
273
328
  // the folders coding agents read. Overwrites only copies we installed earlier (marked
274
329
  // `installed-by: instabox`).
@@ -318,6 +373,8 @@ function installSkills() {
318
373
  if (of('installed').length) parts.push(`installed ${of('installed').join(', ')}`);
319
374
  if (of('unchanged').length) parts.push(`${of('unchanged').join(', ')} already up to date`);
320
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(', ')}`);
321
378
  return parts.length ? `agent skills: ${parts.join('; ')} ${dim('(~/.claude/skills, ~/.agents/skills)')}` : null;
322
379
  }
323
380
 
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.5.0",
3
+ "version": "0.5.2",
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": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: instabox
3
- description: Use when the user wants a cloud Linux box (instabox sandbox) from the terminal with the `instabox` CLI — create one, SSH in, run a command in it, read its files, set its timeout, or delete it. For saving or restoring a box's disks, see instabox-snapshots.
3
+ description: Use when the user wants a cloud Linux box (instabox sandbox) from the terminal with the `instabox` CLI — create one, SSH in, run a command in it, read its files, set its timeout, or delete it, or to report a problem with instabox itself (`instabox feedback`). For saving or restoring a box's disks, see instabox-snapshots.
4
4
  metadata:
5
5
  installed-by: instabox
6
6
  ---
@@ -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,30 @@ 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
+
81
+ ## When instabox itself gets in your way (feedback)
82
+
83
+ If instabox is at fault — a command breaks the contract its help or this skill states, the skill does not match what really happens, a capability is missing, or the CLI is confusing — report it, then carry on with the user's task using a workaround. Never block on the report, and never report problems in the code the user is building.
84
+
85
+ ```sh
86
+ instabox feedback --json --type bug --component cli --area exec \
87
+ --title "exec --timeout 600 stops the command at 60s" \
88
+ --detail "instabox exec work --timeout 600 -- make test was killed after 60 seconds" \
89
+ --command "instabox exec work --timeout 600 -- make test" \
90
+ --expected "the skill says exec accepts up to 600 seconds" --doc "skills/instabox/SKILL.md" \
91
+ --workaround "ran it over instabox ssh instead"
92
+ ```
93
+
94
+ - `--type`: `bug` when something does not work as its help or the docs say (when instructions did not match reality, also give `--doc`, `--expected` and `--workaround`), `feature-request` when what you need is not supported, `friction` when it works but is confusing or awkward, `other` otherwise.
95
+ - `--component`: `cli`, `api`, `sandbox` (the box itself, its templates or tools), `dashboard`, `skills`, `docs` or `other`. `--severity blocker|major|minor` (default `minor`); `--sandbox NAME` names the box involved.
96
+ - Emails, keys, tokens, home paths and public IPv4 addresses are redacted before sending, but do not paste secrets into it. The report carries the signed-in account (its user ID, and its login unless that is an email address), so the team can follow up.
97
+ - A failed or unconfirmed submission prints a warning and exits 0: do not retry. File one report per problem, not one per attempt.
98
+
75
99
  ## Habits
76
100
 
77
- - Give boxes a `--timeout` so forgotten ones pause or delete themselves.
101
+ - 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
102
  - 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
103
  - `instabox usage` shows storage use and limits. A quota error means delete or stop something; do not retry the same create in a loop.
@@ -0,0 +1,108 @@
1
+ ---
2
+ name: instabox-basic-setup
3
+ description: Use when the user wants their instabox boxes set up like their laptop — "put my tokens / dotfiles / git config into instabox", "set up my boxes like my laptop", "give every box my GitHub and Claude tokens", "move my setup to instabox", "move everything to instabox". Scans this machine, lets the user pick from a checkbox plan, then puts the picks into their instabox Vault, so every box (and every box created later) gets them: tokens (GH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, model and service API keys) as environment variables, and git and editor config (~/.gitconfig, global gitignore, ~/.vimrc, nvim, ~/.tmux.conf) as files. Moves no code: for a repo, use instabox-move-repo.
4
+ metadata:
5
+ installed-by: instabox
6
+ ---
7
+
8
+ # Put a basic setup into the instabox Vault
9
+
10
+ This skill puts the user's personal setup into their instabox **Vault** (`instabox vault`: per-user secrets and files, written into their boxes), so every box of theirs starts with it: the ones running now (at their next start or resume, or at once with `instabox vault push <box>`) and every box created later. It copies no repository, no agent history, installs nothing and touches no box directly. To move a repository, use the `instabox-move-repo` skill. See the `instabox` skill for the CLI.
11
+
12
+ What it moves, and nothing else:
13
+
14
+ | Group | Becomes | From |
15
+ | --- | --- | --- |
16
+ | Tokens | Vault `env` items: on tmpfs in the box, set in every login shell; gone at a stop, back at the next start; never in a snapshot | `GH_TOKEN` from `gh auth token`; well-known model and service tokens exported in the shell files (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `OPENROUTER_API_KEY`, `HF_TOKEN`, `NPM_TOKEN`, `VERCEL_TOKEN`, `CLOUDFLARE_API_TOKEN`, `SUPABASE_ACCESS_TOKEN`, `FLY_API_TOKEN`, `SENTRY_AUTH_TOKEN`, `LINEAR_API_KEY`, ...) or in this shell's environment. Other credential-looking exports are listed unchecked. |
17
+ | Git | Vault `file` items in the box's home | `~/.gitconfig` (and `~/.config/git/config`), the global gitignore |
18
+ | Editor and terminal | Vault `file` items | `~/.vimrc`, nvim's `init.lua` / `init.vim` and the `.lua` / `.vim` files around it (no plugin checkouts), `~/.tmux.conf` |
19
+
20
+ Four scripts in `scripts/` next to this file do the parts that must be exact (Node 18+, nothing to install):
21
+
22
+ | Script | Does |
23
+ | --- | --- |
24
+ | `scan.mjs` | Read-only inventory, as JSON: tokens by name and place (never values), the files, and what cannot move. |
25
+ | `plan.mjs` | `write`: the checkbox plan. `parse`: reads the edited plan back, strictly, as JSON. |
26
+ | `push.mjs` | Puts the picks into the Vault, piping each value from its source into `instabox vault set` / `set-file`. `--dry-run` first. |
27
+ | `content.mjs` | What a file becomes in the Vault (used by the other two). |
28
+
29
+ **Never read, print or paste a credential value yourself**: not `gh auth token`, not `echo $OPENAI_API_KEY`, not `cat` of a shell file or of `~/.gitconfig`, never the macOS keychain. `push.mjs` is the only thing that touches values. If something needs a value the scripts cannot get, tell the user the command to run themselves (`instabox vault set NAME` prompts without echo).
30
+
31
+ Before starting, tell the user plainly: a scan of this machine (nothing changes here), a plan file they edit, then their Vault gets what they checked. By default every box they have and will create gets it; they can scope it with a selector. Nothing is copied from a box back here, and nothing keeps the two in sync: to change something later, change it here and run this again.
32
+
33
+ ## 1. Scan
34
+
35
+ ```bash
36
+ instabox whoami # signed in? otherwise: instabox login
37
+ S=~/.claude/skills/instabox-basic-setup/scripts # or ~/.agents/skills/instabox-basic-setup/scripts: wherever this file is
38
+ W=~/instabox-basic-setup && mkdir -p -m 700 "$W"
39
+ node "$S/scan.mjs" > "$W/scan.json"
40
+ instabox vault ls --json > "$W/vault.json" # what is in the Vault already (names, no values)
41
+ instabox usage --json | jq '.limits.vault // {}' > "$W/limits.json"
42
+ ```
43
+
44
+ `instabox vault ls` fails with `API keys cannot manage the vault` when `INSTABOX_API_KEY` is set: ask the user to unset it and `instabox login`. A `404` means the server has no Vault: stop and say so.
45
+
46
+ ## 2. Write the plan and let the user choose
47
+
48
+ ```bash
49
+ node "$S/plan.mjs" write --scan "$W/scan.json" --vault "$W/vault.json" > "$W/plan.md"
50
+ ```
51
+
52
+ Add `--selector key=value` (comma-separated for more) only if the user wants the setup on some boxes, not all; the plan's `Selector:` line can be edited too (`*` is every box). A box gets the items when its metadata has every label (`instabox create --meta key=value`).
53
+
54
+ `plan.md` has a `- [x]` / `- [ ]` line per item, in three sections (Tokens, Git, Editor and terminal), then a "Can't move" list with no checkboxes. Defaults:
55
+
56
+ - Well-known tokens and every file are checked. Other credential-looking exports, and tokens the shell reads from the macOS keychain, are unchecked (checking the latter runs that keychain lookup once, at push time).
57
+ - Something already in the Vault, set there some other way than this skill, is unchecked and says so: checking it **replaces** it (for every box its selector reached). Items this skill set on an earlier run are replaced without asking (the manifest, step 3).
58
+
59
+ Show the user a short summary (which tokens, which files, what cannot move) and the path of the file, and ask them to edit the checkboxes and the selector, then tell you to go on. In Claude Code you may ask a coarse question first with AskUserQuestion (multiSelect: "Tokens / Git / Editor and terminal") and apply the answer by changing only `[x]` and `[ ]`; the user still gets the file for details.
60
+
61
+ When they are done, read it back:
62
+
63
+ ```bash
64
+ node "$S/plan.mjs" parse --scan "$W/scan.json" --vault "$W/vault.json" --limits "$W/limits.json" "$W/plan.md" > "$W/selection.json"
65
+ ```
66
+
67
+ - Exit 1: the plan has problems (a malformed checkbox, a line deleted or duplicated, an unknown id, a bad selector, a plan from another scan or Vault state, a file that became empty or over 64 KiB). All are listed; show them, fix or let the user fix, parse again. Never fix a plan by editing ids or deleting lines. If the scan or Vault changed, run step 1 and 2 again.
68
+ - Exit 3: over the Vault's limits (100 items, 256 KiB in all, items already there included; 64 KiB per item). The message gives the totals and the biggest lines: ask the user what to uncheck (nvim's files are one item each), or which old Vault items to remove (`instabox vault rm NAME`), then parse again.
69
+
70
+ ## 3. Push into the Vault
71
+
72
+ ```bash
73
+ node "$S/push.mjs" --selection "$W/selection.json" --dry-run # names, sizes, totals; writes nothing
74
+ node "$S/push.mjs" --selection "$W/selection.json"
75
+ ```
76
+
77
+ Show the user the dry run's lines before the real run. Values go from their source (`gh auth token`, a variable after sourcing the user's shell file, this environment) straight into the CLI's stdin. A shell file that only exports when interactive (the usual `case $- in *i*)` guard) is sourced a second time in an interactive shell, which may run its prompt setup; a token it still does not yield comes from this environment, or fails with the reason; the output is names, sizes and outcomes. Every value is read and the totals checked before anything is written: over the limits, nothing is written (exit 3). An item that appeared in the Vault after the plan was written is left alone (`kept`). A `failed` line names the item and why; give the user the command to set it themselves (`instabox vault set NAME`, which prompts without echo). Re-running is safe: the same items are set again.
78
+
79
+ The run records what it set in `~/.config/instabox-basic-setup/pushed.json` (names, kinds, paths; no values), so the next run knows those are its own.
80
+
81
+ What a file becomes (counted per file in the plan's sizes):
82
+
83
+ - This machine's HOME is rewritten to the box's (`/data/home`); lines that look like a credential are replaced by a comment; macOS-only lines (`osxkeychain`, Homebrew paths, `pbcopy`, `reattach-to-user-namespace`, ...) are commented out.
84
+ - `~/.gitconfig` also: commit and tag signing is turned off (`gpgsign`: the signing key is not on the box), `url."git@...".insteadOf` rewrites are commented out (the box has no SSH key), and when `GH_TOKEN` is in the Vault, gh's credential helper for github.com is added (what `gh auth setup-git` writes), so `git clone https://github.com/...` and `git push` use the token.
85
+
86
+ ## 4. Verify on a box (optional)
87
+
88
+ If the user names a running box (`instabox ls`), or wants one to try, write the Vault into it now and look:
89
+
90
+ ```bash
91
+ BOX=<box>
92
+ instabox vault push "$BOX" # writes now; lists variables and files written, and any "kept" path
93
+ instabox vault show "$BOX" # what it gets with its metadata
94
+ instabox exec "$BOX" -- 'gh auth status; git config --get user.name; test -n "${CLAUDE_CODE_OAUTH_TOKEN-}${ANTHROPIC_API_KEY-}" && echo claude token set'
95
+ ```
96
+
97
+ A `kept: ~/path (not written: ... symlink ...)` line means the box has a symlink in that path; tell the user. Check presence of a token, never print one. A box created with `--no-vault` gets nothing, ever; one whose metadata does not match the selector gets nothing either. Do not create a box for this unless the user asks.
98
+
99
+ ## 5. Report
100
+
101
+ Tell the user, in short sections:
102
+
103
+ - **In your Vault**: each token by name, each file by path, and which boxes get them (every box, or the selector). Running boxes get them at their next start or resume, or now with `instabox vault push <box>`; new boxes at once.
104
+ - **Left alone**: what they unchecked, items kept because they were set some other way, failures with the reason. Items from an earlier run they unchecked this time are still in the Vault (the push prints them): `instabox vault rm NAME` removes one, and boxes lose it at their next write.
105
+ - **Do these yourself**: every entry of `.manual` in `selection.json`, with its command, for example: Claude Code's sign-in — run `claude setup-token` here yourself, then `instabox vault set CLAUDE_CODE_OAUTH_TOKEN` and paste it (or `claude`, then `/login`, on each box); Codex — `codex login --device-auth` on the box, or `instabox vault set OPENAI_API_KEY`; `gh auth login` here if gh is not signed in.
106
+ - **Good to know**: Vault files are written again at every start and resume, so an edit made to them on a box is replaced; change them here and run this skill again (or `instabox vault rm` the item to keep a box's own copy). Tokens are environment variables of login shells (SSH, `instabox exec`, the terminal), not of systemd services. Shell aliases, SSH keys, cloud credential files and agent config are not part of this setup.
107
+
108
+ Delete `$W` when the user is done with it (it holds the scan, the plan and the Vault's item names, no values).
@@ -0,0 +1,98 @@
1
+ // What a file item's content becomes in the Vault: the local file with this
2
+ // machine's HOME rewritten to the box's, lines that look like credentials
3
+ // removed and macOS-only lines commented out. ~/.gitconfig also gets a few
4
+ // changes so git works on a box that has GH_TOKEN and no SSH key.
5
+ //
6
+ // Used by plan.mjs (for sizes) and push.mjs (for the upload). Nothing here
7
+ // prints a file's content.
8
+ import { readFileSync } from 'node:fs';
9
+ import { basename, extname } from 'node:path';
10
+ import { BOX_HOME, MACOS_ONLY, SECRET_KEY_LINE, SECRET_VALUE } from './lib.mjs';
11
+
12
+ const TAG = '[instabox-basic-setup]';
13
+
14
+ function commentStyle(path) {
15
+ const b = basename(path);
16
+ const e = extname(path);
17
+ if (e === '.lua') return '--';
18
+ if (e === '.vim' || b === '.vimrc' || b === '.gvimrc') return '"';
19
+ return '#';
20
+ }
21
+
22
+ const isComment = (line) => /^\s*(?:#|;|--|")/.test(line);
23
+
24
+ // Filters one text file. Returns the new text and how many lines were changed.
25
+ export function scrubText(text, { path, home }) {
26
+ const c = commentStyle(path);
27
+ let secrets = 0;
28
+ let macos = 0;
29
+ const out = text.split('\n').map((line) => {
30
+ if (SECRET_VALUE.test(line) || (SECRET_KEY_LINE.test(line) && !isComment(line))) {
31
+ secrets++;
32
+ return `${c} ${TAG} removed a line that looked like a credential`;
33
+ }
34
+ if (MACOS_ONLY.test(line) && !isComment(line)) {
35
+ macos++;
36
+ return `${c} ${TAG} macOS only: ${line.trim()}`;
37
+ }
38
+ return home ? line.split(home).join(BOX_HOME) : line;
39
+ });
40
+ return { text: out.join('\n'), secrets, macos };
41
+ }
42
+
43
+ // The credential helper `gh auth setup-git` writes: git over HTTPS uses GH_TOKEN through gh.
44
+ export const GH_HELPER = [
45
+ '',
46
+ `# ${TAG}: git over HTTPS to GitHub uses GH_TOKEN (from the Vault) through gh`,
47
+ '[credential "https://github.com"]',
48
+ '\thelper =',
49
+ '\thelper = !gh auth git-credential',
50
+ '[credential "https://gist.github.com"]',
51
+ '\thelper =',
52
+ '\thelper = !gh auth git-credential',
53
+ '',
54
+ ].join('\n');
55
+
56
+ // ~/.gitconfig for the box. ghToken: GH_TOKEN goes to the Vault too, so the gh helper is added.
57
+ // Changes, besides scrubText's:
58
+ // - commit/tag signing is turned off: the signing key (GPG, SSH, 1Password) is not on the box;
59
+ // - url.<ssh>.insteadOf rewrites are commented out: the box has no SSH key, and they would
60
+ // send HTTPS remotes over SSH.
61
+ export function gitconfigText(text, { home, ghToken }) {
62
+ const r = scrubText(text, { path: '.gitconfig', home });
63
+ let section = '';
64
+ let signing = 0;
65
+ let insteadOf = 0;
66
+ const lines = r.text.split('\n').map((line) => {
67
+ const h = line.match(/^\s*\[([^\]]*)\]/);
68
+ if (h) section = h[1].trim();
69
+ if (isComment(line)) return line;
70
+ if (/^\s*gpgsign\s*=\s*(?:true|yes|on|1)\s*$/i.test(line)) {
71
+ signing++;
72
+ return `# ${TAG} signing off (no signing key on the box): ${line.trim()}`;
73
+ }
74
+ if (/^\s*(?:push)?insteadof\s*=/i.test(line) && /^url\s+"(?:git@|ssh:\/\/)/i.test(section)) {
75
+ insteadOf++;
76
+ return `# ${TAG} no SSH key on the box: ${line.trim()}`;
77
+ }
78
+ return line;
79
+ });
80
+ let out = lines.join('\n');
81
+ if (ghToken) out = out.replace(/\n*$/, '\n') + GH_HELPER;
82
+ return { text: out, secrets: r.secrets, macos: r.macos, signing, insteadOf };
83
+ }
84
+
85
+ // The content of one file of a file item, or {error}. f: {path, src, transform}.
86
+ export function fileContent(f, { home, ghToken = false } = {}) {
87
+ let buf;
88
+ try {
89
+ buf = readFileSync(f.src);
90
+ } catch (e) {
91
+ return { error: `${f.src}: ${e.code || 'unreadable'}` };
92
+ }
93
+ if (buf.includes(0)) return { error: `${f.src}: a binary file (the Vault holds text only)` };
94
+ const text = buf.toString('utf8');
95
+ if (f.transform === 'gitconfig') return gitconfigText(text, { home, ghToken });
96
+ if (f.transform === 'raw') return { text, secrets: 0, macos: 0 };
97
+ return scrubText(text, { path: f.path, home });
98
+ }
@@ -0,0 +1,233 @@
1
+ // Shared by the instabox-basic-setup scripts. Node >= 18, no dependencies.
2
+ //
3
+ // The one rule every function here keeps: a credential's value is never
4
+ // returned to the agent, stored in a file or printed. Code that looks at a
5
+ // secret returns names, places and sizes only.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { join } from 'node:path';
10
+
11
+ // Environment variable names that hold a credential, also with a rotation or environment
12
+ // qualifier after it (OPENAI_API_KEY_2, GITHUB_TOKEN_OLD). The qualifier needs its own `_`,
13
+ // so PATH is not PAT plus H.
14
+ export const SECRET_NAME =
15
+ /(?:^|_)(?:API_?KEY|APIKEY|KEY_?SECRET|TOKEN|ACCESS_?TOKEN|AUTH_?TOKEN|REFRESH_?TOKEN|SECRET|SECRET_?KEY|CLIENT_?SECRET|ACCESS_?KEY|ACCESS_?KEY_?ID|PASSWORD|PASSWD|PASS|PAT|PRIVATE_?KEY|CREDENTIALS?)(?:_(?:\d+|V\d+|OLD|NEW|PREV|PREVIOUS|BAK|BACKUP|ALT|PROD|PRODUCTION|DEV|STAGING|STAGE|TEST|LOCAL|CI))?$/i;
16
+
17
+ // Values that are credentials whatever they are called: token prefixes of the common
18
+ // services, private keys, and URLs with a password in them.
19
+ export const SECRET_VALUE = new RegExp(
20
+ [
21
+ String.raw`\bgh[pousr]_[A-Za-z0-9]{20,}`,
22
+ String.raw`\bgithub_pat_[A-Za-z0-9_]{20,}`,
23
+ String.raw`\bsk-[A-Za-z0-9_-]{20,}`,
24
+ String.raw`\bAKIA[0-9A-Z]{16}\b`,
25
+ String.raw`\bASIA[0-9A-Z]{16}\b`,
26
+ String.raw`\bxox[abposr]-[A-Za-z0-9-]{10,}`,
27
+ String.raw`\bglpat-[A-Za-z0-9_-]{20,}`,
28
+ String.raw`\bnpm_[A-Za-z0-9]{30,}`,
29
+ String.raw`\bAIza[0-9A-Za-z_-]{35}`,
30
+ String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----`,
31
+ String.raw`[a-z][a-z0-9+.-]*://[^/\s:@'"]+:[^@\s'"/]+@`,
32
+ ].join('|'),
33
+ );
34
+
35
+ // key = value / key: value pairs whose key says it is a secret (config files); numbers and
36
+ // booleans are not secrets (max_tokens = 100).
37
+ export const SECRET_KEY_LINE =
38
+ /(?:^|[\s{,;:])["']?[\w.-]*(?:token|password|passwd|secret|api[_-]?key|apikey|_auth|authorization|extraheader|private[_-]?key)[\w.-]*["']?\s*[=:]\s*["']?(?![0-9.]+\b|true\b|false\b)[^\s"',}]/i;
39
+
40
+ // Model-provider and developer-service tokens: moved by default when found. Any other
41
+ // credential-looking variable exported in a shell file is listed unchecked.
42
+ export const WELL_KNOWN = new Set([
43
+ 'GH_TOKEN',
44
+ 'GITHUB_TOKEN',
45
+ 'CLAUDE_CODE_OAUTH_TOKEN',
46
+ 'ANTHROPIC_API_KEY',
47
+ 'OPENAI_API_KEY',
48
+ 'GEMINI_API_KEY',
49
+ 'GOOGLE_API_KEY',
50
+ 'GOOGLE_GENERATIVE_AI_API_KEY',
51
+ 'OPENROUTER_API_KEY',
52
+ 'GROQ_API_KEY',
53
+ 'MISTRAL_API_KEY',
54
+ 'DEEPSEEK_API_KEY',
55
+ 'XAI_API_KEY',
56
+ 'TOGETHER_API_KEY',
57
+ 'FIREWORKS_API_KEY',
58
+ 'PERPLEXITY_API_KEY',
59
+ 'COHERE_API_KEY',
60
+ 'VOYAGE_API_KEY',
61
+ 'REPLICATE_API_TOKEN',
62
+ 'ELEVENLABS_API_KEY',
63
+ 'TAVILY_API_KEY',
64
+ 'EXA_API_KEY',
65
+ 'FIRECRAWL_API_KEY',
66
+ 'BRAVE_API_KEY',
67
+ 'PINECONE_API_KEY',
68
+ 'HF_TOKEN',
69
+ 'HUGGING_FACE_HUB_TOKEN',
70
+ 'NPM_TOKEN',
71
+ 'VERCEL_TOKEN',
72
+ 'NETLIFY_AUTH_TOKEN',
73
+ 'CLOUDFLARE_API_TOKEN',
74
+ 'SUPABASE_ACCESS_TOKEN',
75
+ 'FLY_API_TOKEN',
76
+ 'RAILWAY_TOKEN',
77
+ 'RENDER_API_KEY',
78
+ 'NEON_API_KEY',
79
+ 'SENTRY_AUTH_TOKEN',
80
+ 'LINEAR_API_KEY',
81
+ 'GITLAB_TOKEN',
82
+ 'DIGITALOCEAN_ACCESS_TOKEN',
83
+ ]);
84
+
85
+ // Names the Vault refuses for env items (docs/vault.md).
86
+ const RESERVED = new Set(['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS']);
87
+ export function vaultEnvNameOK(name) {
88
+ return (
89
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
90
+ name.length <= 128 &&
91
+ !RESERVED.has(name) &&
92
+ !name.startsWith('INSTABOX_') &&
93
+ !name.startsWith('SSH_')
94
+ );
95
+ }
96
+
97
+ export const isSecretName = (name) => SECRET_NAME.test(name);
98
+
99
+ // Lines in macOS configs that mean nothing on the Linux box.
100
+ export const MACOS_ONLY =
101
+ /\/opt\/homebrew|\/usr\/local\/Cellar|\/usr\/local\/Homebrew|\/Applications\/|\/Library\/|\bosxkeychain\b|\bUseKeychain\b|\bpbcopy\b|\bpbpaste\b|\bosascript\b|\breattach-to-user-namespace\b|\bterminal-notifier\b|\bafplay\b|\/System\/|\.orbstack\/|1password.*agent\.sock|\bsecurity (?:find|add)-[a-z-]*password\b/i;
102
+
103
+ // The Vault's limits (docs/vault.md, pkg/instaboxapi MaxVaultValueBytes); the server's own
104
+ // come from `instabox usage --json` (.limits.vault) when it answers.
105
+ export const VAULT_MAX_VALUE = 64 * 1024;
106
+ export const VAULT_MAX_TOTAL = 256 * 1024;
107
+ export const VAULT_MAX_ITEMS = 100;
108
+
109
+ export const BOX_HOME = '/data/home';
110
+
111
+ // Runs a command without a shell; never throws. stdout is returned to the caller only:
112
+ // callers that run credential tools (gh auth token) must not keep or print it.
113
+ export function run(argv, { cwd, env, input, timeout = 30_000 } = {}) {
114
+ const r = spawnSync(argv[0], argv.slice(1), {
115
+ cwd,
116
+ env,
117
+ input,
118
+ timeout,
119
+ encoding: 'utf8',
120
+ maxBuffer: 64 * 1024 * 1024,
121
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
122
+ });
123
+ return { ok: r.status === 0 && !r.error, status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
124
+ }
125
+
126
+ export function hasCommand(name, env) {
127
+ for (const dir of (env?.PATH || '').split(':')) {
128
+ if (!dir) continue;
129
+ try {
130
+ const st = lstatSync(join(dir, name));
131
+ if (st.isFile() || st.isSymbolicLink()) return true;
132
+ } catch {}
133
+ }
134
+ return false;
135
+ }
136
+
137
+ // stat, following symlinks (dotfiles are often links into a dotfiles repo), or null.
138
+ export function statOrNull(p) {
139
+ try {
140
+ return statSync(p);
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ // Every regular file under dir (symlinks followed), as paths relative to it, sorted.
147
+ export function listFiles(dir, skip = () => false, rel = '', depth = 0) {
148
+ const out = [];
149
+ if (depth > 8) return out;
150
+ let names = [];
151
+ try {
152
+ names = readdirSync(join(dir, rel)).sort();
153
+ } catch {
154
+ return out;
155
+ }
156
+ for (const n of names) {
157
+ if (skip(n)) continue;
158
+ const r = rel ? `${rel}/${n}` : n;
159
+ const st = statOrNull(join(dir, r));
160
+ if (!st) continue;
161
+ if (st.isDirectory()) out.push(...listFiles(dir, skip, r, depth + 1));
162
+ else if (st.isFile()) out.push(r);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ export function readText(p) {
168
+ try {
169
+ return readFileSync(p, 'utf8');
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ export function human(bytes) {
176
+ if (bytes < 1024) return `${bytes} B`;
177
+ const u = ['KB', 'MB', 'GB'];
178
+ let v = bytes / 1024;
179
+ let i = 0;
180
+ while (v >= 1024 && i < u.length - 1) {
181
+ v /= 1024;
182
+ i++;
183
+ }
184
+ return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
185
+ }
186
+
187
+ // The Vault item name for a file item: its path with / as -, as `instabox vault set-file` does.
188
+ export function vaultFileName(path) {
189
+ return path
190
+ .replace(/^\./, '')
191
+ .replaceAll('/', '-')
192
+ .replace(/[^A-Za-z0-9._-]/g, '-')
193
+ .replace(/^-+|-+$/g, '')
194
+ .slice(0, 128);
195
+ }
196
+
197
+ // A Vault file path must be relative, clean, without .. components (docs/vault.md).
198
+ export function vaultPathOK(p) {
199
+ if (!p || p.startsWith('/') || p.length > 255 || /[\0\\\n]/.test(p)) return false;
200
+ return p.split('/').every((c) => c !== '' && c !== '.' && !c.startsWith('..'));
201
+ }
202
+
203
+ // A selector as the plan writes it: `*` (every box) or `key=value,key=value`.
204
+ export function parseSelector(s) {
205
+ s = s.trim();
206
+ if (s === '*' || s === '') return {};
207
+ const out = {};
208
+ for (const part of s.split(',')) {
209
+ const m = part.trim().match(/^([A-Za-z0-9_.-]{1,63})=([^,]{0,512})$/);
210
+ if (!m) throw new Error(`selector ${JSON.stringify(s)}: write * or key=value,key=value`);
211
+ out[m[1]] = m[2];
212
+ }
213
+ if (Object.keys(out).length > 32) throw new Error('selector: 32 labels at most');
214
+ return out;
215
+ }
216
+
217
+ export function selectorString(sel) {
218
+ const e = Object.entries(sel || {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
219
+ return e.length ? e.map(([k, v]) => `${k}=${v}`).join(',') : '*';
220
+ }
221
+
222
+ export function sameSelector(a, b) {
223
+ return selectorString(a) === selectorString(b);
224
+ }
225
+
226
+ // True when the module is the script node was started with (not imported by a test).
227
+ export function isMain(meta) {
228
+ try {
229
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(meta.url));
230
+ } catch {
231
+ return false;
232
+ }
233
+ }