insta 0.0.82 → 0.1.0

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
@@ -33,15 +33,17 @@ version-pinned `npm install -g` fallback to run yourself). E2e-validated on macO
33
33
  and Windows (PowerShell + cmd):
34
34
 
35
35
  ```bash
36
- npx -y insta@latest setup agent
36
+ npx -y insta@latest agent setup
37
37
  ```
38
38
 
39
+ `insta setup agent` is still accepted as a hidden alias, so older instructions keep working.
40
+
39
41
  This command means **production** (CLI ≥ 0.0.38): if the machine was previously switched to
40
42
  staging it switches back — announced, session dropped, like `insta env use prod`. Staging is
41
43
  its own explicit command, which also persists the choice:
42
44
 
43
45
  ```bash
44
- npx -y insta@latest setup agent --env staging
46
+ npx -y insta@latest agent setup --env staging
45
47
  ```
46
48
 
47
49
  On macOS/Linux without Node, the native-binary installer puts the `insta` CLI on PATH (the
@@ -56,7 +58,7 @@ curl -fsSL agents.instacloud.com | sh
56
58
 
57
59
  Pin a version with `INSTA_VERSION=v0.0.22`; change the install directory with
58
60
  `INSTA_INSTALL_DIR`. While the CLI is pre-1.0 it updates itself on new releases. Turn that
59
- off with `insta autoupdate off`.
61
+ off with `insta config autoupdate off`.
60
62
 
61
63
  ## Quickstart
62
64
 
@@ -72,7 +74,7 @@ insta deploy .
72
74
 
73
75
  `project create` makes an empty project and links the current directory. Services are
74
76
  opt-in, so you add only what you need. `secrets` writes the current branch's user-defined
75
- secrets to `./.env` (the postgres connection string is read with `insta db url`). `deploy .`
77
+ secrets to `./.env` (the postgres connection string is read with `insta postgres url`). `deploy .`
76
78
  builds the directory remotely and ships it to the branch's compute
77
79
  service, with no local Docker. Whether it needs a `Dockerfile` depends on where the
78
80
  service runs: on insta-compute it is optional, and a directory without one is built
@@ -119,7 +121,7 @@ the two branches diverge independently. A project is capped at 10 branches.
119
121
  only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`,
120
122
  `AWS_ACCESS_KEY_ID`, …) are not in that bundle — they reach compute through explicit
121
123
  `insta secrets bind` rules, and the postgres connection string is read directly with
122
- `insta db url` (or `insta db connect` for a psql session).
124
+ `insta postgres url` (or `insta postgres connect` for a psql session).
123
125
 
124
126
  Secrets can be scoped per compute service, so several services may each define the same name — and
125
127
  a flat bundle cannot carry two values for one name. Such a name is **withheld** from the bundle and
@@ -133,8 +135,8 @@ it as `insta run --service compute/<name>` to inject exactly what that one servi
133
135
 
134
136
  Agent requests are governed by the project's `agent-policy`; human requests use normal RBAC.
135
137
  Where the agent policy says `approve`, the command stops and prints an approval id for a human
136
- admin to grant with `insta approvals approve <id>`. The agent then retries the unchanged request.
137
- Run `insta --agent agent-policy get --json` for stored overrides, `defaultRules`, `effectiveRules`,
138
+ admin to grant with `insta agent approvals approve <id>`. The agent then retries the unchanged request.
139
+ Run `insta --agent agent policy get --json` for stored overrides, `defaultRules`, `effectiveRules`,
138
140
  `bootstrapRules` and `ruleNotes`. Rules distinguish no affected branches (`project`), unprotected
139
141
  branches and protected branches. They describe policy, not authorization: RBAC, session checks,
140
142
  actual affected resources and compound actions still apply. An empty override object does not
@@ -143,8 +145,8 @@ mean rules are unavailable. Text output also lists effective rules. The old `pol
143
145
 
144
146
  ### Agents get the same surface
145
147
 
146
- `insta manifest` prints an agent-legible view of every branch and its URLs. `insta setup
147
- agent` installs the InstaCloud skill and registers the remote MCP server for the coding
148
+ `insta agent manifest` prints an agent-legible view of every branch and its URLs. `insta agent
149
+ setup` installs the InstaCloud skill and registers the remote MCP server for the coding
148
150
  agents on the machine — and, when running from the npx cache with no durable `insta` on
149
151
  PATH, first installs the CLI itself globally.
150
152
 
@@ -211,29 +213,26 @@ build never reaches a production installer.
211
213
  | Command | What it covers |
212
214
  |---|---|
213
215
  | `insta login` · `logout` · `status` | Browser sign-in (default), `--email` + password, or `--oauth github\|google`; `status` shows the environment, login and linked project/branch |
214
- | `insta env` | `show` · `use <prod\|staging>` |
215
- | `insta setup` | `agent` — install the CLI (if missing), the skill, and MCP for every coding agent; targets prod, `--env staging` for staging |
216
- | `insta mcp` | `install` — register the remote MCP server only |
217
216
  | `insta org` | `list` · `create` (one free org per user) |
218
217
  | `insta project` | `create` · `list` · `link` · `delete` |
219
218
  | `insta branch` | `create` · `list` · `switch` · `delete` · `merge` |
220
- | `insta services` | `add` · `list` · `remove` · `rename` · `set-access` · `scale` · `upgrade` · `secrets` |
221
- | `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `tree` |
219
+ | `insta service` (`services`, `svc`) | `add` · `list` · `remove` · `rename` |
220
+ | `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `bind` · `unbind` · `bindings` · `sources` · `tree` |
221
+ | `insta domain` | Bought or bring-your-own: `attach` · `check` · `detach`; buy through InstaCloud: `search` · `buy` · `list` · `status` · `records …` |
222
+ | `insta compute` | `start` · `stop` · `suspend` · `restart` · `status` · `scale` · `limits` · `volume` · `always-on` · `exec` · `ssh` · `repo` · `connect-repo` · `watch-paths` · `disconnect-repo` · `logs` · `metrics` |
223
+ | `insta postgres` | `url` (print the DSN) · `connect` (psql) · `stats` · `limits` · `volume` · `always-on` · `logs` · `metrics` — every verb takes `[service]` |
224
+ | `insta redis` · `mysql` · `mongodb` | `query` · `status` · `limits` · `volume` · `always-on` · `logs` · `metrics` |
225
+ | `insta storage` | `list` · `get` · `delete` · `set-access` |
226
+ | `insta build [dir]` · `deploy [dir]` | Verify a source dir would build; deploy a source directory (built remotely) or `--image <url>`. `insta build logs <id>` reads source-build output: `--source archive` (default) uses the deploy operation ID, `--source github` uses a GitHub build ID; `--follow` watches output, `--json` returns one snapshot |
222
227
  | `insta run <cmd>` | Run a command with the branch bundle injected, nothing written to disk |
223
- | `insta deploy [dir]` | Deploy a source directory (built remotely) or `--image <url>` |
224
- | `insta compute` | `start` · `stop` · `suspend` · `status` · `set-domain` · `check-domain` · `remove-domain` |
225
- | `insta domain` | Buy a domain through InstaCloud: `search` · `buy` · `attach` · `list` · `status` |
226
- | `insta db` | `url` (print the postgres DSN) · `connect` (psql session) · `limits` · `stats` · `always-on` · `volume` |
227
- | `insta regions` | Regions available for postgres and compute |
228
- | `insta manifest` | Agent-legible view of every branch and its URLs |
229
- | `insta build-logs <id>` | Read source-build output; `--source archive` (default) uses the deploy operation ID, `--source github` uses a GitHub build ID; `--follow` watches output, `--json` returns one snapshot |
230
- | `insta metrics` · `logs` · `events` | Service metrics; runtime logs (`--deploy` for deploy events); audit timeline |
231
- | `insta usage` · `billing` | Usage by billing dimension; `billing upgrade` · `billing portal` |
232
- | `insta approvals` | `list` · `approve` · `deny` |
233
- | `insta agent-policy` | `get` · `set <mode>` · `protect-branch` · `unprotect-branch` · `rule set <action> <decision>` · `revoke-sessions` |
234
- | `insta observe` | `install` · `uninstall` · `report` · `sync` — local credential audit |
228
+ | `insta template` | `list` · `info` · `deploy` |
229
+ | `insta billing` | Current cycle overview; `subscribe <tier>` · `portal` · `usage` |
230
+ | `insta agent` | `setup` (this machine's coding agents) · `manifest` · `policy …` · `approvals …` · `observe …` · `events` |
231
+ | `insta config` | `install-mcp` · `regions` · `autoupdate` |
235
232
  | `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; works logged-out |
236
- | `insta upgrade` · `autoupdate` | Update the CLI; show or set auto-update |
233
+ | `insta upgrade` | Update the CLI |
234
+
235
+ Every command accepts `--api-url <url>` for this invocation only (internal debugging); for `compute exec`, place it before `compute`. `insta --help` documents it.
237
236
 
238
237
  ## Configuration
239
238
 
@@ -256,10 +255,10 @@ build never reaches a production installer.
256
255
  ## Agent skills
257
256
 
258
257
  The `insta` skill and its task guides live in
259
- [InsForge/instacloud-skills](https://github.com/InsForge/instacloud-skills). `insta setup agent`
258
+ [InsForge/instacloud-skills](https://github.com/InsForge/instacloud-skills). `insta agent setup`
260
259
  installs it user-globally for every coding agent on the machine. `insta project create` and
261
260
  `insta project link` additionally install the stack skills (Tigris, Better Auth) into the
262
- project, along with the `insta observe` credential-audit hook. Postgres needs no stack
261
+ project, along with the `insta agent observe` credential-audit hook. Postgres needs no stack
263
262
  skill — it's plain Postgres, reached directly via `DATABASE_URL`.
264
263
 
265
264
  ## Contributing
package/dist/agent.js CHANGED
@@ -17,7 +17,7 @@ export function canonicalTarget(path) {
17
17
  const url = new URL(path, 'https://platform.invalid');
18
18
  return url.pathname + url.search;
19
19
  }
20
- const guidance = 'agent session missing, expired, or for another project/environment — run `insta setup agent`';
20
+ const guidance = 'agent session missing, expired, or for another project/environment — run `insta agent setup`';
21
21
  export async function issueAgentSession(api, projectId) {
22
22
  const pair = generateKeyPairSync('ed25519');
23
23
  const client = mode?.client ?? detectAgent(false)?.client ?? 'unknown';
@@ -30,7 +30,7 @@ export async function saveAgentSession(session, cwd = process.cwd()) {
30
30
  const root = await findProjectRoot(cwd) ?? cwd;
31
31
  const rel = '.insta/agent-session.json';
32
32
  if (alreadyTracked(root, [rel]).length)
33
- throw new Error('agent-session.json is tracked by Git; untrack it before running insta setup agent');
33
+ throw new Error('agent-session.json is tracked by Git; untrack it before running insta agent setup');
34
34
  ensureGitignore(root, [rel], '# Local agent credentials');
35
35
  const dir = join(root, '.insta');
36
36
  await mkdir(dir, { recursive: true });
package/dist/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Thin API client over the platform control-plane. Handles bearer auth + one-shot refresh on 401.
2
2
  // 2xx (including 202 approval_required) returns the parsed body; >=400 throws ApiError.
3
- import { readGlobal, writeGlobal, readProject, persistAutoLink, resolveProjectLink, foreignLinkMessage } from './config.js';
3
+ import { readGlobal, readPersistedGlobal, writeGlobal, readProject, persistAutoLink, resolveProjectLink, foreignLinkMessage } from './config.js';
4
4
  import { autoResolveProject, promptChoice } from './resolve-project.js';
5
5
  import { die } from './util.js';
6
6
  import { USER_AGENT } from './version.js';
@@ -45,8 +45,23 @@ export class ApiClient {
45
45
  static async load() { return new ApiClient(await readGlobal()); }
46
46
  get apiUrl() { return this.cfg.apiUrl; }
47
47
  get config() { return this.cfg; }
48
- async persist() { await writeGlobal(this.cfg); }
49
- setApiUrl(url) { this.cfg.apiUrl = url; }
48
+ // `this.cfg` came from readGlobal(), which has already folded in the AMBIENT control-plane
49
+ // override (--api-url, INSTA_API_URL, INSTA_ENV) — so writing it back verbatim persists a
50
+ // debugging override as this machine's control plane. `insta logout --api-url <staging>` did
51
+ // exactly that: it re-pointed the stored apiUrl at staging and dropped the real login with it
52
+ // (readGlobal scrubs the session of a foreign deployment, and that scrubbed view was the thing
53
+ // being written). Only an EXPLICIT setApiUrl — a login, which chose the deployment it
54
+ // authenticated against — may move the stored URL; everything else keeps what is on disk.
55
+ async persist() {
56
+ if (this.apiUrlExplicit)
57
+ return writeGlobal(this.cfg);
58
+ await writeGlobal({ ...this.cfg, apiUrl: (await readPersistedGlobal()).apiUrl });
59
+ }
60
+ apiUrlExplicit = false;
61
+ setApiUrl(url) {
62
+ this.cfg.apiUrl = url;
63
+ this.apiUrlExplicit = true;
64
+ }
50
65
  setSession(tokens, user) {
51
66
  this.cfg.accessToken = tokens.accessToken;
52
67
  this.cfg.refreshToken = tokens.refreshToken;
@@ -144,7 +159,7 @@ export async function requireProject(deps = {}) {
144
159
  if (r)
145
160
  return r.link;
146
161
  if (agentMode())
147
- die('agent mode requires a linked project — run `insta setup agent --project <id>`');
162
+ die('agent mode requires a linked project — run `insta agent setup --project <id>`');
148
163
  if (deps.autoResolve)
149
164
  return deps.autoResolve();
150
165
  // One command, just works: unlinked ≠ error. Resolve the project (auto when there's one,
@@ -24,7 +24,20 @@ export async function readBuildLogs(api, projectId, source, buildId, signal = Ab
24
24
  if (++requests > 200)
25
25
  throw new Error('build logs exceed the per-read page limit');
26
26
  const params = new URLSearchParams({ ...(step ? { step } : {}), ...(cursor ? { cursor } : {}) });
27
- const { body } = await api.rawRequest('GET', `/projects/${projectId}/builds/${source}/${encodeURIComponent(buildId)}/logs?${params}`, undefined, { signal });
27
+ signal.throwIfAborted();
28
+ const request = new AbortController();
29
+ const abort = () => request.abort(signal.reason);
30
+ signal.addEventListener('abort', abort, { once: true });
31
+ const timeout = setTimeout(() => request.abort(new DOMException('build log request timed out', 'TimeoutError')), 20_000);
32
+ let body;
33
+ try {
34
+ const response = await api.rawRequest('GET', `/projects/${projectId}/builds/${source}/${encodeURIComponent(buildId)}/logs?${params}`, undefined, { signal: request.signal });
35
+ body = response.body;
36
+ }
37
+ finally {
38
+ clearTimeout(timeout);
39
+ signal.removeEventListener('abort', abort);
40
+ }
28
41
  if (!body || !['ready', 'pending', 'unsupported', 'unavailable'].includes(body.state) || !Array.isArray(body.steps) || !Array.isArray(body.entries))
29
42
  throw new Error('invalid build log response');
30
43
  bytes += Buffer.byteLength(JSON.stringify(body));
@@ -143,20 +156,24 @@ export function archiveLogWatcher(api, projectId, write, wait = sleep) {
143
156
  const follow = { tails: new Map(), emit: snapshot => printer.print(snapshot, write) };
144
157
  let warned = '';
145
158
  let unavailable = false;
146
- return async (buildId, finished, remainingMs = 30_000) => {
159
+ return async (buildId, finished, remainingMs = 30_000, cancelSignal) => {
147
160
  if (unavailable)
148
161
  return;
149
- const deadline = Date.now() + Math.min(remainingMs, finished ? 18_000 : 3000);
162
+ const started = Date.now();
163
+ const deadline = started + remainingMs;
164
+ const refreshDeadline = started + Math.min(remainingMs, finished ? 18_000 : remainingMs);
150
165
  for (let attempt = 0; attempt < (finished ? 6 : 1); attempt++) {
151
166
  if (attempt > 0)
152
- await wait(Math.min(3000, Math.max(0, deadline - Date.now())));
153
- const remaining = deadline - Date.now();
167
+ await wait(Math.min(3000, Math.max(0, refreshDeadline - Date.now())));
168
+ const finalRead = finished && (attempt === 5 || Date.now() >= refreshDeadline);
169
+ const remaining = Math.min(deadline - Date.now(), finalRead ? 30_000 : refreshDeadline - Date.now());
154
170
  if (remaining <= 0)
155
171
  return;
156
- if (finished && (attempt === 0 || attempt === 5))
172
+ if (finished && (attempt === 0 || finalRead))
157
173
  follow.tails.clear();
174
+ const signal = cancelSignal ?? AbortSignal.timeout(remaining);
158
175
  try {
159
- const snapshot = await readBuildLogs(api, projectId, 'archive', buildId, AbortSignal.timeout(remaining), follow);
176
+ const snapshot = await readBuildLogs(api, projectId, 'archive', buildId, signal, follow);
160
177
  if (snapshot.state === 'unsupported') {
161
178
  printer.finishLine(write);
162
179
  write('Build logs are not supported for this build provider.\n');
@@ -168,14 +185,21 @@ export function archiveLogWatcher(api, projectId, write, wait = sleep) {
168
185
  warned = '';
169
186
  }
170
187
  catch (error) {
188
+ if (cancelSignal?.aborted)
189
+ return;
190
+ if (finished && signal.aborted && !finalRead && Date.now() < deadline)
191
+ continue;
171
192
  if (error instanceof ApiError && error.status === 400)
172
193
  follow.tails.clear();
173
194
  printer.finishLine(write);
174
- const message = `Could not read build logs. Retry with: insta build-logs ${buildId}\n`;
195
+ const reason = signal.aborted || (error instanceof Error && error.name === 'TimeoutError') ? ' (timed out)' : error instanceof ApiError ? ` (HTTP ${error.status})` : '';
196
+ const message = `Could not read build logs${reason}. Retry with: insta build logs ${buildId}\n`;
175
197
  if (warned !== message)
176
198
  write(message);
177
199
  warned = message;
178
200
  }
201
+ if (finalRead)
202
+ return;
179
203
  }
180
204
  };
181
205
  }
@@ -82,7 +82,7 @@ export function applyRule(policy, out, action, decision) {
82
82
  return { ...policy, branchDeveloperRules: { ...(policy.branchDeveloperRules ?? {}), [action]: decision } };
83
83
  const entry = catalog.find(e => e.action === action);
84
84
  if (!entry)
85
- throw new Error(`unknown action: ${action}\nrun: insta agent-policy get --json`);
85
+ throw new Error(`unknown action: ${action}\nrun: insta agent policy get --json`);
86
86
  if (!entry.editable)
87
87
  throw new Error(`${action} is a fixed policy invariant and cannot be overridden`);
88
88
  // Snapshot taken from the resolved unprotected-branch view, which is the context these rules
@@ -1,12 +1,19 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { ApiClient, ApiError, linkedProject } from '../api.js';
4
+ import { readGlobal, readPersistedGlobal } from '../config.js';
4
5
  import { agentMode } from '../agent.js';
5
- import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js';
6
+ import { ENVS, ENV_NAMES, envForApiUrl, isEnvName, normalizeUrl } from '../env.js';
6
7
  import { info, die, printJson, promptPassword, openUrl } from '../util.js';
7
8
  /** --api-url and --env both set the target host; --api-url wins (more specific), matching the
8
9
  * INSTA_API_URL > INSTA_ENV precedence in config.ts. Returns the URL to point at, or undefined
9
- * to leave whatever is already resolved alone. */
10
+ * to leave whatever is already resolved alone.
11
+ *
12
+ * Every login entry point feeds this into `api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl)`: a
13
+ * login is the one command that MAY move the machine's stored control-plane URL, and it must
14
+ * store the deployment it actually authenticated against — flag, env var or stored URL alike.
15
+ * Without the explicit set, ApiClient.persist() keeps the URL already on disk (see its comment),
16
+ * which would file a session minted on one deployment under another one's URL. */
10
17
  function targetApiUrl(opts) {
11
18
  if (opts.apiUrl)
12
19
  return opts.apiUrl;
@@ -50,9 +57,7 @@ export async function login(opts, device = loginDevice, claim = loginClaim) {
50
57
  return device(opts, openUrl);
51
58
  }
52
59
  const api = await ApiClient.load();
53
- const target = targetApiUrl(opts);
54
- if (target)
55
- api.setApiUrl(target);
60
+ api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl);
56
61
  const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword());
57
62
  const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false });
58
63
  api.setSession(res, res.user);
@@ -65,9 +70,7 @@ export async function loginOauth(provider, opts) {
65
70
  if (provider !== 'github' && provider !== 'google')
66
71
  die('provider must be github or google');
67
72
  const api = await ApiClient.load();
68
- const target = targetApiUrl(opts);
69
- if (target)
70
- api.setApiUrl(target);
73
+ api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl);
71
74
  const token = await browserOauth(api.apiUrl, provider);
72
75
  api.setSession({ accessToken: token, refreshToken: token });
73
76
  const me = await api.request('GET', '/me');
@@ -82,9 +85,7 @@ export async function loginOauth(provider, opts) {
82
85
  // (which owns the signin round-trip), and poll the platform until they approve.
83
86
  export async function loginDevice(opts, open) {
84
87
  const api = await ApiClient.load();
85
- const target = targetApiUrl(opts);
86
- if (target)
87
- api.setApiUrl(target);
88
+ api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl);
88
89
  const token = await deviceGrant((path, body) => api.request('POST', path, body, { auth: false }), sleepSeconds, open);
89
90
  api.setSession({ accessToken: token, refreshToken: token });
90
91
  const me = await api.request('GET', '/me');
@@ -96,9 +97,7 @@ export async function loginDevice(opts, open) {
96
97
  // the console, the platform mints an insta_ key, and it is stored exactly as --api-key stores one.
97
98
  export async function loginClaim(email, opts, open, grant = claimGrant) {
98
99
  const api = await ApiClient.load();
99
- const target = targetApiUrl(opts);
100
- if (target)
101
- api.setApiUrl(target);
100
+ api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl);
102
101
  const client = agentMode()?.client ?? 'unknown';
103
102
  const key = await grant(email, client, (path, body, signal) => api.request('POST', path, body, { auth: false, signal }), sleepSeconds, open);
104
103
  const user = await applyApiKeyLogin(api, key);
@@ -108,9 +107,7 @@ export async function loginClaim(email, opts, open, grant = claimGrant) {
108
107
  // Non-interactive login with a durable insta_ key (minted via POST /tokens): store it and confirm against /me. No browser, no polling.
109
108
  export async function loginApiKey(key, opts) {
110
109
  const api = await ApiClient.load();
111
- const target = targetApiUrl(opts);
112
- if (target)
113
- api.setApiUrl(target);
110
+ api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl);
114
111
  const user = await applyApiKeyLogin(api, key);
115
112
  await api.persist();
116
113
  info(`logged in as ${user.email ?? user.id} @ ${api.apiUrl}`);
@@ -344,8 +341,25 @@ function browserOauth(apiUrl, provider) {
344
341
  });
345
342
  });
346
343
  }
344
+ /** Log out: revoke the session on the server, then clear the local tokens.
345
+ *
346
+ * Built from the PERSISTED config, not from `ApiClient.load()`'s override-resolved view. There is
347
+ * exactly one stored session, so a runtime `--api-url` (or INSTA_API_URL / INSTA_ENV) has no
348
+ * subject here — and pointing at a foreign deployment made this actively unsafe: readGlobal()
349
+ * scrubs a foreign deployment's tokens, so the revoke below was skipped for want of a refresh
350
+ * token while the local tokens were deleted anyway, leaving the session valid on the server with
351
+ * nothing left on this machine to revoke it with. The revoke now always goes to the deployment
352
+ * the session belongs to, with the real refresh token. `persist()` keeps the stored URL (see its
353
+ * comment): logout never sets one explicitly. */
347
354
  export async function logout() {
348
- const api = await ApiClient.load();
355
+ const stored = await readPersistedGlobal();
356
+ // Say so rather than ignoring it silently: exiting 0 with a bare "logged out" while the flag
357
+ // named a different deployment reads as if that deployment was the one logged out of.
358
+ const resolved = (await readGlobal()).apiUrl;
359
+ if (normalizeUrl(resolved) !== normalizeUrl(stored.apiUrl)) {
360
+ info(`note: the control-plane override (${resolved}) does not apply to logout — there is one stored session; logging out of ${stored.apiUrl}`);
361
+ }
362
+ const api = new ApiClient(stored);
349
363
  if (api.config.refreshToken) {
350
364
  try {
351
365
  await api.request('POST', '/auth/logout', { refreshToken: api.config.refreshToken }, { auth: false });
@@ -49,17 +49,17 @@ export function billingLines(s, org) {
49
49
  const lapsed = s.subscriptionStatus === 'past_due' || s.subscriptionStatus === 'unpaid';
50
50
  const ended = s.subscriptionStatus === 'canceled' || s.subscriptionStatus === 'incomplete_expired';
51
51
  lines.push(s.tier === 'free'
52
- ? `⚠ org suspended — billing limit reached; resumes next cycle (or \`insta billing upgrade pro${flag}\`)`
52
+ ? `⚠ org suspended — billing limit reached; resumes next cycle (or \`insta billing subscribe pro${flag}\`)`
53
53
  : lapsed
54
54
  ? `⚠ org suspended — subscription payment did not go through; settle it in \`insta billing portal${flag}\``
55
55
  : ended
56
56
  ? s.tier === 'enterprise'
57
- // Per-deal, and `billing upgrade` cannot create one: naming a self-serve tier here
57
+ // Per-deal, and `billing subscribe` cannot create one: naming a self-serve tier here
58
58
  // would move them off the plan they negotiated.
59
59
  ? '⚠ org suspended — the subscription ended; contact support to restore this plan'
60
- // Their OWN tier, not a hardcoded one: suggesting `upgrade pro` to a Team org
60
+ // Their OWN tier, not a hardcoded one: suggesting `subscribe pro` to a Team org
61
61
  // resubscribes it onto the wrong plan.
62
- : `⚠ org suspended — the subscription ended; resubscribe with \`insta billing upgrade ${s.tier}${flag}\``
62
+ : `⚠ org suspended — the subscription ended; resubscribe with \`insta billing subscribe ${s.tier}${flag}\``
63
63
  // Deliberately claims nothing about the subscription: `incomplete` reaches here too,
64
64
  // and that one is neither current nor failed. All this branch knows is that the
65
65
  // suspension has no billing cause it can name.
@@ -87,7 +87,7 @@ export async function billing(opts) {
87
87
  for (const l of billingLines(s, opts.org))
88
88
  info(l);
89
89
  }
90
- // insta billing upgrade <tier> — start a Stripe Checkout to subscribe the org to a paid tier.
90
+ // insta billing subscribe <tier> — start a Stripe Checkout to subscribe the org to a paid tier.
91
91
  export async function billingUpgrade(tier, opts) {
92
92
  // pro|team, matching what POST /orgs/:orgId/billing/checkout accepts: `team` is a real
93
93
  // self-serve tier, and `enterprise` is per-deal and 400s at the server.