insta 0.0.72 → 0.0.74
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/dist/agent.js +4 -1
- package/dist/api.js +11 -4
- package/dist/commands/agent-policy.js +119 -19
- package/dist/commands/auth.js +115 -7
- package/dist/commands/compute.js +1132 -2
- package/dist/commands/env.js +1 -0
- package/dist/commands/github.js +56 -12
- package/dist/commands/ssh-config.js +710 -0
- package/dist/commands/upgrade.js +15 -2
- package/dist/config.js +1 -0
- package/dist/index.js +22 -4
- package/dist/util.js +99 -0
- package/package.json +1 -1
package/dist/commands/upgrade.js
CHANGED
|
@@ -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
|
-
|
|
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
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
|
|
@@ -255,7 +263,7 @@ for (const [flags, description] of computeCmd.EXEC_OPTIONS)
|
|
|
255
263
|
execCmd.option(flags, description);
|
|
256
264
|
compute.command('repo [service]').description('Show what a compute service deploys from: the image it runs, or the GitHub repository — owner/repo, the branch it builds, root directory, which paths a push must change to redeploy it, and whether pushes redeploy it at all')
|
|
257
265
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeRepo(service, o)));
|
|
258
|
-
compute.command('connect-repo <owner/repo> [service]').description("Connect a GitHub repository to an EXISTING compute service: the repo is built (its Dockerfile, or nixpacks when there is none) and deployed into that service, and every later push to the tracked repository branch redeploys it. The repo must be one your own GitHub account can reach through the InstaCloud App, or be public; the
|
|
266
|
+
compute.command('connect-repo <owner/repo> [service]').description("Connect a GitHub repository to an EXISTING compute service: the repo is built (its Dockerfile, or nixpacks when there is none) and deployed into that service, and every later push to the tracked repository branch redeploys it. The repo must be one your own GitHub account can reach through the InstaCloud App, or be public; the CLI opens GitHub for device-code authorization and App installation or configuration when needed, then waits for repository access and continues. URLs are also printed for remote terminals; --json requires access to be ready. Build and start commands come from detection and cannot be set. Connecting again replaces the service's current source")
|
|
259
267
|
.option('--public', 'the repo is public and no GitHub App installation is needed (deploys are manual; pushes cannot redeploy)')
|
|
260
268
|
.option('--root-dir <dir>', 'the directory of the repo to build (a monorepo with several deployable directories lists them and exits 1 without it)')
|
|
261
269
|
.option('--repo-branch <name>', "the repository branch to build (default: the repo's default branch)")
|
|
@@ -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-
|
|
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
|
|
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
|