insta 0.0.35 → 0.0.37

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.
@@ -6,12 +6,15 @@
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 { existsSync, readFileSync, statSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
9
11
  import os from 'node:os';
10
12
  import { ApiClient } from '../api.js';
11
13
  import { resolveEnv } from '../config.js';
12
14
  import { DEFAULT_ENV, ENVS, mcpServerName } from '../env.js';
13
15
  import { info } from '../util.js';
14
16
  import { installAgentConfigs } from './mcp.js';
17
+ import { detectChannel } from './upgrade.js';
15
18
  // The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an
16
19
  // "Installing to all N agents" banner, a full N-line install-path box, and a third-party
17
20
  // "Security Risk Assessment" that flags our OWN first-party skill as "Critical Risk". Streamed
@@ -76,13 +79,165 @@ export function summarizeInstall(output) {
76
79
  : `${count} agent${count === 1 ? '' : 's'}`;
77
80
  return `✓ Agent skills — ${list}`;
78
81
  }
82
+ // ---- CLI self-install (makes `npx -y insta setup agent` a complete one-liner) ----
83
+ // Under npx the CLI runs from the npm cache and vanishes when the process exits — but the skill
84
+ // installed below tells every agent to run `insta …`, which then wouldn't exist. So when this
85
+ // process came from the npm channel and no DURABLE `insta` is on PATH, install ourselves
86
+ // globally first. The scan must ignore any PATH entry under a node_modules directory: npx
87
+ // prepends its cache's node_modules/.bin (where this very process's `insta` shim lives), while
88
+ // durable installs (npm -g bin, nvm/volta/fnm, the native binary's ~/.insta/bin) never sit
89
+ // under one.
90
+ // On POSIX a PATH hit only counts if it would actually run: a plain non-executable file (or a
91
+ // directory) named `insta` must not suppress the self-install. Mode bits, not access(X_OK) —
92
+ // access() answers "can THIS process exec it", which for root is always yes, so a root-run
93
+ // setup would wrongly treat a non-executable file as a durable install. On Windows execute
94
+ // permission is extension-driven, so existence of a regular file is the right check.
95
+ const isRunnableFile = (p, win) => {
96
+ try {
97
+ const st = statSync(p);
98
+ if (!st.isFile())
99
+ return false;
100
+ return win || (st.mode & 0o111) !== 0;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ };
106
+ /** Resolve a bare command name to its absolute PATH location (PATHEXT-aware on Windows).
107
+ * cmd.exe searches the CURRENT DIRECTORY before PATH for bare names, so handing it a bare
108
+ * `claude` would let a claude.cmd planted in the project directory shadow the real CLI —
109
+ * the cmd.exe wrapper below only ever passes absolute paths. */
110
+ export function whichOnPath(bin, env = process.env, platform = process.platform) {
111
+ const win = platform === 'win32';
112
+ const exts = win ? [...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';'), ''] : [''];
113
+ for (const dir of (env.PATH ?? '').split(win ? ';' : ':')) {
114
+ if (!dir)
115
+ continue;
116
+ for (const ext of exts) {
117
+ const p = join(dir, bin + ext);
118
+ if (isRunnableFile(p, win))
119
+ return p;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ export function findDurableOnPath(bin, env = process.env, platform = process.platform) {
125
+ const win = platform === 'win32';
126
+ const dirs = (env.PATH ?? '').split(win ? ';' : ':');
127
+ // npm on Windows writes insta.cmd/insta.ps1 plus an extensionless sh shim; PATHEXT covers the
128
+ // former, the bare name the latter.
129
+ const exts = win ? [...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';'), ''] : [''];
130
+ for (const dir of dirs) {
131
+ // Case-insensitive: Windows paths (and the npx cache) may carry any casing.
132
+ if (!dir || dir.toLowerCase().includes('node_modules'))
133
+ continue;
134
+ for (const ext of exts)
135
+ if (isRunnableFile(join(dir, bin + ext), win))
136
+ return true;
137
+ }
138
+ return false;
139
+ }
140
+ const cliVersion = () => {
141
+ try {
142
+ return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
143
+ }
144
+ catch {
145
+ return 'latest';
146
+ }
147
+ };
148
+ /** Best-effort: a failed global install must not block the skill/MCP setup below — the npx run
149
+ * itself still completes the agent onboarding, and the manual fallback is one line. */
150
+ export async function ensureCliInstalled(run, channel = detectChannel(), onPath = findDurableOnPath('insta'), recheck = () => findDurableOnPath('insta')) {
151
+ if (channel !== 'npm' || onPath)
152
+ return;
153
+ info('installing the insta CLI globally (npm) …');
154
+ // Pinned to THIS version so the one-liner installs exactly what it ran. The logical `npm` is
155
+ // resolved to a spawnable invocation ONCE, inside the default runner (resolveSpawnable) —
156
+ // handing it a pre-resolved node/npm-cli.js path here would make the runner resolve it a
157
+ // second time and, on Windows, wrap the real node.exe in cmd.exe.
158
+ const spec = `insta@${cliVersion()}`;
159
+ const res = await run('npm', ['install', '-g', spec]);
160
+ if (res.ok) {
161
+ // A clean `npm i -g` can still land in a bin dir that isn't on PATH (custom npm prefix) —
162
+ // exactly the machines this path exists for. Only claim success after re-finding the shim.
163
+ if (recheck()) {
164
+ info('✓ insta CLI — installed globally (`insta` now works in any shell)');
165
+ }
166
+ else {
167
+ info('✓ insta CLI — installed globally, but npm\'s global bin dir is not on PATH');
168
+ info(' add it to PATH (POSIX: `$(npm prefix -g)/bin`; Windows: the dir `npm prefix -g` prints), then verify with `insta --version`');
169
+ }
170
+ return;
171
+ }
172
+ info(' global CLI install failed — continuing with agent setup; install manually with:');
173
+ info(` npm install -g ${spec}`);
174
+ if (/EACCES|permission denied/i.test(res.output ?? '')) {
175
+ info(' (permission error: the npm prefix is system-owned — use a Node version manager, or elevate that one command)');
176
+ }
177
+ }
178
+ // ---- Windows-safe spawning for npm/npx ----
179
+ // On Windows `npm`/`npx` are .cmd shims, which spawn() without a shell refuses (Node docs:
180
+ // spawning .bat/.cmd needs a shell or cmd.exe). Rather than a shell (argument-quoting hazards),
181
+ // re-enter them as node scripts: the CLI script named by npm_execpath (swapped between
182
+ // npm-cli.js and npx-cli.js as needed), else the one shipped beside the running node, else the
183
+ // bare name (POSIX, where PATH shims resolve fine). Applied ONCE, inside the default runner,
184
+ // so every `run('npm'|'npx', …)` call site benefits and nothing is ever resolved twice.
185
+ export function resolveSpawnable(cmd, args, npmExecpath = process.env.npm_execpath, execPath = process.execPath, platform = process.platform, env = process.env) {
186
+ // Node re-entry is only valid when THIS process runs on node. On the native-binary channel
187
+ // execPath is the compiled `insta` executable — and npm scripts export npm_execpath to their
188
+ // children — so re-entering blindly would spawn `insta npx-cli.js …`. A non-node execPath
189
+ // sends npm/npx down the generic shim path below instead.
190
+ const execIsNode = /(^|[\\/])node(\.exe)?$/i.test(execPath);
191
+ if ((cmd === 'npm' || cmd === 'npx') && execIsNode) {
192
+ if (npmExecpath && /(^|[\\/])np[mx](-cli)?\.[cm]?js$/.test(npmExecpath)) {
193
+ const cli = npmExecpath.replace(/np[mx](-cli)?(\.[cm]?js)$/, `${cmd}$1$2`);
194
+ if (existsSync(cli))
195
+ return { cmd: execPath, args: [cli, ...args] };
196
+ }
197
+ const nodeDir = dirname(execPath);
198
+ const besideNode = platform === 'win32'
199
+ ? join(nodeDir, 'node_modules', 'npm', 'bin', `${cmd}-cli.js`)
200
+ : join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', `${cmd}-cli.js`);
201
+ if (existsSync(besideNode))
202
+ return { cmd: execPath, args: [besideNode, ...args] };
203
+ }
204
+ // Generic shim path — every non-npm CLI we shell out to (claude), plus npm/npx themselves
205
+ // when node isn't resolvable (native binary channel). On Windows these are .cmd shims, which
206
+ // spawn() refuses without a shell, so route them through cmd.exe. Guards, in order:
207
+ // - BARE names only: an absolute path or anything .exe (node.exe from a resolved npm/npx
208
+ // invocation passing back through here) is directly spawnable and must NOT see cmd.exe.
209
+ // - The name is resolved to its ABSOLUTE PATH location first: cmd.exe searches the current
210
+ // directory before PATH, so a bare name would let a shim planted in the project dir
211
+ // shadow the real CLI. No PATH hit → pass through (spawn fails; callers degrade).
212
+ // - No manual quoting: libuv already wraps spaced args when building the child command
213
+ // line — pre-quoting would be quoted AGAIN and arrive as literal quote characters.
214
+ // - That leaves cmd.exe metacharacters unprotectable, so an arg carrying one (e.g. a
215
+ // custom INSTA_MCP_URL with `&`) skips the wrapper: the bare-shim spawn fails and every
216
+ // caller degrades gracefully (probe → not-installed; registration → manual-add
217
+ // fallback). Never hand metacharacters to a shell.
218
+ const bareShim = !/[\\/]/.test(cmd) && !/\.exe$/i.test(cmd);
219
+ if (platform === 'win32' && bareShim && !args.some((a) => /[&|<>^%"]/.test(a))) {
220
+ const abs = whichOnPath(cmd, env, platform);
221
+ if (abs)
222
+ return { cmd: 'cmd.exe', args: ['/d', '/s', '/c', abs, ...args] };
223
+ }
224
+ return { cmd, args };
225
+ }
79
226
  // Capture stdout+stderr silently (don't stream) so we can print our own clean summary.
80
227
  // stdin is 'ignore', NOT 'inherit': under the canonical `curl … | sh` install, stdin is the
81
228
  // piped install script itself — a child that inherits it (npx/skills reads for keypresses even
82
229
  // with -y) consumes the rest of the script, so the shell never runs the trailing "Get started"
83
230
  // guidance. Ignoring stdin keeps the installer's own output intact. (-y means no prompt anyway.)
84
- const defaultRunner = (cmd, args) => new Promise((resolve) => {
231
+ const defaultRunner = (cmdIn, argsIn) => new Promise((resolve) => {
232
+ const { cmd, args } = resolveSpawnable(cmdIn, argsIn);
85
233
  const env = { ...process.env, AI_AGENT: process.env.AI_AGENT || 'insta', FORCE_COLOR: '0' };
234
+ // When THIS process was launched by npx, npx exports its flags as npm_config_* env vars.
235
+ // npm_config_package pins package resolution for every nested npm/npx child — the inner
236
+ // `npx -y skills …` would then resolve `skills` against the insta package and degrade to
237
+ // `sh: skills: command not found`. Scrub the resolution-pinning vars; keep prefix/registry
238
+ // (deliberate user configuration).
239
+ delete env.npm_config_package;
240
+ delete env.npm_config_call;
86
241
  const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
87
242
  let output = '';
88
243
  const grab = (chunk) => { output += chunk.toString(); };
@@ -91,11 +246,14 @@ const defaultRunner = (cmd, args) => new Promise((resolve) => {
91
246
  p.on('error', () => resolve({ ok: false, output }));
92
247
  p.on('close', (code) => resolve({ ok: code === 0, output }));
93
248
  });
249
+ // Leading -y is npx's OWN flag: on a machine where the `skills` package isn't already in the
250
+ // npx cache, non-TTY `npx skills …` refuses to auto-install and degrades to a shell lookup
251
+ // (`sh: skills: command not found`) — the trailing -y only answers the skills TOOL's prompt.
94
252
  // -g = user-level (machine-global); -a '*' = every agent dir the skills tool supports
95
253
  // (Claude Code, Codex, Cursor, OpenCode, Copilot, …); --copy = real files, not cache symlinks.
96
254
  // `spec` is the skill source for the resolved environment (`owner/repo` or `owner/repo@ref`), so a
97
255
  // staging install reads the staging skill text rather than what's published on main.
98
- export const setupArgs = (spec) => ['skills', 'add', spec, '-s', 'insta', '-a', '*', '-g', '-y', '--copy'];
256
+ export const setupArgs = (spec) => ['-y', 'skills', 'add', spec, '-s', 'insta', '-a', '*', '-g', '-y', '--copy'];
99
257
  /** Production's args. Kept as a named export because it is the installed-base default and is
100
258
  * asserted directly by tests; runtime goes through `setupArgs(resolveEnv().skills)`. */
101
259
  export const SETUP_ARGS = setupArgs(ENVS[DEFAULT_ENV].skills);
@@ -158,10 +316,13 @@ export async function registerMcp(run = defaultRunner, mint = defaultMinter, use
158
316
  info(` MCP registration failed — add manually:\n claude mcp add --transport http ${name} ${url}`);
159
317
  }
160
318
  }
161
- export async function setupAgent(opts, run = defaultRunner, mint, installConfigs = installAgentConfigs) {
319
+ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs = installAgentConfigs, ensure = (r) => ensureCliInstalled(r)) {
162
320
  if (!opts.yes && !process.stdout.isTTY) {
163
321
  info('non-interactive shell — assuming -y');
164
322
  }
323
+ // BEFORE the skill install: the skill tells agents to run `insta …`, so a durable CLI must
324
+ // exist by the time it lands.
325
+ await ensure(run);
165
326
  // One resolve for the whole step, so the skills and the MCP registration below cannot disagree
166
327
  // about which environment this machine belongs to.
167
328
  const { env, skills } = await resolveEnv();
@@ -50,7 +50,7 @@ export async function storageList(opts) {
50
50
  const branch = opts.branch ?? p.branch;
51
51
  const svc = await storageTarget(api, p.projectId, branch, opts.service);
52
52
  const res = await api.rawRequest('GET', objectsPath(p.projectId, svc.id, { branch, prefix: opts.prefix, cursor: opts.cursor, limit }));
53
- if (handleApproval(res))
53
+ if (handleApproval(res, opts.json))
54
54
  return;
55
55
  if (opts.json)
56
56
  return printJson(res.body);
@@ -143,7 +143,7 @@ export async function storageGet(key, opts, deps = {}) {
143
143
  const branch = opts.branch ?? p.branch;
144
144
  const svc = await storageTarget(api, p.projectId, branch, opts.service);
145
145
  const res = await api.rawRequest('GET', objectDownloadPath(p.projectId, svc.id, { branch, key }));
146
- if (handleApproval(res))
146
+ if (handleApproval(res, opts.json))
147
147
  return;
148
148
  // --json hands over the presigned URL instead of downloading, as `insta secrets --json` does.
149
149
  // Before outputPath, so a key with no filename still works when nothing is written to disk.
@@ -164,7 +164,7 @@ export async function storageDelete(key, opts) {
164
164
  const branch = opts.branch ?? p.branch;
165
165
  const svc = await storageTarget(api, p.projectId, branch, opts.service);
166
166
  const res = await api.rawRequest('DELETE', objectsPath(p.projectId, svc.id, { branch, key }));
167
- if (handleApproval(res))
167
+ if (handleApproval(res, opts.json))
168
168
  return;
169
169
  if (opts.json)
170
170
  return printJson(res.body);
@@ -9,6 +9,7 @@ import { spawn } from 'node:child_process';
9
9
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { resolveEnv } from './config.js';
12
+ import { resolveSpawnable } from './commands/setup.js';
12
13
  import { DEFAULT_ENV, ENVS } from './env.js';
13
14
  // Where `npx skills add` drops skills for the agents we pin below: Claude Code → .claude/skills/,
14
15
  // Codex → .agents/skills/ (.github/skills/ is the third well-known dir). These are regenerable
@@ -20,8 +21,15 @@ const SKILL_DIRS = ['.claude/skills/', '.agents/skills/', '.github/skills/'];
20
21
  // honest here (this IS programmatic, not an interactive prompt) and, because we already pin the
21
22
  // agents/skills/-y, has no effect on the install beyond quieting the banner. Preserve a caller's
22
23
  // existing AI_AGENT (e.g. running inside another agent) rather than clobbering it.
23
- const defaultRunner = (cmd, args, inherit = false) => new Promise((resolve) => {
24
+ const defaultRunner = (cmdIn, argsIn, inherit = false) => new Promise((resolve) => {
25
+ // resolveSpawnable: on Windows `npx` is a .cmd shim spawn() refuses without a shell —
26
+ // re-enter npm's CLI script via node instead (same treatment as `insta setup agent`).
27
+ const { cmd, args } = resolveSpawnable(cmdIn, argsIn);
24
28
  const env = { ...process.env, AI_AGENT: process.env.AI_AGENT || 'insta' };
29
+ // npx exports its flags as npm_config_* to children; npm_config_package would pin the inner
30
+ // `npx -y skills …` to whatever package launched this CLI (see setup.ts defaultRunner).
31
+ delete env.npm_config_package;
32
+ delete env.npm_config_call;
25
33
  const p = spawn(cmd, args, { stdio: inherit ? 'inherit' : 'ignore', env });
26
34
  p.on('error', () => resolve({ ok: false })); // e.g. npx not on PATH
27
35
  p.on('close', (code) => resolve({ ok: code === 0 }));
@@ -31,17 +39,21 @@ const defaultRunner = (cmd, args, inherit = false) => new Promise((resolve) => {
31
39
  // name the exact skills (-s …) so there's no skill picker; -y to skip the scope/confirm prompt;
32
40
  // --copy to write real files (not symlinks into a transient npx cache).
33
41
  const AGENT_FLAGS = ['-a', 'claude-code', '-a', 'codex', '-y', '--copy'];
42
+ // npx's OWN -y, distinct from the skills tool's -y above: without it, a machine whose npx cache
43
+ // lacks the `skills` package refuses the auto-install in non-TTY runs and the whole command
44
+ // degrades to `sh: skills: command not found`.
45
+ const NPX_YES = ['-y'];
34
46
  // `instaSpec` is the insta skill source for the resolved environment (`owner/repo[@ref]`), so a
35
47
  // project created against staging gets the staging skill text. The third-party stack skills are
36
48
  // environment-independent — they document Tigris/Better Auth, not our control plane.
37
49
  const skillTargets = (instaSpec) => [
38
- { label: 'insta', args: ['skills', 'add', instaSpec, '-s', 'insta', ...AGENT_FLAGS] },
39
- { label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills',
50
+ { label: 'insta', args: [...NPX_YES, 'skills', 'add', instaSpec, '-s', 'insta', ...AGENT_FLAGS] },
51
+ { label: 'tigris', args: [...NPX_YES, 'skills', 'add', 'tigrisdata/skills',
40
52
  '-s', 'tigris-object-operations', '-s', 'file-storage', '-s', 'tigris-sdk-guide',
41
53
  '-s', 'tigris-security-access-control', '-s', 'tigris-image-optimization',
42
54
  '-s', 'tigris-s3-migration', '-s', 'tigris-static-assets', '-s', 'tigris-agent-kit',
43
55
  ...AGENT_FLAGS] },
44
- { label: 'better-auth', args: ['skills', 'add', 'better-auth/skills',
56
+ { label: 'better-auth', args: [...NPX_YES, 'skills', 'add', 'better-auth/skills',
45
57
  '-s', 'better-auth-best-practices', '-s', 'email-and-password-best-practices',
46
58
  '-s', 'better-auth-security-best-practices', ...AGENT_FLAGS] },
47
59
  ];
@@ -4,17 +4,21 @@
4
4
  import { spawn } from 'node:child_process';
5
5
  import { existsSync, writeFileSync, unlinkSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { info } from './util.js';
8
7
  // Spawn flyctl, tee its output to the user (so they see buildkit progress) AND capture it for digest
9
- // parsing.
10
- export const defaultBuildRunner = (cmd, args, opts) => new Promise((resolve) => {
11
- const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['inherit', 'pipe', 'pipe'] });
12
- let output = '';
13
- child.stdout?.on('data', (b) => { const s = b.toString(); output += s; process.stdout.write(s); });
14
- child.stderr?.on('data', (b) => { const s = b.toString(); output += s; process.stderr.write(s); });
15
- child.on('error', (err) => resolve({ code: -1, output: `${output}\n${err.message}` }));
16
- child.on('close', (code) => resolve({ code: code ?? -1, output }));
17
- });
8
+ // parsing. `to` picks the tee destination for the child's stdout: `deploy --json` reserves the
9
+ // process's stdout for the final JSON document, so it tees build progress to stderr instead.
10
+ function teeRunner(to) {
11
+ return (cmd, args, opts) => new Promise((resolve) => {
12
+ const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['inherit', 'pipe', 'pipe'] });
13
+ let output = '';
14
+ child.stdout?.on('data', (b) => { const s = b.toString(); output += s; to.write(s); });
15
+ child.stderr?.on('data', (b) => { const s = b.toString(); output += s; process.stderr.write(s); });
16
+ child.on('error', (err) => resolve({ code: -1, output: `${output}\n${err.message}` }));
17
+ child.on('close', (code) => resolve({ code: code ?? -1, output }));
18
+ });
19
+ }
20
+ export const defaultBuildRunner = teeRunner(process.stdout);
21
+ export const stderrBuildRunner = teeRunner(process.stderr);
18
22
  // buildkit prints "pushing manifest for registry.fly.io/<app>:<label>@sha256:<digest>" on push.
19
23
  // Pin to the digest — the bare tag races on Fly's registry (MANIFEST_UNKNOWN); the digest always resolves.
20
24
  export function parseImageDigest(output, flyApp) {
@@ -57,10 +61,13 @@ export async function flyctlBuildAndPush(opts, run = defaultBuildRunner) {
57
61
  }
58
62
  }
59
63
  // Best-effort: ensure the fly CLI is available (needed for source-directory deploys). Never blocks —
60
- // if it can't be installed the subsequent build surfaces a clear error.
64
+ // if it can't be installed the subsequent build surfaces a clear error. Everything here is one-time
65
+ // bootstrap DIAGNOSTICS, so it all goes to stderr — including the installers' own stdout (fd 2 in
66
+ // the stdio triple) — keeping stdout clean for `deploy --json`.
61
67
  export async function ensureFlyctl() {
68
+ const note = (m) => process.stderr.write(m + '\n');
62
69
  const ok = (cmd, args, inherit = false) => new Promise((resolve) => {
63
- const p = spawn(cmd, args, { stdio: inherit ? 'inherit' : 'ignore' });
70
+ const p = spawn(cmd, args, { stdio: inherit ? ['inherit', 2, 2] : 'ignore' });
64
71
  p.on('error', () => resolve(false));
65
72
  p.on('close', (code) => resolve(code === 0));
66
73
  });
@@ -68,8 +75,8 @@ export async function ensureFlyctl() {
68
75
  if (await ok('flyctl', ['version']))
69
76
  return;
70
77
  if (process.platform === 'darwin' && (await ok('brew', ['--version']))) {
71
- info('flyctl not found — installing with `brew install flyctl` (one-time)…');
72
- info((await ok('brew', ['install', 'flyctl'], true)) ? 'flyctl installed ✓' : 'flyctl install failed — install manually: https://fly.io/docs/flyctl/install/');
78
+ note('flyctl not found — installing with `brew install flyctl` (one-time)…');
79
+ note((await ok('brew', ['install', 'flyctl'], true)) ? 'flyctl installed ✓' : 'flyctl install failed — install manually: https://fly.io/docs/flyctl/install/');
73
80
  return;
74
81
  }
75
82
  if (process.platform === 'linux') {
@@ -77,20 +84,20 @@ export async function ensureFlyctl() {
77
84
  // and no brew; without this branch `insta deploy <dir>` dead-ends on a hand-install of a
78
85
  // third-party CLI. Official installer, pinned into ~/.fly; the current process extends its
79
86
  // own PATH because the installer's shell-profile edit can't reach an already-running process.
80
- info('flyctl not found — installing to ~/.fly (one-time)…');
87
+ note('flyctl not found — installing to ~/.fly (one-time)…');
81
88
  const flyHome = `${process.env.HOME ?? '~'}/.fly`;
82
89
  const installed = await ok('sh', ['-c', `curl -fsSL https://fly.io/install.sh | FLYCTL_INSTALL="${flyHome}" sh`], true);
83
90
  if (installed) {
84
91
  process.env.PATH = `${process.env.PATH}:${flyHome}/bin`;
85
92
  if (await ok('flyctl', ['version'])) {
86
- info('flyctl installed ✓');
93
+ note('flyctl installed ✓');
87
94
  return;
88
95
  }
89
96
  }
90
- info('flyctl install failed — install manually: https://fly.io/docs/flyctl/install/');
97
+ note('flyctl install failed — install manually: https://fly.io/docs/flyctl/install/');
91
98
  return;
92
99
  }
93
- info('flyctl (fly CLI) not found — install it to deploy from source: https://fly.io/docs/flyctl/install/');
100
+ note('flyctl (fly CLI) not found — install it to deploy from source: https://fly.io/docs/flyctl/install/');
94
101
  }
95
102
  catch { /* best-effort convenience */ }
96
103
  }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { resolveServiceArgs, serviceArgsDeps } from './resolve-service.js';
17
17
  import * as regions from './commands/regions.js';
18
18
  import * as secretsCmd from './commands/secrets.js';
19
19
  import { deploy } from './commands/deploy.js';
20
+ import { build } from './commands/build.js';
20
21
  import * as computeCmd from './commands/compute.js';
21
22
  import * as dbCmd from './commands/db.js';
22
23
  import * as storageCmd from './commands/storage.js';
@@ -73,7 +74,7 @@ const envCmd = program.command('env').description('Show or switch the deployment
73
74
  envCmd.command('show', { isDefault: true }).description('Show the current environment and its hosts')
74
75
  .option('--json').action(guard((o) => envCmd_.envShow(o)));
75
76
  envCmd.command('use <name>').description(`Switch environment (${ENV_NAMES.join(' | ')}) — drops the stored session, which is deployment-specific`)
76
- .action(guard((name) => envCmd_.envUse(name)));
77
+ .option('--json').action(guard((name, o) => envCmd_.envUse(name, o)));
77
78
  // ---- run (per-request secret injection — nothing written to disk) ----
78
79
  program.command('run <cmd> [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)')
79
80
  .option('--branch <b>', 'branch bundle to inject (default: linked branch)')
@@ -81,7 +82,7 @@ program.command('run <cmd> [args...]').description('Run a command with the branc
81
82
  .action(guard((cmd, args, o) => runCmd.run([cmd, ...(args ?? [])], o)));
82
83
  // ---- agent setup (the `curl … | sh --agents` target) ----
83
84
  const setupCmd = program.command('setup').description('Set up this machine for InstaCloud agent workflows');
84
- setupCmd.command('agent').description('Install the insta skill user-globally for all coding agents')
85
+ setupCmd.command('agent').description('Install the insta CLI (if missing), the insta skill for all coding agents, and the MCP server')
85
86
  .option('-y, --yes', 'non-interactive')
86
87
  .option('--mcp-token', 'register the MCP server with a minted insta_ API token instead of OAuth (headless machines / CI)')
87
88
  .action(guard((o) => setup.setupAgent(o)));
@@ -94,29 +95,29 @@ mcpCmd.command('install').description('Register the remote MCP server with codin
94
95
  // ---- org ----
95
96
  const orgCmd = program.command('org').description('Manage organizations');
96
97
  orgCmd.command('list').option('--json').action(guard((o) => org.orgList(o)));
97
- orgCmd.command('create <name>').action(guard((name) => org.orgCreate(name)));
98
+ orgCmd.command('create <name>').option('--json').action(guard((name, o) => org.orgCreate(name, o)));
98
99
  // ---- project ----
99
100
  const pj = program.command('project').description('Manage projects');
100
- pj.command('create [name]').option('--org <id>', 'org to create under (default: personal)').action(guard((name, o) => project.projectCreate(name, o)));
101
+ pj.command('create [name]').option('--org <id>', 'org to create under (default: personal)').option('--json').action(guard((name, o) => project.projectCreate(name, o)));
101
102
  pj.command('list').option('--org <id>').option('--json').action(guard((o) => project.projectList(o)));
102
- pj.command('link <id>').description('Link a project to this directory').action(guard((id) => project.projectLink(id)));
103
- pj.command('delete').option('--project <id>').action(guard((o) => project.projectDelete(o)));
103
+ pj.command('link <id>').description('Link a project to this directory').option('--json').action(guard((id, o) => project.projectLink(id, o)));
104
+ pj.command('delete').option('--project <id>').option('--json').action(guard((o) => project.projectDelete(o)));
104
105
  // ---- branch ----
105
106
  const br = program.command('branch').description('Manage branch environments');
106
- br.command('create <name>').option('--from <branch>', 'parent branch (default: current)').action(guard((name, o) => branch.branchCreate(name, o)));
107
+ br.command('create <name>').option('--from <branch>', 'parent branch (default: current)').option('--json').action(guard((name, o) => branch.branchCreate(name, o)));
107
108
  br.command('list').option('--json').action(guard((o) => branch.branchList(o)));
108
- br.command('switch <name>').action(guard((name) => branch.branchSwitch(name)));
109
- br.command('delete <name>').action(guard((name) => branch.branchDelete(name)));
109
+ br.command('switch <name>').option('--json').action(guard((name, o) => branch.branchSwitch(name, o)));
110
+ br.command('delete <name>').option('--json').action(guard((name, o) => branch.branchDelete(name, o)));
110
111
  br.command('merge <source>').description('Merge a branch service set into another (structural, no data)')
111
- .option('--into <branch>', 'target branch (default: current)').action(guard((source, o) => branch.branchMerge(source, o)));
112
- // ---- services (opt-in postgres/storage/compute/redis) ----
113
- const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis)');
112
+ .option('--into <branch>', 'target branch (default: current)').option('--json').action(guard((source, o) => branch.branchMerge(source, o)));
113
+ // ---- services (opt-in postgres/storage/compute/redis/mysql/mongodb) ----
114
+ const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis|mysql|mongodb)');
114
115
  // [type] [name] are optional so the command can answer "what can I add?" — a terminal is walked
115
116
  // through the dashboard's Add Service kinds, anything else gets that list back as an error
116
117
  // (resolve-service.ts). Picking Docker Image also fills in --image/--port from the answers.
117
118
  svc.command('add [type] [name]').description('Provision a service on demand (assigns a default domain for postgres/compute); with no type/name, a terminal picks from the service kinds')
118
119
  .option('--branch <branch>', 'target branch (default: current)')
119
- .option('--region <region>', 'region for postgres/compute/redis, e.g. us-east (see `insta regions`)')
120
+ .option('--region <region>', 'region for postgres/compute/managed databases, e.g. us-east (see `insta regions`)')
120
121
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
121
122
  .option('--image <url>', 'compute only: run this container image at creation')
122
123
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
@@ -130,7 +131,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass
130
131
  svc.command('list').option('--json').option('--branch <branch>', 'branch (default: current)')
131
132
  .action(guard((o) => services.servicesList(o)));
132
133
  svc.command('remove <type> <name>').description('Remove a service and destroy its resources')
133
- .option('--branch <branch>', 'branch (default: current)')
134
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
134
135
  .action(guard((type, name, o) => services.servicesRemove(type, name, o)));
135
136
  svc.command('rename <type> <name> <new-name>').description('Rename a service and re-key its managed secret names')
136
137
  .option('--json').option('--branch <branch>', 'branch (default: current)')
@@ -150,16 +151,47 @@ const sec = program.command('secrets').description('Fetch the credential bundle
150
151
  sec.command('list').description('List secret names, grouped by service').option('--branch <branch>').option('--json').action(guard((o) => secretsCmd.secretsList(o)));
151
152
  sec.command('set <name> [value]').description('Set a user secret (project-wide; value from stdin if omitted)')
152
153
  .option('--branch <branch>', 'scope to one branch').option('--service <type/name>', 'bind to a branch service (implies current branch)')
153
- .action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o)));
154
+ .option('--json').action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o)));
154
155
  sec.command('unset <name>').description('Remove a user secret')
155
- .option('--branch <branch>', 'scope to one branch').action(guard((n, o) => secretsCmd.secretsUnset(n, o)));
156
+ .option('--branch <branch>', 'scope to one branch').option('--json').action(guard((n, o) => secretsCmd.secretsUnset(n, o)));
157
+ sec.command('bind <env-name> <source>').description('Bind a service credential into a compute env var')
158
+ .option('--branch <branch>', 'branch (default: current)')
159
+ .option('--to <compute-service>', 'target compute service, e.g. compute/api')
160
+ .option('--source-name <name>', 'source credential name when the source exposes more than one')
161
+ .option('--json')
162
+ .action(guard((n, source, o) => secretsCmd.secretsBind(n, source, o)));
163
+ sec.command('unbind <env-name>').description('Remove a service credential binding from a compute env var')
164
+ .option('--branch <branch>', 'branch (default: current)')
165
+ .option('--from <compute-service>', 'target compute service, e.g. compute/api')
166
+ .option('--json')
167
+ .action(guard((n, o) => secretsCmd.secretsUnbind(n, o)));
168
+ sec.command('bindings').description('List service credential bindings for a compute service')
169
+ .option('--branch <branch>', 'branch (default: current)')
170
+ .option('--target <compute-service>', 'target compute service, e.g. compute/api')
171
+ .option('--json')
172
+ .action(guard((o) => secretsCmd.secretsBindings(o)));
173
+ sec.command('sources').description('List service credential sources available for binding')
174
+ .option('--branch <branch>', 'branch (default: current)')
175
+ .option('--json')
176
+ .action(guard((o) => secretsCmd.secretsSources(o)));
156
177
  sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json')
157
178
  .action(guard((o) => secretsCmd.secretsTree(o)));
179
+ // ---- build (pre-push verification — local, offline, deploys nothing) ----
180
+ 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')
181
+ .option('--explain', 'include the Dockerfile content in the output')
182
+ .option('--port <p>', 'port the app listens on (else the Dockerfile EXPOSE)')
183
+ .option('--json')
184
+ .action(guard((dir, o) => build(dir, o)));
158
185
  // ---- deploy ----
159
186
  program.command('deploy [dir]').description('Deploy a source directory (built remotely on Fly) or a prebuilt --image to a branch compute group')
160
187
  .option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
161
188
  .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
189
+ .option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
162
190
  .action(guard((dir, o) => deploy(dir, o)));
191
+ // `insta compute exec` needs the command verbatim after a literal `--`; split it out of argv here,
192
+ // before commander parses anything (see splitExecArgs's own comment for why `service` being
193
+ // optional makes commander unable to hold that boundary itself).
194
+ const { argv: computeArgv, command: execCommand } = computeCmd.splitExecArgs(process.argv);
163
195
  // ---- compute (lifecycle control + custom domains) ----
164
196
  const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains');
165
197
  compute.command('set-domain <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
@@ -167,7 +199,7 @@ compute.command('set-domain <host>').description('Attach a custom domain to a br
167
199
  compute.command('check-domain <host>').description("Show a custom domain's cert status + required DNS records")
168
200
  .option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.checkDomain(host, o)));
169
201
  compute.command('remove-domain <host>').description('Detach a custom domain (gated: deploy)')
170
- .option('--branch <b>').option('--group <g>').action(guard((host, o) => computeCmd.removeDomain(host, o)));
202
+ .option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.removeDomain(host, o)));
171
203
  compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)')
172
204
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o)));
173
205
  compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`')
@@ -181,6 +213,9 @@ compute.command('limits [service]').description("Show or set a compute service's
181
213
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
182
214
  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')
183
215
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
216
+ 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)")
217
+ .option('--branch <b>').option('--timeout <sec>', 'command timeout in seconds, 1-180 (platform default: 30)').option('--json')
218
+ .action(guard((service, o) => computeCmd.computeExec(service, execCommand, o)));
184
219
  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")
185
220
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
186
221
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
@@ -225,11 +260,14 @@ program.command('manifest').description('Print an agent-legible view of the proj
225
260
  // ---- regions ----
226
261
  program.command('regions').description('List regions available for postgres/compute services').option('--json').action(guard((o) => regions.regionsList(o)));
227
262
  // ---- observability ----
228
- program.command('metrics <target> [group]').description('Service metrics (target: db|compute)')
263
+ program.command('metrics <target> [group]').description('Service metrics (target: db|compute|redis|mysql|mongodb)')
229
264
  .option('--branch <b>').option('--from <unix>').option('--to <unix>').option('--step <s>').option('--json')
230
265
  .action(guard((target, group, o) => obs.metrics(target, group, o)));
231
- program.command('logs <target> [group]').description('Service logs (runtime by default; --deploy = compute deploy events; target: db|compute)')
232
- .option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--deploy', 'show compute deploy events (machine lifecycle) instead of runtime logs').option('--json')
266
+ program.command('logs <target> [group]').description('Service logs (runtime by default; --deploy = machine lifecycle events; target: db|compute|redis|mysql|mongodb)')
267
+ .option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--deploy', 'show deploy events (machine lifecycle) instead of runtime logs — Fly-backed targets only, not db').option('--json')
268
+ .option('--from <t>', 'window start: unix seconds or ISO-8601 — pages history (~7-day retention); without a window one recent provider page (~100 lines) is returned')
269
+ .option('--to <t>', 'window end: unix seconds or ISO-8601 (default: now)')
270
+ .option('--since <dur>', 'relative window start, e.g. 90s, 30m, 2h, 1d (shorthand for --from now-dur)')
233
271
  .action(guard((target, group, o) => obs.logs(target, group, o)));
234
272
  program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)')
235
273
  .option('--from <unix>').option('--to <unix>').option('--proj [id]', 'show one project (the linked one, or a given id) instead of the whole org').option('--json')
@@ -248,8 +286,8 @@ program.command('events').description('Show the audit + agent-event timeline').o
248
286
  // ---- approvals ----
249
287
  const ap = program.command('approvals').description('Governance approvals (HITL)');
250
288
  ap.command('list').option('--status <s>', 'pending|granted|denied|consumed').option('--json').action(guard((o) => govern.approvalsList(o)));
251
- ap.command('approve <id>').option('--always', 'also set the policy to allow').action(guard((id, o) => govern.approvalsApprove(id, o)));
252
- ap.command('deny <id>').action(guard((id) => govern.approvalsDeny(id)));
289
+ ap.command('approve <id>').option('--always', 'also set the policy to allow').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o)));
290
+ ap.command('deny <id>').option('--json').action(guard((id, o) => govern.approvalsDeny(id, o)));
253
291
  // ---- observe (local credential audit) ----
254
292
  const ob = program.command('observe').description('Local credential-audit hook');
255
293
  ob.command('install').description('Install the PostToolUse hook into this project').action(guard(() => observe.observeInstall()));
@@ -259,7 +297,7 @@ ob.command('sync').description('Upload findings into the project timeline').acti
259
297
  // ---- policy ----
260
298
  const pol = program.command('policy').description('Governance policy');
261
299
  pol.command('get').option('--json').action(guard((o) => govern.policyGet(o)));
262
- pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').action(guard((a, d) => govern.policySet(a, d)));
300
+ pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').option('--json').action(guard((a, d, o) => govern.policySet(a, d, o)));
263
301
  // ---- feedback (agent + human hurdle reports → the InstaCloud team) ----
264
302
  program.command('feedback')
265
303
  .description('Report an InstaCloud-side hurdle (bug / missing feature / friction) to the InstaCloud team — about the insta toolkit itself, NEVER about the app you are building. Works logged-out and unlinked.')
@@ -284,5 +322,5 @@ program.command('autoupdate [mode]').description('Show or set auto-update: on |
284
322
  .action(guard((mode) => selfUpdate.autoupdate(mode)));
285
323
  program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck()));
286
324
  selfUpdate.maybeUpdate(resolveVersion(), process.argv);
287
- program.parseAsync(process.argv);
325
+ program.parseAsync(computeArgv);
288
326
  //# sourceMappingURL=index.js.map