insta 0.0.36 → 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.
@@ -12,7 +12,7 @@ export async function secrets(opts) {
12
12
  const p = await requireProject();
13
13
  const branch = opts.branch ?? p.branch;
14
14
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets${q(branch)}`);
15
- if (handleApproval(res))
15
+ if (handleApproval(res, opts.json))
16
16
  return;
17
17
  const bundle = res.body.secrets;
18
18
  if (opts.json)
@@ -47,7 +47,7 @@ export async function secretsTree(opts) {
47
47
  const api = await ApiClient.load();
48
48
  const p = await requireProject();
49
49
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`);
50
- if (handleApproval(res))
50
+ if (handleApproval(res, opts.json))
51
51
  return;
52
52
  const tree = res.body;
53
53
  if (opts.json)
@@ -68,7 +68,7 @@ export async function secretsList(opts) {
68
68
  const p = await requireProject();
69
69
  const branch = opts.branch ?? p.branch;
70
70
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`);
71
- if (handleApproval(res))
71
+ if (handleApproval(res, opts.json))
72
72
  return;
73
73
  const tree = res.body;
74
74
  const b = tree.branches.find((x) => x.name === branch);
@@ -102,8 +102,10 @@ export async function secretsSet(name, value, opts) {
102
102
  const branch = opts.service ? (opts.branch ?? p.branch) : opts.branch;
103
103
  const payload = { value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
104
104
  const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}`, payload);
105
- if (handleApproval(res))
105
+ if (handleApproval(res, opts.json))
106
106
  return;
107
+ if (opts.json)
108
+ return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
107
109
  info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
108
110
  }
109
111
  export async function secretsUnset(name, opts) {
@@ -111,8 +113,10 @@ export async function secretsUnset(name, opts) {
111
113
  const p = await requireProject();
112
114
  const qs = opts.branch ? `?branch=${encodeURIComponent(opts.branch)}` : '';
113
115
  const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}${qs}`);
114
- if (handleApproval(res))
116
+ if (handleApproval(res, opts.json))
115
117
  return;
118
+ if (opts.json)
119
+ return printJson({ ok: true, name, branch: opts.branch ?? null });
116
120
  info(`unset ${name} (${opts.branch ? `branch ${opts.branch}` : 'project-wide'})`);
117
121
  }
118
122
  export async function secretsBind(envName, source, opts) {
@@ -127,7 +131,7 @@ export async function secretsBind(envName, source, opts) {
127
131
  source,
128
132
  ...(opts.sourceName ? { sourceName: opts.sourceName } : {}),
129
133
  });
130
- if (handleApproval(res))
134
+ if (handleApproval(res, opts.json))
131
135
  return;
132
136
  if (opts.json)
133
137
  return printJson({ ok: true });
@@ -140,7 +144,7 @@ export async function secretsUnbind(envName, opts) {
140
144
  const p = await requireProject();
141
145
  const branch = opts.branch ?? p.branch;
142
146
  const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.from)}`);
143
- if (handleApproval(res))
147
+ if (handleApproval(res, opts.json))
144
148
  return;
145
149
  if (opts.json)
146
150
  return printJson({ ok: true });
@@ -153,7 +157,7 @@ export async function secretsBindings(opts) {
153
157
  const p = await requireProject();
154
158
  const branch = opts.branch ?? p.branch;
155
159
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-bindings?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.target)}`);
156
- if (handleApproval(res))
160
+ if (handleApproval(res, opts.json))
157
161
  return;
158
162
  const bindings = res.body.bindings ?? [];
159
163
  if (opts.json)
@@ -168,7 +172,7 @@ export async function secretsSources(opts) {
168
172
  const p = await requireProject();
169
173
  const branch = opts.branch ?? p.branch;
170
174
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-sources?branch=${encodeURIComponent(branch)}`);
171
- if (handleApproval(res))
175
+ if (handleApproval(res, opts.json))
172
176
  return;
173
177
  const sources = res.body.sources ?? [];
174
178
  if (opts.json)
@@ -111,7 +111,7 @@ export async function servicesAdd(type, name, opts = {}) {
111
111
  const p = await requireProject();
112
112
  const branch = opts.branch ?? p.branch;
113
113
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts));
114
- if (handleApproval(res))
114
+ if (handleApproval(res, opts.json))
115
115
  return;
116
116
  if (opts.json)
117
117
  return printJson(res.body.service);
@@ -151,8 +151,10 @@ export async function servicesRemove(type, name, opts = {}) {
151
151
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
152
152
  const id = resolveServiceId(services, type, name);
153
153
  const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${id}`);
154
- if (handleApproval(res))
154
+ if (handleApproval(res, opts.json))
155
155
  return;
156
+ if (opts.json)
157
+ return printJson({ ok: true, removed: { id, type, name, branch: branch ?? null } });
156
158
  info(`removed ${type} service ${name} from ${branch ?? 'default'}`);
157
159
  }
158
160
  export async function servicesRename(type, name, newName, opts = {}) {
@@ -164,7 +166,7 @@ export async function servicesRename(type, name, newName, opts = {}) {
164
166
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
165
167
  const id = resolveServiceId(services, type, name);
166
168
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/rename`, { name: newName });
167
- if (handleApproval(res))
169
+ if (handleApproval(res, opts.json))
168
170
  return;
169
171
  if (opts.json)
170
172
  return printJson(res.body.service);
@@ -187,7 +189,7 @@ export async function servicesSetAccess(type, name, access, _opts) {
187
189
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(p.branch)}`);
188
190
  const id = resolveServiceId(services, type, name);
189
191
  const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/access`, { public: isPublic });
190
- if (handleApproval(res))
192
+ if (handleApproval(res, _opts.json))
191
193
  return;
192
194
  if (_opts.json)
193
195
  return printJson(res.body.service);
@@ -202,7 +204,7 @@ export async function servicesScale(type, name, number, region, _opts) {
202
204
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(_opts.branch ?? p.branch)}`);
203
205
  const id = resolveServiceId(services, type, name);
204
206
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/scale`, { machineCount, region });
205
- if (handleApproval(res))
207
+ if (handleApproval(res, _opts.json))
206
208
  return;
207
209
  if (_opts.json)
208
210
  return printJson(res.body.service);
@@ -216,7 +218,7 @@ export async function servicesUpgrade(type, name, spec, _opts) {
216
218
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(_opts.branch ?? p.branch)}`);
217
219
  const id = resolveServiceId(services, type, name);
218
220
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/upgrade`, { spec });
219
- if (handleApproval(res))
221
+ if (handleApproval(res, _opts.json))
220
222
  return;
221
223
  if (_opts.json)
222
224
  return printJson(res.body.service);
@@ -230,7 +232,7 @@ export async function servicesSecrets(type, name, opts = {}) {
230
232
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(opts.branch ?? p.branch)}`);
231
233
  const id = resolveServiceId(services, type, name);
232
234
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/services/${id}/secrets`);
233
- if (handleApproval(res))
235
+ if (handleApproval(res, opts.json))
234
236
  return;
235
237
  const { secrets } = res.body;
236
238
  if (opts.json)
@@ -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
  }