insta 0.0.48 → 0.0.51

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
@@ -61,7 +61,7 @@ off with `insta autoupdate off`.
61
61
  ## Quickstart
62
62
 
63
63
  ```bash
64
- insta login --oauth github
64
+ insta login
65
65
  insta project create my-app
66
66
  insta services add postgres db
67
67
  insta services add compute api
@@ -79,13 +79,19 @@ service; it needs a `Dockerfile`, but no local Docker.
79
79
  ## Authentication
80
80
 
81
81
  ```bash
82
+ insta login # sign in from the browser (any account type)
82
83
  insta login --email you@example.com # password from $INSTA_PASSWORD or a prompt
83
84
  insta login --oauth github # or google, through the browser
84
- insta login --env staging --oauth github # log in to a specific deployment
85
+ insta login --env staging # log in to a specific deployment
85
86
  ```
86
87
 
87
88
  Tokens are stored in `~/.insta/config.json` and refresh automatically.
88
89
 
90
+ Bare `insta login` opens the console's device-approval page in your browser: sign in there
91
+ with whatever your account uses (email, GitHub, Google), check the code matches, and approve.
92
+ On a machine that can't open a browser, `--device` prints the same link to open from any
93
+ other device.
94
+
89
95
  `--oauth` starts a loopback listener on `127.0.0.1`, opens the browser at the control
90
96
  plane's `/auth/cli/authorize`, and receives the token back on that listener once the
91
97
  provider has authorized you. Nothing is pasted by hand.
@@ -189,7 +195,7 @@ build never reaches a production installer.
189
195
 
190
196
  | Command | What it covers |
191
197
  |---|---|
192
- | `insta login` · `logout` · `status` | Email/password or `--oauth github\|google`; `status` shows the environment, login and linked project/branch |
198
+ | `insta login` · `logout` · `status` | Browser sign-in (default), `--email` + password, or `--oauth github\|google`; `status` shows the environment, login and linked project/branch |
193
199
  | `insta env` | `show` · `use <prod\|staging>` |
194
200
  | `insta setup` | `agent` — install the CLI (if missing), the skill, and MCP for every coding agent; targets prod, `--env staging` for staging |
195
201
  | `insta mcp` | `install` — register the remote MCP server only |
package/dist/api.js CHANGED
@@ -130,7 +130,7 @@ export async function requireProject() {
130
130
  }
131
131
  catch (e) {
132
132
  if (e instanceof ApiError && e.status === 401) {
133
- die('not logged in — run `insta login --oauth github` (cloud) or point INSTA_API_URL at your insta-oss daemon');
133
+ die('not logged in — run `insta login` (cloud) or point INSTA_API_URL at your insta-oss daemon');
134
134
  }
135
135
  die(e instanceof Error ? e.message : String(e));
136
136
  }
@@ -16,7 +16,8 @@ function targetApiUrl(opts) {
16
16
  die(`unknown --env "${opts.env}" — expected one of: ${ENV_NAMES.join(', ')}`);
17
17
  return ENVS[want].api;
18
18
  }
19
- export async function login(opts) {
19
+ // `device` is injectable so the dispatch itself is testable (repo pattern: DI fakes, no mocks).
20
+ export async function login(opts, device = loginDevice) {
20
21
  // Login modes are exclusive — pick one. Check presence (not truthiness) so an explicit
21
22
  // empty --api-key= is rejected by validation rather than silently falling through.
22
23
  if (opts.apiKey !== undefined) {
@@ -25,15 +26,24 @@ export async function login(opts) {
25
26
  return loginApiKey(opts.apiKey, opts);
26
27
  }
27
28
  if (opts.device)
28
- return loginDevice(opts);
29
+ return device(opts);
29
30
  if (opts.oauth)
30
31
  return loginOauth(opts.oauth, opts);
32
+ // An explicitly empty --email is a mistake, not a request for the bare browser flow.
33
+ if (opts.email === '')
34
+ die('--email must not be empty');
35
+ if (!opts.email) {
36
+ // Bare `insta login` = sign in from the browser. The device grant is the one flow that covers
37
+ // every account type (email, GitHub, Google): the console approval page owns the signin
38
+ // round-trip, so the CLI just opens it here instead of only printing the link.
39
+ if (opts.password !== undefined || process.env.INSTA_PASSWORD !== undefined)
40
+ die('a password (--password / $INSTA_PASSWORD) is only used with --email <email>');
41
+ return device(opts, openUrl);
42
+ }
31
43
  const api = await ApiClient.load();
32
44
  const target = targetApiUrl(opts);
33
45
  if (target)
34
46
  api.setApiUrl(target);
35
- if (!opts.email)
36
- die('--email is required (or use --oauth <github|google>; on a headless machine, --device)');
37
47
  const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword());
38
48
  const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false });
39
49
  api.setSession(res, res.user);
@@ -56,16 +66,17 @@ export async function loginOauth(provider, opts) {
56
66
  await api.persist();
57
67
  info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`);
58
68
  }
59
- // RFC 8628 device authorization — login from a machine with no usable browser (VM, SSH box, CI
60
- // container). The loopback --oauth flow can never work there: its callback targets 127.0.0.1 on
61
- // THIS machine. Here the roles invert — we mint a code, print a link the human opens on ANY
62
- // device, and poll the platform until they approve in the console.
63
- export async function loginDevice(opts) {
69
+ // RFC 8628 device authorization — the default login (bare `insta login` passes `open` to also
70
+ // launch the browser here), and as --device the flow for a machine with no usable browser (VM,
71
+ // SSH box, CI container), where the loopback --oauth flow can never work: its callback targets
72
+ // 127.0.0.1 on THIS machine. We mint a code, hand the human a link to the console approval page
73
+ // (which owns the signin round-trip), and poll the platform until they approve.
74
+ export async function loginDevice(opts, open) {
64
75
  const api = await ApiClient.load();
65
76
  const target = targetApiUrl(opts);
66
77
  if (target)
67
78
  api.setApiUrl(target);
68
- const token = await deviceGrant((path, body) => api.request('POST', path, body, { auth: false }));
79
+ const token = await deviceGrant((path, body) => api.request('POST', path, body, { auth: false }), sleepSeconds, open);
69
80
  api.setSession({ accessToken: token, refreshToken: token });
70
81
  const me = await api.request('GET', '/me');
71
82
  api.setSession({ accessToken: token, refreshToken: token }, me.user);
@@ -106,7 +117,9 @@ const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
106
117
  // Drives the device grant against the platform's Better Auth mount (/api/auth/device*) and
107
118
  // returns the approved session token. Injectable poster + wait keep this testable without a
108
119
  // network or real timers. Poll errors arrive as ApiError with the OAuth error code as message.
109
- export async function deviceGrant(post, wait = sleepSeconds) {
120
+ // `open` (the default browser-login path) launches the verification link locally on top of
121
+ // printing it; without it (--device) the link is print-only, for a browser on another machine.
122
+ export async function deviceGrant(post, wait = sleepSeconds, open) {
110
123
  const start = (await post('/api/auth/device/code', { client_id: 'insta-cli' }));
111
124
  // A missing/garbage expires_in must fail loudly here — carried into the deadline arithmetic it
112
125
  // becomes NaN, every `Date.now() < deadline` is false, and login dies as a bogus instant expiry.
@@ -117,8 +130,18 @@ export async function deviceGrant(post, wait = sleepSeconds) {
117
130
  throw new Error('malformed device authorization response (missing expires_in) — is the platform up to date?');
118
131
  }
119
132
  const lifetime = Math.min(expiresIn, 3600); // no device code sensibly outlives an hour
120
- info('to log in, open this link in a browser on any device:');
121
- info(` ${start.verification_uri_complete ?? start.verification_uri}`);
133
+ const url = start.verification_uri_complete ?? start.verification_uri;
134
+ if (open) {
135
+ info('opening your browser to sign in…');
136
+ // Always print the link too: a launcher that fails to start reports it on spawn's ASYNC
137
+ // error event, so open's return value cannot see it (same reasoning as browserOauth).
138
+ info(`if nothing opens, use this link in a browser on any device:\n ${url}`);
139
+ open(url);
140
+ }
141
+ else {
142
+ info('to log in, open this link in a browser on any device:');
143
+ info(` ${url}`);
144
+ }
122
145
  info(`and check it shows this code: ${start.user_code}`);
123
146
  info(`waiting for approval… (expires in ${Math.round(lifetime / 60)}m, ctrl-c to abort)`);
124
147
  // Absent OR non-finite interval = the RFC 8628 §3.2 default 5s: NaN would fire the timer
@@ -158,7 +181,7 @@ export async function deviceGrant(post, wait = sleepSeconds) {
158
181
  throw new Error('malformed token response (missing access_token)');
159
182
  return grant.access_token;
160
183
  }
161
- throw new Error('device login expired before it was approved — run `insta login --device` again');
184
+ throw new Error(`device login expired before it was approved — run \`insta login${open ? '' : ' --device'}\` again`);
162
185
  }
163
186
  // Start a loopback server, open the browser at the platform bridge, and await the token.
164
187
  function browserOauth(apiUrl, provider) {
@@ -8,7 +8,10 @@ async function resolveOrgId(opts) {
8
8
  return (await requireProject()).orgId;
9
9
  }
10
10
  // Format the billing overview into printable lines (pure, so it's unit-testable).
11
- export function billingLines(s) {
11
+ // `org` is the caller's --org, echoed into the portal hint: `billing` and `billing portal` resolve
12
+ // the target independently, so a hint that drops the flag sends someone reading org A's overview to
13
+ // org B's portal.
14
+ export function billingLines(s, org) {
12
15
  const t = s.totals;
13
16
  const lines = [
14
17
  `tier: ${s.tier}`,
@@ -23,7 +26,37 @@ export function billingLines(s) {
23
26
  if (s.subscriptionStatus)
24
27
  lines.push(`subscription: ${s.subscriptionStatus}`);
25
28
  if (s.billingStatus === 'suspended') {
26
- lines.push('⚠ org suspended — billing limit reached; resumes next cycle (or `insta billing upgrade pro`)');
29
+ // Four causes, five messages, and every one is a dead end for the others. Tier first: only a
30
+ // free org can spend a prepaid wallet, and waiting for the next cycle genuinely fixes that one.
31
+ // (Tier, not subscriptionStatus, because rows written before non-payment suspended carry
32
+ // `unpaid` beside tier 'free' and survive with no migration.) Then the status splits the paid
33
+ // branch three ways: an invoice to settle, a subscription to replace, or — when it reads
34
+ // healthy — a suspension that outlived its cause, which is what a recovery whose compute failed
35
+ // to restart looks like, and where telling them to pay means re-settling a paid invoice. The
36
+ // replace case is the one that splits again, because enterprise has no self-serve checkout.
37
+ //
38
+ // EVERY command here carries the caller's --org. `billing` and the command being suggested
39
+ // resolve the target independently, so a hint that drops the flag acts on a different org than
40
+ // the one being read — and two of them take payment.
41
+ const flag = org ? ` --org ${org}` : '';
42
+ const lapsed = s.subscriptionStatus === 'past_due' || s.subscriptionStatus === 'unpaid';
43
+ const ended = s.subscriptionStatus === 'canceled' || s.subscriptionStatus === 'incomplete_expired';
44
+ lines.push(s.tier === 'free'
45
+ ? `⚠ org suspended — billing limit reached; resumes next cycle (or \`insta billing upgrade pro${flag}\`)`
46
+ : lapsed
47
+ ? `⚠ org suspended — subscription payment did not go through; settle it in \`insta billing portal${flag}\``
48
+ : ended
49
+ ? s.tier === 'enterprise'
50
+ // Per-deal, and `billing upgrade` cannot create one: naming a self-serve tier here
51
+ // would move them off the plan they negotiated.
52
+ ? '⚠ org suspended — the subscription ended; contact support to restore this plan'
53
+ // Their OWN tier, not a hardcoded one: suggesting `upgrade pro` to a Team org
54
+ // resubscribes it onto the wrong plan.
55
+ : `⚠ org suspended — the subscription ended; resubscribe with \`insta billing upgrade ${s.tier}${flag}\``
56
+ // Deliberately claims nothing about the subscription: `incomplete` reaches here too,
57
+ // and that one is neither current nor failed. All this branch knows is that the
58
+ // suspension has no billing cause it can name.
59
+ : '⚠ org suspended — no failed payment on file; contact support');
27
60
  }
28
61
  if (s.byDimension?.length) {
29
62
  lines.push('by dimension:');
@@ -44,13 +77,17 @@ export async function billing(opts) {
44
77
  const s = await api.request('GET', `/orgs/${orgId}/billing/overview`);
45
78
  if (opts.json)
46
79
  return printJson(s);
47
- for (const l of billingLines(s))
80
+ for (const l of billingLines(s, opts.org))
48
81
  info(l);
49
82
  }
50
83
  // insta billing upgrade <tier> — start a Stripe Checkout to subscribe the org to a paid tier.
51
84
  export async function billingUpgrade(tier, opts) {
52
- if (tier !== 'pro' && tier !== 'enterprise')
53
- die('tier must be pro|enterprise');
85
+ // pro|team, matching what POST /orgs/:orgId/billing/checkout actually accepts. This said
86
+ // pro|enterprise, which was wrong both ways: `team` is a real self-serve tier and was refused
87
+ // here, and `enterprise` is per-deal and 400s at the server. The suspension hint above now names
88
+ // the org's own tier, so a Team org was being sent to a command that rejected it.
89
+ if (tier !== 'pro' && tier !== 'team')
90
+ die('tier must be pro|team');
54
91
  const api = await ApiClient.load();
55
92
  const orgId = await resolveOrgId(opts);
56
93
  const { url } = await api.request('POST', `/orgs/${orgId}/billing/checkout`, { tier });
@@ -154,14 +154,31 @@ export async function buildReport(dirArg, opts, deps) {
154
154
  const envExample = join(dir, '.env.example');
155
155
  const envKeys = existsSync(envExample) ? envKeysFromDotEnvExample(readFileSync(envExample, 'utf8')) : [];
156
156
  const checks = [];
157
- checks.push({
158
- id: 'dockerfile',
159
- severity: 'critical',
160
- status: dockerfile.source ? 'pass' : 'fail',
161
- title: 'Dockerfile',
162
- detail: dockerfileDetail,
163
- ...(dockerfile.source ? {} : { nextAction: `add a Dockerfile at ${userDockerfilePath}, or install nixpacks (https://nixpacks.com/docs/install) so insta can generate one` }),
164
- });
157
+ // Only a Dockerfile IN the directory passes. A nixpacks-generated one is a real plan, but it is
158
+ // not a plan `insta deploy <dir>` can execute: that path builds the directory's own Dockerfile and
159
+ // dies without one, and the nixpacks lane runs server-side for GitHub-connected repos only. This
160
+ // check used to pass on the generated Dockerfile, so a verifier said "deployable" about a
161
+ // directory `deploy` refuses — the whole point of the command is to not do that.
162
+ checks.push(dockerfile.source === 'nixpacks'
163
+ ? {
164
+ id: 'dockerfile',
165
+ severity: 'warning',
166
+ status: 'fail',
167
+ title: 'Dockerfile',
168
+ detail: `${dockerfileDetail} — but \`insta deploy <dir>\` builds the directory's own Dockerfile; the nixpacks lane runs server-side for GitHub-connected repos only`,
169
+ // NOT "save the generated Dockerfile here": it is not standalone (it COPYs the
170
+ // .nixpacks/nixpkgs-<hash>.nix support files nixpacks writes beside it, which this
171
+ // directory does not have). The detected commands above are the reusable part.
172
+ nextAction: `to deploy this directory, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point; or connect the repo on GitHub to use the nixpacks lane`,
173
+ }
174
+ : {
175
+ id: 'dockerfile',
176
+ severity: 'critical',
177
+ status: dockerfile.source ? 'pass' : 'fail',
178
+ title: 'Dockerfile',
179
+ detail: dockerfileDetail,
180
+ ...(dockerfile.source ? {} : { nextAction: `add a Dockerfile at ${userDockerfilePath}, or install nixpacks (https://nixpacks.com/docs/install) so insta can show you the one it would generate` }),
181
+ });
165
182
  if (builder === 'dockerfile') {
166
183
  const hasCmd = /^\s*(CMD|ENTRYPOINT)\s/im.test(dockerfile.content ?? '');
167
184
  checks.push({
@@ -207,7 +224,10 @@ const MARK = { pass: '✓', fail: '✗', skip: '·' };
207
224
  export function renderReport(r, explain) {
208
225
  const lines = [];
209
226
  lines.push(`plan for ${r.dir}:`);
210
- lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}`);
227
+ // The builder line is the first thing read (and the thing an agent scrapes), so it carries the
228
+ // lane caveat too — "builder: nixpacks" on its own reads as a promise `insta deploy <dir>` breaks.
229
+ const lane = r.plan.builder === 'nixpacks' ? ' — GitHub lane only; `insta deploy <dir>` needs a Dockerfile' : '';
230
+ lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}${lane}`);
211
231
  if (r.plan.installCommand)
212
232
  lines.push(` install: ${r.plan.installCommand}`);
213
233
  if (r.plan.buildCommand)
@@ -226,6 +246,11 @@ export function renderReport(r, explain) {
226
246
  }
227
247
  if (explain && r.dockerfile.content) {
228
248
  lines.push(`dockerfile (${r.dockerfile.source}):`);
249
+ // A nixpacks Dockerfile is shown for inspection, NOT for copying: it COPYs the
250
+ // .nixpacks/nixpkgs-<hash>.nix support files nixpacks generates beside it, so saving this text
251
+ // alone as ./Dockerfile produces a build that fails on the missing COPY.
252
+ if (r.dockerfile.source === 'nixpacks')
253
+ lines.push(' # for inspection — not standalone: it COPYs .nixpacks/ support files generated alongside it');
229
254
  for (const l of r.dockerfile.content.trimEnd().split('\n'))
230
255
  lines.push(` ${l}`);
231
256
  }