insta 0.0.48 → 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.
- package/dist/commands/build.js +34 -9
- package/dist/commands/compute.js +446 -46
- package/dist/commands/deploy.js +20 -1
- package/dist/commands/setup.js +4 -79
- package/dist/commands/upgrade.js +282 -54
- package/dist/index.js +15 -10
- package/dist/spawn.js +77 -0
- package/dist/util.js +17 -2
- package/package.json +1 -1
package/dist/commands/setup.js
CHANGED
|
@@ -6,19 +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,
|
|
10
|
-
import {
|
|
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';
|
|
18
19
|
import { projectLink } from './project.js';
|
|
19
20
|
import { envUse } from './env.js';
|
|
20
21
|
import { installAgentConfigs } from './mcp.js';
|
|
21
22
|
import { detectChannel } from './upgrade.js';
|
|
23
|
+
export { resolveSpawnable, whichOnPath } from '../spawn.js';
|
|
22
24
|
// The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an
|
|
23
25
|
// "Installing to all N agents" banner, a full N-line install-path box, and a third-party
|
|
24
26
|
// "Security Risk Assessment" that flags our OWN first-party skill as "Critical Risk". Streamed
|
|
@@ -99,35 +101,6 @@ export function summarizeInstall(output) {
|
|
|
99
101
|
// access() answers "can THIS process exec it", which for root is always yes, so a root-run
|
|
100
102
|
// setup would wrongly treat a non-executable file as a durable install. On Windows execute
|
|
101
103
|
// permission is extension-driven, so existence of a regular file is the right check.
|
|
102
|
-
const isRunnableFile = (p, win) => {
|
|
103
|
-
try {
|
|
104
|
-
const st = statSync(p);
|
|
105
|
-
if (!st.isFile())
|
|
106
|
-
return false;
|
|
107
|
-
return win || (st.mode & 0o111) !== 0;
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
/** Resolve a bare command name to its absolute PATH location (PATHEXT-aware on Windows).
|
|
114
|
-
* cmd.exe searches the CURRENT DIRECTORY before PATH for bare names, so handing it a bare
|
|
115
|
-
* `claude` would let a claude.cmd planted in the project directory shadow the real CLI —
|
|
116
|
-
* the cmd.exe wrapper below only ever passes absolute paths. */
|
|
117
|
-
export function whichOnPath(bin, env = process.env, platform = process.platform) {
|
|
118
|
-
const win = platform === 'win32';
|
|
119
|
-
const exts = win ? [...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';'), ''] : [''];
|
|
120
|
-
for (const dir of (env.PATH ?? '').split(win ? ';' : ':')) {
|
|
121
|
-
if (!dir)
|
|
122
|
-
continue;
|
|
123
|
-
for (const ext of exts) {
|
|
124
|
-
const p = join(dir, bin + ext);
|
|
125
|
-
if (isRunnableFile(p, win))
|
|
126
|
-
return p;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
104
|
export function findDurableOnPath(bin, env = process.env, platform = process.platform) {
|
|
132
105
|
const win = platform === 'win32';
|
|
133
106
|
const dirs = (env.PATH ?? '').split(win ? ';' : ':');
|
|
@@ -182,54 +155,6 @@ export async function ensureCliInstalled(run, channel = detectChannel(), onPath
|
|
|
182
155
|
info(' (permission error: the npm prefix is system-owned — use a Node version manager, or elevate that one command)');
|
|
183
156
|
}
|
|
184
157
|
}
|
|
185
|
-
// ---- Windows-safe spawning for npm/npx ----
|
|
186
|
-
// On Windows `npm`/`npx` are .cmd shims, which spawn() without a shell refuses (Node docs:
|
|
187
|
-
// spawning .bat/.cmd needs a shell or cmd.exe). Rather than a shell (argument-quoting hazards),
|
|
188
|
-
// re-enter them as node scripts: the CLI script named by npm_execpath (swapped between
|
|
189
|
-
// npm-cli.js and npx-cli.js as needed), else the one shipped beside the running node, else the
|
|
190
|
-
// bare name (POSIX, where PATH shims resolve fine). Applied ONCE, inside the default runner,
|
|
191
|
-
// so every `run('npm'|'npx', …)` call site benefits and nothing is ever resolved twice.
|
|
192
|
-
export function resolveSpawnable(cmd, args, npmExecpath = process.env.npm_execpath, execPath = process.execPath, platform = process.platform, env = process.env) {
|
|
193
|
-
// Node re-entry is only valid when THIS process runs on node. On the native-binary channel
|
|
194
|
-
// execPath is the compiled `insta` executable — and npm scripts export npm_execpath to their
|
|
195
|
-
// children — so re-entering blindly would spawn `insta npx-cli.js …`. A non-node execPath
|
|
196
|
-
// sends npm/npx down the generic shim path below instead.
|
|
197
|
-
const execIsNode = /(^|[\\/])node(\.exe)?$/i.test(execPath);
|
|
198
|
-
if ((cmd === 'npm' || cmd === 'npx') && execIsNode) {
|
|
199
|
-
if (npmExecpath && /(^|[\\/])np[mx](-cli)?\.[cm]?js$/.test(npmExecpath)) {
|
|
200
|
-
const cli = npmExecpath.replace(/np[mx](-cli)?(\.[cm]?js)$/, `${cmd}$1$2`);
|
|
201
|
-
if (existsSync(cli))
|
|
202
|
-
return { cmd: execPath, args: [cli, ...args] };
|
|
203
|
-
}
|
|
204
|
-
const nodeDir = dirname(execPath);
|
|
205
|
-
const besideNode = platform === 'win32'
|
|
206
|
-
? join(nodeDir, 'node_modules', 'npm', 'bin', `${cmd}-cli.js`)
|
|
207
|
-
: join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', `${cmd}-cli.js`);
|
|
208
|
-
if (existsSync(besideNode))
|
|
209
|
-
return { cmd: execPath, args: [besideNode, ...args] };
|
|
210
|
-
}
|
|
211
|
-
// Generic shim path — every non-npm CLI we shell out to (claude), plus npm/npx themselves
|
|
212
|
-
// when node isn't resolvable (native binary channel). On Windows these are .cmd shims, which
|
|
213
|
-
// spawn() refuses without a shell, so route them through cmd.exe. Guards, in order:
|
|
214
|
-
// - BARE names only: an absolute path or anything .exe (node.exe from a resolved npm/npx
|
|
215
|
-
// invocation passing back through here) is directly spawnable and must NOT see cmd.exe.
|
|
216
|
-
// - The name is resolved to its ABSOLUTE PATH location first: cmd.exe searches the current
|
|
217
|
-
// directory before PATH, so a bare name would let a shim planted in the project dir
|
|
218
|
-
// shadow the real CLI. No PATH hit → pass through (spawn fails; callers degrade).
|
|
219
|
-
// - No manual quoting: libuv already wraps spaced args when building the child command
|
|
220
|
-
// line — pre-quoting would be quoted AGAIN and arrive as literal quote characters.
|
|
221
|
-
// - That leaves cmd.exe metacharacters unprotectable, so an arg carrying one (e.g. a
|
|
222
|
-
// custom INSTA_MCP_URL with `&`) skips the wrapper: the bare-shim spawn fails and every
|
|
223
|
-
// caller degrades gracefully (probe → not-installed; registration → manual-add
|
|
224
|
-
// fallback). Never hand metacharacters to a shell.
|
|
225
|
-
const bareShim = !/[\\/]/.test(cmd) && !/\.exe$/i.test(cmd);
|
|
226
|
-
if (platform === 'win32' && bareShim && !args.some((a) => /[&|<>^%"]/.test(a))) {
|
|
227
|
-
const abs = whichOnPath(cmd, env, platform);
|
|
228
|
-
if (abs)
|
|
229
|
-
return { cmd: 'cmd.exe', args: ['/d', '/s', '/c', abs, ...args] };
|
|
230
|
-
}
|
|
231
|
-
return { cmd, args };
|
|
232
|
-
}
|
|
233
158
|
// Capture stdout+stderr silently (don't stream) so we can print our own clean summary.
|
|
234
159
|
// stdin is 'ignore', NOT 'inherit': under the canonical `curl … | sh` install, stdin is the
|
|
235
160
|
// piped install script itself — a child that inherits it (npx/skills reads for keypresses even
|
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';
|
|
@@ -30,9 +30,11 @@ import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
|
|
|
30
30
|
import * as selfUpdate from './commands/upgrade.js';
|
|
31
31
|
import * as feedbackCmd from './commands/feedback.js';
|
|
32
32
|
function onError(e) {
|
|
33
|
+
if (e instanceof CliExit)
|
|
34
|
+
return;
|
|
33
35
|
if (e instanceof ApiError)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
return fail(`${e.message} (HTTP ${e.status})`);
|
|
37
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
36
38
|
}
|
|
37
39
|
// Wrap an async action so rejections surface as clean CLI errors.
|
|
38
40
|
const guard = (fn) => (...a) => fn(...a).then(() => undefined).catch(onError);
|
|
@@ -180,7 +182,7 @@ sec.command('sources').description('List service credential sources available fo
|
|
|
180
182
|
sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json')
|
|
181
183
|
.action(guard((o) => secretsCmd.secretsTree(o)));
|
|
182
184
|
// ---- 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
|
|
185
|
+
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
186
|
.option('--explain', 'include the Dockerfile content in the output')
|
|
185
187
|
.option('--port <p>', 'port the app listens on (else the Dockerfile EXPOSE)')
|
|
186
188
|
.option('--json')
|
|
@@ -194,7 +196,7 @@ program.command('deploy [dir]').description('Deploy a source directory (built re
|
|
|
194
196
|
// `insta compute exec` needs the command verbatim after a literal `--`; split it out of argv here,
|
|
195
197
|
// before commander parses anything (see splitExecArgs's own comment for why `service` being
|
|
196
198
|
// optional makes commander unable to hold that boundary itself).
|
|
197
|
-
const { argv: computeArgv, command: execCommand } = computeCmd.splitExecArgs(process.argv);
|
|
199
|
+
const { argv: computeArgv, command: execCommand, windowsFallback: execWindowsFallback, } = computeCmd.splitExecArgs(process.argv);
|
|
198
200
|
// ---- compute (lifecycle control + custom domains) ----
|
|
199
201
|
const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains');
|
|
200
202
|
compute.command('set-domain <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
|
|
@@ -216,9 +218,12 @@ compute.command('limits [service]').description("Show or set a compute service's
|
|
|
216
218
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
|
|
217
219
|
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
220
|
.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
|
-
|
|
221
|
+
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)")
|
|
222
|
+
.action(guard((service, o) => computeCmd.computeExec(service, execCommand, o, { windowsFallback: execWindowsFallback })));
|
|
223
|
+
// Declared from the same list splitExecArgs uses to find where the CLI's own arguments stop, so a
|
|
224
|
+
// new option cannot reach the CLI surface while the split still reads it as part of the command.
|
|
225
|
+
for (const [flags, description] of computeCmd.EXEC_OPTIONS)
|
|
226
|
+
execCmd.option(flags, description);
|
|
222
227
|
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
228
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
224
229
|
.option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
|
|
@@ -337,10 +342,10 @@ program.command('feedback')
|
|
|
337
342
|
.action(guard((o) => feedbackCmd.feedback(o)));
|
|
338
343
|
// ---- self-update ----
|
|
339
344
|
program.command('upgrade').description('Update the insta CLI to the latest release (binary or npm install)')
|
|
340
|
-
.action(guard(() => selfUpdate.upgrade()));
|
|
345
|
+
.action(guard(() => selfUpdate.upgrade(resolveVersion())));
|
|
341
346
|
program.command('autoupdate [mode]').description('Show or set auto-update: on | off (default: on while pre-1.0)')
|
|
342
347
|
.action(guard((mode) => selfUpdate.autoupdate(mode)));
|
|
343
|
-
program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck()));
|
|
348
|
+
program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck(resolveVersion())));
|
|
344
349
|
selfUpdate.maybeUpdate(resolveVersion(), process.argv);
|
|
345
350
|
program.parseAsync(computeArgv);
|
|
346
351
|
//# sourceMappingURL=index.js.map
|