insta 0.0.73 → 0.0.75

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.
@@ -343,9 +343,22 @@ export async function autoupdate(mode) {
343
343
  }
344
344
  // Called once at CLI start-up. Never blocks: reads the cache synchronously, prints at most one
345
345
  // stderr line, and (when due) spawns detached children for the registry check / quiet upgrade.
346
+ /** Commands that must never nudge about an update or spawn a background one.
347
+ *
348
+ * `upgrade`/`autoupdate` are the update machinery itself. Every `__` command
349
+ * is internal machinery rather than a user at a prompt, and the rule is
350
+ * written as a PREFIX so the next such command inherits it instead of
351
+ * rediscovering it -- which is exactly how this was missed:
352
+ * `__ssh-ensure-cert` is run by OpenSSH while it PARSES ssh_config, on every
353
+ * ssh, scp, `ssh -G` and IDE connection, so a nudge here is written straight
354
+ * into the ssh session's stderr and an auto-upgrade spawns a detached process
355
+ * mid-connection. Same prefix rule trackCommand already applies to telemetry.
356
+ */
357
+ export function skipsUpdateCheck(cmd) {
358
+ return cmd === 'upgrade' || cmd === 'autoupdate' || !!cmd?.startsWith('__');
359
+ }
346
360
  export function maybeUpdate(current, argv) {
347
- const cmd = argv[2];
348
- if (cmd === 'upgrade' || cmd === 'autoupdate' || cmd === '__update-check')
361
+ if (skipsUpdateCheck(argv[2]))
349
362
  return;
350
363
  const channel = detectChannel();
351
364
  const cache = readCache();
package/dist/config.js CHANGED
@@ -48,6 +48,7 @@ export async function readGlobal() {
48
48
  delete scrubbed.accessToken;
49
49
  delete scrubbed.refreshToken;
50
50
  delete scrubbed.user;
51
+ delete scrubbed.agentCredential;
51
52
  return scrubbed;
52
53
  }
53
54
  return { ...parsed, apiUrl: override ?? persisted };
package/dist/index.js CHANGED
@@ -79,11 +79,12 @@ program.name('insta').description('InstaCloud CLI — manage projects, branches,
79
79
  program.option('--agent', 'run as an agent with a verified project session and project agent policy');
80
80
  program.hook('preAction', () => configureAgent(detectAgent(!!program.opts().agent)));
81
81
  // ---- auth ----
82
- program.command('login').description('Log in — bare: sign in from your browser (any account type); or --email <email> + password, --oauth <github|google>, --device (headless), --api-key <insta_…> (headless, durable token)')
82
+ program.command('login').description('Log in — bare: sign in from your browser (any account type); or --email <email> + password, --oauth <github|google>, --device (headless), --claim <email> (agent: the named user confirms a code), --api-key <insta_…> (headless, durable token)')
83
83
  .option('--email <email>', 'account email (email + password login)')
84
84
  .option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt; needs --email)')
85
85
  .option('--oauth <provider>', 'browser OAuth login: github | google')
86
86
  .option('--device', 'device-code login: like bare login but never opens a browser here — approve from any other machine (VMs, SSH, CI)')
87
+ .option('--claim <email>', 'agent login confirmed by that user: prints a console link + 6-digit code; they sign in as that email and type the code, and the resulting insta_ key is stored here')
87
88
  .option('--api-key <key>', 'non-interactive login with a durable insta_ API token (headless agents / CI)')
88
89
  .option('--api-url <url>', 'control-plane API base URL')
89
90
  .option('--env <name>', `deployment environment: ${ENV_NAMES.join(' | ')}`)
@@ -247,6 +248,13 @@ compute.command('limits [service]').description("Show or set a compute service's
247
248
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
248
249
  compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero (the default for new compute services); off = scale-to-zero. All plans; billing is actual usage either way')
249
250
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
251
+ compute.command('ssh [service]')
252
+ .description("Issue a short-lived SSH certificate for a compute service and print the command that uses it -- this command does NOT open the session itself, it makes `ssh` work. `--setup` does the one-time work: it generates a dedicated key under ~/.insta/ssh (your existing keys are never touched), has the platform sign a SHORT-LIVED certificate for it, adds one @cert-authority line to ~/.ssh/known_hosts so every region is trusted without per-node fingerprint prompts, and writes an ssh_config block AT THE TOP of ~/.ssh/config giving each compute service the alias `<service>.insta`. After that it is plain `ssh api.insta`, scp and -L: the block renews that alias's certificate for you while OpenSSH parses the config. Needs an interactive login -- API keys are refused; use `insta compute exec` for one-shot commands from CI")
253
+ .option('--setup', 'do the one-time client setup as well as issuing a certificate')
254
+ .option('--ensure-cert <alias>', 'renew the certificate for an alias such as api.insta if it is close to expiry, then exit (used by the ssh_config hook; silent by design)')
255
+ .option('-b, --branch <branch>', 'branch (default: linked)')
256
+ .option('--json', 'machine-readable output')
257
+ .action(guard((service, o) => computeCmd.computeSSH(service, o)));
250
258
  const execCmd = compute.command('exec [service]').description("Run a one-shot command inside a compute service's machine (`insta compute exec [service] -- <command> [args…]`) — no interactive shell/PTY: `command` is argv, no shell is invoked (use [\"sh\", \"-c\", \"...\"] for shell features). Wakes the machine first if it's scaled to zero — expect a few seconds of latency, billed as uptime, not an error. Exits with the remote command's own exit code (agents rely on this)")
251
259
  .action(guard((service, o) => computeCmd.computeExec(service, execCommand, o, { windowsFallback: execWindowsFallback })));
252
260
  // Declared from the same list splitExecArgs uses to find where the CLI's own arguments stop, so a
@@ -389,11 +397,11 @@ ob.command('sync').description('Upload findings into the project timeline').acti
389
397
  // ---- policy ----
390
398
  const agentPol = program.command('agent-policy').description('Project agent access policy');
391
399
  agentPol.command('get').option('--json').action(guard((o) => agentPolicy.get(o)));
392
- agentPol.command('set <mode>').description('full-access | read-only | branch-developer')
400
+ agentPol.command('set <mode>').description('full-access | read-only | branch-specific (resets rules; customize comes from `rule set`)')
393
401
  .option('--json').action(guard((mode, o) => agentPolicy.set(mode, o)));
394
402
  agentPol.command('protect-branch <branch>').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, true, o)));
395
403
  agentPol.command('unprotect-branch <branch>').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, false, o)));
396
- agentPol.command('rule').command('set <action> <decision>').description('Set an unprotected-branch rule: allow | deny | approve')
404
+ agentPol.command('rule').command('set <action> <decision>').description('Set an unprotected-branch rule (allow | deny | approve); moves the policy to customize')
397
405
  .option('--json').action(guard((action, decision, o) => agentPolicy.rule(action, decision, o)));
398
406
  agentPol.command('revoke-sessions').description('Revoke ALL CLI agent sessions for this project')
399
407
  .option('--json').action(guard((o) => agentPolicy.revoke(o)));
@@ -420,6 +428,16 @@ program.command('upgrade').description('Update the insta CLI to the latest relea
420
428
  program.command('autoupdate [mode]').description('Show or set auto-update: on | off (default: on while pre-1.0)')
421
429
  .action(guard((mode) => selfUpdate.autoupdate(mode)));
422
430
  program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck(cliVersion())));
431
+ // The ssh_config renewal hook. Hidden, and named with the `__` prefix that
432
+ // trackCommand skips, because OpenSSH runs it while PARSING the config on EVERY
433
+ // ssh/scp/`ssh -G`/IDE connection. Under the normal `compute ssh --ensure-cert`
434
+ // path, guard's trackCommand ran afterwards regardless of the action returning
435
+ // early -- reading config, possibly creating ~/.insta/telemetry.json, and
436
+ // making a PostHog request with a timeout of up to 1.5s. That is a network
437
+ // round trip on the critical path of every ordinary ssh, which is exactly what
438
+ // the hook was specified not to do.
439
+ program.command('__ssh-ensure-cert <alias>', { hidden: true })
440
+ .action(guard((alias) => computeCmd.ensureCertForAlias(alias)));
423
441
  selfUpdate.maybeUpdate(cliVersion(), process.argv);
424
442
  program.parseAsync(computeArgv);
425
443
  //# sourceMappingURL=index.js.map
package/dist/util.js CHANGED
@@ -1,6 +1,105 @@
1
1
  // Output + small pure helpers (env serialization is unit-tested).
2
2
  import { createInterface } from 'node:readline';
3
3
  import { spawn } from 'node:child_process';
4
+ import { chmodSync, copyFileSync, existsSync, lstatSync, readlinkSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
5
+ import { basename, dirname, join, resolve } from 'node:path';
6
+ import { randomBytes } from 'node:crypto';
7
+ /**
8
+ * Replace a file's contents in one step: write a sibling temporary file, then
9
+ * rename it over the target.
10
+ *
11
+ * For files the USER also owns — `~/.ssh/config`, `known_hosts` — a plain
12
+ * writeFileSync is a truncate followed by a write, so an interrupt, a full disk
13
+ * or a crash between the two leaves the user with a half a config and no way to
14
+ * ssh anywhere. rename(2) is atomic, so a reader sees either the old file or
15
+ * the new one. `backup` additionally leaves the previous contents recoverable.
16
+ *
17
+ * A SYMLINK is followed to its target first. Keeping a dotfiles repo and
18
+ * symlinking `~/.ssh/config` at it is a common setup, and rename(2) replaces
19
+ * the link itself rather than writing through it -- so the naive version
20
+ * silently severs the link, leaving the repo holding a copy that no longer
21
+ * matches the file ssh reads and the next dotfiles sync quietly reverting our
22
+ * block. Resolving first keeps the write atomic (the temp file still lands
23
+ * beside the real file, on the real file's filesystem) AND keeps the link.
24
+ */
25
+ export function writeFileAtomicSync(target, data, opts = {}) {
26
+ const mode = opts.mode ?? 0o600;
27
+ const path = resolveThroughSymlink(target);
28
+ const tmp = join(dirname(path), `.${basename(path)}.insta-${process.pid}-${randomBytes(6).toString('hex')}`);
29
+ try {
30
+ writeFileSync(tmp, data, { mode });
31
+ // writeFileSync applies `mode` only when it CREATES the file, and a umask
32
+ // can clear bits even then. ssh refuses a group-readable config outright.
33
+ chmodSync(tmp, mode);
34
+ if (opts.backup && existsSync(path))
35
+ copyFileSync(path, path + '.insta-bak');
36
+ renameSync(tmp, path);
37
+ }
38
+ catch (e) {
39
+ try {
40
+ unlinkSync(tmp);
41
+ }
42
+ catch { /* never created, or already gone */ }
43
+ throw e;
44
+ }
45
+ }
46
+ /** The file `path` ultimately NAMES: itself when it is not a link, otherwise the
47
+ * end of the symlink chain -- whether or not that end exists yet.
48
+ *
49
+ * A DANGLING link resolves to the target it names, not to itself. `realpath`
50
+ * gives up with ENOENT there, and returning the link path made the caller
51
+ * rename over the LINK: a `~/.ssh/config` symlinked into a dotfiles repo that
52
+ * has not been populated yet -- a fresh clone, a new machine -- was silently
53
+ * turned into a regular file, destroying wiring that `readlink` could still
54
+ * read off the link perfectly well. So the chain is walked by hand from there,
55
+ * and the write lands on the file the user actually pointed at, creating it.
56
+ *
57
+ * Every OTHER failure still propagates. A blanket catch was a quiet hole:
58
+ * `ELOOP` (a symlink cycle) and `EACCES` (a directory the user cannot
59
+ * traverse) would both return the link path and sever a link because we could
60
+ * not read it. Cannot-confirm is not a licence to write -- and with the
61
+ * dangling case handled above, there is no longer any case where replacing a
62
+ * link is the right answer. */
63
+ export function resolveThroughSymlink(path) {
64
+ // realpath resolves a live chain in one call and reports ELOOP for a cycle,
65
+ // so a hand-walk only ever runs past the point where the chain dangles --
66
+ // which is finite by construction. The cap is for a link created underneath
67
+ // us mid-walk, where finite is no longer guaranteed.
68
+ for (let hop = 0; hop <= MAX_SYMLINK_HOPS; hop++) {
69
+ let link;
70
+ try {
71
+ if (!lstatSync(path).isSymbolicLink())
72
+ return path;
73
+ return realpathSync(path);
74
+ }
75
+ catch (e) {
76
+ if (e?.code !== 'ENOENT')
77
+ throw e;
78
+ }
79
+ try {
80
+ link = readlinkSync(path);
81
+ }
82
+ catch (e) {
83
+ // ENOENT from both calls means nothing is at `path` at all -- it is the
84
+ // file to create, which is what the caller wants written.
85
+ if (e?.code === 'ENOENT')
86
+ return path;
87
+ throw e;
88
+ }
89
+ // Link text is resolved against the directory holding the LINK, as the
90
+ // kernel does it -- not against the process cwd, which would scatter files
91
+ // into wherever the CLI happened to be run from. The REAL directory, too:
92
+ // the kernel resolves a relative target from where the link actually
93
+ // lives, so when `~/.ssh` is itself a link into a dotfiles repo, a
94
+ // `../x` inside it names a sibling of the repo directory, not of `~/.ssh`.
95
+ // The directory exists (the link was just lstat'ed inside it), and any
96
+ // failure resolving it propagates like every other one above.
97
+ path = resolve(realpathSync(dirname(path)), link);
98
+ }
99
+ throw new Error(`too many levels of symbolic links resolving ${JSON.stringify(path)}`);
100
+ }
101
+ /** Linux allows 40; the exact number does not matter, only that the walk ends. */
102
+ const MAX_SYMLINK_HOPS = 40;
4
103
  /** How to launch the default browser for `url` on `platform`. Pure so the Windows encoding is
5
104
  * testable. On Windows NO shell may ever parse the URL: cmd.exe splits at bare `&` (which #138
6
105
  * fixed by quoting) but ALSO expands `%…%` sequences even inside quotes, and a percent-encoded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.73",
3
+ "version": "0.0.75",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [