insta 0.0.49 → 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 });
@@ -338,6 +338,18 @@ export function renderRemoveDomain(body, json, row) {
338
338
  const region = body.region ?? row?.region;
339
339
  info(`removed custom domain ${body.hostname} from ${body.service ?? row?.name ?? body.flyApp}${region ? ` (${region})` : ''}`);
340
340
  }
341
+ // The line a lifecycle verb prints. restart gets its own wording: `running` is a PRECONDITION of a
342
+ // restart (the platform refuses it in any other desired state), so echoing desired_state back says
343
+ // nothing — what the operator needs is which image came back up and whether it is live. Pure,
344
+ // exported for tests.
345
+ export function lifecycleLine(verb, fallbackName, body) {
346
+ const name = body.service?.name ?? fallbackName;
347
+ if (verb === 'restart') {
348
+ const image = body.service?.image ? ` on ${body.service.image}` : '';
349
+ return `restarted compute ${name}${image} — env re-resolved from the current secrets (live: ${body.state})`;
350
+ }
351
+ return `compute ${name}: ${verb} → desired=${body.service?.desired_state} (live: ${body.state})`;
352
+ }
341
353
  async function lifecycle(verb, serviceName, opts) {
342
354
  const api = await ApiClient.load();
343
355
  const p = await requireProject();
@@ -349,11 +361,12 @@ async function lifecycle(verb, serviceName, opts) {
349
361
  return;
350
362
  if (opts.json)
351
363
  return printJson(res.body);
352
- info(`compute ${res.body.service?.name ?? id}: ${verb} → desired=${res.body.service?.desired_state} (live: ${res.body.state})`);
364
+ info(lifecycleLine(verb, id, res.body));
353
365
  }
354
366
  export const computeStart = (service, opts) => lifecycle('start', service, opts);
355
367
  export const computeStop = (service, opts) => lifecycle('stop', service, opts);
356
368
  export const computeSuspend = (service, opts) => lifecycle('suspend', service, opts);
369
+ export const computeRestart = (service, opts) => lifecycle('restart', service, opts);
357
370
  export async function computeStatus(serviceName, opts) {
358
371
  const api = await ApiClient.load();
359
372
  const p = await requireProject();
@@ -0,0 +1,102 @@
1
+ // `insta db query <service> [args...]` — run a query/command against a MANAGED database
2
+ // (mysql/redis/mongodb) through the platform's console exec API. Postgres is not a console target
3
+ // (it has the SQL editor / DATABASE_URL, and `insta db url|connect`), so a postgres service is
4
+ // rejected here. The shape logic — path, request body, result rendering — lives in pure,
5
+ // unit-tested seams; the handler just resolves the service and wires them to the API, this repo's
6
+ // pure-seam convention.
7
+ import { ApiClient, requireProject } from '../api.js';
8
+ import { info, printJson, die, handleApproval } from '../util.js';
9
+ import { q } from './services.js';
10
+ export const MANAGED_ENGINES = ['mysql', 'redis', 'mongodb'];
11
+ // pure: the console exec route for a managed-DB service.
12
+ export function consoleExecPath(projectId, serviceId) {
13
+ return `/projects/${projectId}/database/console/${serviceId}/exec`;
14
+ }
15
+ // pure: map the engine + trailing args to the exec request body. mysql/mongodb take a single
16
+ // command string (args joined with a space — the user quotes the whole statement); redis takes a
17
+ // pre-tokenized argv (each arg verbatim, so a value with spaces survives as one token). Only
18
+ // mongodb carries an optional --database.
19
+ export function execBody(engine, args, database) {
20
+ if (engine === 'redis')
21
+ return { argv: args };
22
+ const command = args.join(' ');
23
+ if (engine === 'mongodb')
24
+ return { command, ...(database ? { database } : {}) };
25
+ return { command };
26
+ }
27
+ // pure: render a mysql result set as a simple left-aligned table — the header from columns, then
28
+ // the rows, every column but the last padded so cells line up. A null cell renders as an em-dash
29
+ // (the repo norm for a missing value), never an empty string. A trailing count line closes it.
30
+ export function renderMysqlRows(data) {
31
+ const headers = (data.columns ?? []).map((c) => c.name);
32
+ const rows = data.rows ?? [];
33
+ const cell = (v) => (v === null || v === undefined ? '—' : String(v));
34
+ const widths = headers.map((h, i) => {
35
+ let w = h.length;
36
+ for (const r of rows)
37
+ w = Math.max(w, cell(r[i]).length);
38
+ return w;
39
+ });
40
+ const fmtRow = (vals) => vals.map((v, i) => (i === vals.length - 1 ? v : v.padEnd(widths[i] ?? 0))).join(' ');
41
+ const lines = [fmtRow(headers)];
42
+ for (const r of rows)
43
+ lines.push(fmtRow(headers.map((_, i) => cell(r[i]))));
44
+ const rowCount = typeof data.rowCount === 'number' ? data.rowCount : rows.length;
45
+ lines.push(`(${rowCount} rows${data.truncated ? ', truncated' : ''})`);
46
+ return lines;
47
+ }
48
+ // pure: a redis reply — a scalar prints raw, anything structured pretty-prints as JSON.
49
+ export function renderRedisReply(reply) {
50
+ if (typeof reply === 'string' || typeof reply === 'number')
51
+ return String(reply);
52
+ return JSON.stringify(reply, null, 2);
53
+ }
54
+ // pure: a mongodb result is arbitrary JSON — pretty-print it.
55
+ export function renderMongoResult(result) {
56
+ return JSON.stringify(result, null, 2);
57
+ }
58
+ async function dbQueryDeps(deps) {
59
+ if (deps)
60
+ return deps;
61
+ const [api, project] = [await ApiClient.load(), await requireProject()];
62
+ return { api, project };
63
+ }
64
+ // Resolve <service> (a service NAME) to its id + engine, then dispatch to the console exec API.
65
+ export async function dbQuery(service, args, opts = {}, deps) {
66
+ // An empty command is never valid — reject it before loading config or hitting the network,
67
+ // rather than posting an empty statement/argv to the console.
68
+ if (args.length === 0) {
69
+ die('usage: insta db query <service> <query…> (mysql/mongodb: one quoted statement; redis: e.g. GET mykey)');
70
+ }
71
+ const { api, project: p } = await dbQueryDeps(deps);
72
+ const branch = opts.branch ?? p.branch;
73
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
74
+ const svc = services.find((s) => s.name === service);
75
+ if (!svc)
76
+ die(`service not found: ${service}`);
77
+ if (!MANAGED_ENGINES.includes(svc.type)) {
78
+ die('db query is for managed databases (mysql/redis/mongodb); postgres uses the SQL editor / DATABASE_URL');
79
+ }
80
+ const engine = svc.type;
81
+ // --database is a mongodb-only selector (execBody drops it for the others). Rejecting it here,
82
+ // rather than silently ignoring it, keeps the documented mongodb-only contract honest.
83
+ if (opts.database !== undefined && engine !== 'mongodb') {
84
+ die('--database is only supported for mongodb services');
85
+ }
86
+ const res = await api.rawRequest('POST', consoleExecPath(p.projectId, svc.id), execBody(engine, args, opts.database));
87
+ if (handleApproval(res, opts.json))
88
+ return;
89
+ if (opts.json)
90
+ return printJson(res.body);
91
+ if (engine === 'mysql') {
92
+ for (const line of renderMysqlRows(res.body ?? {}))
93
+ info(line);
94
+ }
95
+ else if (engine === 'redis') {
96
+ info(renderRedisReply(res.body?.reply));
97
+ }
98
+ else {
99
+ info(renderMongoResult(res.body?.result));
100
+ }
101
+ }
102
+ //# sourceMappingURL=db-query.js.map
@@ -70,7 +70,7 @@ export async function envUse(name, opts = {}) {
70
70
  info(` api: ${nextApi}`);
71
71
  info(` mcp: ${ENVS[target].mcp} (registers as \`${mcpServerName(target)}\`)`);
72
72
  if (hadSession)
73
- info(' previous session dropped (separate deployment) — run `insta login --oauth github`');
73
+ info(' previous session dropped (separate deployment) — run `insta login`');
74
74
  // Switching the CLI does NOT re-point already-installed agents: their MCP registration and skill
75
75
  // files were written for the previous environment and are keyed by a different server name, so
76
76
  // they keep talking to it until setup is re-run. --env is REQUIRED in the hint: since 0.0.38 a
@@ -13,9 +13,9 @@ import { createInterface } from 'node:readline';
13
13
  import { ApiClient } from '../api.js';
14
14
  import { readPersistedGlobal, resolveEnv } from '../config.js';
15
15
  import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, envFromEnvVar, isEnvName, mcpServerName } from '../env.js';
16
- import { info } from '../util.js';
16
+ import { info, openUrl } from '../util.js';
17
17
  import { isRunnableFile, resolveSpawnable } from '../spawn.js';
18
- import { loginOauth } from './auth.js';
18
+ import { loginDevice } from './auth.js';
19
19
  import { projectLink } from './project.js';
20
20
  import { envUse } from './env.js';
21
21
  import { installAgentConfigs } from './mcp.js';
@@ -347,7 +347,7 @@ const defaultAsk = async (question) => {
347
347
  };
348
348
  export async function setupAgent(opts, run = defaultRunner, mint, installConfigs = installAgentConfigs, ensure = (r) => ensureCliInstalled(r), readStored = readPersistedGlobal, switchEnv = (n) => envUse(n), loginFlow = {
349
349
  ask: defaultAsk,
350
- login: () => loginOauth('github', {}),
350
+ login: () => loginDevice({}, openUrl),
351
351
  stdinTty: canPromptViaTty(),
352
352
  stdoutTty: !!process.stdout.isTTY,
353
353
  }, link = projectLink) {
@@ -398,7 +398,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
398
398
  const stored = await readStored();
399
399
  let loggedIn = !!(stored.accessToken || stored.user);
400
400
  if (shouldOfferLogin(!!opts.yes, loggedIn, loginFlow.stdinTty, loginFlow.stdoutTty)) {
401
- if (await loginFlow.ask('log in now with GitHub? (Y/n) ')) {
401
+ if (await loginFlow.ask('log in now in the browser? (Y/n) ')) {
402
402
  try {
403
403
  await loginFlow.login();
404
404
  loggedIn = true;
@@ -407,7 +407,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
407
407
  }
408
408
  catch (e) {
409
409
  info(` login did not complete (${e instanceof Error ? e.message : String(e)}) — no problem, setup itself is done.`);
410
- info(' on a remote/SSH machine the browser flow cannot call back here — use `insta login --device` instead.');
410
+ info(' run `insta login` to try again — the sign-in link it prints works from a browser on any device.');
411
411
  }
412
412
  }
413
413
  }
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { deploy } from './commands/deploy.js';
20
20
  import { build } from './commands/build.js';
21
21
  import * as computeCmd from './commands/compute.js';
22
22
  import * as dbCmd from './commands/db.js';
23
+ import * as dbQueryCmd from './commands/db-query.js';
23
24
  import * as storageCmd from './commands/storage.js';
24
25
  import { manifest } from './commands/manifest.js';
25
26
  import * as template from './commands/template.js';
@@ -61,11 +62,11 @@ function resolveVersion() {
61
62
  }
62
63
  program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(resolveVersion());
63
64
  // ---- auth ----
64
- program.command('login').description('Log in with email + password, --oauth <github|google> (browser), --device (headless), or --api-key <insta_…> (headless, durable token)')
65
- .option('--email <email>', 'account email')
66
- .option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt)')
65
+ program.command('login').description('Log in — bare: sign in from your browser (any account type); or --email <email> + password, --oauth <github|google>, --device (headless), --api-key <insta_…> (headless, durable token)')
66
+ .option('--email <email>', 'account email (email + password login)')
67
+ .option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt; needs --email)')
67
68
  .option('--oauth <provider>', 'browser OAuth login: github | google')
68
- .option('--device', 'device-code login: approve from a browser on any other machine (VMs, SSH, CI)')
69
+ .option('--device', 'device-code login: like bare login but never opens a browser here — approve from any other machine (VMs, SSH, CI)')
69
70
  .option('--api-key <key>', 'non-interactive login with a durable insta_ API token (headless agents / CI)')
70
71
  .option('--api-url <url>', 'control-plane API base URL')
71
72
  .option('--env <name>', `deployment environment: ${ENV_NAMES.join(' | ')}`)
@@ -198,7 +199,7 @@ program.command('deploy [dir]').description('Deploy a source directory (built re
198
199
  // optional makes commander unable to hold that boundary itself).
199
200
  const { argv: computeArgv, command: execCommand, windowsFallback: execWindowsFallback, } = computeCmd.splitExecArgs(process.argv);
200
201
  // ---- compute (lifecycle control + custom domains) ----
201
- const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains');
202
+ const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/restart/status) + custom domains');
202
203
  compute.command('set-domain <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
203
204
  .option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o)));
204
205
  compute.command('check-domain <host>').description("Show a custom domain's cert status + required DNS records")
@@ -211,6 +212,8 @@ compute.command('stop [service]').description('Take a compute service offline; t
211
212
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStop(service, o)));
212
213
  compute.command('suspend [service]').description('Suspend a compute service (RAM snapshot); stays down until `start`')
213
214
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeSuspend(service, o)));
215
+ compute.command('restart [service]').description("Restart a compute service by re-running the image it already runs against a freshly resolved env bundle — this is how a changed secret or binding reaches a running machine (env is baked into the machine at deploy time), and how a machine that is up but wedged gets cycled (`start` no-ops on one that is already started). No new image, no new spec. The service must be running: a stopped or suspended one comes back with `insta compute start`. All plans; gated: deploy — it lands configuration the same way a deploy does, so a policy denying deploys denies this too (`start`/`stop` stay ungated, and cycle a wedged machine without one). A service whose app fails to answer on its port coming back up reports that failure, and the machines are rolled back, best-effort, to the config they were serving")
216
+ .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(service, o)));
214
217
  compute.command('status [service]').description("Show a compute service's desired vs. live state")
215
218
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o)));
216
219
  compute.command('limits [service]').description("Show or set a compute service's resource ceiling (paid plans). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price")
@@ -228,8 +231,8 @@ compute.command('volume [service]').description("Show, attach, grow, or delete a
228
231
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
229
232
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
230
233
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
231
- // ---- db (postgres service controls) ----
232
- const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero)');
234
+ // ---- db (postgres service controls + managed-DB query) ----
235
+ const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero) + managed-DB query (mysql/redis/mongodb)');
233
236
  db.command('url').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta db url)"` (gated: secrets.read). Provider credentials are not in `insta secrets` — this is the command that yields the DSN')
234
237
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
235
238
  .action(guard((o) => dbCmd.dbUrl(o)));
@@ -250,6 +253,11 @@ db.command('volume').description("Show or grow a postgres service's provisioned
250
253
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
251
254
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
252
255
  .action(guard((o) => dbCmd.dbVolume(o)));
256
+ db.command('query <service> [args...]').description('Run a query/command against a managed database (mysql/redis/mongodb) via the console exec API. mysql/mongodb take one quoted statement; redis takes a pre-tokenized argv (e.g. `GET mykey`). Not for postgres — use `insta db url|connect` / the SQL editor')
257
+ .option('--database <db>', 'mongodb only — the database to run against (default admin)')
258
+ .option('--branch <branch>', 'branch (default: current)')
259
+ .option('--json')
260
+ .action(guard((service, args, o) => dbQueryCmd.dbQuery(service, args, o)));
253
261
  // ---- storage (bucket objects) ----
254
262
  const storage = program.command('storage').description("Browse, download, and delete a storage service's bucket objects");
255
263
  storage.command('list').description("List the bucket's objects. S3 filters by prefix only — there is no substring search")
@@ -300,7 +308,7 @@ program.command('usage').description('Usage for the current billing cycle by bil
300
308
  const bill = program.command('billing').description('Current billing cycle overview (tier / used / included / overage / credits / forecast + per-dimension & per-project breakdown)')
301
309
  .option('--org <id>', 'target org (default: linked project\'s org)').option('--json')
302
310
  .action(guard((o) => billing(o)));
303
- bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|enterprise) via Stripe Checkout')
311
+ bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout')
304
312
  .option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
305
313
  .action(guard((tier, o) => billingUpgrade(tier, o)));
306
314
  bill.command('portal').description('Open the Stripe Customer Portal (change plan / card / cancel)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.49",
3
+ "version": "0.0.51",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [