insta 0.1.4 → 0.1.6

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
@@ -214,6 +214,7 @@ build never reaches a production installer.
214
214
  |---|---|
215
215
  | `insta login` · `logout` · `status` | Browser sign-in (default), `--email` + password, or `--oauth github\|google`; `status` shows the environment, login and linked project/branch |
216
216
  | `insta org` | `list` · `create` (one free org per user) |
217
+ | `insta tokens` | API tokens for CI and agents: `list` · `create <name>` · `revoke <id>`. A new token binds to the current org by default (`--org <id>`, `--project <id>`, or an explicit `--account` for everything the account can do); `--read-only` (GET only); `--expires 30d\|90d\|1y\|never` (default 90d). The plaintext is printed once |
217
218
  | `insta project` | `create` · `list` · `link` · `delete` |
218
219
  | `insta branch` | `create` · `list` · `switch` · `delete` · `merge` |
219
220
  | `insta service` (`services`, `svc`) | `add` · `list` · `remove` · `rename` |
@@ -230,7 +231,7 @@ build never reaches a production installer.
230
231
  | `insta billing` | Current cycle overview; `subscribe <tier>` · `portal` · `usage` |
231
232
  | `insta agent` | `setup` (this machine's coding agents) · `manifest` · `policy …` · `approvals …` · `observe …` · `events` |
232
233
  | `insta config` | `install-mcp` · `regions` · `autoupdate` |
233
- | `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; works logged-out |
234
+ | `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; on InstaCloud it needs `insta login`, so the team can reply |
234
235
  | `insta upgrade` | Update the CLI |
235
236
 
236
237
  Every command accepts `--api-url <url>` for this invocation only (internal debugging); for `compute exec`, place it before `compute`. `insta --help` documents it.
@@ -239,7 +240,7 @@ Every command accepts `--api-url <url>` for this invocation only (internal debug
239
240
 
240
241
  | Location | Contents |
241
242
  |---|---|
242
- | `~/.insta/config.json` | API URL, access and refresh tokens, user, auto-update preference |
243
+ | `~/.insta/config.json` | API URL, access and refresh tokens, user, the API token's scope (org/project-bound logins), auto-update preference |
243
244
  | `./.insta/project.json` | Project id, org id, current branch |
244
245
  | `./.insta/link-plane.json` | The control-plane URL this machine linked against. Gitignored and per machine; a link made against a different control plane is refused rather than reused. The home directory is never a project |
245
246
 
package/dist/api.js CHANGED
@@ -26,7 +26,9 @@ export class AgentApprovalRequired extends Error {
26
26
  }
27
27
  }
28
28
  // Store a durable insta_ key as the credential: set it as the bearer and drop any refresh token (an insta_ key never rotates; a stale one would leak to /auth/refresh on a 401).
29
- export function storeApiKeyCredential(cfg, token, user, agentCredential = false) {
29
+ // `tokenScope` is the key's binding from /me (spec §6). It belongs to THIS key: a plain key replacing a
30
+ // scoped one must not inherit a binding that would steer project resolution for the wrong credential.
31
+ export function storeApiKeyCredential(cfg, token, user, agentCredential = false, tokenScope) {
30
32
  cfg.accessToken = token;
31
33
  delete cfg.refreshToken;
32
34
  if (user)
@@ -35,6 +37,10 @@ export function storeApiKeyCredential(cfg, token, user, agentCredential = false)
35
37
  cfg.agentCredential = true;
36
38
  else
37
39
  delete cfg.agentCredential;
40
+ if (tokenScope)
41
+ cfg.tokenScope = tokenScope;
42
+ else
43
+ delete cfg.tokenScope;
38
44
  }
39
45
  export class ApiClient {
40
46
  cfg;
@@ -69,10 +75,11 @@ export class ApiClient {
69
75
  if (user)
70
76
  this.cfg.user = user;
71
77
  delete this.cfg.agentCredential;
78
+ delete this.cfg.tokenScope;
72
79
  }
73
80
  // Adopt a durable insta_ key as the credential (non-interactive `login --api-key`).
74
- setApiKey(token, user, agentCredential) {
75
- storeApiKeyCredential(this.cfg, token, user, agentCredential);
81
+ setApiKey(token, user, agentCredential, tokenScope) {
82
+ storeApiKeyCredential(this.cfg, token, user, agentCredential, tokenScope);
76
83
  }
77
84
  get agentCredential() { return this.cfg.agentCredential === true; }
78
85
  clearSession() {
@@ -80,6 +87,7 @@ export class ApiClient {
80
87
  delete this.cfg.refreshToken;
81
88
  delete this.cfg.user;
82
89
  delete this.cfg.agentCredential;
90
+ delete this.cfg.tokenScope;
83
91
  }
84
92
  // Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
85
93
  async request(method, path, body, opts = {}) {
@@ -169,10 +177,7 @@ export async function requireProject(deps = {}) {
169
177
  // one-keystroke picker when several) and persist the choice so this happens once per dir.
170
178
  const api = await ApiClient.load();
171
179
  try {
172
- const orgs = (await api.request('GET', '/orgs')).orgs;
173
- const orgId = orgs[0]?.id ?? 'local';
174
- return await autoResolveProject(orgId, {
175
- listProjects: async () => (await api.request('GET', `/orgs/${orgId}/projects`)).projects,
180
+ return await resolveProjectFromApi(api, {
176
181
  promptChoice,
177
182
  save: async (c) => {
178
183
  // stderr: this is a diagnostic that can precede ANY command's output — under --json,
@@ -196,4 +201,26 @@ export async function requireProject(deps = {}) {
196
201
  die(e instanceof Error ? e.message : String(e));
197
202
  }
198
203
  }
204
+ // The unlinked resolution given a client — exported, with the client injectable, so the scoped
205
+ // paths are testable over a fake fetch. The stored tokenScope is read FIRST (spec §9.2): a
206
+ // project-scoped key gets 403 token_scope from GET /orgs and has exactly one project anyway, so
207
+ // its link is built from the project itself; an org-scoped key sees exactly one org, so /orgs is
208
+ // skipped for the bound one. No scope (a session, or a key adopted before scopes were recorded)
209
+ // keeps the original path.
210
+ export async function resolveProjectFromApi(api, deps) {
211
+ const scope = api.config.tokenScope;
212
+ if (scope?.projectId) {
213
+ const { project, branches } = await api.request('GET', `/projects/${scope.projectId}`);
214
+ // The default branch comes from the project detail (Branch.is_default); `main` is only the
215
+ // fallback an older platform without the list would get, like `project link` assumes.
216
+ const link = { projectId: project.id, orgId: project.org_id, branch: branches?.find((b) => b.is_default)?.name ?? 'main' };
217
+ await deps.save(link);
218
+ return link;
219
+ }
220
+ const orgId = scope?.orgId ?? (await api.request('GET', '/orgs')).orgs[0]?.id ?? 'local';
221
+ return autoResolveProject(orgId, {
222
+ listProjects: async () => (await api.request('GET', `/orgs/${orgId}/projects`)).projects,
223
+ ...deps,
224
+ });
225
+ }
199
226
  //# sourceMappingURL=api.js.map
@@ -2,6 +2,7 @@ import { createServer } from 'node:http';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { ApiClient, ApiError, linkedProject } from '../api.js';
4
4
  import { readGlobal, readPersistedGlobal } from '../config.js';
5
+ import { describeTokenScope } from './tokens.js';
5
6
  import { agentMode } from '../agent.js';
6
7
  import { ENVS, ENV_NAMES, envForApiUrl, isEnvName, normalizeUrl } from '../env.js';
7
8
  import { info, die, printJson, promptPassword, openUrl } from '../util.js';
@@ -111,12 +112,38 @@ export async function loginApiKey(key, opts) {
111
112
  const user = await applyApiKeyLogin(api, key);
112
113
  await api.persist();
113
114
  info(`logged in as ${user.email ?? user.id} @ ${api.apiUrl}`);
115
+ // Say what this credential can reach: a scoped token's 403s later are easier to place.
116
+ const scope = api.config.tokenScope;
117
+ if (scope && (scope.scope !== 'account' || scope.access !== 'full'))
118
+ info(` token scope: ${describeTokenScope(scope)}`);
114
119
  }
115
- // Verify an insta_ key with a bare /me probe (an agent-minted key cannot enroll a session, and /me says which kind this is), then store it with the user and that kind.
120
+ // The binding /me reports for an insta_ key (`token`, spec §6), validated rather than cast. A shape
121
+ // this CLI does not know is stored as NO scope — account-wide behaviour, which the platform still
122
+ // enforces against, and the guard's token_scope hint covers the surprise — never as a half-parsed one.
123
+ export function parseTokenScope(v) {
124
+ if (!v || typeof v !== 'object')
125
+ return undefined;
126
+ const t = v;
127
+ if (t.scope !== 'account' && t.scope !== 'org' && t.scope !== 'project')
128
+ return undefined;
129
+ if (t.access !== 'full' && t.access !== 'read_only')
130
+ return undefined;
131
+ const out = { scope: t.scope, access: t.access };
132
+ if (typeof t.orgId === 'string' && t.orgId)
133
+ out.orgId = t.orgId;
134
+ if (typeof t.projectId === 'string' && t.projectId)
135
+ out.projectId = t.projectId;
136
+ if (out.scope !== 'account' && !out.orgId)
137
+ return undefined;
138
+ if (out.scope === 'project' && !out.projectId)
139
+ return undefined;
140
+ return out;
141
+ }
142
+ // Verify an insta_ key with a bare /me probe (an agent-minted key cannot enroll a session, and /me says which kind this is), then store it with the user, that kind, and the key's scope.
116
143
  export async function applyApiKeyLogin(client, key) {
117
144
  key = key.trim(); // tolerate a trailing newline / stray whitespace from `--api-key "$(cat token)"`
118
145
  if (!key.startsWith('insta_'))
119
- throw new Error('--api-key expects an insta_ token (mint one with POST /tokens)');
146
+ throw new Error('--api-key expects an insta_ token (mint one with `insta tokens create <name>`)');
120
147
  client.setApiKey(key);
121
148
  let me;
122
149
  try {
@@ -129,7 +156,7 @@ export async function applyApiKeyLogin(client, key) {
129
156
  }
130
157
  if (!me?.user)
131
158
  throw new Error('unexpected response while verifying the API key');
132
- client.setApiKey(key, me.user, me.agentCredential === true);
159
+ client.setApiKey(key, me.user, me.agentCredential === true, parseTokenScope(me.token));
133
160
  return me.user;
134
161
  }
135
162
  const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
@@ -381,11 +408,15 @@ export async function status(opts) {
381
408
  // Surface the environment name alongside the URL: "api: https://api.staging.instacloud.com" is
382
409
  // easy to skim past, and mistaking staging for prod is the mistake worth making loud.
383
410
  const env = envForApiUrl(api.apiUrl);
411
+ const tokenScope = api.config.tokenScope ?? null;
384
412
  if (opts.json)
385
- return printJson({ env, apiUrl: api.apiUrl, user, project });
413
+ return printJson({ env, apiUrl: api.apiUrl, user, project, tokenScope });
386
414
  info(`env: ${env ?? '(custom)'}`);
387
415
  info(`api: ${api.apiUrl}`);
388
416
  info(`user: ${user ? (user.email ?? user.id) : '(not logged in)'}`);
417
+ // Only a scoped insta_ key has one; a session login is the account itself.
418
+ if (tokenScope)
419
+ info(`token: ${describeTokenScope(tokenScope)}`);
389
420
  info(`project: ${project ? `${project.projectId} (branch ${project.branch})` : '(none linked)'}`);
390
421
  }
391
422
  //# sourceMappingURL=auth.js.map
@@ -64,6 +64,7 @@ export async function envUse(name, opts = {}) {
64
64
  delete next.refreshToken;
65
65
  delete next.user;
66
66
  delete next.agentCredential;
67
+ delete next.tokenScope; // belongs to the dropped key; status must not report a scope while logged out
67
68
  await writeGlobal(next);
68
69
  if (opts.json)
69
70
  return printJson(envUseResult(target, from ?? null, true, hadSession));
@@ -5,14 +5,15 @@
5
5
  //
6
6
  // The backend is InstaCloud dogfooding itself: the "InstaCloud Agent Feedback" project runs the
7
7
  // ingest service (InsForge/instacloud-feedback repo) on a postgres + compute pair. It is NOT the
8
- // control-plane API on purpose — feedback must work logged-out, unlinked, and from insta-oss,
9
- // and a control-plane outage is exactly when we most want reports to still arrive.
8
+ // control-plane API on purpose — feedback must work unlinked, from insta-oss, and through a
9
+ // control-plane outage, which is exactly when we most want reports to still arrive.
10
10
  import { readFileSync, statSync } from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import * as clack from '@clack/prompts';
13
+ import { ApiClient, ApiError } from '../api.js';
13
14
  import { readGlobal, readProject } from '../config.js';
14
15
  import { envForApiUrl } from '../env.js';
15
- import { info, printJson, CliCancel } from '../util.js';
16
+ import { info, printJson, refuse, CliCancel } from '../util.js';
16
17
  import { clean } from '../redact.js';
17
18
  import { cliVersion } from '../version.js';
18
19
  export const TYPES = ['bug', 'feature-request', 'friction', 'other'];
@@ -42,6 +43,8 @@ const FEEDBACK_INGEST_TOKEN = process.env.INSTA_FEEDBACK_TOKEN || 'insta-feedbac
42
43
  // the DB wake and persists, so a report can land after a shorter deadline gave up on it).
43
44
  // An expired deadline is reported as UNCONFIRMED, not failed — the report may well be stored.
44
45
  const FEEDBACK_TIMEOUT_MS = 15_000;
46
+ // A slow control plane may cost a report its ticket, never the report itself.
47
+ const ASSERTION_TIMEOUT_MS = 5_000;
45
48
  const MAX_FILE_BYTES = 256 * 1024;
46
49
  function requireEnum(value, allowed, flag) {
47
50
  if (!allowed.includes(value)) {
@@ -156,7 +159,7 @@ export async function buildPayload(opts, ctx) {
156
159
  /** One POST, one bounded attempt (FEEDBACK_TIMEOUT_MS), zero retries — feedback is a side quest and must never hang the CLI.
157
160
  * Transport and server failures come back as a result, not an exception: the caller downgrades
158
161
  * them to a warning so a broken feedback backend can't fail the user's actual task. */
159
- export async function submit(payload, fetchImpl) {
162
+ export async function submit(payload, fetchImpl, assertion) {
160
163
  let res;
161
164
  try {
162
165
  res = await fetchImpl(FEEDBACK_ENDPOINT, {
@@ -164,6 +167,7 @@ export async function submit(payload, fetchImpl) {
164
167
  headers: {
165
168
  'Content-Type': 'application/json',
166
169
  Authorization: `Bearer ${FEEDBACK_INGEST_TOKEN}`,
170
+ ...(assertion ? { 'Insta-User-Assertion': assertion } : {}),
167
171
  },
168
172
  body: JSON.stringify(payload),
169
173
  signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS),
@@ -184,7 +188,34 @@ export async function submit(payload, fetchImpl) {
184
188
  return { status: 'error', error: body?.error ?? `HTTP ${res.status}` };
185
189
  return { status: body?.status === 'duplicate' ? 'duplicate' : 'received', id: body?.id ?? null };
186
190
  }
191
+ const SIGNED_OUT = 'not signed in to InstaCloud — run `insta login`, then send this again so the team can reply to you';
192
+ const STAGING = 'not accepted from staging — send InstaCloud feedback from production';
193
+ // Exit 2, not the submit path's 0: the caller can act on this one.
194
+ function refuseFeedback(message, json) {
195
+ if (json)
196
+ printJson({ status: 'refused', submitted: false, error: message });
197
+ refuse([`insta feedback: ${message}`]);
198
+ }
199
+ function inputError(e, json) {
200
+ if (!json)
201
+ throw e;
202
+ printJson({ status: 'error', submitted: false, error: e instanceof Error ? e.message : String(e) });
203
+ process.exitCode = 1;
204
+ }
187
205
  export async function feedback(opts, deps = {}) {
206
+ let api;
207
+ try {
208
+ api = deps.api ?? await ApiClient.load();
209
+ }
210
+ catch (e) {
211
+ return inputError(e, opts.json);
212
+ }
213
+ const env = envForApiUrl(api.apiUrl);
214
+ if (env === 'staging')
215
+ refuseFeedback(STAGING, opts.json);
216
+ // Before the prompts, so nobody types out a report only to be told to sign in.
217
+ if (env === 'prod' && !api.config.accessToken)
218
+ refuseFeedback(SIGNED_OUT, opts.json);
188
219
  const interactive = deps.interactive ?? (!opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY);
189
220
  const missingRequired = !opts.type || !opts.component || !opts.title || (!opts.detail && !opts.file);
190
221
  if (missingRequired && interactive)
@@ -198,13 +229,22 @@ export async function feedback(opts, deps = {}) {
198
229
  payload = await buildPayload(opts, { cliVersion: deps.cliVersion ?? cliVersion() });
199
230
  }
200
231
  catch (e) {
201
- if (!opts.json)
202
- throw e;
203
- printJson({ status: 'error', submitted: false, error: e instanceof Error ? e.message : String(e) });
204
- process.exitCode = 1;
205
- return;
232
+ return inputError(e, opts.json);
233
+ }
234
+ // Fetched after the prompts: it lives five minutes, and a person can take longer than that to type.
235
+ let assertion;
236
+ if (env === 'prod') {
237
+ try {
238
+ // Bearer only: agent evidence adds a session round trip the timeout cannot bound, and 401s signing in cannot fix.
239
+ assertion = (await api.request('GET', '/me/feedback-assertion', undefined, { evidence: false, signal: AbortSignal.timeout(ASSERTION_TIMEOUT_MS) })).token;
240
+ }
241
+ catch (e) {
242
+ if (e instanceof ApiError && e.status === 401)
243
+ refuseFeedback(SIGNED_OUT, opts.json);
244
+ process.stderr.write(`warning: could not confirm who you are (${e instanceof Error ? e.message : String(e)}) — sending anyway, but nobody can reply to this report\n`);
245
+ }
206
246
  }
207
- const result = await submit(payload, deps.fetchImpl ?? fetch);
247
+ const result = await submit(payload, deps.fetchImpl ?? fetch, assertion);
208
248
  if (result.status === 'unconfirmed') {
209
249
  // NOT a failure claim: the request was still in flight at the deadline and the server
210
250
  // finishes what it started, so saying "not submitted" here would be a false negative.
@@ -0,0 +1,132 @@
1
+ // `insta tokens` — mint, list and revoke insta_ API tokens (spec 2026-09-23-scoped-api-tokens §9).
2
+ // A new token binds to an org (or a project) by default; account-wide is an EXPLICIT --account,
3
+ // mirroring the platform rule that "no org" has to be said out loud. The plaintext is printed once.
4
+ import { ApiClient, linkedProject } from '../api.js';
5
+ import { die, info, printJson } from '../util.js';
6
+ const loadApi = async (deps) => deps.api ?? ApiClient.load();
7
+ /** `--expires` → `expiresInDays`: `<n>d` → n, `<n>y` → n×365, 'never' (or absent) → undefined, i.e.
8
+ * the field is not sent. 30d / 90d / 1y are the documented presets; any positive `<n>d` / `<n>y` is
9
+ * accepted because the platform takes any day count (it caps at its own maximum). Anything else is
10
+ * a usage error, raised before any request is made. */
11
+ export function parseExpires(v) {
12
+ if (v === undefined)
13
+ return undefined;
14
+ const s = v.trim().toLowerCase();
15
+ if (s === 'never')
16
+ return undefined;
17
+ const m = /^(\d+)([dy])$/.exec(s);
18
+ const n = m ? Number(m[1]) : 0;
19
+ if (!m || !Number.isSafeInteger(n) || n <= 0)
20
+ throw new Error(`--expires expects a duration like 30d, 90d, 1y (any <n>d or <n>y) or never, got "${v}"`);
21
+ return m[2] === 'y' ? n * 365 : n;
22
+ }
23
+ /** One phrase for a credential's reach — the login line, `insta status`, and the token_scope hint. */
24
+ export function describeTokenScope(s) {
25
+ const ro = s?.access === 'read_only' ? ', read-only' : '';
26
+ if (!s || s.scope === 'account')
27
+ return `account-wide${ro}`;
28
+ if (s.scope === 'project')
29
+ return `project ${s.projectId} (org ${s.orgId})${ro}`;
30
+ return `org ${s.orgId}${ro}`;
31
+ }
32
+ /** What the command guard prints for a 403 token_scope: the platform's message verbatim, then ONE
33
+ * hint naming the credential this login holds. Deliberately not a permissions message — the user
34
+ * IS a member; only the credential is narrow (spec §5.2), and "no permission" sends people off to
35
+ * check roles. */
36
+ export function tokenScopeErrorLines(body, scope) {
37
+ const message = typeof body.message === 'string' && body.message ? body.message : 'refused by the scope of this token';
38
+ const held = scope ? `this login's token: ${describeTokenScope(scope)}` : 'this login uses a scoped token';
39
+ return `${message}\n ${held} — mint a wider one with \`insta tokens create <name> --account\` (from an account login) or run \`insta login\``;
40
+ }
41
+ const id8 = (s) => (s ?? '').slice(0, 8);
42
+ const day = (iso) => (iso ? iso.slice(0, 10) : 'never');
43
+ /** The scope column of `tokens list`: `account`, `org:<id8>`, or `<org id8>/<project id8>`. */
44
+ export function scopeColumn(t) {
45
+ if (t.scope === 'project')
46
+ return `${id8(t.orgId)}/${id8(t.projectId)}`;
47
+ if (t.scope === 'org')
48
+ return `org:${id8(t.orgId)}`;
49
+ return 'account';
50
+ }
51
+ const scopeOf = (r) => ({
52
+ scope: r.scope, access: r.access,
53
+ ...(r.orgId ? { orgId: r.orgId } : {}),
54
+ ...(r.projectId ? { projectId: r.projectId } : {}),
55
+ });
56
+ // Column-aligned rows; trailing spaces trimmed so an empty last cell leaves no ragged edge.
57
+ function table(rows) {
58
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => (r[i] ?? '').length)));
59
+ return rows.map((r) => r.map((c, i) => (c ?? '').padEnd(widths[i])).join(' ').trimEnd());
60
+ }
61
+ export async function tokensList(opts, deps = {}) {
62
+ const api = await loadApi(deps);
63
+ const { tokens } = (await api.request('GET', '/tokens'));
64
+ if (opts.json)
65
+ return printJson(tokens);
66
+ if (!tokens.length)
67
+ return info('(no tokens)');
68
+ const rows = tokens.map((t) => [t.id, t.name, scopeColumn(t), t.access, day(t.expiresAt), day(t.lastUsedAt), t.revokedAt ? 'revoked' : '']);
69
+ for (const line of table([['id', 'name', 'scope', 'access', 'expires', 'last-used', ''], ...rows]))
70
+ info(line);
71
+ }
72
+ export async function tokensCreate(name, opts, deps = {}) {
73
+ if (opts.account && (opts.org || opts.project))
74
+ die('--account is mutually exclusive with --org / --project: an account-wide token has no org');
75
+ const expiresInDays = parseExpires(opts.expires ?? '90d'); // validated before any request
76
+ const api = await loadApi(deps);
77
+ const binding = opts.account ? {} : await resolveBinding(api, opts, deps.linked ?? linkedProject);
78
+ const body = {
79
+ name,
80
+ ...binding,
81
+ access: opts.readOnly ? 'read_only' : 'full',
82
+ ...(expiresInDays === undefined ? {} : { expiresInDays }),
83
+ };
84
+ const { token, record } = (await api.request('POST', '/tokens', body));
85
+ if (opts.json)
86
+ return printJson({ token, record });
87
+ // The plaintext ALONE on stdout, so `$(insta tokens create ci)` captures exactly the token; the
88
+ // note goes to stderr and never repeats the secret.
89
+ process.stdout.write(token + '\n');
90
+ const expiry = record.expiresAt ? `expires ${day(record.expiresAt)}` : 'never expires';
91
+ process.stderr.write(`created token ${record.id} (${record.name}: ${describeTokenScope(scopeOf(record))}; ${expiry}) — the plaintext above is shown once; store it now\n`);
92
+ }
93
+ // Default-org rule (spec §9.1 / §9.2), most explicit first: --project → its org; --org; the org an
94
+ // org-scoped login is bound to (the only one it can mint in); the linked project's org; the caller's
95
+ // only org. Several orgs — or none — stop here rather than guess: binding a CI token to the wrong
96
+ // org is exactly the mistake the scope exists to prevent.
97
+ async function resolveBinding(api, opts, linked) {
98
+ if (opts.project) {
99
+ const { project } = (await api.request('GET', `/projects/${encodeURIComponent(opts.project)}`));
100
+ // A pair the platform would 404 (project outside the org, spec §4.4) is caught here with the real reason.
101
+ if (opts.org && opts.org !== project.org_id)
102
+ die(`project ${opts.project} belongs to org ${project.org_id}, not --org ${opts.org}`);
103
+ return { orgId: project.org_id, projectId: project.id };
104
+ }
105
+ if (opts.org)
106
+ return { orgId: opts.org };
107
+ // A project-scoped login cannot mint at all (the platform answers 403 token_scope on POST /tokens);
108
+ // say so here rather than requesting an org token it could never be granted.
109
+ if (api.config.tokenScope?.projectId)
110
+ die(`this login is a project-scoped token (${describeTokenScope(api.config.tokenScope)}) and cannot mint tokens — log in with an org or account credential first`);
111
+ const bound = api.config.tokenScope?.orgId;
112
+ if (bound)
113
+ return { orgId: bound };
114
+ const link = await linked();
115
+ if (link?.orgId)
116
+ return { orgId: link.orgId };
117
+ const { orgs } = (await api.request('GET', '/orgs'));
118
+ if (orgs.length === 1)
119
+ return { orgId: orgs[0].id };
120
+ if (orgs.length === 0)
121
+ die('no org found — pass --account for an account-wide token, or create an org first: insta org create <name>');
122
+ const list = orgs.map((o) => ` ${o.id} ${o.name}`).join('\n');
123
+ die(`several orgs — pass --org <id> to bind the token to one, or --account for an account-wide token:\n${list}`);
124
+ }
125
+ export async function tokensRevoke(id, opts, deps = {}) {
126
+ const api = await loadApi(deps);
127
+ await api.request('DELETE', `/tokens/${encodeURIComponent(id)}`);
128
+ if (opts.json)
129
+ return printJson({ ok: true, id });
130
+ info(`revoked token ${id}`);
131
+ }
132
+ //# sourceMappingURL=tokens.js.map
package/dist/config.js CHANGED
@@ -60,6 +60,7 @@ export function pickApiUrl(parsed, env, cliOverride) {
60
60
  delete scrubbed.refreshToken;
61
61
  delete scrubbed.user;
62
62
  delete scrubbed.agentCredential;
63
+ delete scrubbed.tokenScope;
63
64
  return scrubbed;
64
65
  }
65
66
  return { ...parsed, apiUrl: override ?? persisted };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Option } from 'commander';
3
3
  import { configureAgent, detectAgent } from './agent.js';
4
- import { setApiUrlOverride } from './config.js';
4
+ import { readGlobal, setApiUrlOverride } from './config.js';
5
5
  import * as agentPolicy from './commands/agent-policy.js';
6
6
  import { ApiError, AgentApprovalRequired } from './api.js';
7
7
  import { CliCancel, CliExit, fail, relayedExitCode } from './util.js';
@@ -14,6 +14,7 @@ import * as setup from './commands/setup.js';
14
14
  import * as mcp from './commands/mcp.js';
15
15
  import * as runCmd from './commands/run.js';
16
16
  import * as org from './commands/org.js';
17
+ import * as tokens from './commands/tokens.js';
17
18
  import * as project from './commands/project.js';
18
19
  import * as branch from './commands/branch.js';
19
20
  import * as services from './commands/services.js';
@@ -39,7 +40,7 @@ import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
39
40
  import * as domainCmd from './commands/domain.js';
40
41
  import * as selfUpdate from './commands/upgrade.js';
41
42
  import * as feedbackCmd from './commands/feedback.js';
42
- function onError(e) {
43
+ async function onError(e) {
43
44
  if (e instanceof AgentApprovalRequired) {
44
45
  if (process.argv.includes('--json'))
45
46
  process.stdout.write(JSON.stringify(e.body) + '\n');
@@ -50,8 +51,13 @@ function onError(e) {
50
51
  }
51
52
  if (e instanceof CliExit || e instanceof CliCancel)
52
53
  return;
53
- if (e instanceof ApiError)
54
+ if (e instanceof ApiError) {
55
+ // A scope refusal is not a permissions problem (spec 2026-09-23-scoped-api-tokens §5.2): relay
56
+ // the platform's words and name the credential this login holds, so nobody goes checking roles.
57
+ if (e.body?.error === 'token_scope')
58
+ return fail(tokens.tokenScopeErrorLines(e.body, (await readGlobal()).tokenScope));
54
59
  return fail(`${e.message} (HTTP ${e.status})`);
60
+ }
55
61
  fail(e instanceof Error ? e.message : String(e));
56
62
  }
57
63
  // Wrap an async action so rejections surface as clean CLI errors.
@@ -64,7 +70,7 @@ const guard = (fn) => async (...a) => {
64
70
  }
65
71
  catch (e) {
66
72
  error = e;
67
- onError(e);
73
+ await onError(e);
68
74
  }
69
75
  await trackCommand(a[a.length - 1], a.slice(0, -2), {
70
76
  error, durationMs: Date.now() - started, exitCode: Number(process.exitCode ?? 0), childExitCode: relayedExitCode(),
@@ -110,6 +116,18 @@ envCmd.command('use <name>').description(`Switch environment (${ENV_NAMES.join('
110
116
  const orgCmd = program.command('org').description('Manage organizations');
111
117
  orgCmd.command('list').option('--json').action(guard((o) => org.orgList(o)));
112
118
  orgCmd.command('create <name>').option('--json').action(guard((name, o) => org.orgCreate(name, o)));
119
+ // ---- tokens ----
120
+ const tk = program.command('tokens').description('Manage API tokens (account, org or project scoped)');
121
+ tk.command('list').description('List your API tokens: scope, access, expiry, last use').option('--json').action(guard((o) => tokens.tokensList(o)));
122
+ tk.command('create <name>').description('Mint a token; defaults to the current org — pass --account for an account-wide token. The plaintext is printed once')
123
+ .option('--org <id>', "bind to this org (default: linked project's org, or your only org)")
124
+ .option('--project <id>', 'bind to this project (implies its org)')
125
+ .option('--account', 'account-wide token: everything your account can do (mutually exclusive with --org/--project)')
126
+ .option('--read-only', 'GET only; cannot run SQL or change anything')
127
+ .option('--expires <dur>', '30d | 90d | 1y | never — any <n>d or <n>y works', '90d')
128
+ .option('--json')
129
+ .action(guard((name, o) => tokens.tokensCreate(name, o)));
130
+ tk.command('revoke <id>').description('Revoke a token — it stops working immediately').option('--json').action(guard((id, o) => tokens.tokensRevoke(id, o)));
113
131
  // ---- project ----
114
132
  const pj = program.command('project').description('Manage projects');
115
133
  pj.command('create [name]').option('--org <id>', 'org to create under (default: personal)').option('--json').action(guard((name, o) => project.projectCreate(name, o)));
@@ -529,7 +547,7 @@ function withSetupAgentOptions(cmd) {
529
547
  return cmd
530
548
  .option('-y, --yes', 'non-interactive')
531
549
  .option('--env <prod|staging>', 'deployment to set this machine up for (default: prod — switches and persists, like `insta env use`)')
532
- .option('--mcp-token', 'register Claude Code with a minted insta_ API token instead of OAuth (requires login and token-creation permission)')
550
+ .option('--mcp-token', 'register Claude Code with a minted account-wide insta_ API token (everything your account can do) instead of OAuth (requires login and token-creation permission)')
533
551
  .option('--project <id>', 'also link this directory to an existing project after setup (flows through login first if needed)')
534
552
  .option('--create [name]', 'also create a new project and link this directory after setup (default name: this directory; mutually exclusive with --project)')
535
553
  .action(guard((o) => setup.setupAgent(o)));
@@ -562,7 +580,7 @@ agent.command('events').description('Show the audit + agent-event timeline').opt
562
580
  const cfg = program.command('config').description('CLI configuration: register the remote MCP server with coding agents, list regions, auto-update');
563
581
  cfg.command('install-mcp').description('Register the remote MCP server with coding agents (default: Claude Code + all detected)')
564
582
  .option('--agent <slug>', 'one agent: claude-code, cursor, codex, opencode, copilot, factory-droid')
565
- .option('--mcp-token', 'claude-code only: minted insta_ API token instead of OAuth (requires login and token-creation permission)')
583
+ .option('--mcp-token', 'claude-code only: a minted account-wide insta_ API token (everything your account can do) instead of OAuth (requires login and token-creation permission)')
566
584
  .action(guard((o) => mcp.mcpInstall(o)));
567
585
  cfg.command('regions').description('List regions available for postgres/compute services').option('--json').action(guard((o) => regions.regionsList(o)));
568
586
  cfg.command('autoupdate [mode]').description('Show or set auto-update: on | off (default: on while pre-1.0)').action(guard((mode) => selfUpdate.autoupdate(mode)));
@@ -574,7 +592,7 @@ const setupCompat = program.command('setup', { hidden: true }).description('Comp
574
592
  withSetupAgentOptions(setupCompat.command('agent').description('Alias of `insta agent setup`, kept for the console one-liner'));
575
593
  // ---- feedback (agent + human hurdle reports → the InstaCloud team) ----
576
594
  program.command('feedback')
577
- .description('Report an InstaCloud-side hurdle (bug / missing feature / friction) to the InstaCloud team — about the insta toolkit itself, NEVER about the app you are building. Works logged-out and unlinked.')
595
+ .description('Report an InstaCloud-side hurdle (bug / missing feature / friction) to the InstaCloud team — about the insta toolkit itself, NEVER about the app you are building. On InstaCloud it needs `insta login`, so the team can reply; works unlinked.')
578
596
  .option('--type <type>', `what kind of hurdle: ${feedbackCmd.TYPES.join(' | ')}`)
579
597
  .option('--component <component>', `which part of the toolkit: ${feedbackCmd.COMPONENTS.join(' | ')}`)
580
598
  .option('--title <title>', 'one-line summary (≤200 chars)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [