insta 0.0.65 → 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/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')
@@ -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();
@@ -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
@@ -252,16 +252,21 @@ const execCmd = compute.command('exec [service]').description("Run a one-shot co
252
252
  // new option cannot reach the CLI surface while the split still reads it as part of the command.
253
253
  for (const [flags, description] of computeCmd.EXEC_OPTIONS)
254
254
  execCmd.option(flags, description);
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, 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')
256
256
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeRepo(service, o)));
257
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")
258
258
  .option('--public', 'the repo is public and no GitHub App installation is needed (deploys are manual; pushes cannot redeploy)')
259
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)')
260
260
  .option('--repo-branch <name>', "the repository branch to build (default: the repo's default branch)")
261
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 *)")
262
263
  .option('--port <n>', 'port the app listens on (default: detected)')
263
264
  .option('--branch <branch>', 'branch (default: current) — the environment the service is on')
264
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)));
265
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')
266
271
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o)));
267
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.65",
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": [