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.
@@ -1,132 +0,0 @@
1
- ---
2
- name: instabox-migrate
3
- description: Use when the user wants to move their local project or setup onto instabox — "move my setup to instabox", "migrate this repo to a box", "continue this work in the cloud". Copies the current git repo (uncommitted and untracked work included, .gitignored files left out) and its Claude Code / Codex conversation history into a new box, then installs dependencies there until it builds.
4
- metadata:
5
- installed-by: instabox
6
- ---
7
-
8
- # Move a local project to instabox
9
-
10
- Moves the git repo of the current directory into one new box (see the `instabox` skill for the CLI). What goes over:
11
-
12
- - The whole `.git` directory (branches, stashes, the index, so staged changes stay staged) and every working-tree file that is tracked or untracked but **not ignored by git**. Files git ignores (usually `node_modules`, build output, `.env`) stay behind: dependencies are reinstalled on the box. Untracked `.env` and `.env.*` files are also left out even when `.gitignore` does not list them. A `.env` that is committed or staged (`git add`ed) is in `.git` and goes with it.
13
- - Claude Code and Codex conversations about this project, copied unchanged.
14
-
15
- Not moved: credentials (git, gh, cloud, API keys), other local tools and config, other repos.
16
-
17
- Before starting, tell the user plainly:
18
-
19
- - **git push/pull from the box will not work until they set up credentials there** (for example `instabox ssh <box>` then `gh auth login`).
20
- - **The copied conversation history may contain secrets they pasted earlier** (tokens, keys, passwords). Offer to skip the history.
21
-
22
- ## 1. Check and create the box
23
-
24
- ```bash
25
- instabox whoami # signed in? otherwise: instabox login
26
- git rev-parse --show-toplevel # must be inside a git repo
27
- du -sh "$(git rev-parse --show-toplevel)/.git" # must fit on /data (instabox usage shows the size)
28
- instabox ls # pick a box name that is not taken
29
- ```
30
-
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
-
33
- ```bash
34
- instabox create --name <box>
35
- ```
36
-
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
-
39
- ## 2. Copy the repo and history
40
-
41
- Run this with **bash** from inside the repo; set `BOX` first. The repo lands in `/data/workspace/<repo-name>` (`/data` is the box's persistent disk; HOME is `/data/home`).
42
-
43
- ```bash
44
- BOX=<box>
45
- export COPYFILE_DISABLE=1 # macOS tar: no ._ AppleDouble files
46
- SRC=$(git rev-parse --show-toplevel)
47
- NAME=$(basename "$SRC" | LC_ALL=C tr -c 'A-Za-z0-9._\n-' '-') # safe inside the quoted remote commands
48
- DEST=/data/workspace/$NAME
49
- OLD_ENC=$(printf %s "$SRC" | sed 's/[^A-Za-z0-9]/-/g') # Claude Code's project dir name
50
- NEW_ENC=$(printf %s "$DEST" | sed 's/[^A-Za-z0-9]/-/g')
51
- X='tar -xzf - --warning=no-unknown-keyword' # the box runs GNU tar
52
- cd "$SRC"
53
-
54
- # Repo: all of .git, plus tracked files and untracked files that are not ignored, if they still exist.
55
- # Untracked .env / .env.* files are skipped even if .gitignore misses them.
56
- { find .git -print0
57
- { git ls-files -z --cached
58
- git ls-files -z --others --exclude-standard -- . ':(exclude,glob)**/.env' ':(exclude,glob)**/.env.*'
59
- } | while IFS= read -r -d '' f; do { [ -e "$f" ] || [ -L "$f" ]; } && printf '%s\0' "$f"; done
60
- } | tar --null --no-recursion -T - -czf - |
61
- instabox ssh "$BOX" -- "mkdir -p '$DEST' && $X -C '$DEST'"
62
-
63
- # Claude Code history: ~/.claude/projects/<encoded path>/ (the name starts with '-': keep the ./)
64
- [ -d ~/.claude/projects/"$OLD_ENC" ] &&
65
- tar -C ~/.claude/projects -czf - "./$OLD_ENC" |
66
- instabox ssh "$BOX" -- "mkdir -p ~/.claude/projects && $X -C ~/.claude/projects"
67
-
68
- # Codex history: rollout files that recorded this cwd.
69
- LIST=$(mktemp)
70
- [ -d ~/.codex/sessions ] && (cd ~/.codex && grep -rlF --include='rollout-*.jsonl' "\"cwd\":\"$SRC\"" sessions) > "$LIST"
71
- [ -s "$LIST" ] && tar -C ~/.codex -czf - -T "$LIST" |
72
- instabox ssh "$BOX" -- "mkdir -p ~/.codex && $X -C ~/.codex"
73
-
74
- # Session ids for step 5, read from the local copy that was sent: a count and the 5 most recent.
75
- n=$(ls ~/.claude/projects/"$OLD_ENC"/*.jsonl 2>/dev/null | wc -l)
76
- [ "$n" -gt 0 ] && { echo "Claude: $n sessions, most recent:"
77
- ls -t ~/.claude/projects/"$OLD_ENC"/*.jsonl | head -n 5 | sed -nE 's;.*/(.*)\.jsonl$;\1;p'; }
78
- n=$(wc -l < "$LIST")
79
- [ "$n" -gt 0 ] && { echo "Codex: $n sessions, most recent:" # file names start with the date
80
- sed -E 's;.*/;;' "$LIST" | sort -r | head -n 5 | sed -nE 's/.*-([0-9a-f-]{36})\.jsonl$/\1/p'; }
81
- rm -f "$LIST"
82
- ```
83
-
84
- Check that the box has the same state. The only allowed difference is `??` lines for the skipped `.env` files, including a whole `?? dir/` line when that directory held only `.env` files:
85
-
86
- ```bash
87
- git status --porcelain; git rev-parse HEAD
88
- instabox exec "$BOX" --cwd "$DEST" -- 'git status --porcelain; git rev-parse HEAD'
89
- ```
90
-
91
- ## 3. Make the history resumable
92
-
93
- Link Claude Code's project directory for the new path to the copied one, so `claude --resume` (picker) and `claude --continue` in `$DEST` show the old conversations. Do it before anyone runs `claude` in `$DEST` on the box (it creates the directory otherwise). Nothing is rewritten.
94
-
95
- ```bash
96
- [ "$OLD_ENC" = "$NEW_ENC" ] || instabox ssh "$BOX" -- "cd ~/.claude/projects &&
97
- if [ ! -d './$OLD_ENC' ]; then echo 'no Claude Code history copied; nothing to link'
98
- elif [ -e './$NEW_ENC' ] || [ -L './$NEW_ENC' ]; then ls -ld './$NEW_ENC'; echo 'already exists: link NOT created'
99
- else ln -s -- '$OLD_ENC' '$NEW_ENC' && echo linked; fi"
100
- ```
101
-
102
- If it prints `already exists: link NOT created` and `$NEW_ENC` is a real directory (someone already ran `claude` in `$DEST`), the old conversations will not show in the picker or `--continue`. Tell the user. `claude --resume <session-id>` still works.
103
-
104
- Do not write any notes about the move into `CLAUDE.md`, `AGENTS.md` or settings files on the box, global or in the repo.
105
-
106
- ## 4. Install dependencies until it builds
107
-
108
- There is no fixed recipe. Inspect the project on the box, install, then build and test, and repeat until it builds:
109
-
110
- 1. Look at what the project declares: lockfiles and manifests (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, `requirements*.txt`, `pyproject.toml`/`uv.lock`, `go.mod`, `Cargo.toml`, `Gemfile`, `pom.xml`, ...), toolchain pins (`.nvmrc`, `.node-version`, `.python-version`, `.tool-versions`, `rust-toolchain*`, `go` line in `go.mod`), `Dockerfile`/`docker-compose.yml`, the README and CI config (`.github/workflows`), which often lists the exact setup steps.
111
- 2. Install the matching toolchain versions and the dependencies with the project's own package manager (`sudo apt-get install -y ...` for system libraries).
112
- 3. Run the build and the tests the project defines. Read each failure, install what is missing (a package, a system library, a different toolchain version), and run again.
113
-
114
- Run short steps (looking at files, checking versions) with `instabox exec "$BOX" --cwd "$DEST" -- '<cmd>'`. It runs `bash -lc` as user `dev`, with sudo. `--timeout` defaults to 60 seconds and cannot go above 600. Installs, builds and test runs can take longer, so run those over `instabox ssh`, which has no time limit and passes the exit status back. Use a login shell so the PATH matches `exec` (`~/go/bin`, `~/.cargo/bin`):
115
-
116
- ```bash
117
- instabox ssh "$BOX" -- "cd '$DEST' && bash -l" <<'EOF'
118
- npm ci && npm run build # the step's commands
119
- EOF
120
- ```
121
-
122
- The script arrives on stdin, so a command that reads stdin would eat the rest of it. Pass `-y` or `</dev/null` to anything that might prompt. Stop and ask the user when something needs a secret (`.env` values, private registry tokens) or a choice only they can make.
123
-
124
- ## 5. Tell the user
125
-
126
- - The box name and where the repo is: `instabox ssh <box>`, then `cd /data/workspace/<name>`.
127
- - What was installed, whether build and tests pass, and anything left to do.
128
- - Again: git push/pull needs credentials set up on the box. Ignored files and untracked `.env` files were not copied. Secrets in committed or staged files, or in the copied history, did go over.
129
- - How to pick up old conversations, kept short, with the session ids printed in step 2:
130
- - Claude Code, in the repo directory on the box: `claude --continue` (latest) or `claude --resume` (picker, or `claude --resume <id>`).
131
- - Codex keys its picker on the old path: `codex resume <id>` (or pick from `codex resume --all`) and choose "Use current directory"; after that it shows up in plain `codex resume` there too.
132
- - The local copy is untouched; nothing keeps the two in sync after this.
@@ -1,158 +0,0 @@
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.
@@ -1,226 +0,0 @@
1
- // Shared by the instabox-move-all scripts. Node >= 18, no dependencies.
2
- //
3
- // The one rule every function here keeps: a credential's value is never
4
- // returned, stored or printed. Code that looks at a secret-bearing file
5
- // returns booleans, names and sizes only.
6
- import { spawnSync } from 'node:child_process';
7
- import { lstatSync, readdirSync, readFileSync, openSync, readSync, closeSync, realpathSync } 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, DB_PASSWORD_PROD). The qualifier
13
- // needs its own `_`, 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 the variable is called: token
18
- // prefixes of the common services, 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), at the start
36
- // of a line or inside an inline table; numbers and 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|private[_-]?key)[\w.-]*["']?\s*[=:]\s*["']?(?![0-9.]+\b|true\b|false\b)[^\s"',}]/i;
39
-
40
- // Names the Vault refuses for env items (docs/vault.md), plus our own.
41
- const RESERVED = new Set(['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS']);
42
- export function vaultEnvNameOK(name) {
43
- return (
44
- /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
45
- name.length <= 128 &&
46
- !RESERVED.has(name) &&
47
- !name.startsWith('INSTABOX_') &&
48
- !name.startsWith('SSH_')
49
- );
50
- }
51
-
52
- // Whether a variable's name says it holds a credential. This is the scrubber's question; whether
53
- // the Vault can store it under that name is a separate one (vaultEnvNameOK).
54
- export function isSecretName(name) {
55
- return SECRET_NAME.test(name);
56
- }
57
-
58
- // Lines in macOS shells and configs that mean nothing on the Linux box.
59
- export const MACOS_ONLY =
60
- /\/opt\/homebrew|\/usr\/local\/Cellar|\/usr\/local\/Homebrew|\/Applications\/|\/Library\/|\bosxkeychain\b|\bUseKeychain\b|\bpbcopy\b|\bpbpaste\b|\bosascript\b|\bbrew shellenv\b|\bterminal-notifier\b|\bafplay\b|\/System\/|\.orbstack\/|1password.*agent\.sock|\bsecurity (?:find|add)-[a-z-]*password\b/i;
61
-
62
- // The Vault's limits (docs/vault.md).
63
- export const VAULT_MAX_VALUE = 64 * 1024;
64
- export const VAULT_MAX_TOTAL = 256 * 1024;
65
- export const VAULT_MAX_ITEMS = 100;
66
-
67
- export const BOX_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
68
- export const BOX_HOME = '/data/home';
69
- export const BOX_WORKSPACE = '/data/workspace';
70
- // Staged files that are merged into the box's own copies rather than replacing them.
71
- export const STAGE_DIR = '.config/instabox-move';
72
-
73
- // Runs a command without a shell; never throws. stdout is returned to the
74
- // caller only: callers that run credential tools (gh auth token) must not
75
- // keep or print it.
76
- export function run(argv, { cwd, env, input, timeout = 30_000 } = {}) {
77
- const r = spawnSync(argv[0], argv.slice(1), {
78
- cwd,
79
- env,
80
- input,
81
- timeout,
82
- encoding: 'utf8',
83
- maxBuffer: 64 * 1024 * 1024,
84
- stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
85
- });
86
- return { ok: r.status === 0 && !r.error, status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
87
- }
88
-
89
- export function hasCommand(name, env) {
90
- for (const dir of (env?.PATH || '').split(':')) {
91
- if (!dir) continue;
92
- try {
93
- const st = lstatSync(join(dir, name));
94
- if (st.isFile() || st.isSymbolicLink()) return true;
95
- } catch {}
96
- }
97
- return false;
98
- }
99
-
100
- export function lstatOrNull(p) {
101
- try {
102
- return lstatSync(p);
103
- } catch {
104
- return null;
105
- }
106
- }
107
-
108
- // Bytes under a path, symlinks counted as links, not followed.
109
- export function treeBytes(p, skip = () => false) {
110
- const st = lstatOrNull(p);
111
- if (!st) return 0;
112
- if (!st.isDirectory()) return st.size;
113
- let total = 0;
114
- let names = [];
115
- try {
116
- names = readdirSync(p);
117
- } catch {
118
- return 0;
119
- }
120
- for (const n of names) if (!skip(n)) total += treeBytes(join(p, n), skip);
121
- return total;
122
- }
123
-
124
- // Every regular file and symlink under dir, as paths relative to it, sorted.
125
- export function listFiles(dir, skip = () => false, rel = '') {
126
- const out = [];
127
- let names = [];
128
- try {
129
- names = readdirSync(join(dir, rel)).sort();
130
- } catch {
131
- return out;
132
- }
133
- for (const n of names) {
134
- if (skip(n)) continue;
135
- const r = rel ? `${rel}/${n}` : n;
136
- const st = lstatOrNull(join(dir, r));
137
- if (!st) continue;
138
- if (st.isDirectory()) out.push(...listFiles(dir, skip, r));
139
- else if (st.isFile() || st.isSymbolicLink()) out.push(r);
140
- }
141
- return out;
142
- }
143
-
144
- export function readText(p) {
145
- try {
146
- return readFileSync(p, 'utf8');
147
- } catch {
148
- return null;
149
- }
150
- }
151
-
152
- export function readJSON(p) {
153
- const t = readText(p);
154
- if (t === null) return null;
155
- try {
156
- return JSON.parse(t);
157
- } catch {
158
- return null;
159
- }
160
- }
161
-
162
- // The first n bytes of a file, for sniffing a header.
163
- export function readHead(p, n = 4096) {
164
- let fd;
165
- try {
166
- fd = openSync(p, 'r');
167
- const buf = Buffer.alloc(n);
168
- const len = readSync(fd, buf, 0, n, 0);
169
- return buf.subarray(0, len).toString('utf8');
170
- } catch {
171
- return '';
172
- } finally {
173
- if (fd !== undefined) closeSync(fd);
174
- }
175
- }
176
-
177
- export function isBinary(buf) {
178
- return buf.includes(0);
179
- }
180
-
181
- export function human(bytes) {
182
- if (bytes < 1024) return `${bytes} B`;
183
- const u = ['KB', 'MB', 'GB', 'TB'];
184
- let v = bytes / 1024;
185
- let i = 0;
186
- while (v >= 1024 && i < u.length - 1) {
187
- v /= 1024;
188
- i++;
189
- }
190
- return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
191
- }
192
-
193
- // Claude Code's name for a project directory: every non-alphanumeric character becomes '-'.
194
- export function claudeProjectName(path) {
195
- return path.replace(/[^A-Za-z0-9]/g, '-');
196
- }
197
-
198
- // A name that is safe in a path and a quoted shell word (as instabox-migrate does).
199
- export function safeName(s) {
200
- return s.replace(/[^A-Za-z0-9._-]/g, '-');
201
- }
202
-
203
- // The Vault item name for a file item: its path with / as -, as `instabox vault set-file` does.
204
- export function vaultFileName(path) {
205
- return safeName(path.replace(/^\./, '').replaceAll('/', '-')).replace(/^-+|-+$/g, '');
206
- }
207
-
208
- // A Vault file path must be relative, clean, without .. components (docs/vault.md).
209
- export function vaultPathOK(p) {
210
- if (!p || p.startsWith('/') || p.length > 255 || /[\0\\\n]/.test(p)) return false;
211
- const parts = p.split('/');
212
- return parts.every((c) => c !== '' && c !== '.' && !c.startsWith('..'));
213
- }
214
-
215
- export function homeRel(home, p) {
216
- return p.startsWith(home + '/') ? '~/' + p.slice(home.length + 1) : p;
217
- }
218
-
219
- // True when the module is the script node was started with (not imported by a test).
220
- export function isMain(meta) {
221
- try {
222
- return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(meta.url));
223
- } catch {
224
- return false;
225
- }
226
- }