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 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 client = createClient(API);
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 ready = `ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`;
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
- { fetch = globalThis.fetch, load = () => loadCredentials(server), save = (t) => saveCredentials(server, t) } = {},
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instabox",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "A cloud box for your coding agent, the instabox CLI and its agent skills in one command: npx instabox@latest",
5
5
  "type": "module",
6
6
  "bin": {