instabox 0.5.3 → 0.6.0
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 +46 -3
- package/lib/api.mjs +8 -1
- package/lib/cli.mjs +1 -1
- package/lib/ssh.mjs +10 -0
- package/lib/telemetry.mjs +97 -0
- package/package.json +1 -1
package/bin/instabox.mjs
CHANGED
|
@@ -23,8 +23,9 @@ import { orderBoxes } from '../lib/boxes.mjs';
|
|
|
23
23
|
import { guestUserNote, localGuestUser } from '../lib/guestuser.mjs';
|
|
24
24
|
import { checkCli, findCli, installCli, installCommand, isOnboarding, real, runCli } from '../lib/cli.mjs';
|
|
25
25
|
import { browserCommand, callbackHandler } from '../lib/login.mjs';
|
|
26
|
-
import { sessionArgs } from '../lib/ssh.mjs';
|
|
26
|
+
import { makeDirPrivate, sessionArgs } from '../lib/ssh.mjs';
|
|
27
27
|
import { loadCredentials, saveCredentials } from '../lib/credentials.mjs';
|
|
28
|
+
import { agentName, track } from '../lib/telemetry.mjs';
|
|
28
29
|
|
|
29
30
|
// Same variables, same order as the instabox CLI, so both talk to one server.
|
|
30
31
|
const API = (
|
|
@@ -61,11 +62,22 @@ function usage() {
|
|
|
61
62
|
--no-cli do not install or update the instabox CLI
|
|
62
63
|
--no-skills do not install the instabox agent skills
|
|
63
64
|
npx instabox@latest <command> run an instabox CLI command (instabox help lists them)
|
|
65
|
+
|
|
66
|
+
Usage data (steps and outcomes, never names or arguments) goes to the instabox team;
|
|
67
|
+
INSTABOX_NO_TELEMETRY=1 or DO_NOT_TRACK=1 turns it off.
|
|
64
68
|
`);
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
// ---- instabox API ------------------------------------------------------------------------------
|
|
68
|
-
const
|
|
72
|
+
const VERSION = (() => {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
75
|
+
} catch {
|
|
76
|
+
return 'unknown';
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
const client = createClient(API, { userAgent: `instabox-npm/${VERSION}` });
|
|
80
|
+
let freshSignIn = false;
|
|
69
81
|
|
|
70
82
|
function openBrowser(url) {
|
|
71
83
|
const cmd = browserCommand(url);
|
|
@@ -210,6 +222,7 @@ async function signedIn() {
|
|
|
210
222
|
} catch {}
|
|
211
223
|
}
|
|
212
224
|
const t = await signInWithInvite();
|
|
225
|
+
freshSignIn = true;
|
|
213
226
|
saveCredentials(API, t);
|
|
214
227
|
return t;
|
|
215
228
|
}
|
|
@@ -396,8 +409,31 @@ function nextSteps(rows) {
|
|
|
396
409
|
async function onboard(wantBox) {
|
|
397
410
|
console.log(`\n ${bold('instabox')} ${dim('· a cloud box in seconds')}\n`);
|
|
398
411
|
|
|
412
|
+
const started = Date.now();
|
|
399
413
|
client.use(await signedIn());
|
|
400
414
|
const me = await client.call('GET', '/v1/me').catch((e) => die(`instabox did not accept the sign-in: ${e.message}`));
|
|
415
|
+
// outcome: ready, or quota_full; never names, paths or messages.
|
|
416
|
+
const report = (outcome, extra = {}) =>
|
|
417
|
+
track(
|
|
418
|
+
'npx_onboard',
|
|
419
|
+
me.userId,
|
|
420
|
+
{
|
|
421
|
+
outcome,
|
|
422
|
+
want_box: wantBox,
|
|
423
|
+
fresh_sign_in: freshSignIn,
|
|
424
|
+
duration_ms: Date.now() - started,
|
|
425
|
+
no_cli: process.argv.includes('--no-cli'),
|
|
426
|
+
no_skills: process.argv.includes('--no-skills'),
|
|
427
|
+
tty: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
428
|
+
agent: agentName(),
|
|
429
|
+
os: process.platform,
|
|
430
|
+
arch: process.arch,
|
|
431
|
+
node_version: process.versions.node,
|
|
432
|
+
npm_version: VERSION,
|
|
433
|
+
...extra,
|
|
434
|
+
},
|
|
435
|
+
{ server: API },
|
|
436
|
+
);
|
|
401
437
|
console.log(
|
|
402
438
|
` ${dim('signed in as')} ${bold(me.name || me.login)}${me.login && me.name ? dim(` (${me.login})`) : ''} ${dim('· not you?')} npx instabox@latest logout`,
|
|
403
439
|
);
|
|
@@ -405,6 +441,7 @@ async function onboard(wantBox) {
|
|
|
405
441
|
const cli = ensureCli();
|
|
406
442
|
if (!wantBox) {
|
|
407
443
|
await reportInstalls(cli);
|
|
444
|
+
await report('ready');
|
|
408
445
|
return nextSteps([
|
|
409
446
|
['npx instabox@latest sh', 'start a new box and SSH into it'],
|
|
410
447
|
['instabox ls', 'list your boxes'],
|
|
@@ -426,15 +463,18 @@ async function onboard(wantBox) {
|
|
|
426
463
|
// The way out of the limit is `instabox rm`, so the CLI and the skills land before the exit.
|
|
427
464
|
console.log();
|
|
428
465
|
await reportInstalls(cli);
|
|
466
|
+
await report('quota_full');
|
|
429
467
|
return quotaFull(e);
|
|
430
468
|
});
|
|
431
|
-
const
|
|
469
|
+
const boxMs = Date.now() - t0;
|
|
470
|
+
const ready = `ready in ${(boxMs / 1000).toFixed(1)}s`;
|
|
432
471
|
if (!tty) {
|
|
433
472
|
console.log(`\n ${green('✓')} box ${bold(box.name)} ${ready}`);
|
|
434
473
|
const got = await client.call('GET', '/v1/machines/' + enc(box.id)).catch(() => null);
|
|
435
474
|
const note = guestUserNote(box.user, box.name, got?.guestUser);
|
|
436
475
|
if (note) console.log(dim(` ${note}`));
|
|
437
476
|
await reportInstalls(cli);
|
|
477
|
+
await report('ready', { box_ready_ms: boxMs });
|
|
438
478
|
return nextSteps([
|
|
439
479
|
[`instabox ssh ${box.name} -- <command>`, 'run a command in it'],
|
|
440
480
|
[`instabox ssh ${box.name}`, 'open a shell in it, from a terminal'],
|
|
@@ -448,6 +488,8 @@ async function onboard(wantBox) {
|
|
|
448
488
|
rmSync(dir, { recursive: true, force: true });
|
|
449
489
|
} catch {}
|
|
450
490
|
});
|
|
491
|
+
// before ssh-keygen, so the key inherits only the owner's access
|
|
492
|
+
if (!makeDirPrivate(dir)) console.log(dim(` could not make ${dir} private (icacls failed); ssh may refuse the key`));
|
|
451
493
|
const key = join(dir, 'id_ed25519');
|
|
452
494
|
const kg = spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'instabox', '-f', key], {
|
|
453
495
|
stdio: 'ignore',
|
|
@@ -470,6 +512,7 @@ async function onboard(wantBox) {
|
|
|
470
512
|
const note = guestUserNote(box.user, m.name, m.guestUser);
|
|
471
513
|
if (note) console.log(dim(` ${note}`));
|
|
472
514
|
await reportInstalls(cli);
|
|
515
|
+
await report('ready', { box_ready_ms: boxMs, ssh: true });
|
|
473
516
|
console.log(dim(` type ${'exit'} to leave · stuck? press Enter, then ~.`));
|
|
474
517
|
// The box's prompt is ~37 columns; narrower terminals make bash scroll the input line sideways.
|
|
475
518
|
if (process.stdout.isTTY && process.stdout.columns > 0 && process.stdout.columns < 60)
|
package/lib/api.mjs
CHANGED
|
@@ -12,12 +12,19 @@ export class ApiError extends Error {
|
|
|
12
12
|
|
|
13
13
|
export function createClient(
|
|
14
14
|
server,
|
|
15
|
-
{
|
|
15
|
+
{
|
|
16
|
+
fetch = globalThis.fetch,
|
|
17
|
+
load = () => loadCredentials(server),
|
|
18
|
+
save = (t) => saveCredentials(server, t),
|
|
19
|
+
userAgent = '',
|
|
20
|
+
} = {},
|
|
16
21
|
) {
|
|
17
22
|
let session = null;
|
|
18
23
|
|
|
19
24
|
const send = (token, method, path, body, key) => {
|
|
20
25
|
const headers = { Accept: 'application/json' };
|
|
26
|
+
// instabox-npm/<version>: the server attributes the requests to this package.
|
|
27
|
+
if (userAgent) headers['User-Agent'] = userAgent;
|
|
21
28
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
22
29
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
23
30
|
if (key) headers['Idempotency-Key'] = key;
|
package/lib/cli.mjs
CHANGED
|
@@ -201,7 +201,7 @@ async function installWindows(
|
|
|
201
201
|
version: v,
|
|
202
202
|
pathHint: onPath
|
|
203
203
|
? null
|
|
204
|
-
: `[Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path', 'User') + ';${to.replaceAll("'", "''")}', 'User')`,
|
|
204
|
+
: `[Environment]::SetEnvironmentVariable('Path', ([string][Environment]::GetEnvironmentVariable('Path', 'User')).TrimEnd(';') + ';${to.replaceAll("'", "''")}', 'User')`,
|
|
205
205
|
};
|
|
206
206
|
} catch (e) {
|
|
207
207
|
rmSync(tmp, { force: true });
|
package/lib/ssh.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// ssh's argv for a session with this run's throwaway key, certificate and known_hosts.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
|
|
4
5
|
// ssh splits an -o value at whitespace and reads ' as an open quote; Windows profile paths often have either.
|
|
@@ -24,3 +25,12 @@ export function sessionArgs({ dir, key, port, username, host }) {
|
|
|
24
25
|
`${username}@${host}`,
|
|
25
26
|
];
|
|
26
27
|
}
|
|
28
|
+
|
|
29
|
+
// Windows OpenSSH rejects a key that anyone but its owner may read, and a temp dir can pass on other users' ACEs.
|
|
30
|
+
export function makeDirPrivate(dir, { platform = process.platform, run = spawnSync } = {}) {
|
|
31
|
+
if (platform !== 'win32') return true;
|
|
32
|
+
const who = run('whoami', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8' });
|
|
33
|
+
const sid = String(who.stdout || '').match(/"(S-1-[0-9-]+)"/)?.[1];
|
|
34
|
+
if (!sid) return false;
|
|
35
|
+
return run('icacls', [dir, '/inheritance:r', '/grant:r', `*${sid}:(OI)(CI)F`], { stdio: 'ignore' }).status === 0;
|
|
36
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Usage telemetry (docs/analytics.md); telemetry.json is shared with cmd/instabox/telemetry.go.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { configDir } from './credentials.mjs';
|
|
6
|
+
|
|
7
|
+
// A write-only capture key, not a secret.
|
|
8
|
+
export const TELEMETRY_KEY = 'phc_nPznuUD5U36v4aSQVHaV4mbKwGTpjjR8Xt9Ukb5mATfY';
|
|
9
|
+
export const TELEMETRY_HOST = 'https://us.i.posthog.com';
|
|
10
|
+
export const DEFAULT_SERVER = 'https://api.instabox.sh';
|
|
11
|
+
// The instabox CLI prints the same when it creates telemetry.json (cmd/instabox/telemetry.go).
|
|
12
|
+
const NOTICE =
|
|
13
|
+
'instabox sends usage data (command names and outcomes, never arguments) to help improve it;\n' +
|
|
14
|
+
'set INSTABOX_NO_TELEMETRY=1 or DO_NOT_TRACK=1 to turn it off.\n';
|
|
15
|
+
|
|
16
|
+
export function telemetryDisabled(env = process.env) {
|
|
17
|
+
const on = (k) =>
|
|
18
|
+
!['', '0', 'false'].includes(
|
|
19
|
+
String(env[k] ?? '')
|
|
20
|
+
.trim()
|
|
21
|
+
.toLowerCase(),
|
|
22
|
+
);
|
|
23
|
+
return on('DO_NOT_TRACK') || on('INSTABOX_NO_TELEMETRY');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function agentName(env = process.env) {
|
|
27
|
+
if (env.CLAUDECODE) return 'claude-code';
|
|
28
|
+
if (env.CURSOR_TRACE_ID) return 'cursor';
|
|
29
|
+
if (env.CODEX_SANDBOX) return 'codex';
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readState(env) {
|
|
34
|
+
try {
|
|
35
|
+
const st = JSON.parse(readFileSync(join(configDir(env), 'telemetry.json'), 'utf8'));
|
|
36
|
+
return st?.anonymousId ? st : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeState(env, st) {
|
|
43
|
+
const dir = configDir(env);
|
|
44
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
|
+
chmodSync(dir, 0o700);
|
|
46
|
+
const tmp = join(dir, `.telemetry.${randomUUID()}.tmp`);
|
|
47
|
+
writeFileSync(tmp, JSON.stringify(st) + '\n', { mode: 0o600 });
|
|
48
|
+
renameSync(tmp, join(dir, 'telemetry.json'));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Sends event for the account userId. Never throws; waits at most 1.5 s.
|
|
52
|
+
export async function track(
|
|
53
|
+
event,
|
|
54
|
+
userId,
|
|
55
|
+
properties,
|
|
56
|
+
{
|
|
57
|
+
server,
|
|
58
|
+
env = process.env,
|
|
59
|
+
fetch = globalThis.fetch,
|
|
60
|
+
key = TELEMETRY_KEY,
|
|
61
|
+
host = TELEMETRY_HOST,
|
|
62
|
+
stderr = process.stderr,
|
|
63
|
+
} = {},
|
|
64
|
+
) {
|
|
65
|
+
if (!key || !userId || String(server).replace(/\/+$/, '') !== DEFAULT_SERVER || telemetryDisabled(env)) return;
|
|
66
|
+
try {
|
|
67
|
+
let st = readState(env);
|
|
68
|
+
if (!st) {
|
|
69
|
+
st = { anonymousId: randomUUID() };
|
|
70
|
+
writeState(env, st);
|
|
71
|
+
if (stderr.isTTY) stderr.write(NOTICE);
|
|
72
|
+
}
|
|
73
|
+
// Another account than the last one: a fresh anonymous ID to link.
|
|
74
|
+
if (st.userId && st.userId !== userId) st = { anonymousId: randomUUID() };
|
|
75
|
+
const id = String(userId);
|
|
76
|
+
const lib = { $lib: 'instabox-npm' };
|
|
77
|
+
const batch = [
|
|
78
|
+
{
|
|
79
|
+
event,
|
|
80
|
+
distinct_id: id,
|
|
81
|
+
uuid: randomUUID(),
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
properties: { ...properties, ...lib },
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
const merge = st.userId !== userId || !st.merged;
|
|
87
|
+
if (merge)
|
|
88
|
+
batch.push({ event: '$identify', distinct_id: id, properties: { $anon_distinct_id: st.anonymousId, ...lib } });
|
|
89
|
+
const res = await fetch(host + '/batch/', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ api_key: key, batch }),
|
|
93
|
+
signal: AbortSignal.timeout(1500),
|
|
94
|
+
});
|
|
95
|
+
if (res.ok && merge) writeState(env, { anonymousId: st.anonymousId, userId, merged: true });
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|