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 +62 -5
- package/lib/login.mjs +5 -2
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +28 -4
- package/skills/instabox-basic-setup/SKILL.md +108 -0
- package/skills/instabox-basic-setup/scripts/content.mjs +98 -0
- package/skills/instabox-basic-setup/scripts/lib.mjs +233 -0
- package/skills/instabox-basic-setup/scripts/plan.mjs +295 -0
- package/skills/instabox-basic-setup/scripts/push.mjs +252 -0
- package/skills/instabox-basic-setup/scripts/scan.mjs +246 -0
- package/skills/instabox-move-repo/SKILL.md +193 -0
- package/skills/instabox-move-repo/scripts/deps.sh +370 -0
- package/skills/instabox-move-repo/scripts/history.mjs +340 -0
- package/skills/instabox-move-repo/scripts/repo-files.mjs +552 -0
- package/skills/instabox-snapshots/SKILL.md +2 -2
- package/skills/instabox-migrate/SKILL.md +0 -132
- package/skills/instabox-move-all/SKILL.md +0 -158
- package/skills/instabox-move-all/scripts/lib.mjs +0 -226
- package/skills/instabox-move-all/scripts/plan.mjs +0 -263
- package/skills/instabox-move-all/scripts/push-creds.mjs +0 -154
- package/skills/instabox-move-all/scripts/scan.mjs +0 -811
- package/skills/instabox-move-all/scripts/stage-env.mjs +0 -313
- package/skills/instabox-move-all/scripts/tools.mjs +0 -173
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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
package/skills/instabox/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
-
|
|
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
|
+
}
|