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.
- package/README.md +7 -4
- package/bin/instabox.mjs +125 -84
- package/lib/api.mjs +4 -2
- package/lib/boxes.mjs +1 -10
- package/lib/cli.mjs +2 -15
- package/lib/login.mjs +5 -2
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +11 -5
- 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/README.md
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
# instabox (npm)
|
|
2
2
|
|
|
3
3
|
```
|
|
4
|
-
npx instabox@latest
|
|
4
|
+
npx instabox@latest # sign in, install the CLI and the agent skills
|
|
5
|
+
npx instabox@latest sh # the same, then start a new box and SSH into it
|
|
5
6
|
```
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
`npx instabox@latest` signs you in to instabox in your browser (first run only; confirm its current Instabox account or choose another), installs the `instabox` CLI already signed in or updates an out-of-date one (macOS/Linux; skip with `--no-cli`), installs or updates the agent skills from [`../skills`](../skills) in `~/.claude/skills` and `~/.agents/skills` (skip with `--no-skills`), and prints the next steps. It does not touch your boxes.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
`npx instabox@latest sh` does all that, then starts a new box (`box-<hex>`) and drops you into it over SSH, no questions. Run by an agent (no terminal), it starts the box and prints `instabox ssh <box> -- <command>` instead of opening a shell. At the box limit it starts nothing and lists your boxes, to remove one (`instabox rm <box>`) or open one (`instabox ssh <box>`).
|
|
11
|
+
|
|
12
|
+
A GitHub and a Google sign-in are separate instabox accounts, each with its own boxes. Both this entry point and `instabox login` let the browser confirm the account. Explicit `--github` or `--google` starts that provider directly, as does `instabox login --provider github|google`.
|
|
10
13
|
|
|
11
14
|
Any other command line is the instabox CLI's ([`cmd/instabox`](../cmd/instabox)): `npx instabox@latest ls` runs `instabox ls`, installing the CLI first if it is missing.
|
|
12
15
|
|
|
13
16
|
## How it works
|
|
14
17
|
|
|
15
18
|
- `bin/instabox.mjs` runs instabox's loopback sign-in (RFC 8252 + PKCE) and saves the tokens where the CLI keeps its own, `~/.config/instabox/credentials.json`, so both are signed in at once. There is one copy on purpose: the server rotates refresh tokens and ends the session when a rotated one comes back.
|
|
16
|
-
-
|
|
19
|
+
- `sh` always starts a new box, with no timeout and no idle auto-suspend of its own. An existing box is entered with the CLI: `instabox ssh <box>`.
|
|
17
20
|
- An installed CLI is checked against what the server's `/install.sh` would install: the binary's sha256 against the server's `/dl/SHA256SUMS` (release builds are named after a commit, so their versions do not sort). One the server no longer serves is replaced in place through the same installer; a development build (`instabox dev`, or `-dirty`) is left alone with a one-line notice of the install command.
|
|
18
21
|
- Skills are written only over copies marked `installed-by: instabox`; the output says which were installed, updated, already up to date, or kept because they are the user's own.
|
|
19
22
|
- The bin is also called `instabox`, so `lib/cli.mjs` finds the real CLI the way the insta CLI does: PATH without `node_modules` dirs and without links back to this script, then the installer's own targets.
|
package/bin/instabox.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// npx instabox@latest:
|
|
2
|
+
// npx instabox@latest: sign in + CLI + skills; `sh` also starts a new box and SSHes in. Anything else goes to the CLI.
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto';
|
|
5
5
|
import {
|
|
@@ -14,13 +14,13 @@ 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
|
-
import { createInterface } from 'node:readline/promises';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { createClient } from '../lib/api.mjs';
|
|
22
|
-
import { orderBoxes
|
|
23
|
-
import {
|
|
22
|
+
import { orderBoxes } from '../lib/boxes.mjs';
|
|
23
|
+
import { checkCli, findCli, installCli, installCommand, isOnboarding, real, runCli } from '../lib/cli.mjs';
|
|
24
24
|
import { callbackHandler } from '../lib/login.mjs';
|
|
25
25
|
import { loadCredentials, saveCredentials } from '../lib/credentials.mjs';
|
|
26
26
|
|
|
@@ -34,10 +34,9 @@ const API = (
|
|
|
34
34
|
const IMAGE = process.env.INSTABOX_IMAGE || 'universal';
|
|
35
35
|
// SSH certificate lifetime for a box with no timeout of its own.
|
|
36
36
|
const CERT_MIN = 120;
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
const PROVIDER = process.argv.includes('--google') ? 'google' : 'github';
|
|
37
|
+
// With no provider selected, the browser confirms its existing Instabox account
|
|
38
|
+
// or offers sign-in. Explicit flags still start the chosen provider directly.
|
|
39
|
+
const PROVIDER = process.argv.includes('--google') ? 'google' : process.argv.includes('--github') ? 'github' : '';
|
|
41
40
|
const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
|
|
42
41
|
const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
43
42
|
const green = (s) => (process.stdout.isTTY ? `\x1b[32m${s}\x1b[0m` : s);
|
|
@@ -53,12 +52,10 @@ function usage() {
|
|
|
53
52
|
console.log(`
|
|
54
53
|
${bold('instabox')}: a cloud box in seconds
|
|
55
54
|
|
|
56
|
-
npx instabox@latest sign in (first time),
|
|
57
|
-
|
|
58
|
-
--
|
|
59
|
-
|
|
60
|
-
with its own boxes; instabox login --provider google
|
|
61
|
-
signs the CLI in to the same one)
|
|
55
|
+
npx instabox@latest sign in (first time), install the instabox CLI and agent skills
|
|
56
|
+
npx instabox@latest sh the same, then start a new box and SSH into it
|
|
57
|
+
--github, --google sign in directly with that provider instead of confirming
|
|
58
|
+
the browser's current Instabox account
|
|
62
59
|
--no-cli do not install or update the instabox CLI
|
|
63
60
|
--no-skills do not install the instabox agent skills
|
|
64
61
|
npx instabox@latest <command> run an instabox CLI command (instabox help lists them)
|
|
@@ -130,7 +127,38 @@ function signInPage(ok, title, text, who) {
|
|
|
130
127
|
|
|
131
128
|
// Browser sign-in, the same loopback flow as instabox's own CLI (RFC 8252 + PKCE): listen on
|
|
132
129
|
// 127.0.0.1, open /auth/cli/start, receive the code at /callback, exchange it with the verifier.
|
|
133
|
-
|
|
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) {
|
|
134
162
|
const verifier = randomBytes(32).toString('base64url');
|
|
135
163
|
const state = randomBytes(24).toString('base64url');
|
|
136
164
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
@@ -151,13 +179,17 @@ async function browserLogin() {
|
|
|
151
179
|
stale: () => signInPage(false, 'This sign-in link is stale', 'Run the command in your terminal again.'),
|
|
152
180
|
again: () => signInPage(true, 'Already signing you in', 'Head back to your terminal.'),
|
|
153
181
|
ok: (r) => signInPage(true, "You're in", 'Head back to your terminal.', r.me && (r.me.name || r.me.login)),
|
|
154
|
-
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.`),
|
|
155
186
|
};
|
|
156
187
|
const server = http.createServer(callbackHandler({ state, exchange, finish, page }));
|
|
157
188
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
158
189
|
const redirect = `http://127.0.0.1:${server.address().port}/callback`;
|
|
159
190
|
const q = { redirect_uri: redirect, state, code_challenge: challenge, code_challenge_method: 'S256' };
|
|
160
191
|
if (PROVIDER) q.provider = PROVIDER;
|
|
192
|
+
if (invite) q.invite = invite;
|
|
161
193
|
const start = `${API}/auth/cli/start?` + new URLSearchParams(q);
|
|
162
194
|
console.log(` ${bold('Sign in to instabox')} in your browser ${dim('(opening it now)')}`);
|
|
163
195
|
console.log(` ${dim('If it did not open:')} ${start}\n`);
|
|
@@ -166,8 +198,7 @@ async function browserLogin() {
|
|
|
166
198
|
const result = await done;
|
|
167
199
|
clearTimeout(timer);
|
|
168
200
|
server.close();
|
|
169
|
-
|
|
170
|
-
return result.tok;
|
|
201
|
+
return result.err ? { err: result.err, code: result.code } : { tok: result.tok };
|
|
171
202
|
}
|
|
172
203
|
|
|
173
204
|
// The CLI's saved token, a refreshed one, or a browser sign-in; saved where the CLI reads it.
|
|
@@ -181,7 +212,7 @@ async function signedIn() {
|
|
|
181
212
|
return t;
|
|
182
213
|
} catch {}
|
|
183
214
|
}
|
|
184
|
-
const t = await
|
|
215
|
+
const t = await signInWithInvite();
|
|
185
216
|
saveCredentials(API, t);
|
|
186
217
|
return t;
|
|
187
218
|
}
|
|
@@ -199,51 +230,17 @@ async function waitOp(op) {
|
|
|
199
230
|
return op;
|
|
200
231
|
}
|
|
201
232
|
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const { machines = [] } = await client
|
|
206
|
-
.call('GET', '/v1/machines?limit=100')
|
|
207
|
-
.catch((e) => die(`could not list your boxes: ${e.message}`));
|
|
233
|
+
// At the box limit: say so, and show the boxes there are to remove or open instead.
|
|
234
|
+
async function quotaFull(err) {
|
|
235
|
+
const { machines = [] } = await client.call('GET', '/v1/machines?limit=100').catch(() => ({}));
|
|
208
236
|
const boxes = orderBoxes(machines);
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const w = Math.max(...boxes.map((b) => b.name.length));
|
|
214
|
-
console.log(`\n ${bold('Your boxes')}`);
|
|
215
|
-
boxes.forEach((b, i) =>
|
|
216
|
-
console.log(
|
|
217
|
-
` ${ask ? `${i + 1}) ` : ''}${b.name.padEnd(w)} ${b.state.padEnd(9)} ${dim(`${b.template?.id || ''} created ${new Date(b.createdAt).toLocaleString()}`)}`,
|
|
218
|
-
),
|
|
219
|
-
);
|
|
220
|
-
if (!ask) return { listed: true };
|
|
221
|
-
console.log(` ${boxes.length + 1}) ${green('+ new box')}`);
|
|
222
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
223
|
-
try {
|
|
224
|
-
for (;;) {
|
|
225
|
-
const i = parseChoice(await rl.question(' Enter a box [1]: '), boxes.length);
|
|
226
|
-
if (i !== null) return i === boxes.length ? null : boxes[i];
|
|
227
|
-
}
|
|
228
|
-
} finally {
|
|
229
|
-
rl.close();
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Wakes a suspended or stopped box and leaves its settings (timeout, auto-suspend) alone.
|
|
234
|
-
async function enterBox(id) {
|
|
235
|
-
const path = '/v1/machines/' + enc(id);
|
|
236
|
-
let m = await client.call('GET', path);
|
|
237
|
-
if (m.operationId) {
|
|
238
|
-
await waitOp(await client.call('GET', '/v1/operations/' + enc(m.operationId)));
|
|
239
|
-
m = await client.call('GET', path);
|
|
240
|
-
}
|
|
241
|
-
if (m.state === 'suspended' || m.state === 'stopped') {
|
|
242
|
-
await waitOp(await client.call('POST', `${path}/${m.state === 'suspended' ? 'resume' : 'start'}`));
|
|
243
|
-
return { id: m.id, resumed: true };
|
|
237
|
+
console.log(`\n ${bold('Box limit reached')} ${dim(`(${err.message})`)}`);
|
|
238
|
+
if (boxes.length) {
|
|
239
|
+
const w = Math.max(...boxes.map((b) => b.name.length));
|
|
240
|
+
for (const b of boxes) console.log(` ${b.name.padEnd(w)} ${b.state}`);
|
|
244
241
|
}
|
|
245
|
-
|
|
246
|
-
|
|
242
|
+
console.log(`\n Remove one with ${bold('instabox rm <box>')}, or open one with ${bold('instabox ssh <box>')}.\n`);
|
|
243
|
+
process.exit(1);
|
|
247
244
|
}
|
|
248
245
|
|
|
249
246
|
async function newBox() {
|
|
@@ -257,7 +254,7 @@ async function newBox() {
|
|
|
257
254
|
if (img) body.imageId = img.id;
|
|
258
255
|
else body.templateId = IMAGE;
|
|
259
256
|
const op = await waitOp(await client.call('POST', '/v1/machines', body));
|
|
260
|
-
return { id: op.machineId,
|
|
257
|
+
return { id: op.machineId, name: body.name };
|
|
261
258
|
}
|
|
262
259
|
|
|
263
260
|
// Runs while the box starts; never fatal. Returns a line to print, or null.
|
|
@@ -307,6 +304,26 @@ function skillFiles(dir, rel = '') {
|
|
|
307
304
|
});
|
|
308
305
|
}
|
|
309
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
|
+
|
|
310
327
|
// Agent skills shipped in this package (skills/<name>/SKILL.md, with any scripts beside it) into
|
|
311
328
|
// the folders coding agents read. Overwrites only copies we installed earlier (marked
|
|
312
329
|
// `installed-by: instabox`).
|
|
@@ -356,6 +373,8 @@ function installSkills() {
|
|
|
356
373
|
if (of('installed').length) parts.push(`installed ${of('installed').join(', ')}`);
|
|
357
374
|
if (of('unchanged').length) parts.push(`${of('unchanged').join(', ')} already up to date`);
|
|
358
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(', ')}`);
|
|
359
378
|
return parts.length ? `agent skills: ${parts.join('; ')} ${dim('(~/.claude/skills, ~/.agents/skills)')}` : null;
|
|
360
379
|
}
|
|
361
380
|
|
|
@@ -367,7 +386,16 @@ async function reportInstalls(cli) {
|
|
|
367
386
|
if (skillsLine) console.log(` ${green('✓')} ${skillsLine}`);
|
|
368
387
|
}
|
|
369
388
|
|
|
370
|
-
|
|
389
|
+
// The commands to run next, one per line, descriptions lined up.
|
|
390
|
+
function nextSteps(rows) {
|
|
391
|
+
const w = Math.max(...rows.map(([cmd]) => cmd.length));
|
|
392
|
+
console.log(`\n ${bold('Next steps')}`);
|
|
393
|
+
for (const [cmd, what] of rows) console.log(` ${cmd.padEnd(w)} ${dim(what)}`);
|
|
394
|
+
console.log();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Sign-in, the CLI and the skills; with `sh` (wantBox) also a new box and an SSH session in it.
|
|
398
|
+
async function onboard(wantBox) {
|
|
371
399
|
console.log(`\n ${bold('instabox')} ${dim('· a cloud box in seconds')}\n`);
|
|
372
400
|
|
|
373
401
|
client.use(await signedIn());
|
|
@@ -377,22 +405,40 @@ async function sh() {
|
|
|
377
405
|
);
|
|
378
406
|
|
|
379
407
|
const cli = ensureCli();
|
|
380
|
-
|
|
381
|
-
if (picked?.listed) {
|
|
408
|
+
if (!wantBox) {
|
|
382
409
|
await reportInstalls(cli);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
410
|
+
return nextSteps([
|
|
411
|
+
['npx instabox@latest sh', 'start a new box and SSH into it'],
|
|
412
|
+
['instabox ls', 'list your boxes'],
|
|
413
|
+
['instabox ssh <box>', 'open a shell in one; add -- <command> to run just that'],
|
|
414
|
+
]);
|
|
386
415
|
}
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
416
|
+
// An agent (no terminal) cannot use an interactive shell, so it gets the commands instead.
|
|
417
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
418
|
+
if (tty) {
|
|
419
|
+
for (const bin of ['ssh', 'ssh-keygen']) {
|
|
420
|
+
if (spawnSync(bin, ['-V'], { stdio: 'ignore' }).error)
|
|
421
|
+
die(`${bin} was not found. Install OpenSSH and try again.`);
|
|
422
|
+
}
|
|
390
423
|
}
|
|
391
424
|
const t0 = Date.now();
|
|
392
|
-
process.stdout.write(`\n ${dim(
|
|
393
|
-
const box = await
|
|
394
|
-
die(`could not start your box: ${e.message}`)
|
|
395
|
-
|
|
425
|
+
process.stdout.write(`\n ${dim('starting a new box…')}`);
|
|
426
|
+
const box = await newBox().catch(async (e) => {
|
|
427
|
+
if (e.code !== 'quota_exceeded') die(`could not start your box: ${e.message}`);
|
|
428
|
+
// The way out of the limit is `instabox rm`, so the CLI and the skills land before the exit.
|
|
429
|
+
console.log();
|
|
430
|
+
await reportInstalls(cli);
|
|
431
|
+
return quotaFull(e);
|
|
432
|
+
});
|
|
433
|
+
const ready = `ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
|
434
|
+
if (!tty) {
|
|
435
|
+
console.log(`\n ${green('✓')} box ${bold(box.name)} ${ready}`);
|
|
436
|
+
await reportInstalls(cli);
|
|
437
|
+
return nextSteps([
|
|
438
|
+
[`instabox ssh ${box.name} -- <command>`, 'run a command in it'],
|
|
439
|
+
[`instabox ssh ${box.name}`, 'open a shell in it, from a terminal'],
|
|
440
|
+
]);
|
|
441
|
+
}
|
|
396
442
|
|
|
397
443
|
// A throwaway key for this session; only its public half leaves this machine.
|
|
398
444
|
const dir = mkdtempSync(join(tmpdir(), 'instabox-'));
|
|
@@ -419,12 +465,7 @@ async function sh() {
|
|
|
419
465
|
writeFileSync(join(dir, 'known_hosts'), `@cert-authority [${c.host}]:${c.port} ${c.hostTrust.publicKey}\n`);
|
|
420
466
|
|
|
421
467
|
const pauses = m.timeoutAt ? dim(` · pauses itself in ${Math.max(1, Math.round(ttlSeconds / 60))} min`) : '';
|
|
422
|
-
|
|
423
|
-
? `resumed in ${((Date.now() - t0) / 1000).toFixed(1)}s`
|
|
424
|
-
: box.reused
|
|
425
|
-
? 'back in'
|
|
426
|
-
: `ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
|
427
|
-
process.stdout.write(`${process.stdout.isTTY ? '\r' : '\n'} ${green('✓')} box ${bold(m.name)} ${took}${pauses}\n`);
|
|
468
|
+
process.stdout.write(`\r ${green('✓')} box ${bold(m.name)} ${ready}${pauses}\n`);
|
|
428
469
|
await reportInstalls(cli);
|
|
429
470
|
console.log(dim(` type ${'exit'} to leave · stuck? press Enter, then ~.`));
|
|
430
471
|
// The box's prompt is ~37 columns; narrower terminals make bash scroll the input line sideways.
|
|
@@ -452,7 +493,7 @@ async function sh() {
|
|
|
452
493
|
];
|
|
453
494
|
const child = spawn('ssh', args, { stdio: 'inherit' });
|
|
454
495
|
child.on('exit', (code) => {
|
|
455
|
-
console.log(`\n ${dim(`left ${m.name}.
|
|
496
|
+
console.log(`\n ${dim(`left ${m.name}. Back in:`)} instabox ssh ${m.name}\n`);
|
|
456
497
|
process.exit(code ?? 0);
|
|
457
498
|
});
|
|
458
499
|
}
|
|
@@ -460,7 +501,7 @@ async function sh() {
|
|
|
460
501
|
// Any other command line is the CLI's, so `npm i -g instabox` still gives a working `instabox`.
|
|
461
502
|
const args = process.argv.slice(2);
|
|
462
503
|
if (args.length === 1 && (args[0] === '-h' || args[0] === '--help')) usage();
|
|
463
|
-
else if (isOnboarding(args)) await sh
|
|
504
|
+
else if (isOnboarding(args)) await onboard(args.includes('sh'));
|
|
464
505
|
else {
|
|
465
506
|
let path = findCli();
|
|
466
507
|
if (!path) {
|
package/lib/api.mjs
CHANGED
|
@@ -3,9 +3,10 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { loadCredentials, saveCredentials } from './credentials.mjs';
|
|
4
4
|
|
|
5
5
|
export class ApiError extends Error {
|
|
6
|
-
constructor(status, message) {
|
|
6
|
+
constructor(status, message, code) {
|
|
7
7
|
super(message);
|
|
8
8
|
this.status = status;
|
|
9
|
+
this.code = code;
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -25,7 +26,8 @@ export function createClient(
|
|
|
25
26
|
|
|
26
27
|
const parse = async (res) => {
|
|
27
28
|
const data = res.status === 204 ? null : await res.json().catch(() => null);
|
|
28
|
-
if (!res.ok)
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
throw new ApiError(res.status, data?.error?.message || `instabox HTTP ${res.status}`, data?.error?.code);
|
|
29
31
|
return data;
|
|
30
32
|
};
|
|
31
33
|
|
package/lib/boxes.mjs
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Your boxes in the order to show them: live ones only, running first, then newest.
|
|
2
2
|
export function orderBoxes(machines) {
|
|
3
3
|
return machines
|
|
4
4
|
.filter((m) => !['deleting', 'deleted', 'error'].includes(m.state))
|
|
5
5
|
.sort((a, b) => (b.state === 'running') - (a.state === 'running') || new Date(b.createdAt) - new Date(a.createdAt));
|
|
6
6
|
}
|
|
7
|
-
|
|
8
|
-
// The 0-based pick from a typed answer: Enter is the first box, count itself is "new box", else null.
|
|
9
|
-
export function parseChoice(answer, count) {
|
|
10
|
-
const s = answer.trim();
|
|
11
|
-
if (s === '') return 0;
|
|
12
|
-
if (!/^\d+$/.test(s)) return null;
|
|
13
|
-
const n = Number(s);
|
|
14
|
-
return n >= 1 && n <= count + 1 ? n - 1 : null;
|
|
15
|
-
}
|
package/lib/cli.mjs
CHANGED
|
@@ -7,22 +7,9 @@ import { delimiter, join, sep } from 'node:path';
|
|
|
7
7
|
|
|
8
8
|
const OUR_FLAGS = new Set(['--github', '--google', '--no-cli', '--no-skills']);
|
|
9
9
|
|
|
10
|
-
// Bare `instabox`
|
|
10
|
+
// Bare `instabox` or `sh`, with only our flags; anything else is a CLI command.
|
|
11
11
|
export function isOnboarding(args) {
|
|
12
|
-
|
|
13
|
-
const a = args[i];
|
|
14
|
-
if (a === '--box' && i + 1 < args.length) i++;
|
|
15
|
-
else if (a !== 'sh' && !OUR_FLAGS.has(a) && !a.startsWith('--box=')) return false;
|
|
16
|
-
}
|
|
17
|
-
return true;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// The box named by --box NAME or --box=NAME, else null.
|
|
21
|
-
export function boxFlag(args) {
|
|
22
|
-
const i = args.indexOf('--box');
|
|
23
|
-
if (i !== -1) return args[i + 1] ?? null;
|
|
24
|
-
const eq = args.find((a) => a.startsWith('--box='));
|
|
25
|
-
return eq ? eq.slice('--box='.length) : null;
|
|
12
|
+
return args.every((a) => a === 'sh' || OUR_FLAGS.has(a));
|
|
26
13
|
}
|
|
27
14
|
|
|
28
15
|
export const real = (p) => {
|
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
|
@@ -13,10 +13,10 @@ An instabox sandbox is a Linux VM (Debian, user `dev`, sudo). It has a root disk
|
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
15
|
instabox whoami # who is signed in (fails if nobody)
|
|
16
|
-
instabox login # browser
|
|
16
|
+
instabox login # confirm the browser's account, or choose GitHub / Google
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
A GitHub sign-in and a Google sign-in are separate accounts with separate boxes
|
|
19
|
+
A GitHub sign-in and a Google sign-in are separate accounts with separate boxes. Both `instabox login` and `npx instabox@latest` open account confirmation in the browser: use its current Instabox account or choose another. To go directly to a provider, use `instabox login --provider github|google`, or `npx instabox@latest --github|--google`. The CLI shares the sign-in `npx` made. Device-code sign-in (`--device`) uses GitHub.
|
|
20
20
|
|
|
21
21
|
For CI or an agent without a browser, use an API key instead: `export INSTABOX_API_KEY=ibk_...` (the user creates it on the dashboard's API Keys page). Never print, commit or log a key.
|
|
22
22
|
|
|
@@ -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,12 @@ 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
|
+
|
|
75
81
|
## Habits
|
|
76
82
|
|
|
77
|
-
-
|
|
83
|
+
- 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
84
|
- 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
85
|
- `instabox usage` shows storage use and limits. A quota error means delete or stop something; do not retry the same create in a loop.
|