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.
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ // Read-only inventory of the basic setup that can go into the instabox Vault:
3
+ // tokens (by name and place, never values), git config, editor and terminal
4
+ // config. Prints JSON.
5
+ //
6
+ // node scan.mjs > scan.json
7
+ //
8
+ // Nothing is written. Shell files are read for the names of the variables
9
+ // they export, never their values.
10
+ import { realpathSync } from 'node:fs';
11
+ import { join, relative, resolve } from 'node:path';
12
+ import {
13
+ MACOS_ONLY,
14
+ SECRET_VALUE,
15
+ VAULT_MAX_VALUE,
16
+ WELL_KNOWN,
17
+ hasCommand,
18
+ isMain,
19
+ isSecretName,
20
+ listFiles,
21
+ readText,
22
+ run,
23
+ statOrNull,
24
+ vaultEnvNameOK,
25
+ vaultFileName,
26
+ vaultPathOK,
27
+ } from './lib.mjs';
28
+
29
+ // Shell startup files: read for exported tokens only. They do not move.
30
+ export const SHELL_RC = ['.zshenv', '.zprofile', '.zshrc', '.bash_profile', '.bash_login', '.profile', '.bashrc', '.bash_aliases'];
31
+
32
+ // nvim: config files only. Plugin managers' checkouts and editor state stay behind.
33
+ const NVIM_SKIP = new Set(['.git', 'node_modules', 'pack', 'plugged', 'bundle', 'site', 'undo', 'backup', 'swap', 'sessions', 'spell', '.repro']);
34
+ const NVIM_MAX_FILES = 40;
35
+ const NVIM_MAX_BYTES = 128 * 1024;
36
+
37
+ // `export NAME=value` / `NAME=value` lines of a shell file: names, line numbers and flags only.
38
+ function shellAssignments(text) {
39
+ const out = [];
40
+ text.split('\n').forEach((line, i) => {
41
+ const m = line.match(/^\s*(?:export\s+|declare\s+-x\s+|typeset\s+-x\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
42
+ if (!m) return;
43
+ out.push({ name: m[1], line: i + 1, secretValue: SECRET_VALUE.test(m[2]), keychain: MACOS_ONLY.test(m[2]) });
44
+ });
45
+ return out;
46
+ }
47
+
48
+ function scanTokens({ home, env, procEnv }) {
49
+ const items = [];
50
+ const manual = [];
51
+ const byName = new Map();
52
+ const add = (name, source, from, extra = {}) => {
53
+ if (byName.has(name)) return; // the first place a name is found wins: the Vault holds one value per name
54
+ const item = { id: `env:${name}`, section: 'tokens', kind: 'env', name, source, from, checked: true, ...extra };
55
+ byName.set(name, item);
56
+ items.push(item);
57
+ };
58
+ const unvaultable = new Set();
59
+
60
+ // gh: the token gh keeps (keychain or hosts.yml), read with `gh auth token` at push time.
61
+ if (hasCommand('gh', env)) {
62
+ const st = run(['gh', 'auth', 'status', '--hostname', 'github.com'], { env });
63
+ if (st.ok) {
64
+ const who = (st.stdout + st.stderr).match(/account (\S+)/)?.[1] || null;
65
+ add('GH_TOKEN', { type: 'cmd', argv: ['gh', 'auth', 'token', '--hostname', 'github.com'] }, '`gh auth token`', {
66
+ note: `${who ? `GitHub account ${who}; ` : ''}gh, and git over HTTPS to GitHub, use it on the box`,
67
+ });
68
+ }
69
+ }
70
+
71
+ // Shell startup files: exported tokens.
72
+ for (const f of SHELL_RC) {
73
+ const text = readText(join(home, f));
74
+ if (text === null) continue;
75
+ for (const a of shellAssignments(text)) {
76
+ if (!WELL_KNOWN.has(a.name) && !isSecretName(a.name) && !a.secretValue) continue;
77
+ if (!vaultEnvNameOK(a.name)) {
78
+ if (!unvaultable.has(a.name)) {
79
+ unvaultable.add(a.name);
80
+ manual.push({
81
+ what: `${a.name} (~/${f}:${a.line})`,
82
+ why: 'the Vault does not take env items with this name (INSTABOX_*, SSH_* and a few system names)',
83
+ todo: `if a box needs it, set it there by hand`,
84
+ });
85
+ }
86
+ continue;
87
+ }
88
+ const extra = {};
89
+ if (a.keychain) {
90
+ extra.checked = false;
91
+ extra.reason = 'your shell reads it from the macOS keychain: checking it runs that lookup once, at push time';
92
+ } else if (!WELL_KNOWN.has(a.name)) {
93
+ extra.checked = false;
94
+ extra.reason = 'not a well-known model or service token: check it if every box should have it';
95
+ }
96
+ add(a.name, { type: 'rc', file: join(home, f), name: a.name }, `~/${f}:${a.line}`, extra);
97
+ }
98
+ }
99
+ // Well-known tokens in the environment the scan runs in (set some other way than a shell file).
100
+ for (const name of Object.keys(procEnv).sort()) {
101
+ if (!WELL_KNOWN.has(name) || !procEnv[name]) continue;
102
+ add(name, { type: 'env', name }, 'this shell’s environment');
103
+ }
104
+
105
+ if (!byName.has('GH_TOKEN'))
106
+ manual.push({
107
+ what: 'GitHub token (GH_TOKEN)',
108
+ why: hasCommand('gh', env) ? 'gh is not signed in on this machine' : 'gh is not installed on this machine',
109
+ todo: 'sign in here with `gh auth login` and run this again; or put a token in yourself: `instabox vault set GH_TOKEN` (it prompts without echo)',
110
+ });
111
+ if (!byName.has('CLAUDE_CODE_OAUTH_TOKEN'))
112
+ manual.push({
113
+ what: 'Claude Code sign-in (CLAUDE_CODE_OAUTH_TOKEN)',
114
+ why: 'not found in your shell files; the sign-in on this machine is in the macOS keychain, and is never read',
115
+ todo:
116
+ 'run `claude setup-token` yourself (it prints a token that lasts a year), then `instabox vault set CLAUDE_CODE_OAUTH_TOKEN` and paste it at the prompt' +
117
+ (byName.has('ANTHROPIC_API_KEY') ? ' (or skip it: ANTHROPIC_API_KEY is in the plan, and Claude Code uses that)' : '; or `claude`, then /login, on each box'),
118
+ });
119
+ if (!byName.has('OPENAI_API_KEY') && (hasCommand('codex', env) || statOrNull(join(home, '.codex'))))
120
+ manual.push({
121
+ what: 'Codex sign-in',
122
+ why: 'no OPENAI_API_KEY in your shell files; a ChatGPT sign-in cannot be copied (its refresh token rotates)',
123
+ todo: 'on each box: `codex login --device-auth`; or `instabox vault set OPENAI_API_KEY` once for every box',
124
+ });
125
+ return { items, manual };
126
+ }
127
+
128
+ // A home-relative path for a file, or null when it is outside HOME or not one the Vault takes.
129
+ function homePath(home, p) {
130
+ const rel = relative(home, p);
131
+ return rel && !rel.startsWith('..') && vaultPathOK(rel) ? rel : null;
132
+ }
133
+
134
+ function fileEntry(home, section, rel, transform, extra = {}) {
135
+ const src = join(home, rel);
136
+ const st = statOrNull(src);
137
+ if (!st?.isFile()) return null;
138
+ const file = { path: rel, name: vaultFileName(rel), src, transform, bytes: st.size };
139
+ const e = { id: `file:${rel}`, section, kind: 'file', path: rel, files: [file], bytes: st.size, checked: true, ...extra };
140
+ if (st.size > VAULT_MAX_VALUE) {
141
+ e.checked = false;
142
+ e.reason = `${st.size} bytes: over the Vault's 64 KiB per item`;
143
+ }
144
+ return e;
145
+ }
146
+
147
+ function scanGit({ home, env }) {
148
+ const out = [];
149
+ const gc = fileEntry(home, 'git', '.gitconfig', 'gitconfig', {
150
+ note: 'osxkeychain and other macOS-only lines, credentials and commit signing left out',
151
+ });
152
+ if (gc) out.push(gc);
153
+ const xdg = fileEntry(home, 'git', '.config/git/config', 'gitconfig', { note: 'macOS-only lines and credentials left out' });
154
+ if (xdg) out.push(xdg);
155
+ // The global gitignore: core.excludesfile, else git's default.
156
+ let ignore = null;
157
+ const r = run(['git', 'config', '--global', '--get', 'core.excludesfile'], { env: { ...env, HOME: home } });
158
+ const v = r.ok ? r.stdout.trim() : '';
159
+ if (v) {
160
+ const abs = v.startsWith('~/') ? join(home, v.slice(2)) : v.replace(/^\$HOME(?=\/)/, home);
161
+ if (abs.startsWith('/')) ignore = homePath(home, abs);
162
+ }
163
+ if (!ignore && statOrNull(join(home, '.config/git/ignore'))) ignore = '.config/git/ignore';
164
+ if (ignore) {
165
+ const e = fileEntry(home, 'git', ignore, 'text', { note: 'your global gitignore' });
166
+ if (e) out.push(e);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function scanEditor({ home }) {
172
+ const out = [];
173
+ for (const rel of ['.vimrc', '.tmux.conf', '.config/tmux/tmux.conf']) {
174
+ const e = fileEntry(home, 'editor', rel, 'text');
175
+ if (e) out.push(e);
176
+ }
177
+ // nvim: init.lua / init.vim and the config files around it, never plugin checkouts.
178
+ const nvimDir = join(home, '.config', 'nvim');
179
+ let real = nvimDir;
180
+ try {
181
+ real = realpathSync(nvimDir);
182
+ } catch {}
183
+ if (statOrNull(real)?.isDirectory()) {
184
+ const files = [];
185
+ const left = [];
186
+ for (const f of listFiles(real, (n) => NVIM_SKIP.has(n))) {
187
+ const top = !f.includes('/');
188
+ const keep = /\.(?:lua|vim)$/.test(f) || (top && f === 'lazy-lock.json');
189
+ if (!keep) continue;
190
+ const rel = `.config/nvim/${f}`;
191
+ const st = statOrNull(join(real, f));
192
+ if (!vaultPathOK(rel) || !st) continue;
193
+ if (st.size > VAULT_MAX_VALUE) {
194
+ left.push(`${f} (${st.size} bytes)`);
195
+ continue;
196
+ }
197
+ files.push({ path: rel, name: vaultFileName(rel), src: join(real, f), transform: f.endsWith('.json') ? 'raw' : 'text', bytes: st.size });
198
+ }
199
+ if (files.some((f) => /^\.config\/nvim\/init\.(?:lua|vim)$/.test(f.path))) {
200
+ const bytes = files.reduce((s, f) => s + f.bytes, 0);
201
+ const e = { id: 'files:.config/nvim', section: 'editor', kind: 'file', path: '.config/nvim', files, bytes, checked: true };
202
+ const notes = ['plugins are not copied: your plugin manager installs them on the box'];
203
+ if (left.length) notes.push(`left out, over 64 KiB: ${left.join(', ')}`);
204
+ e.note = notes.join('; ');
205
+ if (files.length > NVIM_MAX_FILES || bytes > NVIM_MAX_BYTES) {
206
+ e.checked = false;
207
+ e.reason = `${files.length} files: a big share of the Vault's 100 items and 256 KiB`;
208
+ }
209
+ out.push(e);
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+
215
+ // Two paths can map to one item name (a-b.lua and a/b.lua): the second gets -2, -3...
216
+ function uniqueNames(entries) {
217
+ const used = new Set();
218
+ for (const e of entries) {
219
+ if (e.kind === 'env') used.add(e.name);
220
+ for (const f of e.files || []) {
221
+ let n = f.name;
222
+ for (let i = 2; used.has(n); i++) n = `${f.name}-${i}`;
223
+ f.name = n;
224
+ used.add(n);
225
+ }
226
+ }
227
+ }
228
+
229
+ export function scan(opts = {}) {
230
+ const env = opts.env || process.env;
231
+ const home = resolve(opts.home || env.HOME);
232
+ const tokens = scanTokens({ home, env, procEnv: opts.procEnv || env });
233
+ const items = [...tokens.items, ...scanGit({ home, env }), ...scanEditor({ home })];
234
+ uniqueNames(items);
235
+ return { version: 2, home, platform: opts.platform || process.platform, items, manual: tokens.manual };
236
+ }
237
+
238
+ function main() {
239
+ if (process.argv.length > 2) {
240
+ process.stderr.write('usage: scan.mjs > scan.json\n');
241
+ process.exit(2);
242
+ }
243
+ process.stdout.write(JSON.stringify(scan(), null, 2) + '\n');
244
+ }
245
+
246
+ if (isMain(import.meta)) main();
@@ -0,0 +1,193 @@
1
+ ---
2
+ name: instabox-move-repo
3
+ description: Use when the user wants to move the repo they are working in onto instabox — "move this repo to instabox", "migrate this repo to a box", "continue this work in the cloud", "move my project to a box". Copies the current git repo and all its worktrees exactly (uncommitted, untracked, stashed and unpushed work; no build output or dependencies), moves its Claude Code / Codex conversations so they resume on the box, then installs the project's dependencies there.
4
+ metadata:
5
+ installed-by: instabox
6
+ ---
7
+
8
+ # Move a local repo to instabox
9
+
10
+ Moves the git repo of the current directory, with its worktrees, into one new box (see the `instabox` skill for the CLI). The box ends up with the same `git status`, HEAD, branches, stashes and worktrees, the agent conversations about the repo, and its dependencies installed.
11
+
12
+ Three scripts in `scripts/` next to this file do the parts that must be exact (Node 18+ and bash, nothing to install):
13
+
14
+ | Script | Does |
15
+ | --- | --- |
16
+ | `repo-files.mjs` | The plan: the main checkout and each linked worktree, where each lands on the box, sizes before and after, what is left out. `--list0 PATH`: the files of one checkout for `tar`. Names and sizes only, never contents. |
17
+ | `history.mjs` | Stages the Claude Code and Codex sessions of those paths with every path rewritten to the box path. Prints counts and session ids, never contents. |
18
+ | `deps.sh` | Runs on the box: detects the toolchain, installs missing runtimes and the dependencies, runs an obvious build. |
19
+
20
+ What goes over:
21
+
22
+ - All of the main checkout's `.git` (branches, tags, stashes, unpushed commits, the index so staged changes stay staged, each worktree's own HEAD and index), every tracked file, and every untracked file git does not ignore, in the main checkout and in each linked worktree.
23
+ - **Never build output or dependencies**, even when the repo forgot to ignore them: `node_modules`, `target`, `dist`, `build`, `out`, `.next`, `.nuxt`, `.svelte-kit`, `.turbo`, `.cache`, `coverage`, `__pycache__`, `.venv`, `venv`, `.tox`, `.mypy_cache`, `.pytest_cache`, `.gradle`, `bazel-*`, `*.egg-info`, `*.o`, `*.so`, `*.pyc`, `*.class`, ... (the full list is `EXCLUDED_DIRS` and `EXCLUDED_FILES` in `repo-files.mjs`). Files git ignores stay too. A **tracked** file always goes, even under such a name, so `git status` matches. `--keep NAME` copies an excluded name anyway, for a repo whose source really lives in, say, `build/` (pass it to every `repo-files.mjs` run, step 1 and step 3, through `KEEP`). It does not bring back an untracked `.env`: those only go through the Vault (step 7).
24
+ - Claude Code and Codex conversations for the repo and its worktrees (on by default, the user can opt out).
25
+
26
+ Not moved: `.env` files that are not committed (they go through the Vault, step 7), credentials stored outside the repo (git credential helpers, gh, cloud, API keys in your environment), other local tools and config, other repos. A `.env` that is committed or staged is in `.git` and goes with it. Everything inside `.git` goes, including `.git/config` and so a remote URL with a token embedded in it (`https://x-access-token:TOKEN@...`); and only `.env`-named untracked files are held back, so an untracked `.npmrc` with a token, a `*.pem` or a `secrets.yaml` that git does not ignore goes too.
27
+
28
+ **Never read, print or paste a credential value yourself**: not a `.env`, not `gh auth token`, not `echo $SOME_KEY`.
29
+
30
+ Before starting, tell the user plainly:
31
+
32
+ - **git push/pull from the box will not work until they set up credentials there** (for example `instabox ssh <box>` then `gh auth login`; the `instabox-basic-setup` skill, where installed, puts tokens and git config into the Vault).
33
+ - **The copied conversation history may contain secrets they pasted earlier** (tokens, keys, passwords). It goes by default; ask whether to skip it (or only Codex / only Claude Code).
34
+ - Their local copy stays as it is, and nothing keeps the two in sync afterwards.
35
+
36
+ ## 1. The plan
37
+
38
+ Run with **bash**, anywhere inside the repo or one of its worktrees:
39
+
40
+ ```bash
41
+ instabox whoami # signed in? otherwise: instabox login
42
+ S=~/.claude/skills/instabox-move-repo/scripts # or ~/.agents/skills/instabox-move-repo/scripts: wherever this file is
43
+ W=$(mktemp -d)
44
+ node "$S/repo-files.mjs" # the plan, for you and the user
45
+ ```
46
+
47
+ It always plans the **main checkout plus its linked worktrees** (from `git worktree list`), even when run from inside a worktree: a worktree alone is half a repo. For each it shows the branch (or detached HEAD), uncommitted changes, locked state, where it lands, and the sizes:
48
+
49
+ - `before`: everything in the directory on disk, and what "`.git` plus every file git does not ignore" would be.
50
+ - `left out`: build output and dependencies git does not ignore, biggest first, and `.env` files.
51
+ - `after`: what is actually sent.
52
+
53
+ Where things land: the main checkout in `/data/workspace/<repo-name>` (`/data` is the box's persistent disk; HOME is `/data/home`); a worktree inside it (such as `.claude/worktrees/<name>`) at the same relative path; any other worktree in `/data/workspace/<repo-name>.worktrees/<its directory name>`. Pass `--dest /data/workspace/<other>` if the user wants another name. Prunable worktrees (directory gone) are listed as not moved and stay behind.
54
+
55
+ Show the user the plan in short: the worktrees with their sizes and state, the total sent, the biggest things left out, the `.env` files. If there are worktrees, ask which to leave behind (in Claude Code, AskUserQuestion with multiSelect); each one left behind is a `--skip-worktree PATH`. Ask about the history (above). Then write the final plan:
56
+
57
+ ```bash
58
+ SKIP=() # one --skip-worktree per worktree the user unchecked: SKIP+=(--skip-worktree /path/to/it)
59
+ KEEP=() # one --keep per excluded name the user wants copied anyway: KEEP+=(--keep build)
60
+ node "$S/repo-files.mjs" "${SKIP[@]}" "${KEEP[@]}" --json > "$W/plan.json"
61
+ DEST=$(jq -r .main.box "$W/plan.json")
62
+ jq -r '.totals' "$W/plan.json" # sendBytes must fit well inside /data: instabox usage
63
+ ```
64
+
65
+ Submodules: their history comes along inside `.git/modules`; run `git submodule update --init` on the box. Uncommitted changes inside a submodule, and an untracked nested repo's files, are not copied. A bare repo with worktrees is refused: move its worktrees one by one by hand.
66
+
67
+ ## 2. Create the box
68
+
69
+ ```bash
70
+ instabox ls # pick a box name that is not taken
71
+ BOX=<box>
72
+ instabox create --name "$BOX" --meta repo="$(basename "$DEST")"
73
+ ```
74
+
75
+ Box names are lowercase letters, digits and `-`. Every box is the same size. The default template (`universal`) has git, Node.js 22, Python (uv), Go, Rust, a C toolchain 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). The `repo=` label lets Vault items (step 7) reach this box only.
76
+
77
+ ## 3. Copy the repo and its worktrees
78
+
79
+ The main checkout first (it carries `.git`), then each worktree, each as its own `tar` stream:
80
+
81
+ ```bash
82
+ export COPYFILE_DISABLE=1 # macOS tar: no ._ AppleDouble files
83
+ X='tar -xzf - --warning=no-unknown-keyword' # the box runs GNU tar
84
+ jq -r '.main, (.worktrees[] | select(.move)) | [.src, .box] | @tsv' "$W/plan.json" |
85
+ while IFS=$'\t' read -r src box; do
86
+ echo "== $src -> $box"
87
+ node "$S/repo-files.mjs" --list0 "$src" "${SKIP[@]}" "${KEEP[@]}" </dev/null |
88
+ tar -C "$src" --null --no-recursion -T - -czf - |
89
+ instabox ssh "$BOX" -- "mkdir -p '$box' && $X -C '$box'"
90
+ done
91
+ ```
92
+
93
+ Then point the worktrees at their new places (each worktree's `.git` file and `.git/worktrees/<id>/gitdir` still name the local paths; `git worktree repair` with the new paths rewrites both sides):
94
+
95
+ ```bash
96
+ WTS=$(jq -r '[.worktrees[] | select(.move) | .box] | join(" ")' "$W/plan.json")
97
+ [ -z "$WTS" ] || instabox ssh "$BOX" -- "cd '$DEST' && git worktree repair $WTS"
98
+ ```
99
+
100
+ Box paths contain only letters, digits and `._-/`, so they are safe unquoted there.
101
+
102
+ Check that every checkout has the same state, and the same worktrees:
103
+
104
+ ```bash
105
+ jq -r '.main, (.worktrees[] | select(.move)) | [.src, .box] | @tsv' "$W/plan.json" |
106
+ while IFS=$'\t' read -r src box; do
107
+ echo "== $box"
108
+ diff <(cd "$src" && { git status --porcelain | sort; git rev-parse HEAD; }) \
109
+ <(instabox exec "$BOX" --cwd "$box" -- 'git status --porcelain | sort; git rev-parse HEAD' </dev/null)
110
+ done
111
+ git worktree list; instabox exec "$BOX" --cwd "$DEST" -- git worktree list
112
+ ```
113
+
114
+ The only allowed differences are `<` lines starting with `??` for what the plan left out (a build directory, an untracked `.env`, or a whole `?? dir/` that held only those), and ` M <submodule>` for a submodule with uncommitted changes inside it (those are not copied: note it for step 8). A name passed with `--keep` must not show up as a `??` difference: it was meant to go. HEADs must match, and the box's `git worktree list` must show the same branches, detached HEADs and `locked` marks at the box paths, without the worktrees left behind. Anything else: stop and look before going on.
115
+
116
+ ## 4. Conversation history (unless the user opted out)
117
+
118
+ ```bash
119
+ H=$(mktemp -d)
120
+ node "$S/history.mjs" --plan "$W/plan.json" --out "$H" > "$W/history.json" # --no-claude / --no-codex to skip one
121
+ jq '{claude: .claude | {sessions, bytes, recent}, codex: .codex | {sessions, bytes, recent}}' "$W/history.json" # null: skipped
122
+ tar -C "$H" -czf - $(jq -r '.entries[]' "$W/history.json") | instabox ssh "$BOX" -- "$X --no-overwrite-dir -C ~"
123
+ instabox ssh "$BOX" -- 'bash ~/.cache/instabox-move-repo/apply-history.sh'
124
+ rm -rf "$H"
125
+ ```
126
+
127
+ What it moves, for the main checkout, each moved worktree, and any directory under them:
128
+
129
+ - Claude Code: `~/.claude/projects/<path with every non-alphanumeric character as ->/` (sessions, subagent transcripts, tool results). Which path a directory belongs to is read from the `cwd` its sessions recorded, so `/a/app-2` is never taken for `/a/app`. On the box it is stored under the box path's name, so `claude --continue` and `claude --resume` in that directory find it. The prompt history lines (`~/.claude/history.jsonl`) for those paths are appended.
130
+ - Codex: the `rollout-*.jsonl` files under `~/.codex/sessions` (and `archived_sessions`) whose `cwd` is one of those paths, and their lines of `~/.codex/history.jsonl`.
131
+ - In every copied file each local path is replaced by its box path (a longer name such as `/a/app-2` is not touched), so the sessions' `cwd` and the file paths in them point at the box. File times are kept, so the latest session stays the latest. The local files are not changed.
132
+
133
+ Do not write any notes about the move into `CLAUDE.md`, `AGENTS.md` or settings files on the box, global or in the repo.
134
+
135
+ ## 5. Install dependencies
136
+
137
+ ```bash
138
+ instabox ssh "$BOX" -- 'mkdir -p ~/.cache/instabox-move-repo && cat > ~/.cache/instabox-move-repo/deps.sh' < "$S/deps.sh"
139
+ instabox ssh "$BOX" -- "bash -l ~/.cache/instabox-move-repo/deps.sh '$DEST'" # --dry-run first if you want to see the steps
140
+ ```
141
+
142
+ Over `ssh`, which has no time limit (installs and builds take minutes; `instabox exec` stops at 600 seconds). It looks at the repo root and each first-level subdirectory with a lockfile of its own:
143
+
144
+ | Found | Runtime, installed only when missing or another version is pinned | Install | Smoke |
145
+ | --- | --- | --- | --- |
146
+ | `.tool-versions`, `mise.toml` | mise | `mise install` | |
147
+ | `package.json` + `pnpm-lock.yaml` / `yarn.lock` / `bun.lock(b)` / `package-lock.json` (or `packageManager`) | Node per `.nvmrc` / `.node-version` (nvm), pnpm/yarn via corepack, bun | `pnpm install --frozen-lockfile`, `yarn install --immutable` (berry) or `--frozen-lockfile`, `bun install --frozen-lockfile`, `npm ci`; no lockfile: `npm install --no-package-lock` | `<pm> run build` if there is a `build` script |
148
+ | `uv.lock`, `poetry.lock`, `Pipfile.lock`, `pdm.lock`, `requirements*.txt`, `pyproject.toml` / `setup.py` | uv (it fetches the Python in `.python-version`), poetry/pipenv/pdm via `uv tool` | `uv sync --frozen`, `poetry install`, `pipenv sync --dev`, `pdm sync`, `uv venv && uv pip install -r ...`, `uv pip install -e .` | |
149
+ | `Cargo.toml` | rustup (follows `rust-toolchain`) | `cargo fetch [--locked]` | `cargo build` |
150
+ | `go.mod` | Go (newer `go` lines are fetched by Go itself) | `go mod download` | `go build ./...` |
151
+ | `Gemfile` | ruby-full, bundler (apt, gem) | `bundle install` (gems in `~/.bundle/gems`) | |
152
+ | `pom.xml` / `build.gradle(.kts)` | JDK, Maven (apt) | `./mvnw` or `mvn dependency:go-offline` | `compile`, `./gradlew assemble` |
153
+ | `composer.json` | PHP, composer (apt) | `composer install` | |
154
+ | `mix.exs`, `deno.json`, `*.csproj`, `Package.swift`, `pubspec.yaml`, compose files | | noted, not installed | |
155
+
156
+ It never rewrites a lockfile (frozen installs only), carries on past a failed step, lists every step as ok or FAILED, adds notes (an expected `.env`, compose services not started, git status changed by an install), and exits 1 if anything failed. Failures are not the end: read each one, fix it on the box (a system library with `sudo apt-get install -y ...`, another toolchain version, a step from the README or CI config in `.github/workflows`), and run the step again, until it builds. Run tests if the project defines them and they do not need services or secrets. For a script you send over stdin instead, give anything that might prompt `-y` or `</dev/null`. Stop and ask the user when something needs a secret (private registry token, `.env` values) or a choice only they can make.
157
+
158
+ Worktrees: install only in the main checkout unless the user wants a worktree ready too (`deps.sh '<its box path>'`); each install takes disk space.
159
+
160
+ ## 6. Check git did not change
161
+
162
+ ```bash
163
+ instabox exec "$BOX" --cwd "$DEST" -- 'git status --porcelain | sort; git rev-parse HEAD'
164
+ ```
165
+
166
+ Compare with step 3. New `??` lines from the installs (for example a lockfile the project does not commit) are worth a mention; a modified tracked file is not expected: find which step did it.
167
+
168
+ ## 7. `.env` files: through the Vault, never copied
169
+
170
+ The plan's `envLeftOut` lists them by name (`jq -r '.main.envLeftOut, (.worktrees[] | select(.move) | .envLeftOut) | .[]' "$W/plan.json"`). Do not open them. If the user wants them on the box, the Vault carries them as files under HOME, and a link puts each into the repo. With their go-ahead, per file (the CLI reads the file; nothing passes through you):
171
+
172
+ ```bash
173
+ R=$(basename "$DEST")
174
+ instabox vault set-file ".config/instabox-move-repo/env/$R/<path in repo>" --from "<local path>" --selector repo="$R"
175
+ instabox vault push "$BOX"
176
+ instabox exec "$BOX" -- "mkdir -p '$DEST/<dir in repo>' && ln -sfn ~/.config/instabox-move-repo/env/$R/<path in repo> '$DEST/<path in repo>'"
177
+ ```
178
+
179
+ The `--selector repo=<name>` keeps the file to boxes created with `--meta repo=<name>` (step 2). For tokens, git config and editor setup across all boxes, point the user to the `instabox-basic-setup` skill.
180
+
181
+ ## 8. Tell the user
182
+
183
+ - The box name and where the repo is: `instabox ssh <box>`, then `cd /data/workspace/<name>`; each worktree with its box path and branch; worktrees left behind and why.
184
+ - Uncommitted work inside a submodule (a ` M <submodule>` line in step 3) did not come over: name each such submodule so the user can commit or copy that work themselves.
185
+ - Sizes: on disk before, sent after, and the biggest things left out (they were rebuilt or reinstalled on the box).
186
+ - What was installed, whether build and tests pass, and anything left to do (each FAILED step and note from `deps.sh` that is not fixed).
187
+ - Again: git push/pull needs credentials set up on the box. Ignored files, build output and untracked `.env` files were not copied. Secrets in committed or staged files, anywhere in `.git` (such as a token in a remote URL in `.git/config`), in untracked files not named `.env*`, or in the copied history, did go over.
188
+ - How to pick up old conversations, kept short, with the session ids from step 4:
189
+ - Claude Code, in the repo (or worktree) directory on the box: `claude --continue` (latest) or `claude --resume` (picker, or `claude --resume <id>`).
190
+ - Codex, in that directory: `codex resume` (picker for this directory), `codex resume --last`, or `codex resume <id>`.
191
+ - The local copy is untouched; nothing keeps the two in sync after this.
192
+
193
+ Delete `$W` when done (plans and lists, no values).