insta 0.0.47 → 0.0.49

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.
@@ -1,5 +1,28 @@
1
1
  import { ApiClient, requireProject } from '../api.js';
2
2
  import { info, printJson } from '../util.js';
3
+ /**
4
+ * The label that names WHERE a resource runs.
5
+ *
6
+ * `kind` is the platform's internal resource kind, and for compute it is always 'fly' — 'fly' is
7
+ * the compute SEAT, occupied by the microvm plane on any environment that has cut over. Printing
8
+ * it is how `insta manifest` came to tell users and agents that a microvm-backed service ran on
9
+ * Fly (staging, 2026-08-25: `fly(api)` for a row serving from warm pod insta-warm-00178a-16).
10
+ *
11
+ * So for compute rows the label is the platform's explicit `provider`, and when that is absent --
12
+ * an older platform, or a row whose provider the platform itself could not determine -- we fall
13
+ * back to the neutral 'compute'. That names the resource without asserting a plane: no provider
14
+ * beats a wrong one. Non-compute kinds keep printing their kind.
15
+ */
16
+ export function resourceLabel(r) {
17
+ const kind = r.kind ?? 'resource';
18
+ const label = kind === 'fly' ? (r.provider === 'fly' || r.provider === 'microvm' ? r.provider : 'compute') : kind;
19
+ return `${label}${r.name ? `(${r.name})` : ''}`;
20
+ }
21
+ // One `manifest` resource line. Pure, so the label is unit-tested without a network mock.
22
+ export function resourceLine(r) {
23
+ const where = r.ref?.url ?? r.ref?.bucket ?? r.ref?.neonProjectId ?? '';
24
+ return ` - ${resourceLabel(r)} ${where} [${r.status}]`;
25
+ }
3
26
  // Agent-legible view of each environment's databases / storage / compute.
4
27
  export async function manifest(opts) {
5
28
  const api = await ApiClient.load();
@@ -11,10 +34,8 @@ export async function manifest(opts) {
11
34
  for (const b of detail.branches) {
12
35
  info(` branch ${b.name}${b.is_default ? ' *' : ''} [${b.status}]`);
13
36
  const rs = detail.resources.filter((r) => r.branchId === b.id || (b.is_default && r.branchId === null));
14
- for (const r of rs) {
15
- const where = r.ref?.url ?? r.ref?.bucket ?? r.ref?.neonProjectId ?? '';
16
- info(` - ${r.kind}${r.name ? `(${r.name})` : ''} ${where} [${r.status}]`);
17
- }
37
+ for (const r of rs)
38
+ info(resourceLine(r));
18
39
  }
19
40
  }
20
41
  //# sourceMappingURL=manifest.js.map
@@ -6,18 +6,21 @@
6
6
  // Stack skills (tigris/better-auth) intentionally stay per-project: their presence in a
7
7
  // project doubles as its stack manifest — that install happens on `project create|link`.
8
8
  import { spawn } from 'node:child_process';
9
- import { closeSync, createReadStream, existsSync, openSync, readFileSync, statSync } from 'node:fs';
10
- import { dirname, join } from 'node:path';
9
+ import { closeSync, createReadStream, openSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
11
  import os from 'node:os';
12
12
  import { createInterface } from 'node:readline';
13
13
  import { ApiClient } from '../api.js';
14
14
  import { readPersistedGlobal, resolveEnv } from '../config.js';
15
15
  import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, envFromEnvVar, isEnvName, mcpServerName } from '../env.js';
16
16
  import { info } from '../util.js';
17
+ import { isRunnableFile, resolveSpawnable } from '../spawn.js';
17
18
  import { loginOauth } from './auth.js';
19
+ import { projectLink } from './project.js';
18
20
  import { envUse } from './env.js';
19
21
  import { installAgentConfigs } from './mcp.js';
20
22
  import { detectChannel } from './upgrade.js';
23
+ export { resolveSpawnable, whichOnPath } from '../spawn.js';
21
24
  // The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an
22
25
  // "Installing to all N agents" banner, a full N-line install-path box, and a third-party
23
26
  // "Security Risk Assessment" that flags our OWN first-party skill as "Critical Risk". Streamed
@@ -98,35 +101,6 @@ export function summarizeInstall(output) {
98
101
  // access() answers "can THIS process exec it", which for root is always yes, so a root-run
99
102
  // setup would wrongly treat a non-executable file as a durable install. On Windows execute
100
103
  // permission is extension-driven, so existence of a regular file is the right check.
101
- const isRunnableFile = (p, win) => {
102
- try {
103
- const st = statSync(p);
104
- if (!st.isFile())
105
- return false;
106
- return win || (st.mode & 0o111) !== 0;
107
- }
108
- catch {
109
- return false;
110
- }
111
- };
112
- /** Resolve a bare command name to its absolute PATH location (PATHEXT-aware on Windows).
113
- * cmd.exe searches the CURRENT DIRECTORY before PATH for bare names, so handing it a bare
114
- * `claude` would let a claude.cmd planted in the project directory shadow the real CLI —
115
- * the cmd.exe wrapper below only ever passes absolute paths. */
116
- export function whichOnPath(bin, env = process.env, platform = process.platform) {
117
- const win = platform === 'win32';
118
- const exts = win ? [...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';'), ''] : [''];
119
- for (const dir of (env.PATH ?? '').split(win ? ';' : ':')) {
120
- if (!dir)
121
- continue;
122
- for (const ext of exts) {
123
- const p = join(dir, bin + ext);
124
- if (isRunnableFile(p, win))
125
- return p;
126
- }
127
- }
128
- return null;
129
- }
130
104
  export function findDurableOnPath(bin, env = process.env, platform = process.platform) {
131
105
  const win = platform === 'win32';
132
106
  const dirs = (env.PATH ?? '').split(win ? ';' : ':');
@@ -181,54 +155,6 @@ export async function ensureCliInstalled(run, channel = detectChannel(), onPath
181
155
  info(' (permission error: the npm prefix is system-owned — use a Node version manager, or elevate that one command)');
182
156
  }
183
157
  }
184
- // ---- Windows-safe spawning for npm/npx ----
185
- // On Windows `npm`/`npx` are .cmd shims, which spawn() without a shell refuses (Node docs:
186
- // spawning .bat/.cmd needs a shell or cmd.exe). Rather than a shell (argument-quoting hazards),
187
- // re-enter them as node scripts: the CLI script named by npm_execpath (swapped between
188
- // npm-cli.js and npx-cli.js as needed), else the one shipped beside the running node, else the
189
- // bare name (POSIX, where PATH shims resolve fine). Applied ONCE, inside the default runner,
190
- // so every `run('npm'|'npx', …)` call site benefits and nothing is ever resolved twice.
191
- export function resolveSpawnable(cmd, args, npmExecpath = process.env.npm_execpath, execPath = process.execPath, platform = process.platform, env = process.env) {
192
- // Node re-entry is only valid when THIS process runs on node. On the native-binary channel
193
- // execPath is the compiled `insta` executable — and npm scripts export npm_execpath to their
194
- // children — so re-entering blindly would spawn `insta npx-cli.js …`. A non-node execPath
195
- // sends npm/npx down the generic shim path below instead.
196
- const execIsNode = /(^|[\\/])node(\.exe)?$/i.test(execPath);
197
- if ((cmd === 'npm' || cmd === 'npx') && execIsNode) {
198
- if (npmExecpath && /(^|[\\/])np[mx](-cli)?\.[cm]?js$/.test(npmExecpath)) {
199
- const cli = npmExecpath.replace(/np[mx](-cli)?(\.[cm]?js)$/, `${cmd}$1$2`);
200
- if (existsSync(cli))
201
- return { cmd: execPath, args: [cli, ...args] };
202
- }
203
- const nodeDir = dirname(execPath);
204
- const besideNode = platform === 'win32'
205
- ? join(nodeDir, 'node_modules', 'npm', 'bin', `${cmd}-cli.js`)
206
- : join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', `${cmd}-cli.js`);
207
- if (existsSync(besideNode))
208
- return { cmd: execPath, args: [besideNode, ...args] };
209
- }
210
- // Generic shim path — every non-npm CLI we shell out to (claude), plus npm/npx themselves
211
- // when node isn't resolvable (native binary channel). On Windows these are .cmd shims, which
212
- // spawn() refuses without a shell, so route them through cmd.exe. Guards, in order:
213
- // - BARE names only: an absolute path or anything .exe (node.exe from a resolved npm/npx
214
- // invocation passing back through here) is directly spawnable and must NOT see cmd.exe.
215
- // - The name is resolved to its ABSOLUTE PATH location first: cmd.exe searches the current
216
- // directory before PATH, so a bare name would let a shim planted in the project dir
217
- // shadow the real CLI. No PATH hit → pass through (spawn fails; callers degrade).
218
- // - No manual quoting: libuv already wraps spaced args when building the child command
219
- // line — pre-quoting would be quoted AGAIN and arrive as literal quote characters.
220
- // - That leaves cmd.exe metacharacters unprotectable, so an arg carrying one (e.g. a
221
- // custom INSTA_MCP_URL with `&`) skips the wrapper: the bare-shim spawn fails and every
222
- // caller degrades gracefully (probe → not-installed; registration → manual-add
223
- // fallback). Never hand metacharacters to a shell.
224
- const bareShim = !/[\\/]/.test(cmd) && !/\.exe$/i.test(cmd);
225
- if (platform === 'win32' && bareShim && !args.some((a) => /[&|<>^%"]/.test(a))) {
226
- const abs = whichOnPath(cmd, env, platform);
227
- if (abs)
228
- return { cmd: 'cmd.exe', args: ['/d', '/s', '/c', abs, ...args] };
229
- }
230
- return { cmd, args };
231
- }
232
158
  // Capture stdout+stderr silently (don't stream) so we can print our own clean summary.
233
159
  // stdin is 'ignore', NOT 'inherit': under the canonical `curl … | sh` install, stdin is the
234
160
  // piped install script itself — a child that inherits it (npx/skills reads for keypresses even
@@ -424,7 +350,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
424
350
  login: () => loginOauth('github', {}),
425
351
  stdinTty: canPromptViaTty(),
426
352
  stdoutTty: !!process.stdout.isTTY,
427
- }) {
353
+ }, link = projectLink) {
428
354
  if (!opts.yes && !process.stdout.isTTY) {
429
355
  info('non-interactive shell — assuming -y');
430
356
  }
@@ -485,6 +411,31 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
485
411
  }
486
412
  }
487
413
  }
414
+ // --project: link this directory inside the SAME process. The console's connect panel used to
415
+ // print `setup agent && insta project link <id>` as one paste — but no shell joiner survives
416
+ // every Windows shell, and in shells without bracketed paste the queued link line is eaten as
417
+ // the answer to the login prompt above (console PR #290). Carrying the id as a flag is the one
418
+ // form where "one line" is safe. Linking needs the session: without one the manual command is
419
+ // the hint, never a hang; a failed link (bad id, no access) is a REAL error — the link is the
420
+ // entire point of the flag — so it sets the exit code instead of pretending setup succeeded.
421
+ if (opts.project) {
422
+ if (!loggedIn) {
423
+ info(` not logged in — project not linked; run \`insta login\`, then \`insta project link ${opts.project}\``);
424
+ }
425
+ else {
426
+ try {
427
+ await link(opts.project);
428
+ }
429
+ catch (e) {
430
+ // Stop here — like the skill-install failure above, finishing with the success summary
431
+ // and a cheerful `next:` after an error is mixed messaging. Setup itself did succeed,
432
+ // so say exactly that alongside the retry command.
433
+ info(` project link failed (${e instanceof Error ? e.message : String(e)}) — agent setup itself is done; run \`insta project link ${opts.project}\` to retry the link`);
434
+ process.exitCode = 1;
435
+ return;
436
+ }
437
+ }
438
+ }
488
439
  // THE summary line. The restart note exists because config-file agents only read their MCP
489
440
  // config at startup; the skill files need no restart.
490
441
  const mcpOk = claude === 'new' || claude === 'existing' || others.length > 0;
@@ -1,19 +1,43 @@
1
1
  // Self-update: `insta upgrade` updates the CLI in place, channel-aware (native binary via the
2
- // release installer; npm via `npm i -g`). A background version check (detached, cached 24h in
2
+ // release installer; npm via `npm i -g`). A background version check (detached, cached in
3
3
  // ~/.insta/update-check.json) powers an update nudge — and, since the CLI is young and moves
4
- // fast, AUTO-UPDATE IS ON BY DEFAULT: when a newer version is known, the next invocation spawns
5
- // a quiet detached upgrade. `insta autoupdate off` (or INSTA_NO_AUTOUPDATE=1) disables that,
6
- // leaving just the stderr nudge.
4
+ // fast, AUTO-UPDATE IS ON BY DEFAULT: when a newer version is known, a quiet upgrade runs in the
5
+ // background. `insta autoupdate off` (or INSTA_NO_AUTOUPDATE=1) disables that, leaving just the
6
+ // stderr nudge.
7
+ //
8
+ // ONE SOURCE OF TRUTH. "What is the latest insta?" is answered in exactly one place —
9
+ // `resolveLatest()`, reading npm's `latest` dist-tag, the same thing `npx insta@latest` and
10
+ // `npm i -g insta` resolve. Both the background check and `insta upgrade` go through it, and the
11
+ // binary channel then installs THAT version by tag (INSTA_VERSION=v<latest>) rather than asking
12
+ // GitHub independently for /releases/latest. Two independent resolvers is how the two paths came
13
+ // to disagree; they have drifted before (v0.0.46 was merged but never tagged, so it exists on
14
+ // neither npm nor GitHub).
15
+ //
16
+ // A CACHE MUST NOT LIE. ~/.insta/update-check.json is a cache of that one answer, never a second
17
+ // source. Every path that learns the real latest rewrites it (including a successful upgrade),
18
+ // and any entry that is old, malformed, or behind the running build is refused rather than used
19
+ // — a sticky wrong `latest` is what silently pinned existing installs to an old version.
7
20
  import { spawn } from 'node:child_process';
8
21
  import { dirname, join } from 'node:path';
9
22
  import { homedir } from 'node:os';
10
23
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
11
24
  import { readGlobal, writeGlobal } from '../config.js';
25
+ import { resolveSpawnable } from '../spawn.js';
12
26
  import { info } from '../util.js';
13
27
  const INSTALL_SH = 'https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh';
28
+ // The dist-tag document is the authoritative, tiny (~50 byte) answer for `latest`. The full
29
+ // `latest` manifest is the fallback if that route is ever unavailable.
30
+ const REGISTRY_DIST_TAGS = 'https://registry.npmjs.org/-/package/insta/dist-tags';
14
31
  const REGISTRY_LATEST = 'https://registry.npmjs.org/insta/latest';
15
- const CHECK_TTL_MS = 24 * 60 * 60 * 1000; // re-check the registry at most once a day
16
- const AUTO_THROTTLE_MS = 60 * 60 * 1000; // don't respawn a failed auto-upgrade more than hourly
32
+ // Re-check at most this often. Deliberately short: releases ship every day or two, so a TTL
33
+ // measured in days strands every existing install whenever a release lands just after a check.
34
+ export const CHECK_TTL_MS = 3 * 60 * 60 * 1000;
35
+ const AUTO_THROTTLE_MS = 60 * 60 * 1000; // don't retry a failed auto-upgrade more than hourly
36
+ const FETCH_TIMEOUT_MS = 5000;
37
+ export function resolveRunSpec(spec, npmExecpath = spec.env.npm_execpath, execPath = process.execPath, platform = process.platform) {
38
+ const resolved = resolveSpawnable(spec.cmd, spec.args, npmExecpath, execPath, platform, spec.env);
39
+ return { ...spec, ...resolved };
40
+ }
17
41
  const cachePath = () => process.env.INSTA_UPDATE_CACHE ?? join(homedir(), '.insta', 'update-check.json');
18
42
  // How is this CLI running? Bun standalone → execPath IS the insta binary (not node);
19
43
  // npm global → the module lives under node_modules; anything else is a source checkout.
@@ -24,17 +48,110 @@ export function detectChannel(execPath = process.execPath, moduleUrl = import.me
24
48
  return 'npm';
25
49
  return 'source';
26
50
  }
27
- // -1 / 0 / 1 for a<b / a==b / a>b on dotted numeric versions ("0.0.4" style).
51
+ // A publishable, non-prerelease version. Anything carrying a `-suffix` (0.0.23-rc.1 — the `next`
52
+ // dist-tag today) is not a stable release and must never be offered as "latest".
53
+ const STABLE_VERSION_RE = /^\d+\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?$/;
54
+ export function isStableVersion(v) {
55
+ return typeof v === 'string' && STABLE_VERSION_RE.test(v.trim().replace(/^v/, ''));
56
+ }
57
+ function parseVersion(v) {
58
+ const noBuild = String(v ?? '')
59
+ .trim()
60
+ .replace(/^v/, '')
61
+ .split('+')[0] ?? '';
62
+ const dash = noBuild.indexOf('-');
63
+ const coreStr = dash === -1 ? noBuild : noBuild.slice(0, dash);
64
+ const preStr = dash === -1 ? '' : noBuild.slice(dash + 1);
65
+ return {
66
+ core: coreStr.split('.').map((n) => {
67
+ const p = parseInt(n, 10);
68
+ return Number.isFinite(p) ? p : 0;
69
+ }),
70
+ pre: preStr ? preStr.split('.') : [],
71
+ };
72
+ }
73
+ // -1 / 0 / 1 for a<b / a==b / a>b, by semver precedence: numeric core compare (so 0.0.9 < 0.0.10,
74
+ // which a string compare gets backwards), then a release outranks any prerelease of the same core
75
+ // (1.0.0 > 1.0.0-rc.1), then identifier by identifier.
28
76
  export function cmpSemver(a, b) {
29
- const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
30
- const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
31
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
32
- const d = (pa[i] ?? 0) - (pb[i] ?? 0);
77
+ const A = parseVersion(a);
78
+ const B = parseVersion(b);
79
+ for (let i = 0; i < Math.max(A.core.length, B.core.length); i++) {
80
+ const d = (A.core[i] ?? 0) - (B.core[i] ?? 0);
33
81
  if (d !== 0)
34
82
  return d < 0 ? -1 : 1;
35
83
  }
84
+ if (!A.pre.length && !B.pre.length)
85
+ return 0;
86
+ if (!A.pre.length)
87
+ return 1;
88
+ if (!B.pre.length)
89
+ return -1;
90
+ for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i++) {
91
+ const x = A.pre[i];
92
+ const y = B.pre[i];
93
+ if (x === undefined)
94
+ return -1; // a shorter prerelease series ranks lower
95
+ if (y === undefined)
96
+ return 1;
97
+ const nx = /^\d+$/.test(x);
98
+ const ny = /^\d+$/.test(y);
99
+ if (nx && ny) {
100
+ const d = parseInt(x, 10) - parseInt(y, 10);
101
+ if (d !== 0)
102
+ return d < 0 ? -1 : 1;
103
+ continue;
104
+ }
105
+ if (nx !== ny)
106
+ return nx ? -1 : 1; // numeric identifiers rank lower than alphanumeric
107
+ if (x !== y)
108
+ return x < y ? -1 : 1;
109
+ }
36
110
  return 0;
37
111
  }
112
+ // Pick `latest` — and only `latest` — out of npm's dist-tag document. `next` (a prerelease
113
+ // channel: 0.0.23-rc.1 today) must never win, and a `latest` that is itself a prerelease or
114
+ // malformed is refused rather than guessed at.
115
+ export function pickLatestDistTag(tags) {
116
+ const v = tags?.latest;
117
+ if (!isStableVersion(v))
118
+ return null;
119
+ return v.trim().replace(/^v/, '');
120
+ }
121
+ async function getJson(url, fetchImpl, timeoutMs) {
122
+ try {
123
+ const ctl = new AbortController();
124
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
125
+ try {
126
+ // no-cache: an intermediary serving a stale dist-tag would re-create the very bug this
127
+ // resolver exists to kill.
128
+ const res = await fetchImpl(url, {
129
+ signal: ctl.signal,
130
+ headers: { accept: 'application/json', 'cache-control': 'no-cache' },
131
+ });
132
+ if (!res.ok)
133
+ return null;
134
+ return (await res.json());
135
+ }
136
+ finally {
137
+ clearTimeout(t);
138
+ }
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ // THE resolver: the live latest published version, or null when the registry can't be reached.
145
+ // Never consults the cache — callers decide whether a cached answer may stand in for this.
146
+ export async function resolveLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
147
+ const tags = await getJson(REGISTRY_DIST_TAGS, fetchImpl, timeoutMs);
148
+ const fromTags = pickLatestDistTag(tags);
149
+ if (fromTags)
150
+ return fromTags;
151
+ const manifest = await getJson(REGISTRY_LATEST, fetchImpl, timeoutMs);
152
+ const v = manifest?.version;
153
+ return isStableVersion(v) ? v.trim().replace(/^v/, '') : null;
154
+ }
38
155
  export function readCache() {
39
156
  try {
40
157
  return JSON.parse(readFileSync(cachePath(), 'utf8'));
@@ -47,9 +164,25 @@ export function writeCache(c) {
47
164
  mkdirSync(dirname(cachePath()), { recursive: true });
48
165
  writeFileSync(cachePath(), JSON.stringify(c));
49
166
  }
167
+ // May this cached answer stand in for a live registry call? An entry that is old, malformed, or
168
+ // provably behind the build we are running must not suppress the check — that is exactly how a
169
+ // wrong `latest` pins an install to an old version indefinitely. Note the deliberate `>= 0`:
170
+ // `latest === current` is the ordinary up-to-date state and stays cacheable for the TTL, while a
171
+ // `latest` BELOW the running build (an out-of-band upgrade happened) is refused outright.
172
+ export function cacheIsFresh(cache, current, now = Date.now()) {
173
+ if (!cache || !isStableVersion(cache.latest))
174
+ return false;
175
+ if (!Number.isFinite(cache.checkedAt))
176
+ return false;
177
+ if (now < cache.checkedAt)
178
+ return false; // clock went backwards — don't trust the stamp
179
+ if (now - cache.checkedAt > CHECK_TTL_MS)
180
+ return false;
181
+ return cmpSemver(cache.latest, current) >= 0;
182
+ }
50
183
  // Pure decision for what start-up should do given the cache. Exported for tests.
51
184
  export function decideAction(cache, current, autoUpdate, channel, now = Date.now()) {
52
- if (!cache || cmpSemver(cache.latest, current) <= 0)
185
+ if (!cache || !isStableVersion(cache.latest) || cmpSemver(cache.latest, current) <= 0)
53
186
  return 'none';
54
187
  if (!autoUpdate || channel === 'source')
55
188
  return 'nudge';
@@ -57,46 +190,145 @@ export function decideAction(cache, current, autoUpdate, channel, now = Date.now
57
190
  return 'nudge';
58
191
  return 'auto';
59
192
  }
193
+ // Is auto-update on? (env kill-switch wins, then the persisted preference, default on.)
194
+ export function autoEnabled(home = homedir()) {
195
+ if (process.env.INSTA_NO_AUTOUPDATE)
196
+ return false;
197
+ try {
198
+ // config read is async elsewhere; a tiny sync read keeps start-up non-blocking
199
+ const raw = JSON.parse(readFileSync(join(home, '.insta', 'config.json'), 'utf8'));
200
+ return raw.autoUpdate !== false;
201
+ }
202
+ catch {
203
+ return true; // no config yet
204
+ }
205
+ }
206
+ const spawnRun = ({ cmd, args, env }) => new Promise((resolve, reject) => {
207
+ const resolved = resolveRunSpec({ cmd, args, env });
208
+ const p = spawn(resolved.cmd, resolved.args, { stdio: 'inherit', env });
209
+ p.on('error', reject);
210
+ p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`upgrade failed (exit ${code})`))));
211
+ });
212
+ const observeInstalled = (channel, installDir) => new Promise((resolve) => {
213
+ const cmd = channel === 'binary' ? join(installDir, 'insta') : 'insta';
214
+ let out = '';
215
+ try {
216
+ // INSTA_NO_AUTOUPDATE: the child must not kick off yet another upgrade while we are reading it.
217
+ const env = { ...process.env, INSTA_NO_AUTOUPDATE: '1' };
218
+ const resolved = resolveRunSpec({ cmd, args: ['--version'], env });
219
+ const p = spawn(resolved.cmd, resolved.args, { stdio: ['ignore', 'pipe', 'ignore'], env });
220
+ p.stdout.on('data', (d) => (out += String(d)));
221
+ p.on('error', () => resolve(null));
222
+ p.on('close', () => {
223
+ const last = out.trim().split('\n').pop()?.trim().replace(/^v/, '');
224
+ resolve(isStableVersion(last) ? last : null);
225
+ });
226
+ }
227
+ catch {
228
+ resolve(null);
229
+ }
230
+ });
60
231
  // `insta upgrade` — synchronous, visible self-update on the detected channel.
61
- export async function upgrade() {
62
- const channel = detectChannel();
232
+ //
233
+ // ALWAYS resolves the target live: an explicitly requested upgrade must never be answered out of
234
+ // ~/.insta/update-check.json, or the documented remedy for a stale cache would itself be stale.
235
+ //
236
+ // NEVER REPORTS A VERSION IT DID NOT OBSERVE. After any install path it reads the installed
237
+ // binary's actual `--version` and prints the transition from THAT. In the drift case (npm's tag
238
+ // ahead of GitHub Releases) the pinned install 404s and the unpinned retry legitimately exits 0
239
+ // having installed nothing — the honest line is "still 0.0.45", not "upgraded → 0.0.47". The
240
+ // cache is rewritten from the observed version too: it then says the newest version THIS channel
241
+ // can actually install, and the start-up nudge stops advertising a build the user cannot get.
242
+ export async function upgrade(current, deps = {}) {
243
+ const say = deps.report ?? info;
244
+ const channel = deps.channel ?? detectChannel();
63
245
  if (channel === 'source') {
64
- info('running from a source checkout — `git pull` to update');
246
+ say('running from a source checkout — `git pull` to update');
247
+ return;
248
+ }
249
+ const latest = deps.latest !== undefined ? deps.latest : await resolveLatest(deps.fetchImpl ?? fetch);
250
+ const now = deps.now ?? Date.now();
251
+ if (latest) {
252
+ // Record what we just learned so the start-up nudge can't keep quoting a stale answer.
253
+ writeCache({ ...(readCache() ?? { checkedAt: 0, latest }), checkedAt: now, latest });
254
+ if (cmpSemver(latest, current) <= 0) {
255
+ say(`✓ insta ${current} is already the latest release — nothing to upgrade`);
256
+ return;
257
+ }
258
+ }
259
+ else {
260
+ say('could not reach the npm registry — installing the newest release available');
261
+ }
262
+ say(`upgrading insta ${current} → ${latest ?? 'latest'} via ${channel} …`);
263
+ const run = deps.run ?? spawnRun;
264
+ const installDir = deps.installDir ?? dirname(process.execPath);
265
+ if (channel === 'npm') {
266
+ await run({ cmd: 'npm', args: ['install', '-g', `insta@${latest ?? 'latest'}`], env: process.env });
267
+ }
268
+ else {
269
+ const shellEnv = { ...process.env, INSTA_INSTALL_DIR: installDir };
270
+ const sh = { cmd: 'sh', args: ['-c', `curl -fsSL ${INSTALL_SH} | sh`] };
271
+ if (!latest) {
272
+ await run({ ...sh, env: shellEnv });
273
+ }
274
+ else {
275
+ try {
276
+ // Pin the binary install to the EXACT version npm's `latest` dist-tag names, so the two
277
+ // channels cannot land on different builds.
278
+ await run({ ...sh, env: { ...shellEnv, INSTA_VERSION: `v${latest}` } });
279
+ }
280
+ catch (e) {
281
+ // Release assets can lag the npm tag. Retry unpinned so the user at least gets the newest
282
+ // binary that IS published — and let the observation below say what that turned out to be.
283
+ say(`pinned install of v${latest} failed (${e.message}) — retrying with the newest published release`);
284
+ await run({ ...sh, env: shellEnv });
285
+ }
286
+ }
287
+ }
288
+ // Report only what is actually on disk now. The installer prints its own generic onboarding
289
+ // banner and can exit 0 without changing anything, so its exit code proves nothing.
290
+ const observed = await (deps.observe ?? observeInstalled)(channel, installDir);
291
+ if (!observed) {
292
+ say(`insta install finished, but the installed version could not be read — run \`insta --version\` to confirm`);
65
293
  return;
66
294
  }
67
- info(`upgrading insta via ${channel} …`);
68
- let cmd;
69
- let args;
70
- let env = process.env;
295
+ writeCache({ ...(readCache() ?? { checkedAt: 0, latest: observed }), checkedAt: now, latest: observed });
296
+ if (cmpSemver(observed, current) > 0) {
297
+ say(`✓ insta upgraded ${current} → ${observed}`);
298
+ return;
299
+ }
300
+ const target = latest ?? 'the newest release';
71
301
  if (channel === 'binary') {
72
- cmd = 'sh';
73
- args = ['-c', `curl -fsSL ${INSTALL_SH} | sh`];
74
- env = { ...process.env, INSTA_INSTALL_DIR: dirname(process.execPath) };
302
+ say(`insta is still ${observed} — the newest release (${target}) is published on npm but its binary assets are not on GitHub Releases yet; ` +
303
+ `try again later or install via npm (npm i -g insta@latest)`);
75
304
  }
76
305
  else {
77
- cmd = 'npm';
78
- args = ['install', '-g', 'insta@latest'];
306
+ say(`insta is still ${observed} — npm reports ${target} as latest but the install did not change the version; try again later`);
79
307
  }
80
- await new Promise((resolve, reject) => {
81
- const p = spawn(cmd, args, { stdio: 'inherit', env });
82
- p.on('error', reject);
83
- p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`upgrade failed (exit ${code})`))));
84
- });
85
308
  }
86
- // Hidden `insta __update-check` — runs detached in the background: fetch latest, refresh cache.
87
- export async function backgroundCheck() {
309
+ // Hidden `insta __update-check` — runs detached in the background: resolve latest, refresh the
310
+ // cache, and ACT on what it just learned. Acting here (rather than only priming the cache for
311
+ // some later invocation) is what stops a release that lands just after a check from stranding the
312
+ // install for a whole TTL plus one more run.
313
+ export async function backgroundCheck(current, deps = {}) {
314
+ const latest = await resolveLatest(deps.fetchImpl ?? fetch);
315
+ if (!latest)
316
+ return 'none'; // offline / registry down — try again next TTL
317
+ const now = deps.now ?? Date.now();
318
+ const cache = { ...(readCache() ?? { checkedAt: 0, latest }), checkedAt: now, latest };
319
+ writeCache(cache);
320
+ const channel = deps.channel ?? detectChannel();
321
+ const action = decideAction(cache, current, deps.auto ?? autoEnabled(), channel, now);
322
+ if (action !== 'auto')
323
+ return action;
324
+ writeCache({ ...cache, lastAutoAt: now });
88
325
  try {
89
- const ctl = new AbortController();
90
- const t = setTimeout(() => ctl.abort(), 5000);
91
- const res = await fetch(REGISTRY_LATEST, { signal: ctl.signal });
92
- clearTimeout(t);
93
- if (!res.ok)
94
- return;
95
- const { version } = (await res.json());
96
- if (version)
97
- writeCache({ ...(readCache() ?? { checkedAt: 0, latest: '0.0.0' }), checkedAt: Date.now(), latest: version });
326
+ await (deps.runUpgrade ?? ((c, l) => upgrade(c, { latest: l, channel })))(current, latest);
327
+ }
328
+ catch {
329
+ /* best-effort; the hourly throttle retries */
98
330
  }
99
- catch { /* offline / registry down — try again next TTL */ }
331
+ return 'auto';
100
332
  }
101
333
  // `insta autoupdate [on|off]` — toggle / show the auto-update preference (default: on).
102
334
  export async function autoupdate(mode) {
@@ -117,23 +349,17 @@ export function maybeUpdate(current, argv) {
117
349
  return;
118
350
  const channel = detectChannel();
119
351
  const cache = readCache();
120
- // keep the cache fresh (detached; survives this process exiting)
121
- if (!cache || Date.now() - cache.checkedAt > CHECK_TTL_MS) {
352
+ const now = Date.now();
353
+ // Re-resolve unless the cached answer is genuinely usable (fresh, well-formed, not behind us).
354
+ // The child refreshes the cache AND performs the upgrade if one is due.
355
+ if (!cacheIsFresh(cache, current, now))
122
356
  respawnDetached(['__update-check']);
123
- }
124
- let auto = !process.env.INSTA_NO_AUTOUPDATE;
125
- try { // config read is async elsewhere; a tiny sync read keeps start-up non-blocking
126
- const raw = JSON.parse(readFileSync(join(homedir(), '.insta', 'config.json'), 'utf8'));
127
- if (raw.autoUpdate === false)
128
- auto = false;
129
- }
130
- catch { /* no config yet */ }
131
- const action = decideAction(cache, current, auto, channel);
357
+ const action = decideAction(cache, current, autoEnabled(), channel, now);
132
358
  if (action === 'nudge') {
133
359
  console.error(`↑ insta ${cache.latest} is available (you have ${current}) — run \`insta upgrade\``);
134
360
  }
135
361
  else if (action === 'auto') {
136
- writeCache({ ...cache, lastAutoAt: Date.now() });
362
+ writeCache({ ...cache, lastAutoAt: now });
137
363
  respawnDetached(['upgrade']);
138
364
  console.error(`↑ auto-updating insta ${current} → ${cache.latest} in the background (\`insta autoupdate off\` to disable)`);
139
365
  }
@@ -146,6 +372,8 @@ function respawnDetached(args) {
146
372
  const p = spawn(process.execPath, argv, { detached: true, stdio: 'ignore' });
147
373
  p.unref();
148
374
  }
149
- catch { /* best-effort */ }
375
+ catch {
376
+ /* best-effort */
377
+ }
150
378
  }
151
379
  //# sourceMappingURL=upgrade.js.map