insta 0.0.48 → 0.0.51

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,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
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { Command } from 'commander';
4
4
  import { ApiError } from './api.js';
5
- import { die } from './util.js';
5
+ import { CliExit, fail } from './util.js';
6
6
  import * as auth from './commands/auth.js';
7
7
  import * as envCmd_ from './commands/env.js';
8
8
  import { ENV_NAMES } from './env.js';
@@ -20,6 +20,7 @@ import { deploy } from './commands/deploy.js';
20
20
  import { build } from './commands/build.js';
21
21
  import * as computeCmd from './commands/compute.js';
22
22
  import * as dbCmd from './commands/db.js';
23
+ import * as dbQueryCmd from './commands/db-query.js';
23
24
  import * as storageCmd from './commands/storage.js';
24
25
  import { manifest } from './commands/manifest.js';
25
26
  import * as template from './commands/template.js';
@@ -30,9 +31,11 @@ import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
30
31
  import * as selfUpdate from './commands/upgrade.js';
31
32
  import * as feedbackCmd from './commands/feedback.js';
32
33
  function onError(e) {
34
+ if (e instanceof CliExit)
35
+ return;
33
36
  if (e instanceof ApiError)
34
- die(`${e.message} (HTTP ${e.status})`);
35
- die(e instanceof Error ? e.message : String(e));
37
+ return fail(`${e.message} (HTTP ${e.status})`);
38
+ fail(e instanceof Error ? e.message : String(e));
36
39
  }
37
40
  // Wrap an async action so rejections surface as clean CLI errors.
38
41
  const guard = (fn) => (...a) => fn(...a).then(() => undefined).catch(onError);
@@ -59,11 +62,11 @@ function resolveVersion() {
59
62
  }
60
63
  program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(resolveVersion());
61
64
  // ---- auth ----
62
- program.command('login').description('Log in with email + password, --oauth <github|google> (browser), --device (headless), or --api-key <insta_…> (headless, durable token)')
63
- .option('--email <email>', 'account email')
64
- .option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt)')
65
+ 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)')
66
+ .option('--email <email>', 'account email (email + password login)')
67
+ .option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt; needs --email)')
65
68
  .option('--oauth <provider>', 'browser OAuth login: github | google')
66
- .option('--device', 'device-code login: approve from a browser on any other machine (VMs, SSH, CI)')
69
+ .option('--device', 'device-code login: like bare login but never opens a browser here — approve from any other machine (VMs, SSH, CI)')
67
70
  .option('--api-key <key>', 'non-interactive login with a durable insta_ API token (headless agents / CI)')
68
71
  .option('--api-url <url>', 'control-plane API base URL')
69
72
  .option('--env <name>', `deployment environment: ${ENV_NAMES.join(' | ')}`)
@@ -180,7 +183,7 @@ sec.command('sources').description('List service credential sources available fo
180
183
  sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json')
181
184
  .action(guard((o) => secretsCmd.secretsTree(o)));
182
185
  // ---- build (pre-push verification — local, offline, deploys nothing) ----
183
- program.command('build [dir]').description('Verify a source directory would build before deploying: detection plan + the Dockerfile that would be used (yours, or nixpacks-generated) + static checks. Local and offline — no login needed, nothing pushed. Exit 1 when the verdict is failed')
186
+ program.command('build [dir]').description('Verify a source directory would build before deploying: detection plan + the Dockerfile (yours, or the one nixpacks would generate for the GitHub lane — `insta deploy <dir>` needs your own) + static checks. Local and offline — no login needed, nothing pushed. Exit 1 when the verdict is failed')
184
187
  .option('--explain', 'include the Dockerfile content in the output')
185
188
  .option('--port <p>', 'port the app listens on (else the Dockerfile EXPOSE)')
186
189
  .option('--json')
@@ -194,9 +197,9 @@ program.command('deploy [dir]').description('Deploy a source directory (built re
194
197
  // `insta compute exec` needs the command verbatim after a literal `--`; split it out of argv here,
195
198
  // before commander parses anything (see splitExecArgs's own comment for why `service` being
196
199
  // optional makes commander unable to hold that boundary itself).
197
- const { argv: computeArgv, command: execCommand } = computeCmd.splitExecArgs(process.argv);
200
+ const { argv: computeArgv, command: execCommand, windowsFallback: execWindowsFallback, } = computeCmd.splitExecArgs(process.argv);
198
201
  // ---- compute (lifecycle control + custom domains) ----
199
- const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains');
202
+ const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/restart/status) + custom domains');
200
203
  compute.command('set-domain <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
201
204
  .option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o)));
202
205
  compute.command('check-domain <host>').description("Show a custom domain's cert status + required DNS records")
@@ -209,6 +212,8 @@ compute.command('stop [service]').description('Take a compute service offline; t
209
212
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStop(service, o)));
210
213
  compute.command('suspend [service]').description('Suspend a compute service (RAM snapshot); stays down until `start`')
211
214
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeSuspend(service, o)));
215
+ compute.command('restart [service]').description("Restart a compute service by re-running the image it already runs against a freshly resolved env bundle — this is how a changed secret or binding reaches a running machine (env is baked into the machine at deploy time), and how a machine that is up but wedged gets cycled (`start` no-ops on one that is already started). No new image, no new spec. The service must be running: a stopped or suspended one comes back with `insta compute start`. All plans; gated: deploy — it lands configuration the same way a deploy does, so a policy denying deploys denies this too (`start`/`stop` stay ungated, and cycle a wedged machine without one). A service whose app fails to answer on its port coming back up reports that failure, and the machines are rolled back, best-effort, to the config they were serving")
216
+ .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(service, o)));
212
217
  compute.command('status [service]').description("Show a compute service's desired vs. live state")
213
218
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o)));
214
219
  compute.command('limits [service]').description("Show or set a compute service's resource ceiling (paid plans). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price")
@@ -216,15 +221,18 @@ compute.command('limits [service]').description("Show or set a compute service's
216
221
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
217
222
  compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way')
218
223
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
219
- 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)")
220
- .option('--branch <b>').option('--timeout <sec>', 'command timeout in seconds, 1-180 (platform default: 30)').option('--json')
221
- .action(guard((service, o) => computeCmd.computeExec(service, execCommand, o)));
224
+ 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)")
225
+ .action(guard((service, o) => computeCmd.computeExec(service, execCommand, o, { windowsFallback: execWindowsFallback })));
226
+ // Declared from the same list splitExecArgs uses to find where the CLI's own arguments stop, so a
227
+ // new option cannot reach the CLI surface while the split still reads it as part of the command.
228
+ for (const [flags, description] of computeCmd.EXEC_OPTIONS)
229
+ execCmd.option(flags, description);
222
230
  compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 1Gi; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
223
231
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
224
232
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
225
233
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
226
- // ---- db (postgres service controls) ----
227
- const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero)');
234
+ // ---- db (postgres service controls + managed-DB query) ----
235
+ const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero) + managed-DB query (mysql/redis/mongodb)');
228
236
  db.command('url').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta db url)"` (gated: secrets.read). Provider credentials are not in `insta secrets` — this is the command that yields the DSN')
229
237
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
230
238
  .action(guard((o) => dbCmd.dbUrl(o)));
@@ -245,6 +253,11 @@ db.command('volume').description("Show or grow a postgres service's provisioned
245
253
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
246
254
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
247
255
  .action(guard((o) => dbCmd.dbVolume(o)));
256
+ db.command('query <service> [args...]').description('Run a query/command against a managed database (mysql/redis/mongodb) via the console exec API. mysql/mongodb take one quoted statement; redis takes a pre-tokenized argv (e.g. `GET mykey`). Not for postgres — use `insta db url|connect` / the SQL editor')
257
+ .option('--database <db>', 'mongodb only — the database to run against (default admin)')
258
+ .option('--branch <branch>', 'branch (default: current)')
259
+ .option('--json')
260
+ .action(guard((service, args, o) => dbQueryCmd.dbQuery(service, args, o)));
248
261
  // ---- storage (bucket objects) ----
249
262
  const storage = program.command('storage').description("Browse, download, and delete a storage service's bucket objects");
250
263
  storage.command('list').description("List the bucket's objects. S3 filters by prefix only — there is no substring search")
@@ -295,7 +308,7 @@ program.command('usage').description('Usage for the current billing cycle by bil
295
308
  const bill = program.command('billing').description('Current billing cycle overview (tier / used / included / overage / credits / forecast + per-dimension & per-project breakdown)')
296
309
  .option('--org <id>', 'target org (default: linked project\'s org)').option('--json')
297
310
  .action(guard((o) => billing(o)));
298
- bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|enterprise) via Stripe Checkout')
311
+ bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout')
299
312
  .option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
300
313
  .action(guard((tier, o) => billingUpgrade(tier, o)));
301
314
  bill.command('portal').description('Open the Stripe Customer Portal (change plan / card / cancel)')
@@ -337,10 +350,10 @@ program.command('feedback')
337
350
  .action(guard((o) => feedbackCmd.feedback(o)));
338
351
  // ---- self-update ----
339
352
  program.command('upgrade').description('Update the insta CLI to the latest release (binary or npm install)')
340
- .action(guard(() => selfUpdate.upgrade()));
353
+ .action(guard(() => selfUpdate.upgrade(resolveVersion())));
341
354
  program.command('autoupdate [mode]').description('Show or set auto-update: on | off (default: on while pre-1.0)')
342
355
  .action(guard((mode) => selfUpdate.autoupdate(mode)));
343
- program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck()));
356
+ program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck(resolveVersion())));
344
357
  selfUpdate.maybeUpdate(resolveVersion(), process.argv);
345
358
  program.parseAsync(computeArgv);
346
359
  //# sourceMappingURL=index.js.map
package/dist/spawn.js ADDED
@@ -0,0 +1,77 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { dirname, join, win32 } from 'node:path';
3
+ export const isRunnableFile = (path, win) => {
4
+ try {
5
+ const stat = statSync(path);
6
+ if (!stat.isFile())
7
+ return false;
8
+ return win || (stat.mode & 0o111) !== 0;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ };
14
+ /** Read an environment variable the way Windows itself does: case-insensitively.
15
+ * `process.env` is already case-insensitive on win32, but a COPY of it (`{ ...process.env }`,
16
+ * which every caller that adds a variable makes) is an ordinary object that keeps whatever
17
+ * casing Windows used — and Windows overwhelmingly spells it `Path`, not `PATH`. Reading
18
+ * `env.PATH` off such a copy yields undefined, nothing resolves, and the caller falls back to
19
+ * spawning the bare `.cmd` shim this module exists to avoid. */
20
+ export function envVar(env, name, win) {
21
+ const direct = env[name];
22
+ if (direct !== undefined || !win)
23
+ return direct;
24
+ const wanted = name.toLowerCase();
25
+ for (const [key, value] of Object.entries(env))
26
+ if (key.toLowerCase() === wanted)
27
+ return value;
28
+ return undefined;
29
+ }
30
+ /** Resolve a bare command name to its absolute PATH location (PATHEXT-aware on Windows).
31
+ * cmd.exe searches the CURRENT DIRECTORY before PATH for bare names, so callers must pass
32
+ * the absolute shim path to the cmd.exe wrapper below. */
33
+ export function whichOnPath(bin, env = process.env, platform = process.platform) {
34
+ const win = platform === 'win32';
35
+ const exts = win ? [...(envVar(env, 'PATHEXT', win) ?? '.COM;.EXE;.BAT;.CMD').split(';'), ''] : [''];
36
+ for (const dir of (envVar(env, 'PATH', win) ?? '').split(win ? ';' : ':')) {
37
+ if (!dir)
38
+ continue;
39
+ for (const ext of exts) {
40
+ const path = join(dir, bin + ext);
41
+ if (isRunnableFile(path, win))
42
+ return path;
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+ // On Windows `npm`/`npx` and most npm-installed CLIs are .cmd shims, which spawn() without a
48
+ // shell refuses. Prefer re-entering npm/npx through node; otherwise resolve a safe absolute shim
49
+ // path and invoke it through cmd.exe. Kept shared so every child-process call uses the same rules.
50
+ export function resolveSpawnable(cmd, args, npmExecpath = process.env.npm_execpath, execPath = process.execPath, platform = process.platform, env = process.env, systemRoot = process.env.SYSTEMROOT ?? process.env.windir ?? 'C:\\Windows') {
51
+ const execIsNode = /(^|[\\/])node(\.exe)?$/i.test(execPath);
52
+ if ((cmd === 'npm' || cmd === 'npx') && execIsNode) {
53
+ if (npmExecpath && /(^|[\\/])np[mx](-cli)?\.[cm]?js$/.test(npmExecpath)) {
54
+ const cli = npmExecpath.replace(/np[mx](-cli)?(\.[cm]?js)$/, `${cmd}$1$2`);
55
+ if (existsSync(cli))
56
+ return { cmd: execPath, args: [cli, ...args] };
57
+ }
58
+ const nodeDir = dirname(execPath);
59
+ const besideNode = platform === 'win32'
60
+ ? join(nodeDir, 'node_modules', 'npm', 'bin', `${cmd}-cli.js`)
61
+ : join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', `${cmd}-cli.js`);
62
+ if (existsSync(besideNode))
63
+ return { cmd: execPath, args: [besideNode, ...args] };
64
+ }
65
+ const bareShim = !/[\\/]/.test(cmd) && !/\.exe$/i.test(cmd);
66
+ if (platform === 'win32' && bareShim && !args.some((arg) => /[&|<>^%"]/.test(arg))) {
67
+ const absolute = whichOnPath(cmd, env, platform);
68
+ // The resolved path itself also becomes cmd.exe input. If a PATH directory contains a shell
69
+ // metacharacter, fail via the caller's normal bare-spawn fallback rather than interpret it.
70
+ // Pin cmd.exe to System32 too: CreateProcess-style lookup checks cwd before PATH.
71
+ if (absolute && !/[&|<>^%"]/.test(absolute)) {
72
+ return { cmd: win32.join(systemRoot, 'System32', 'cmd.exe'), args: ['/d', '/s', '/c', absolute, ...args] };
73
+ }
74
+ }
75
+ return { cmd, args };
76
+ }
77
+ //# sourceMappingURL=spawn.js.map