insta 0.0.36 → 0.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,14 +26,33 @@ npm install -g insta
26
26
  ```
27
27
 
28
28
  For coding agents. Installs the CLI, the `insta` skill for every agent on the machine, and
29
- registers the MCP server:
29
+ registers the MCP server — one command for macOS, Linux, WSL, and native Windows shells
30
+ (PowerShell/cmd). Needs Node 18+ with a writable npm global prefix (a Node version manager
31
+ qualifies; if the global install can't write, setup continues and prints the exact
32
+ version-pinned `npm install -g` fallback to run yourself). E2e-validated on macOS, Linux,
33
+ and Windows (PowerShell + cmd):
30
34
 
31
35
  ```bash
32
- curl -fsSL agents.instacloud.com | sh
36
+ npx -y insta setup agent
37
+ ```
38
+
39
+ This command means **production** (CLI ≥ 0.0.38): if the machine was previously switched to
40
+ staging it switches back — announced, session dropped, like `insta env use prod`. Staging is
41
+ its own explicit command, which also persists the choice:
42
+
43
+ ```bash
44
+ npx -y insta setup agent --env staging
33
45
  ```
34
46
 
35
- On Windows, download `insta-windows-x64.exe` from the
36
- [releases page](https://github.com/InsForge/insta-cli/releases).
47
+ On macOS/Linux without Node, the native-binary installer puts the `insta` CLI on PATH (the
48
+ skill + MCP steps it then runs still need Node — the skills tool runs via npx). Never run it
49
+ on native Windows — PowerShell's `curl` alias and the WSL `bash` shim break it; use npx
50
+ above, or download `insta-windows-x64.exe` from the
51
+ [releases page](https://github.com/InsForge/insta-cli/releases):
52
+
53
+ ```bash
54
+ curl -fsSL agents.instacloud.com | sh
55
+ ```
37
56
 
38
57
  Pin a version with `INSTA_VERSION=v0.0.22`; change the install directory with
39
58
  `INSTA_INSTALL_DIR`. While the CLI is pre-1.0 it updates itself on new releases. Turn that
@@ -101,7 +120,8 @@ prints an approval id for an admin to grant with `insta approvals approve <id>`.
101
120
 
102
121
  `insta manifest` prints an agent-legible view of every branch and its URLs. `insta setup
103
122
  agent` installs the InstaCloud skill and registers the remote MCP server for the coding
104
- agents on the machine.
123
+ agents on the machine — and, when running from the npx cache with no durable `insta` on
124
+ PATH, first installs the CLI itself globally.
105
125
 
106
126
  ## Environments
107
127
 
@@ -167,7 +187,7 @@ build never reaches a production installer.
167
187
  |---|---|
168
188
  | `insta login` · `logout` · `status` | Email/password or `--oauth github\|google`; `status` shows the environment, login and linked project/branch |
169
189
  | `insta env` | `show` · `use <prod\|staging>` |
170
- | `insta setup` | `agent` — install the skill and register MCP for every coding agent |
190
+ | `insta setup` | `agent` — install the CLI (if missing), the skill, and MCP for every coding agent; targets prod, `--env staging` for staging |
171
191
  | `insta mcp` | `install` — register the remote MCP server only |
172
192
  | `insta org` | `list` · `create` (one free org per user) |
173
193
  | `insta project` | `create` · `list` · `link` · `delete` |
package/dist/api.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // 2xx (including 202 approval_required) returns the parsed body; >=400 throws ApiError.
3
3
  import { readGlobal, writeGlobal, readProject, writeProject } from './config.js';
4
4
  import { autoResolveProject, promptChoice } from './resolve-project.js';
5
- import { die, info } from './util.js';
5
+ import { die } from './util.js';
6
6
  export class ApiError extends Error {
7
7
  status;
8
8
  constructor(status, msg) {
@@ -117,7 +117,9 @@ export async function requireProject() {
117
117
  promptChoice,
118
118
  save: async (c) => {
119
119
  await writeProject(c);
120
- info(`auto-linked project ${c.projectId} → ./.insta/project.json`);
120
+ // stderr: this is a diagnostic that can precede ANY command's output — under --json,
121
+ // stdout must stay one parseable document.
122
+ process.stderr.write(`auto-linked project ${c.projectId} → ./.insta/project.json\n`);
121
123
  },
122
124
  tty: !!process.stdin.isTTY && !!process.stderr.isTTY,
123
125
  });
@@ -5,6 +5,8 @@ export async function branchCreate(name, opts) {
5
5
  const api = await ApiClient.load();
6
6
  const p = await requireProject();
7
7
  const out = await api.request('POST', `/projects/${p.projectId}/branches`, { name, from: opts.from ?? p.branch });
8
+ if (opts.json)
9
+ return printJson(out);
8
10
  info(`created branch ${out.branch.name} (${out.branch.id})`);
9
11
  renderNextActions(out.nextActions);
10
12
  }
@@ -17,16 +19,18 @@ export async function branchList(opts) {
17
19
  for (const b of branches)
18
20
  info(`${b.is_default ? '*' : ' '} ${b.name} [${b.status}] ${b.id}`);
19
21
  }
20
- export async function branchSwitch(name) {
22
+ export async function branchSwitch(name, opts = {}) {
21
23
  const api = await ApiClient.load();
22
24
  const p = await requireProject();
23
25
  const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
24
26
  if (!branches.some((b) => b.name === name))
25
27
  die(`branch not found: ${name}`);
26
28
  await writeProject({ ...p, branch: name });
29
+ if (opts.json)
30
+ return printJson({ projectId: p.projectId, branch: name });
27
31
  info(`switched to branch ${name} — run \`insta secrets\` to refresh .env`);
28
32
  }
29
- export async function branchDelete(name) {
33
+ export async function branchDelete(name, opts = {}) {
30
34
  const api = await ApiClient.load();
31
35
  const p = await requireProject();
32
36
  const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
@@ -34,8 +38,10 @@ export async function branchDelete(name) {
34
38
  if (!b)
35
39
  die(`branch not found: ${name}`);
36
40
  const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/branches/${b.id}`);
37
- if (handleApproval(res))
41
+ if (handleApproval(res, opts.json))
38
42
  return;
43
+ if (opts.json)
44
+ return printJson({ ok: true, branch: { id: b.id, name: b.name } });
39
45
  info(`deleted branch ${name}`);
40
46
  }
41
47
  // insta branch merge <source> [--into <target>] — structurally merge source's services into target
@@ -47,8 +53,10 @@ export async function branchMerge(source, opts = {}) {
47
53
  if (!target)
48
54
  throw new Error('no target branch — pass --into <branch> (or link a branch first)');
49
55
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/branches/${encodeURIComponent(target)}/merge`, { from: source });
50
- if (handleApproval(res))
56
+ if (handleApproval(res, opts.json))
51
57
  return;
58
+ if (opts.json)
59
+ return printJson(res.body ?? {});
52
60
  const { created = [], skipped = [] } = (res.body ?? {});
53
61
  info(`merged ${source} → ${target}: ${created.length} created, ${skipped.length} skipped`);
54
62
  for (const c of created)
@@ -7,7 +7,7 @@ export async function setDomain(host, opts) {
7
7
  const api = await ApiClient.load();
8
8
  const p = await requireProject();
9
9
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group });
10
- if (handleApproval(res))
10
+ if (handleApproval(res, opts.json))
11
11
  return;
12
12
  printDomain(res.body, opts.json);
13
13
  }
@@ -26,9 +26,16 @@ export async function removeDomain(host, opts) {
26
26
  const api = await ApiClient.load();
27
27
  const p = await requireProject();
28
28
  const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group });
29
- if (handleApproval(res))
29
+ if (handleApproval(res, opts.json))
30
30
  return;
31
- info(`removed custom domain ${res.body.hostname} from ${res.body.flyApp}`);
31
+ renderRemoveDomain(res.body, opts.json);
32
+ }
33
+ // Split out (same pattern as applyExecResult) so the --json contract — stdout carries the platform
34
+ // response, never prose — is unit-testable without a network mock.
35
+ export function renderRemoveDomain(body, json) {
36
+ if (json)
37
+ return printJson(body);
38
+ info(`removed custom domain ${body.hostname} from ${body.flyApp}`);
32
39
  }
33
40
  function printDomain(r, json) {
34
41
  if (json)
@@ -50,7 +57,7 @@ async function lifecycle(verb, serviceName, opts) {
50
57
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
51
58
  const id = resolveComputeServiceId(services, serviceName);
52
59
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/${verb}`);
53
- if (handleApproval(res))
60
+ if (handleApproval(res, opts.json))
54
61
  return;
55
62
  if (opts.json)
56
63
  return printJson(res.body);
@@ -107,19 +114,13 @@ export function execRequestBody(command, timeoutSec) {
107
114
  // of (res, json) so it's unit-testable without a network mock, same as handleApproval's own
108
115
  // {status, body} shape.
109
116
  //
110
- // A 202 means the command has NOT run: unlike every other gated command (where "nothing happened"
111
- // is the safe default), a caller chaining `insta compute exec … && next` must not see exit 0 here,
112
- // or `next` runs believing the command succeeded. --json prints the raw envelope (so a scripted
113
- // caller can inspect approvalId/action) instead of the human hint; either way exit 1.
117
+ // A 202 means the command has NOT run: handleApproval owns the whole contract (hint on stderr,
118
+ // raw envelope on stdout with --json, exit 2), so a caller chaining `insta compute exec … && next`
119
+ // can never mistake a pending gate for the command having succeeded — and exit 2 stays
120
+ // distinguishable from the remote command's own exit 1.
114
121
  export function applyExecResult(res, json) {
115
- if (res.status === 202 && res.body?.status === 'approval_required') {
116
- if (json)
117
- printJson(res.body);
118
- else
119
- handleApproval(res);
120
- process.exitCode = 1;
122
+ if (handleApproval(res, json))
121
123
  return;
122
- }
123
124
  const { exitCode, stdout, stderr, truncated } = res.body;
124
125
  if (json) {
125
126
  printJson(res.body);
@@ -170,7 +171,7 @@ export async function computeAlwaysOn(mode, serviceName, opts) {
170
171
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
171
172
  const id = resolveComputeServiceId(services, serviceName);
172
173
  const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/always-on`, { enabled: mode === 'on' });
173
- if (handleApproval(res))
174
+ if (handleApproval(res, opts.json))
174
175
  return;
175
176
  if (opts.json)
176
177
  return printJson(res.body);
@@ -271,7 +272,7 @@ export async function computeVolume(serviceName, opts) {
271
272
  catch (e) {
272
273
  throw volumeDeleteError(e);
273
274
  }
274
- if (handleApproval(res))
275
+ if (handleApproval(res, opts.json))
275
276
  return;
276
277
  if (opts.json)
277
278
  return printJson(res.body);
@@ -288,7 +289,7 @@ export async function computeVolume(serviceName, opts) {
288
289
  }
289
290
  const sizeGib = parseVolumeGib(opts.size);
290
291
  const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/volume`, { sizeGib });
291
- if (handleApproval(res))
292
+ if (handleApproval(res, opts.json))
292
293
  return;
293
294
  if (opts.json)
294
295
  return printJson(res.body);
@@ -317,7 +318,7 @@ export async function computeLimits(serviceName, opts) {
317
318
  if (opts.cpu)
318
319
  body.cpu = parseCpu(opts.cpu);
319
320
  const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/limits`, body);
320
- if (handleApproval(res))
321
+ if (handleApproval(res, opts.json))
321
322
  return;
322
323
  if (opts.json)
323
324
  return printJson(res.body);
@@ -19,7 +19,7 @@ export async function dbAlwaysOn(mode, opts) {
19
19
  if (opts.group)
20
20
  qs.set('group', opts.group);
21
21
  const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${qs.toString() ? `?${qs}` : ''}`, { scaleToZero: mode !== 'on' });
22
- if (handleApproval(res))
22
+ if (handleApproval(res, opts.json))
23
23
  return;
24
24
  if (opts.json)
25
25
  return printJson(res.body);
@@ -111,7 +111,7 @@ export async function dbLimits(opts) {
111
111
  throw new Error(`setting the ceiling failed (${e.status}): ${e.message}`);
112
112
  throw e;
113
113
  }
114
- if (handleApproval(res))
114
+ if (handleApproval(res, opts.json))
115
115
  return;
116
116
  if (opts.json)
117
117
  return printJson(res.body);
@@ -230,7 +230,7 @@ export async function dbVolume(opts) {
230
230
  throw new Error(`growing the volume failed (${e.status}): ${e.message}`);
231
231
  throw e;
232
232
  }
233
- if (handleApproval(res))
233
+ if (handleApproval(res, opts.json))
234
234
  return;
235
235
  if (opts.json)
236
236
  return printJson(res.body);
@@ -1,8 +1,11 @@
1
1
  import { resolve, join } from 'node:path';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { ApiClient, ApiError, requireProject } from '../api.js';
4
- import { info, die, handleApproval, renderNextActions } from '../util.js';
5
- import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner } from '../flyctl-build.js';
4
+ import { info, die, printJson, handleApproval, renderNextActions } from '../util.js';
5
+ import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, stderrBuildRunner } from '../flyctl-build.js';
6
+ // With --json, stdout must carry exactly one JSON document (the deploy result), so every progress
7
+ // line moves to stderr.
8
+ const note = (opts) => (opts.json ? (m) => void process.stderr.write(m + '\n') : info);
6
9
  // Map CLI options to the platform deploy request body. Pure, so it's unit-tested. --websocket is only
7
10
  // sent when set (plain deploys unchanged).
8
11
  export function deployRequestBody(image, branch, opts) {
@@ -36,20 +39,23 @@ export async function deploy(dir, opts) {
36
39
  const api = await ApiClient.load();
37
40
  const p = await requireProject();
38
41
  const branch = opts.branch ?? p.branch;
42
+ const log = note(opts);
39
43
  let port = opts.port ? Number(opts.port) : undefined;
40
44
  if (dir && port === undefined) {
41
45
  const dockerfile = join(resolve(process.cwd(), dir), 'Dockerfile');
42
46
  const exposed = existsSync(dockerfile) ? dockerfileExposedPort(readFileSync(dockerfile, 'utf8')) : undefined;
43
47
  if (exposed) {
44
48
  port = exposed;
45
- info(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`);
49
+ log(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`);
46
50
  }
47
51
  }
48
52
  const effOpts = { ...opts, port: port?.toString() };
49
53
  const image = dir ? await buildFromSource(api, p.projectId, dir, branch, effOpts) : opts.image;
50
54
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts));
51
- if (handleApproval(res))
55
+ if (handleApproval(res, opts.json))
52
56
  return;
57
+ if (opts.json)
58
+ return printJson({ image, ...res.body });
53
59
  info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
54
60
  renderNextActions(res.body.nextActions);
55
61
  }
@@ -71,10 +77,11 @@ export async function dockerBuildLocal(absDir, tag, run = defaultBuildRunner) {
71
77
  // Dockerfile) with flyctl's remote builder, returning the pushed image ref to deploy. Against a
72
78
  // local daemon (insta-oss) the token mint answers 501 — build with docker instead, same contract.
73
79
  // Exported with injectable pieces for tests (the repo's DI pattern; no global mocks).
74
- export async function buildFromSource(api, projectId, dir, branch, opts, run = defaultBuildRunner) {
80
+ export async function buildFromSource(api, projectId, dir, branch, opts, run = opts.json ? stderrBuildRunner : defaultBuildRunner) {
75
81
  const absDir = resolve(process.cwd(), dir);
76
82
  if (!existsSync(join(absDir, 'Dockerfile')))
77
83
  die(`no Dockerfile at ${join(absDir, 'Dockerfile')} — add one, or use --image <url>`);
84
+ const log = note(opts);
78
85
  let tok;
79
86
  try {
80
87
  tok = await api.rawRequest('POST', `/projects/${projectId}/deploy-token`, { branch, group: opts.group });
@@ -85,19 +92,20 @@ export async function buildFromSource(api, projectId, dir, branch, opts, run = d
85
92
  if (!(e instanceof ApiError) || e.status !== 501)
86
93
  throw e;
87
94
  const tag = localImageTag(projectId, opts.group);
88
- info(`no remote builder on this daemon — building ${dir} locally with docker…`);
95
+ log(`no remote builder on this daemon — building ${dir} locally with docker…`);
89
96
  const built = await dockerBuildLocal(absDir, tag, run);
90
- info(` built ${built}`);
97
+ log(` built ${built}`);
91
98
  return built;
92
99
  }
93
- if (handleApproval(tok))
94
- die('deploy requires approval — get it approved, then re-run');
100
+ // exit() with no argument honors the exit code handleApproval just set (2).
101
+ if (handleApproval(tok, opts.json))
102
+ process.exit();
95
103
  const { token, flyApp } = tok.body;
96
104
  await ensureFlyctl(); // cloud path only — the local path needs docker, which the daemon requires anyway
97
105
  const port = opts.port ? Number(opts.port) : 8080;
98
- info(`building ${dir} for ${flyApp} (remote builder)…`);
106
+ log(`building ${dir} for ${flyApp} (remote builder)…`);
99
107
  const { imageRef } = await flyctlBuildAndPush({ dir: absDir, flyApp, imageLabel: `insta-${Date.now()}`, token, port }, run);
100
- info(` pushed ${imageRef}`);
108
+ log(` pushed ${imageRef}`);
101
109
  return imageRef;
102
110
  }
103
111
  //# sourceMappingURL=deploy.js.map
@@ -20,7 +20,20 @@ export async function envShow(opts) {
20
20
  if (!env)
21
21
  info(' (custom apiUrl — `insta env use <name>` to switch to a named environment)');
22
22
  }
23
- export async function envUse(name) {
23
+ // One stable schema for BOTH envUse outcomes (no-op and real switch), so a scripted caller can key
24
+ // on any field — mcpServer, previous — without probing which branch ran. Pure, unit-tested.
25
+ export function envUseResult(target, previous, changed, sessionDropped) {
26
+ return {
27
+ env: target,
28
+ previous,
29
+ apiUrl: ENVS[target].api,
30
+ mcpUrl: ENVS[target].mcp,
31
+ mcpServer: mcpServerName(target),
32
+ changed,
33
+ sessionDropped,
34
+ };
35
+ }
36
+ export async function envUse(name, opts = {}) {
24
37
  const want = name.trim().toLowerCase();
25
38
  if (!isEnvName(want))
26
39
  die(`unknown environment "${name}" — expected one of: ${ENV_NAMES.join(', ')}`);
@@ -33,6 +46,8 @@ export async function envUse(name) {
33
46
  // how envForApiUrl already treats it) instead of being rewritten as a "switch" that needlessly
34
47
  // drops a perfectly good session.
35
48
  if (normalizeUrl(stored.apiUrl) === normalizeUrl(nextApi)) {
49
+ if (opts.json)
50
+ return printJson(envUseResult(target, from ?? target, false, false));
36
51
  info(`already on ${target} (${nextApi})`);
37
52
  return;
38
53
  }
@@ -49,6 +64,8 @@ export async function envUse(name) {
49
64
  delete next.refreshToken;
50
65
  delete next.user;
51
66
  await writeGlobal(next);
67
+ if (opts.json)
68
+ return printJson(envUseResult(target, from ?? null, true, hadSession));
52
69
  info(`switched ${from ?? '(custom)'} → ${target}`);
53
70
  info(` api: ${nextApi}`);
54
71
  info(` mcp: ${ENVS[target].mcp} (registers as \`${mcpServerName(target)}\`)`);
@@ -56,8 +73,8 @@ export async function envUse(name) {
56
73
  info(' previous session dropped (separate deployment) — run `insta login --oauth github`');
57
74
  // Switching the CLI does NOT re-point already-installed agents: their MCP registration and skill
58
75
  // files were written for the previous environment and are keyed by a different server name, so
59
- // they keep talking to it until setup is re-run. (The installer path is fine — install.sh runs
60
- // `env use` before `setup agent`.)
61
- info(' re-point this machine\'s agents at it with: insta setup agent');
76
+ // they keep talking to it until setup is re-run. --env is REQUIRED in the hint: since 0.0.38 a
77
+ // bare `setup agent` forces prod, which would silently undo the switch the user just made.
78
+ info(` re-point this machine's agents at it with: insta setup agent --env ${target}`);
62
79
  }
63
80
  //# sourceMappingURL=env.js.map
@@ -37,7 +37,10 @@ export const LIMITS = {
37
37
  const FEEDBACK_ENDPOINT = process.env.INSTA_FEEDBACK_URL ||
38
38
  'https://feedback.instacloud.com/v1/feedback';
39
39
  const FEEDBACK_INGEST_TOKEN = process.env.INSTA_FEEDBACK_TOKEN || 'insta-feedback-public-v1';
40
- const FEEDBACK_TIMEOUT_MS = 10_000;
40
+ // 15s gives the backend's scale-to-zero cold start room to answer (the ingest service waits out
41
+ // the DB wake and persists, so a report can land after the old 10s deadline gave up on it).
42
+ // An expired deadline is reported as UNCONFIRMED, not failed — the report may well be stored.
43
+ const FEEDBACK_TIMEOUT_MS = 15_000;
41
44
  const MAX_FILE_BYTES = 256 * 1024;
42
45
  function resolveCliVersion() {
43
46
  // Same resolution as index.ts: the standalone binary bakes INSTA_CLI_VERSION via --define;
@@ -178,8 +181,10 @@ export async function submit(payload, fetchImpl) {
178
181
  });
179
182
  }
180
183
  catch (e) {
181
- const timedOut = e instanceof Error && e.name === 'TimeoutError';
182
- return { status: 'error', error: timedOut ? `timed out after ${FEEDBACK_TIMEOUT_MS / 1000}s` : `network error: ${e instanceof Error ? e.message : String(e)}` };
184
+ if (e instanceof Error && e.name === 'TimeoutError') {
185
+ return { status: 'unconfirmed', error: `no response after ${FEEDBACK_TIMEOUT_MS / 1000}s — the report may have been recorded anyway` };
186
+ }
187
+ return { status: 'error', error: `network error: ${e instanceof Error ? e.message : String(e)}` };
183
188
  }
184
189
  let body = {};
185
190
  try {
@@ -211,6 +216,14 @@ export async function feedback(opts, deps = {}) {
211
216
  return;
212
217
  }
213
218
  const result = await submit(payload, deps.fetchImpl ?? fetch);
219
+ if (result.status === 'unconfirmed') {
220
+ // NOT a failure claim: the request was still in flight at the deadline and the server
221
+ // finishes what it started, so saying "not submitted" here would be a false negative.
222
+ if (opts.json)
223
+ return printJson({ status: 'unconfirmed', error: result.error });
224
+ process.stderr.write(`warning: feedback receipt unconfirmed (${result.error}) — continue with your task, do not retry\n`);
225
+ return;
226
+ }
214
227
  if (result.status === 'error') {
215
228
  // Deliberate exit 0: an agent CANNOT fix a down/rate-limited backend, and feedback must never
216
229
  // fail or distract from the task the user actually asked for. Do not retry.
@@ -29,12 +29,16 @@ export async function approvalsApprove(id, opts) {
29
29
  const api = await ApiClient.load();
30
30
  const p = await requireProject();
31
31
  const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/approve`, { always: !!opts.always });
32
+ if (opts.json)
33
+ return printJson(out);
32
34
  info(`approved ${out.approval.action} (${id})${opts.always ? ' — policy set to allow' : ''}`);
33
35
  }
34
- export async function approvalsDeny(id) {
36
+ export async function approvalsDeny(id, opts = {}) {
35
37
  const api = await ApiClient.load();
36
38
  const p = await requireProject();
37
39
  const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/deny`);
40
+ if (opts.json)
41
+ return printJson(out);
38
42
  info(`denied ${out.approval.action} (${id})`);
39
43
  }
40
44
  export async function policyGet(opts) {
@@ -46,10 +50,12 @@ export async function policyGet(opts) {
46
50
  for (const [action, decision] of Object.entries(policy))
47
51
  info(`${action}: ${decision}`);
48
52
  }
49
- export async function policySet(action, decision) {
53
+ export async function policySet(action, decision, opts = {}) {
50
54
  const api = await ApiClient.load();
51
55
  const p = await requireProject();
52
- await api.request('PUT', `/projects/${p.projectId}/policy/${action}`, { decision });
56
+ const out = await api.request('PUT', `/projects/${p.projectId}/policy/${action}`, { decision });
57
+ if (opts.json)
58
+ return printJson({ action, decision, ...(out ?? {}) });
53
59
  info(`policy ${action} = ${decision}`);
54
60
  }
55
61
  //# sourceMappingURL=govern.js.map
@@ -140,8 +140,42 @@ export function deployEventLine(ev) {
140
140
  const inst = ev.instance ? ` (${ev.instance})` : '';
141
141
  return `${ev.ts ?? ''} [${ev.origin ?? ''}] ${ev.type ?? ''}: ${ev.status ?? ''}${inst}`;
142
142
  }
143
+ // A log-window instant from the CLI: unix seconds (all digits) or anything Date.parse reads
144
+ // (ISO-8601 etc.). Throws on junk — a mistyped instant must fail here, not become NaN on the wire.
145
+ export function parseLogInstant(raw, flag) {
146
+ if (/^\d+$/.test(raw))
147
+ return Number(raw);
148
+ const ms = Date.parse(raw);
149
+ if (Number.isNaN(ms))
150
+ throw new Error(`invalid ${flag}: ${raw} (unix seconds or an ISO-8601 date)`);
151
+ return Math.floor(ms / 1000);
152
+ }
153
+ // '90s' | '30m' | '2h' | '1d' → seconds. Throws on junk, zero, and unknown units.
154
+ export function parseSinceSeconds(raw) {
155
+ const m = /^(\d+)([smhd])$/.exec(raw.trim());
156
+ if (!m)
157
+ throw new Error(`invalid --since: ${raw} (e.g. 90s, 30m, 2h, 1d)`);
158
+ const n = Number(m[1]);
159
+ if (n === 0)
160
+ throw new Error(`invalid --since: ${raw} (must be > 0)`);
161
+ return n * { s: 1, m: 60, h: 3600, d: 86400 }[m[2]];
162
+ }
163
+ // The from/to pair for the logs request, from whichever window flags were given. Pure, unit-tested.
164
+ export function resolveLogWindow(opts, now = Math.floor(Date.now() / 1000)) {
165
+ if (opts.since && opts.from)
166
+ throw new Error('pass --since or --from, not both');
167
+ const from = opts.since ? now - parseSinceSeconds(opts.since) : opts.from !== undefined ? parseLogInstant(opts.from, '--from') : undefined;
168
+ const to = opts.to !== undefined ? parseLogInstant(opts.to, '--to') : undefined;
169
+ if (from !== undefined && to !== undefined && to < from)
170
+ throw new Error('--to is before --from');
171
+ return { from, to };
172
+ }
143
173
  // insta logs <db|compute|redis|mysql|mongodb> [group]
144
174
  export async function logs(component, group, opts) {
175
+ const windowFlags = opts.from !== undefined || opts.to !== undefined || opts.since !== undefined;
176
+ if (opts.deploy && windowFlags)
177
+ throw new Error('--from/--to/--since apply to runtime logs, not --deploy events');
178
+ const { from, to } = resolveLogWindow(opts);
145
179
  const api = await ApiClient.load();
146
180
  const p = await requireProject();
147
181
  if (opts.deploy) {
@@ -159,7 +193,7 @@ export async function logs(component, group, opts) {
159
193
  info(deployEventLine(ev));
160
194
  return;
161
195
  }
162
- const res = await api.request('GET', `/projects/${p.projectId}/logs${qs({ component, group, branch: opts.branch ?? p.branch, limit: opts.limit, region: opts.region, instance: opts.instance })}`);
196
+ const res = await api.request('GET', `/projects/${p.projectId}/logs${qs({ component, group, branch: opts.branch ?? p.branch, limit: opts.limit, region: opts.region, instance: opts.instance, from: from !== undefined ? String(from) : undefined, to: to !== undefined ? String(to) : undefined })}`);
163
197
  if (opts.json)
164
198
  return printJson(res);
165
199
  if (res.note)
@@ -8,9 +8,11 @@ export async function orgList(opts) {
8
8
  for (const o of orgs)
9
9
  info(`${o.id} ${o.name}${o.is_personal ? ' (personal)' : ''} [${o.role}]`);
10
10
  }
11
- export async function orgCreate(name) {
11
+ export async function orgCreate(name, opts = {}) {
12
12
  const api = await ApiClient.load();
13
13
  const { org } = await api.request('POST', '/orgs', { name });
14
+ if (opts.json)
15
+ return printJson(org);
14
16
  info(`created org ${org.id} (${org.name})`);
15
17
  }
16
18
  //# sourceMappingURL=org.js.map
@@ -13,14 +13,19 @@ const GENERIC_DIRS = new Set([
13
13
  'app', 'apps', 'users', 'user', 'bin', 'new', 'test', 'tests',
14
14
  ]);
15
15
  // Best-effort: wire the credential-audit hook into the project (no-op if assets aren't built).
16
- function tryInstallObserve() {
16
+ // quiet: with --json the install still runs, but its note moves to stderr (stdout is JSON-only).
17
+ function tryInstallObserve(quiet = false) {
17
18
  try {
18
19
  const r = installObserve({ cwd: process.cwd() });
19
- if (r.claude || r.codex)
20
- info(' installed observe hook (credential audit) → ./.insta/observe');
20
+ if (r.claude || r.codex) {
21
+ const line = ' installed observe hook (credential audit) → ./.insta/observe';
22
+ quiet ? process.stderr.write(line + '\n') : info(line);
23
+ }
21
24
  }
22
25
  catch { /* assets missing (dev/unbuilt) — skip silently */ }
23
26
  }
27
+ // installSkills prints to stdout by default; with --json its notes go to stderr instead.
28
+ const skillsPrint = (json) => (json ? (s) => void process.stderr.write(s + '\n') : undefined);
24
29
  async function resolveOrg(api, given) {
25
30
  if (given)
26
31
  return given;
@@ -51,7 +56,10 @@ export async function projectCreate(name, opts) {
51
56
  const resolved = resolveProjectName(name, process.cwd());
52
57
  if (!resolved) {
53
58
  // No name given and the cwd name is generic — don't provision resources under a junk name.
54
- // Guide instead (no hang, no error): name it explicitly, or just ask the skill-equipped agent.
59
+ // A terminal gets guidance (no hang, no error); --json is a scripted caller with no human to
60
+ // guide, so it gets a hard error instead of an empty success.
61
+ if (opts.json)
62
+ die('no project name — pass one: insta project create <name>');
55
63
  info('name your project: insta project create <name>');
56
64
  info(' (or just ask your coding agent — it has the insta skill and will do this for you)');
57
65
  return;
@@ -60,12 +68,17 @@ export async function projectCreate(name, opts) {
60
68
  const orgId = await resolveOrg(api, opts.org);
61
69
  const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved });
62
70
  await writeProject({ projectId: out.project.id, orgId, branch: out.defaultBranch.name });
63
- info(`created project ${out.project.id} (${resolved})`);
64
- info(` resources: ${out.resources.map((r) => r.kind).join(', ')}`);
65
- info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`);
66
- renderNextActions(out.nextActions);
67
- tryInstallObserve();
68
- await installSkills({ cwd: process.cwd() });
71
+ if (opts.json) {
72
+ printJson({ ...out, linked: { projectId: out.project.id, orgId, branch: out.defaultBranch.name } });
73
+ }
74
+ else {
75
+ info(`created project ${out.project.id} (${resolved})`);
76
+ info(` resources: ${out.resources.map((r) => r.kind).join(', ')}`);
77
+ info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`);
78
+ renderNextActions(out.nextActions);
79
+ }
80
+ tryInstallObserve(opts.json);
81
+ await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) });
69
82
  }
70
83
  export async function projectList(opts) {
71
84
  const api = await ApiClient.load();
@@ -78,20 +91,25 @@ export async function projectList(opts) {
78
91
  for (const p of projects)
79
92
  info(`${p.id} ${p.name} [${p.status}]`);
80
93
  }
81
- export async function projectLink(id) {
94
+ export async function projectLink(id, opts = {}) {
82
95
  const api = await ApiClient.load();
83
96
  const { project } = await api.request('GET', `/projects/${id}`);
84
97
  await writeProject({ projectId: project.id, orgId: project.org_id, branch: 'main' });
85
- info(`linked project ${project.id} (${project.name})`);
86
- tryInstallObserve();
87
- await installSkills({ cwd: process.cwd() });
98
+ if (opts.json)
99
+ printJson({ project, linked: { projectId: project.id, orgId: project.org_id, branch: 'main' } });
100
+ else
101
+ info(`linked project ${project.id} (${project.name})`);
102
+ tryInstallObserve(opts.json);
103
+ await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) });
88
104
  }
89
105
  export async function projectDelete(opts) {
90
106
  const api = await ApiClient.load();
91
107
  const projectId = opts.project ?? (await requireProject()).projectId;
92
108
  const res = await api.rawRequest('DELETE', `/projects/${projectId}`);
93
- if (handleApproval(res))
109
+ if (handleApproval(res, opts.json))
94
110
  return;
111
+ if (opts.json)
112
+ return printJson({ ok: true, projectId });
95
113
  info(`deleted project ${projectId}`);
96
114
  }
97
115
  //# sourceMappingURL=project.js.map
@@ -4,7 +4,7 @@
4
4
  // as the process does.
5
5
  import { spawn } from 'node:child_process';
6
6
  import { ApiClient, requireProject } from '../api.js';
7
- import { die, info } from '../util.js';
7
+ import { die, handleApproval } from '../util.js';
8
8
  /** Core, dependency-injected for tests: spawn cmd with the bundle in env, return its exit code. */
9
9
  export async function runWithSecrets(cmd, args, deps) {
10
10
  const bundle = await deps.fetchBundle();
@@ -28,10 +28,12 @@ export async function run(cmdAndArgs, opts) {
28
28
  const code = await runWithSecrets(cmd, rest, {
29
29
  fetchBundle: async () => {
30
30
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets?branch=${encodeURIComponent(branch)}`);
31
- if (res.status === 202) {
32
- die(`secrets.read requires approval — run: insta approvals approve ${res.body.approvalId}, then re-run`);
33
- }
34
- info(`running with ${Object.keys(res.body.secrets).length} injected secrets (branch ${branch}) — nothing written to disk`);
31
+ // exit() with no argument honors the exit code handleApproval just set (2).
32
+ if (handleApproval(res))
33
+ process.exit();
34
+ // stderr, not stdout: `insta run`'s stdout belongs entirely to the child command (that's why
35
+ // run has no --json — wrapping would break the child's own output contract).
36
+ process.stderr.write(`running with ${Object.keys(res.body.secrets).length} injected secrets (branch ${branch}) — nothing written to disk\n`);
35
37
  return res.body.secrets;
36
38
  },
37
39
  });