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.
@@ -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
+ }