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.
- package/README.md +9 -3
- package/dist/api.js +1 -1
- package/dist/commands/auth.js +37 -14
- package/dist/commands/billing.js +42 -5
- package/dist/commands/build.js +34 -9
- package/dist/commands/compute.js +458 -45
- package/dist/commands/db-query.js +102 -0
- package/dist/commands/deploy.js +20 -1
- package/dist/commands/env.js +1 -1
- package/dist/commands/setup.js +9 -84
- package/dist/commands/upgrade.js +282 -54
- package/dist/index.js +31 -18
- package/dist/spawn.js +77 -0
- package/dist/util.js +17 -2
- package/package.json +1 -1
package/dist/commands/upgrade.js
CHANGED
|
@@ -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
|
|
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,
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
//
|
|
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
|
|
30
|
-
const
|
|
31
|
-
for (let i = 0; i < Math.max(
|
|
32
|
-
const d = (
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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:
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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:
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
.
|
|
221
|
-
|
|
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|
|
|
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
|