insta 0.0.64 → 0.0.66

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
@@ -119,6 +119,14 @@ only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`,
119
119
  `insta secrets bind` rules, and the postgres connection string is read directly with
120
120
  `insta db url` (or `insta db connect` for a psql session).
121
121
 
122
+ Secrets can be scoped per compute service, so several services may each define the same name — and
123
+ a flat bundle cannot carry two values for one name. Such a name is **withheld** from the bundle and
124
+ reported on stderr (which services define it, and how to read one). `insta run` then **refuses to
125
+ start the command** rather than let it inherit a stale value for that name from your shell; re-run
126
+ it as `insta run --service compute/<name>` to inject exactly what that one service receives, or
127
+ `--ignore-collisions` to run with the name removed from the child environment altogether.
128
+ `insta secrets --service compute/<name>` reads the same scoped env into `.env`.
129
+
122
130
  ### Destructive actions can require approval
123
131
 
124
132
  Agent requests are governed by the project's `agent-policy`; human requests use normal RBAC.
package/dist/agent.js CHANGED
@@ -61,7 +61,13 @@ export async function loadAgentSession(apiUrl, projectId, cwd = process.cwd()) {
61
61
  throw new Error(guidance);
62
62
  }
63
63
  }
64
- export async function agentHeaders(api, method, path, rawBody) {
64
+ // Routes the CLI may call on an account-level (bootstrap) session, by first path segment. Every
65
+ // other route is project-owned: either its path names the project or the caller passes
66
+ // scope.projectId. A miss fails HERE, naming the route, instead of on the platform as a
67
+ // "for a different project" 403 whose setup hint cannot help — keep this list in step with the
68
+ // account-level paths in src/commands/.
69
+ export const ACCOUNT_ROUTES = new Set(['agent', 'auth', 'me', 'orgs', 'regions', 'templates', 'tokens', 'github']);
70
+ export async function agentHeaders(api, method, path, rawBody, scope = {}) {
65
71
  if (!mode)
66
72
  return {};
67
73
  if (canonicalTarget(path) === '/agent/sessions' && method === 'POST')
@@ -70,9 +76,13 @@ export async function agentHeaders(api, method, path, rawBody) {
70
76
  };
71
77
  const target = canonicalTarget(path);
72
78
  const match = target.match(/^\/projects\/([^/?]+)/);
79
+ const projectId = scope.projectId ?? (match ? decodeURIComponent(match[1]) : undefined);
80
+ if (!projectId && !ACCOUNT_ROUTES.has(target.split('/')[1]?.split('?')[0] ?? '')) {
81
+ throw new Error(`${method.toUpperCase()} ${target} is a project route but no project was given — this is an insta CLI bug; please report it with \`insta feedback\``);
82
+ }
73
83
  // Account reads/project creation have no project policy yet. Mint a short-lived bootstrap
74
84
  // assertion in memory. It cannot access project routes; never downgrade to a human request.
75
- const session = match ? await loadAgentSession(api.apiUrl, decodeURIComponent(match[1])) : await issueAgentSession(api);
85
+ const session = projectId ? await loadAgentSession(api.apiUrl, projectId) : await issueAgentSession(api);
76
86
  const timestamp = String(Math.floor(Date.now() / 1000));
77
87
  const nonce = randomUUID();
78
88
  const proof = [method.toUpperCase(), target, hash(rawBody), session.agentSessionId, timestamp, nonce, mode.source, session.client].join('\n');
package/dist/api.js CHANGED
@@ -60,7 +60,7 @@ export class ApiClient {
60
60
  }
61
61
  // Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
62
62
  async request(method, path, body, opts = {}) {
63
- const res = await this.raw(method, path, body, opts.auth ?? true);
63
+ const res = await this.raw(method, path, body, opts.auth ?? true, opts);
64
64
  if (agentMode() && res.status === 202 && res.body?.status === 'approval_required')
65
65
  throw new AgentApprovalRequired(res.body);
66
66
  if (res.status >= 400)
@@ -69,25 +69,25 @@ export class ApiClient {
69
69
  }
70
70
  // Like request but returns {status, body} so callers can branch on 202 (approval_required).
71
71
  async rawRequest(method, path, body, opts = {}) {
72
- const res = await this.raw(method, path, body, opts.auth ?? true);
72
+ const res = await this.raw(method, path, body, opts.auth ?? true, opts);
73
73
  if (res.status >= 400)
74
74
  throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body);
75
75
  return res;
76
76
  }
77
- async raw(method, path, body, auth) {
78
- let r = await this.fetch(method, path, body, auth);
77
+ async raw(method, path, body, auth, scope = {}) {
78
+ let r = await this.fetch(method, path, body, auth, scope);
79
79
  if (r.status === 401 && auth && this.cfg.refreshToken) {
80
80
  if (await this.refresh())
81
- r = await this.fetch(method, path, body, auth);
81
+ r = await this.fetch(method, path, body, auth, scope);
82
82
  }
83
83
  return r;
84
84
  }
85
- async fetch(method, path, body, auth) {
85
+ async fetch(method, path, body, auth, scope = {}) {
86
86
  const headers = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT };
87
87
  if (auth && this.cfg.accessToken)
88
88
  headers.Authorization = `Bearer ${this.cfg.accessToken}`;
89
89
  if (auth)
90
- Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body)));
90
+ Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body), scope));
91
91
  const res = await this.fetchImpl(this.apiUrl + path, {
92
92
  method,
93
93
  headers,
@@ -116,7 +116,8 @@ export async function applyApiKeyLogin(client, key) {
116
116
  const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
117
117
  // Drives the device grant against the platform's Better Auth mount (/api/auth/device*) and
118
118
  // returns the approved session token. Injectable poster + wait keep this testable without a
119
- // network or real timers. Poll errors arrive as ApiError with the OAuth error code as message.
119
+ // network or real timers. Poll errors arrive as ApiError with the OAuth error code as message
120
+ // (or a bare `HTTP 429` from the platform's rate limiter, treated as slow_down).
120
121
  // `open` (the default browser-login path) launches the verification link locally on top of
121
122
  // printing it; without it (--device) the link is print-only, for a browser on another machine.
122
123
  export async function deviceGrant(post, wait = sleepSeconds, open) {
@@ -169,6 +170,14 @@ export async function deviceGrant(post, wait = sleepSeconds, open) {
169
170
  interval += 5;
170
171
  continue;
171
172
  } // RFC 8628 §3.5: back off by 5s
173
+ // The platform's per-IP limiter answers a bare HTTP 429 (no OAuth error code) when a poll
174
+ // trips it — seen on prod 2026-09-10 after ~8 min of steady 5s polling. That is the same
175
+ // instruction as slow_down: the code is still pending, so back off and keep waiting rather
176
+ // than abort a login the human may be one click away from approving.
177
+ if (e.status === 429) {
178
+ interval += 5;
179
+ continue;
180
+ }
172
181
  if (code === 'expired_token')
173
182
  break;
174
183
  if (code === 'access_denied')
@@ -71,7 +71,8 @@ export async function deploy(dir, opts) {
71
71
  }
72
72
  const effOpts = { ...opts, port: port?.toString() };
73
73
  const image = dir ? await buildFromSource(api, p.projectId, dir, branch, effOpts) : opts.image;
74
- const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts));
74
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts))
75
+ .catch((e) => { throw e instanceof ApiError && e.status === 409 ? new ApiError(e.status, repoConnectedHint(e.message), e.body) : e; });
75
76
  if (handleApproval(res, opts.json))
76
77
  return;
77
78
  if (opts.json)
@@ -79,6 +80,11 @@ export async function deploy(dir, opts) {
79
80
  info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
80
81
  renderNextActions(res.body.nextActions);
81
82
  }
83
+ // The platform refuses an image deploy onto a repo-connected service and names the body field it
84
+ // wants; a CLI user can only pass the flag. Pure, so it's unit-tested.
85
+ export function repoConnectedHint(message) {
86
+ return message.replace(/pass replaceSource: true/g, 'pass --replace-source');
87
+ }
82
88
  // The local image tag a daemon-side deploy runs: unique per build so a redeploy replaces, and
83
89
  // legible in `docker images`. Pure, so it's unit-tested.
84
90
  export function localImageTag(projectId, group, now = Date.now()) {
@@ -32,6 +32,14 @@ export function pickCandidate(candidates, rootDir) {
32
32
  ...candidates.map((c) => ` ${(c.rootDir ?? '(repo root)').padEnd(24)} ${c.builder}${c.startCommand ? ` start: ${c.startCommand}` : ''}`),
33
33
  ].join('\n'));
34
34
  }
35
+ // A comma-separated list, because a shell would glob an unquoted `apps/web/**` into filenames — which
36
+ // also means a pattern containing a comma cannot be expressed. The server does the validation.
37
+ export function parseWatchPaths(raw) {
38
+ const out = raw.split(',').map((p) => p.trim()).filter(Boolean);
39
+ if (!out.length)
40
+ throw new Error("watch paths need at least one pattern, e.g. 'apps/web/**,packages/ui/**' (quote them, or the shell expands the *)");
41
+ return out;
42
+ }
35
43
  // Build/start come from detection only: the platform's nixpacks lane fails a build whose commands differ from it.
36
44
  // autoDeploy rides along only when switched off: a public repo 400s on autoDeploy: true.
37
45
  export function sourceBody(src, c, o) {
@@ -46,8 +54,14 @@ export function sourceBody(src, c, o) {
46
54
  port: o.port !== undefined ? parsePort(o.port) : c.port,
47
55
  ...(o.repoBranch ? { branch: o.repoBranch } : {}),
48
56
  ...(o.autoDeploy === false ? { autoDeploy: false } : {}),
57
+ ...(o.watchPaths !== undefined ? { watchPaths: parseWatchPaths(o.watchPaths) } : {}),
49
58
  };
50
59
  }
60
+ // Named as repo-root paths wherever it is printed: every line that carries this also carries root_dir,
61
+ // which the patterns are NOT relative to.
62
+ export function watchPathsClause(paths) {
63
+ return `, but only when a push changes these repo-root paths: ${paths.join(', ')}`;
64
+ }
51
65
  export function repoLine(serviceName, s) {
52
66
  if (s.type !== 'github')
53
67
  return `compute ${serviceName}: no repository connected${s.image ? ` (runs image ${s.image})` : ''} — connect one with \`insta compute connect-repo <owner/repo> ${serviceName}\``;
@@ -55,7 +69,10 @@ export function repoLine(serviceName, s) {
55
69
  ? 'public repo, deploys are manual (pushes do not redeploy)'
56
70
  : s.auto_deploy ? `every push to ${s.branch} redeploys it` : 'auto-deploy off (pushes do not redeploy)';
57
71
  const where = s.root_dir ? ` (${s.root_dir}/)` : '';
58
- return `compute ${serviceName}: deploys from ${s.owner}/${s.repo}@${s.branch}${where} — ${how}`;
72
+ const only = !s.watch_paths?.length ? ''
73
+ : s.auto_deploy ? watchPathsClause(s.watch_paths)
74
+ : `; watch paths ${s.watch_paths.join(', ')} are stored but cannot apply`;
75
+ return `compute ${serviceName}: deploys from ${s.owner}/${s.repo}@${s.branch}${where} — ${how}${only}`;
59
76
  }
60
77
  export async function findInstalledRepo(api, orgId, ref) {
61
78
  if (!orgId)
@@ -105,11 +122,31 @@ export async function computeConnectRepo(rawRef, serviceName, opts) {
105
122
  const branch = res.source.branch;
106
123
  const where = candidate.rootDir ? `${candidate.rootDir}/, ${candidate.builder}` : candidate.builder;
107
124
  const now = res.build.queued ? `building ${branch} now` : `${branch} is already live at this commit`;
125
+ // From the server's answer, not from the flag: what it stored is what a push is matched against.
126
+ const filtered = res.source.watch_paths?.length ? watchPathsClause(res.source.watch_paths) : '';
108
127
  const how = src.source === 'public' ? 'deploys are manual from here: pushes will not redeploy (public repo)'
109
128
  : opts.autoDeploy === false ? 'auto-deploy is off: redeploy with `insta compute connect-repo` again or from the console'
110
- : `every push to ${branch} redeploys it`;
129
+ : `every push to ${branch} redeploys it${filtered}`;
111
130
  info(`connected ${ref.owner}/${ref.repo} → compute ${svc.name} (${where}): ${now} — ${how}`);
112
131
  }
132
+ // Do not reconnect to change these: watch paths do not change what a build produces, and `connect-repo`
133
+ // would rebuild the service.
134
+ export async function computeWatchPaths(serviceName, opts) {
135
+ if (opts.set !== undefined && opts.clear)
136
+ throw new Error('pass --set or --clear, not both');
137
+ const patch = opts.clear ? { watchPaths: null } : opts.set !== undefined ? { watchPaths: parseWatchPaths(opts.set) } : null;
138
+ const api = await ApiClient.load();
139
+ const p = await requireProject();
140
+ const branch = opts.branch ?? p.branch;
141
+ const svc = await targetService(api, p.projectId, branch, serviceName);
142
+ const url = `/projects/${p.projectId}/services/${svc.id}/source${q(branch)}`;
143
+ const { source } = patch
144
+ ? await api.request('PATCH', url, patch)
145
+ : await api.request('GET', url);
146
+ if (opts.json)
147
+ return printJson({ service: { id: svc.id, name: svc.name }, source });
148
+ info(patch ? `updated — ${repoLine(svc.name, source)}` : repoLine(svc.name, source));
149
+ }
113
150
  export async function computeDisconnectRepo(serviceName, opts) {
114
151
  const api = await ApiClient.load();
115
152
  const p = await requireProject();
@@ -4,16 +4,93 @@
4
4
  // as the process does.
5
5
  import { spawn } from 'node:child_process';
6
6
  import { ApiClient, requireProject } from '../api.js';
7
- import { CliExit, die, handleApproval, relayExitCode } from '../util.js';
7
+ import { CliExit, die, refuse, relayExitCode } from '../util.js';
8
+ import { assertServiceRef, branchHint, collisionLines, fetchSecretBundle } from './secrets.js';
9
+ /** The bundle read, as run does it: the same GET as `insta secrets`, general or --service. */
10
+ export function bundleFetcher(api, projectId, opts) {
11
+ return async () => {
12
+ const b = await fetchSecretBundle(api, projectId, opts);
13
+ if (!b)
14
+ throw new CliExit(); // gated (202): handleApproval already said how to unblock it
15
+ return b;
16
+ };
17
+ }
18
+ /** The child's environment: the parent's, plus the bundle, MINUS every colliding name.
19
+ * Deleting is not belt-and-braces. A withheld name is simply ABSENT from the bundle, so the
20
+ * spread would let the parent's own export stand in for it — the developer who once exported
21
+ * codex's password would run hermes' command against it, with no sign anything was withheld.
22
+ *
23
+ * Windows needs more than an exact-key delete, for both halves. Env names there are
24
+ * case-insensitive; a plain JS object's keys are not, so this object can hold `Database_Url` from
25
+ * the shell and `DATABASE_URL` from the bundle as two keys for one variable — and CreateProcess,
26
+ * which does collapse them, picks. That makes a stale shell export able to beat the injected
27
+ * credential, which is the whole point of `insta run`, not just a hazard for a colliding name.
28
+ * So on win32 every name we DECIDE — injected or withheld — wins over whatever casings the parent
29
+ * had: the parent's are removed, then the bundle's own casing is written back. Off win32 nothing
30
+ * changes: POSIX names really are case-sensitive, so `Database_Url` there is a different variable
31
+ * that is none of our business, and collapsing the two would itself be the bug. */
32
+ export function childEnv(parent, bundle, collisions, platform = process.platform) {
33
+ const withheld = new Set(collisions.map((c) => c.name.toLowerCase()));
34
+ if (platform === 'win32') {
35
+ const env = { ...parent };
36
+ // Every name this run decides, whether it ends up injected or withheld.
37
+ const claimed = new Set([...Object.keys(bundle), ...collisions.map((c) => c.name)].map((n) => n.toLowerCase()));
38
+ for (const key of Object.keys(env))
39
+ if (claimed.has(key.toLowerCase()))
40
+ delete env[key];
41
+ // A withheld name stays gone in every casing — including one the bundle itself carried, which
42
+ // is a platform that answered `collisions` while still merging the values.
43
+ //
44
+ // defineProperty, not `env[k] = v`: a key of `__proto__` would otherwise set the prototype
45
+ // instead of creating an entry, and the secret would vanish silently. The platform's name rule
46
+ // (`^[A-Z][A-Z0-9_]{0,63}$`) makes that unreachable today — and that is exactly why it is worth
47
+ // one call rather than a trusted invariant: this CLI points at whatever `INSTA_API_URL` names,
48
+ // including a self-hosted insta-oss daemon whose validation is not this repo's to guarantee.
49
+ //
50
+ // The same rule is what makes two bundle keys differing ONLY by case impossible (case-differing
51
+ // needs a lowercase letter, which the rule forbids). If it ever loosens, this loop is where it
52
+ // bites: both variants would be written and Windows would resolve one of them arbitrarily.
53
+ for (const [k, v] of Object.entries(bundle)) {
54
+ if (withheld.has(k.toLowerCase()))
55
+ continue;
56
+ Object.defineProperty(env, k, { value: v, enumerable: true, configurable: true, writable: true });
57
+ }
58
+ return env;
59
+ }
60
+ const env = { ...parent, ...bundle };
61
+ for (const c of collisions)
62
+ delete env[c.name];
63
+ return env;
64
+ }
65
+ /** Pure: why run stopped, and the two ways forward. `branchHint` is the branch to name in the
66
+ * suggested commands — set only when the run was NOT on the linked branch, since a hint that
67
+ * drops an explicit --branch would send the user to read a different branch's secrets. */
68
+ export function refusalLines(collisions, branchHint) {
69
+ const n = collisions.length;
70
+ const b = branchHint ? ` --branch ${branchHint}` : '';
71
+ return [
72
+ `refusing to run: ${n} secret name${n === 1 ? '' : 's'} ${n === 1 ? 'is' : 'are'} defined by more than one service, so the bundle cannot say which value the command should get:`,
73
+ ...collisionLines(collisions, branchHint),
74
+ `pick one service's env: insta run --service ${collisions[0]?.services[0] ?? '<type>/<name>'}${b} -- <cmd>`,
75
+ `or run without the name: insta run --ignore-collisions${b} -- <cmd>`,
76
+ ];
77
+ }
8
78
  /** Core, dependency-injected for tests: spawn cmd with the bundle in env, return its exit code. */
9
79
  export async function runWithSecrets(cmd, args, deps) {
10
80
  const bundle = await deps.fetchBundle();
81
+ const collisions = bundle.collisions ?? [];
82
+ if (collisions.length) {
83
+ // Refuse rather than warn: with the name missing from the bundle the child would silently
84
+ // inherit the parent's value, and even clearing it isn't enough — the child may hold a
85
+ // compiled-in default or load its own .env, so a missing credential need not be observable.
86
+ if (!deps.ignoreCollisions)
87
+ refuse(refusalLines(collisions, deps.branchHint));
88
+ process.stderr.write(`warning: --ignore-collisions — ${collisions.map((c) => c.name).join(', ')} removed from the child environment (${collisions.length} name${collisions.length === 1 ? '' : 's'} defined by more than one service)\n`);
89
+ }
90
+ const env = childEnv(process.env, bundle.secrets, collisions);
91
+ deps.announce?.(bundle);
11
92
  return await new Promise((resolve, reject) => {
12
- const child = (deps.spawnImpl ?? spawn)(cmd, args, {
13
- stdio: 'inherit',
14
- cwd: deps.cwd,
15
- env: { ...process.env, ...bundle },
16
- });
93
+ const child = (deps.spawnImpl ?? spawn)(cmd, args, { stdio: 'inherit', cwd: deps.cwd, env });
17
94
  child.on('error', reject);
18
95
  child.on('close', (code) => resolve(code ?? 1));
19
96
  });
@@ -21,20 +98,18 @@ export async function runWithSecrets(cmd, args, deps) {
21
98
  export async function run(cmdAndArgs, opts) {
22
99
  const [cmd, ...rest] = cmdAndArgs;
23
100
  if (!cmd)
24
- die('usage: insta run [--branch <b>] -- <command> [args…]');
101
+ die('usage: insta run [--branch <b>] [--service <type/name>] -- <command> [args…]');
102
+ assertServiceRef(opts.service);
25
103
  const api = await ApiClient.load();
26
104
  const p = await requireProject();
27
105
  const branch = opts.branch ?? p.branch;
28
106
  const code = await runWithSecrets(cmd, rest, {
29
- fetchBundle: async () => {
30
- const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets?branch=${encodeURIComponent(branch)}`);
31
- if (handleApproval(res))
32
- throw new CliExit();
33
- // stderr, not stdout: `insta run`'s stdout belongs entirely to the child command (that's why
34
- // run has no --json — wrapping would break the child's own output contract).
35
- process.stderr.write(`running with ${Object.keys(res.body.secrets).length} injected secrets (branch ${branch}) — nothing written to disk\n`);
36
- return res.body.secrets;
37
- },
107
+ fetchBundle: bundleFetcher(api, p.projectId, { branch, service: opts.service }),
108
+ ignoreCollisions: opts.ignoreCollisions,
109
+ branchHint: branchHint(branch, p.branch),
110
+ // stderr, not stdout: `insta run`'s stdout belongs entirely to the child command (that's why
111
+ // run has no --json — wrapping would break the child's own output contract).
112
+ announce: (b) => process.stderr.write(`running with ${Object.keys(b.secrets).length} injected secrets (${opts.service ? `${opts.service}, ` : ''}branch ${branch}) — nothing written to disk\n`),
38
113
  });
39
114
  relayExitCode(code);
40
115
  }
@@ -3,27 +3,99 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { ApiClient, requireProject } from '../api.js';
5
5
  import { info, printJson, serializeEnv, handleApproval, die } from '../util.js';
6
- function q(branch) {
7
- return branch ? `?branch=${encodeURIComponent(branch)}` : '';
8
- }
9
- // Fetch the credential bundle (the secret seam) and write it to .env (or print).
10
- export async function secrets(opts) {
6
+ async function loadDeps() {
11
7
  const api = await ApiClient.load();
12
8
  const p = await requireProject();
13
- const branch = opts.branch ?? p.branch;
14
- const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets${q(branch)}`);
9
+ return { api, projectId: p.projectId, linkedBranch: p.branch };
10
+ }
11
+ /** A `--service` that arrived empty (`--service ""`, or an unset variable in a script) is a typo,
12
+ * not a request: the platform 400s it, and silently falling back to the branch-wide read would
13
+ * answer a different question than the one asked. Fail locally, naming the shape. */
14
+ export function assertServiceRef(service) {
15
+ if (service !== undefined && service.trim() === '')
16
+ die('--service requires <type>/<name>, e.g. compute/api');
17
+ }
18
+ /** Query for a bundle read. `--service` asks for the env ONE compute service actually receives
19
+ * (unambiguous by construction, so no collision can arise); a general read asks the platform to
20
+ * WITHHOLD any name several services define rather than silently returning one of the values. */
21
+ export function bundleQuery(opts) {
22
+ const parts = [];
23
+ if (opts.branch)
24
+ parts.push(`branch=${encodeURIComponent(opts.branch)}`);
25
+ // A bare/empty --service is a 400 on the platform; the flag is only ever sent with a value.
26
+ if (opts.service)
27
+ parts.push(`service=${encodeURIComponent(opts.service)}`);
28
+ else
29
+ parts.push('on_collision=withhold');
30
+ return `?${parts.join('&')}`;
31
+ }
32
+ /** GET the bundle. Returns null when the platform gated the read (202). A platform that doesn't
33
+ * know `collisions` reads back as none.
34
+ *
35
+ * That fallback FAILS OPEN and is deliberate: such a platform also ignores `on_collision`, so it
36
+ * answers a colliding name with a merged value and no report, and `insta run` would spawn against
37
+ * it believing nothing was withheld. It is safe only by shipping order — insta-platform#388 merges
38
+ * before this CLI is released, so no released build ever talks to a platform without the field
39
+ * (repo owner's call). Do not read the `?? []` as unconditionally safe: if that ordering ever
40
+ * changes, this is where capability detection belongs. */
41
+ export async function fetchSecretBundle(api, projectId, opts) {
42
+ const res = await api.rawRequest('GET', `/projects/${projectId}/secrets${bundleQuery(opts)}`);
15
43
  if (handleApproval(res, opts.json))
44
+ return null;
45
+ return { secrets: res.body.secrets, collisions: (res.body.collisions ?? []) };
46
+ }
47
+ /** The branch a remediation hint has to name: the one that was actually read, whenever that is not
48
+ * the linked branch. A hint that silently dropped an explicit `--branch feat-x` would send the
49
+ * user to read the linked branch instead — a different set of secrets, and no sign of the swap. */
50
+ export function branchHint(read, linked) {
51
+ return read && read !== linked ? read : undefined;
52
+ }
53
+ /** Pure: the report for each withheld name — who defines it, and how to read one of them. */
54
+ export function collisionLines(collisions, hintBranch) {
55
+ return collisions.flatMap((c) => [
56
+ `${c.name} omitted — ${c.services.length} services define it:`,
57
+ ` ${c.services.join(', ')}`,
58
+ ` read one with: insta secrets --service ${c.services[0] ?? '<type>/<name>'}${hintBranch ? ` --branch ${hintBranch}` : ''}`,
59
+ ]);
60
+ }
61
+ // STDERR, always: `secrets --print` writes the env to stdout and `insta run`'s stdout belongs to
62
+ // the child command, so a collision report on stdout would corrupt both.
63
+ export function warnCollisions(collisions, hintBranch) {
64
+ for (const line of collisionLines(collisions, hintBranch))
65
+ process.stderr.write(line + '\n');
66
+ }
67
+ // The same report for a machine reader, on stderr for the same reason: stdout carries the payload,
68
+ // which under --json is the bare `{NAME: value}` map every existing consumer parses. Nothing is
69
+ // written when there is nothing to choose between — a quiet stream is the signal, not `[]`.
70
+ export function warnCollisionsJson(collisions) {
71
+ if (collisions.length)
72
+ process.stderr.write(JSON.stringify({ collisions }) + '\n');
73
+ }
74
+ // Fetch the credential bundle (the secret seam) and write it to .env (or print). --service reads
75
+ // one compute service's own env instead of the branch-wide merge.
76
+ export async function secrets(opts, deps) {
77
+ assertServiceRef(opts.service);
78
+ const d = deps ?? (await loadDeps());
79
+ const branch = opts.branch ?? d.linkedBranch;
80
+ const b = await fetchSecretBundle(d.api, d.projectId, { branch, service: opts.service, json: opts.json });
81
+ if (!b)
16
82
  return;
17
- const bundle = res.body.secrets;
18
- if (opts.json)
83
+ const bundle = b.secrets;
84
+ // --json's stdout stays the bare map it has always been; the collisions ride stderr as one JSON
85
+ // line, so a consumer that passes none of the new flags parses exactly what it parsed before.
86
+ if (opts.json) {
87
+ warnCollisionsJson(b.collisions);
19
88
  return printJson(bundle);
89
+ }
90
+ warnCollisions(b.collisions, branchHint(branch, d.linkedBranch));
20
91
  if (opts.print) {
21
92
  process.stdout.write(serializeEnv(bundle));
22
93
  return;
23
94
  }
24
95
  const out = opts.output ?? '.env';
25
96
  await writeFile(out, serializeEnv(bundle));
26
- info(`wrote ${Object.keys(bundle).length} secrets to ${out} (branch ${branch})`);
97
+ const scope = opts.service ? `${opts.service}, branch ${branch}` : `branch ${branch}`;
98
+ info(`wrote ${Object.keys(bundle).length} secrets to ${out} (${scope})`);
27
99
  if (ensureIgnored(process.cwd(), out))
28
100
  info(` .gitignore += ${out} (credentials must never be committed)`);
29
101
  info(' tip: `insta run -- <cmd>` injects these per-run with nothing written to disk');
@@ -94,6 +166,10 @@ async function readStdin() {
94
166
  // it to a branch service instead, which implies the current branch (binding requires one). Value
95
167
  // comes from the argument, or stdin when omitted (keeps secret values out of shell history).
96
168
  export async function secretsSet(name, value, opts) {
169
+ // An empty --service must not fall through to a project-wide WRITE. The scoping test below is a
170
+ // truthiness check, so `--service ''` (a client interpolating an absent variable) would have put
171
+ // the secret at a WIDER scope than the caller asked for, visible to every service on the branch.
172
+ assertServiceRef(opts.service);
97
173
  const api = await ApiClient.load();
98
174
  const p = await requireProject();
99
175
  const v = value ?? (await readStdin());
@@ -108,16 +184,29 @@ export async function secretsSet(name, value, opts) {
108
184
  return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
109
185
  info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
110
186
  }
111
- export async function secretsUnset(name, opts) {
112
- const api = await ApiClient.load();
113
- const p = await requireProject();
114
- const qs = opts.branch ? `?branch=${encodeURIComponent(opts.branch)}` : '';
115
- const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}${qs}`);
187
+ // Remove a user secret. --service removes only THAT service's copy (the platform has always
188
+ // honoured ?service= here; without the flag a name several services define stays defined).
189
+ export async function secretsUnset(name, opts, deps) {
190
+ assertServiceRef(opts.service);
191
+ const d = deps ?? (await loadDeps());
192
+ // Service scoping REQUIRES a branch (a service exists on a branch, so the platform rejects the
193
+ // pair without one) — so --service defaults to the linked branch, exactly as `secrets set` does.
194
+ const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
195
+ const parts = [];
196
+ if (branch)
197
+ parts.push(`branch=${encodeURIComponent(branch)}`);
198
+ if (opts.service)
199
+ parts.push(`service=${encodeURIComponent(opts.service)}`);
200
+ const qs = parts.length ? `?${parts.join('&')}` : '';
201
+ const res = await d.api.rawRequest('DELETE', `/projects/${d.projectId}/secrets/${encodeURIComponent(name)}${qs}`);
116
202
  if (handleApproval(res, opts.json))
117
203
  return;
204
+ // The EFFECTIVE branch, not the flag: with --service and no --branch the scope that was deleted
205
+ // is the linked branch's, and the output has to say which scope it actually touched.
118
206
  if (opts.json)
119
- return printJson({ ok: true, name, branch: opts.branch ?? null });
120
- info(`unset ${name} (${opts.branch ? `branch ${opts.branch}` : 'project-wide'})`);
207
+ return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
208
+ const scope = opts.service ? `${opts.service}, branch ${branch}` : branch ? `branch ${branch}` : 'project-wide';
209
+ info(`unset ${name} (${scope})`);
121
210
  }
122
211
  export async function secretsBind(envName, source, opts) {
123
212
  if (!opts.to)
@@ -374,7 +374,9 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
374
374
  const codeLabel = manifest?.code ?? target;
375
375
  if (!quiet)
376
376
  info(`deploying template ${codeLabel} to branch ${branchName} (${deploymentId})`);
377
- const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`), deploymentId, quiet ? () => { } : info, deps.wait);
377
+ // The poll route is keyed by deployment id, not project: name the project so agent mode signs
378
+ // with the project-bound session (a bootstrap session is rejected as "for a different project").
379
+ const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, quiet ? () => { } : info, deps.wait);
378
380
  if (opts.json)
379
381
  return printJson(source ? { source, ...dep } : dep);
380
382
  info(`template ${codeLabel} deployed to branch ${branchName}`);
package/dist/index.js CHANGED
@@ -98,6 +98,8 @@ envCmd.command('use <name>').description(`Switch environment (${ENV_NAMES.join('
98
98
  // ---- run (per-request secret injection — nothing written to disk) ----
99
99
  program.command('run <cmd> [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)')
100
100
  .option('--branch <b>', 'branch bundle to inject (default: linked branch)')
101
+ .option('--service <type/name>', "inject one compute service's own slice of the branch bundle, e.g. compute/api — the unambiguous read when several services define the same name (NOT the container's env: it also carries the branch's provider credentials, which a container gets only where bound)")
102
+ .option('--ignore-collisions', 'run even when several services define the same name; every such name is REMOVED from the child environment (never inherited from your shell)')
101
103
  .passThroughOptions().allowUnknownOption()
102
104
  .action(guard((cmd, args, o) => runCmd.run([cmd, ...(args ?? [])], o)));
103
105
  // ---- agent setup (the `curl … | sh --agents` target) ----
@@ -170,14 +172,18 @@ svc.command('secrets <type> <name>').description("List a service's secret names"
170
172
  .option('--branch <b>').option('--json').action(guard((type, name, o) => services.servicesSecrets(type, name, o)));
171
173
  // ---- secrets (seam) ----
172
174
  const sec = program.command('secrets').description('Fetch the credential bundle (secret seam) into .env')
173
- .option('--branch <branch>').option('-o, --output <file>', 'output file (default .env)').option('--print', 'print instead of writing').option('--json')
175
+ .option('--branch <branch>')
176
+ .option('--service <type/name>', "read one compute service's own slice of the bundle instead of the branch-wide merge, e.g. compute/api")
177
+ .option('-o, --output <file>', 'output file (default .env)').option('--print', 'print instead of writing').option('--json')
174
178
  .action(guard((o) => secretsCmd.secrets(o)));
175
179
  sec.command('list').description('List secret names, grouped by service').option('--branch <branch>').option('--json').action(guard((o) => secretsCmd.secretsList(o)));
176
180
  sec.command('set <name> [value]').description('Set a user secret (project-wide; value from stdin if omitted)')
177
181
  .option('--branch <branch>', 'scope to one branch').option('--service <type/name>', 'bind to a branch service (implies current branch)')
178
182
  .option('--json').action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o)));
179
183
  sec.command('unset <name>').description('Remove a user secret')
180
- .option('--branch <branch>', 'scope to one branch').option('--json').action(guard((n, o) => secretsCmd.secretsUnset(n, o)));
184
+ .option('--branch <branch>', 'scope to one branch')
185
+ .option('--service <type/name>', "remove only that service's copy, e.g. compute/api")
186
+ .option('--json').action(guard((n, o) => secretsCmd.secretsUnset(n, o)));
181
187
  sec.command('bind <env-name> <source>').description('Bind a service credential into a compute env var')
182
188
  .option('--branch <branch>', 'branch (default: current)')
183
189
  .option('--to <compute-service>', 'target compute service, e.g. compute/api')
@@ -246,16 +252,21 @@ const execCmd = compute.command('exec [service]').description("Run a one-shot co
246
252
  // new option cannot reach the CLI surface while the split still reads it as part of the command.
247
253
  for (const [flags, description] of computeCmd.EXEC_OPTIONS)
248
254
  execCmd.option(flags, description);
249
- compute.command('repo [service]').description('Show what a compute service deploys from: the image it runs, or the GitHub repository — owner/repo, the branch it builds, root directory, and whether pushes redeploy it')
255
+ compute.command('repo [service]').description('Show what a compute service deploys from: the image it runs, or the GitHub repository — owner/repo, the branch it builds, root directory, which paths a push must change to redeploy it, and whether pushes redeploy it at all')
250
256
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeRepo(service, o)));
251
257
  compute.command('connect-repo <owner/repo> [service]').description("Connect a GitHub repository to an EXISTING compute service: the repo is built (its Dockerfile, or nixpacks when there is none) and deployed into that service, and every later push to the tracked repository branch redeploys it. The repo must be reachable through the org's GitHub App installation — connect GitHub in the console first (Add Service → GitHub Repo) — or be public. Build and start commands come from detection and cannot be set. Connecting again replaces the service's current source")
252
258
  .option('--public', 'the repo is public and no GitHub App installation is needed (deploys are manual; pushes cannot redeploy)')
253
259
  .option('--root-dir <dir>', 'the directory of the repo to build (a monorepo with several deployable directories lists them and exits 1 without it)')
254
260
  .option('--repo-branch <name>', "the repository branch to build (default: the repo's default branch)")
255
261
  .option('--no-auto-deploy', 'do not rebuild on pushes; redeploy by connecting again or from the console')
262
+ .option('--watch-paths <patterns>', "only redeploy when a push changes a matching path — a comma-separated list of gitignore patterns relative to the REPOSITORY ROOT, not to --root-dir, e.g. 'apps/web/**,packages/ui/**' (quote them, or the shell expands the *)")
256
263
  .option('--port <n>', 'port the app listens on (default: detected)')
257
264
  .option('--branch <branch>', 'branch (default: current) — the environment the service is on')
258
265
  .option('--json').action(guard((ref, service, o) => githubCmd.computeConnectRepo(ref, service, o)));
266
+ compute.command('watch-paths [service]').description("Show or change which paths make a push redeploy a compute service. No flag prints them. --set narrows to a comma-separated list of gitignore patterns matched against paths relative to the REPOSITORY ROOT (not to the service's root directory), so a monorepo push that touched nothing on the list leaves this service alone — unless GitHub cannot report what a push changed (a force-push, or a comparison of 300 or more files, where its list stops being complete), in which case it deploys rather than risk skipping a real change. --clear removes the filter: every push that deploys this service deploys it again. Neither rebuilds the service — this changes which pushes deploy, not what a deploy builds")
267
+ .option('--set <patterns>', "the patterns, comma-separated, e.g. 'apps/web/**,packages/ui/**' — quote them, or the shell expands the *; a leading ! excludes, under git's rule that a path cannot be re-included once an earlier pattern took its directory")
268
+ .option('--clear', 'remove the filter: every push that deploys this service deploys it again')
269
+ .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeWatchPaths(service, o)));
259
270
  compute.command('disconnect-repo [service]').description('Disconnect the GitHub repository from a compute service. The service keeps running its current image; pushes no longer deploy it, and its build history stays')
260
271
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o)));
261
272
  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 10Gi, the free cap; 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")
package/dist/util.js CHANGED
@@ -81,6 +81,16 @@ export function die(msg) {
81
81
  fail(msg);
82
82
  throw new CliExit();
83
83
  }
84
+ // The CLI declined to act and the caller must choose how to proceed — the same shape as the 202
85
+ // approval gate below, so it takes the same exit code 2: not success (a redirected stdout must
86
+ // never read the refusal as output), and not a plain failure either (die owns 1). Nothing ran,
87
+ // and re-running with the flag the message names will work.
88
+ export function refuse(lines) {
89
+ for (const line of lines)
90
+ process.stderr.write(line + '\n');
91
+ process.exitCode = 2;
92
+ throw new CliExit();
93
+ }
84
94
  export function printJson(v) {
85
95
  process.stdout.write(JSON.stringify(v, null, 2) + '\n');
86
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.64",
3
+ "version": "0.0.66",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [