insta 0.0.49 → 0.0.52

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,10 +13,10 @@ 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';
19
- import { projectLink } from './project.js';
18
+ import { loginDevice } from './auth.js';
19
+ import { projectCreate, projectLink, slugifyName } from './project.js';
20
20
  import { envUse } from './env.js';
21
21
  import { installAgentConfigs } from './mcp.js';
22
22
  import { detectChannel } from './upgrade.js';
@@ -276,6 +276,18 @@ export function planSetupEnv(flagEnv, persistedApiUrl, apiUrlOverride = process.
276
276
  const target = envVar ?? DEFAULT_ENV;
277
277
  return { target, switch: persisted !== target };
278
278
  }
279
+ /** The project step. Only a contradictory flag pair throws: rejecting a nameless `--create` here
280
+ * would abort the whole setup, and `projectCreate` already guides that case. */
281
+ export function planProject(opts) {
282
+ if (opts.create !== undefined && opts.project !== undefined) {
283
+ throw new Error('--create and --project are mutually exclusive — create a new project, or link an existing one');
284
+ }
285
+ if (opts.project)
286
+ return { kind: 'link', id: opts.project };
287
+ if (opts.create === undefined)
288
+ return { kind: 'none' };
289
+ return { kind: 'create', name: typeof opts.create === 'string' ? opts.create : undefined };
290
+ }
279
291
  /** Whether setup should flow straight into login: an interactive human terminal with no session.
280
292
  * Pure. Non-TTY (agents, CI, pipes) and -y runs never prompt — a browser OAuth flow cannot work
281
293
  * there anyway; they get the printed `next:` hint instead, and prompt.md walks agents through
@@ -347,13 +359,15 @@ const defaultAsk = async (question) => {
347
359
  };
348
360
  export async function setupAgent(opts, run = defaultRunner, mint, installConfigs = installAgentConfigs, ensure = (r) => ensureCliInstalled(r), readStored = readPersistedGlobal, switchEnv = (n) => envUse(n), loginFlow = {
349
361
  ask: defaultAsk,
350
- login: () => loginOauth('github', {}),
362
+ login: () => loginDevice({}, openUrl),
351
363
  stdinTty: canPromptViaTty(),
352
364
  stdoutTty: !!process.stdout.isTTY,
353
- }, link = projectLink) {
365
+ }, link = projectLink, create = (n) => projectCreate(n, {})) {
354
366
  if (!opts.yes && !process.stdout.isTTY) {
355
367
  info('non-interactive shell — assuming -y');
356
368
  }
369
+ // Reject an impossible --project/--create request here, while the machine is still untouched.
370
+ const project = planProject(opts);
357
371
  // Pin the environment BEFORE anything is installed (see planSetupEnv). A required switch goes
358
372
  // through `env use` — the one path that persists the choice and drops the now-foreign session —
359
373
  // and announces itself, so the machine can never end up with its CLI on one deployment and its
@@ -398,7 +412,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
398
412
  const stored = await readStored();
399
413
  let loggedIn = !!(stored.accessToken || stored.user);
400
414
  if (shouldOfferLogin(!!opts.yes, loggedIn, loginFlow.stdinTty, loginFlow.stdoutTty)) {
401
- if (await loginFlow.ask('log in now with GitHub? (Y/n) ')) {
415
+ if (await loginFlow.ask('log in now in the browser? (Y/n) ')) {
402
416
  try {
403
417
  await loginFlow.login();
404
418
  loggedIn = true;
@@ -407,30 +421,37 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
407
421
  }
408
422
  catch (e) {
409
423
  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.');
424
+ info(' run `insta login` to try again — the sign-in link it prints works from a browser on any device.');
411
425
  }
412
426
  }
413
427
  }
414
- // --project: link this directory inside the SAME process. The console's connect panel used to
415
- // print `setup agent && insta project link <id>` as one paste — but no shell joiner survives
416
- // every Windows shell, and in shells without bracketed paste the queued link line is eaten as
417
- // the answer to the login prompt above (console PR #290). Carrying the id as a flag is the one
418
- // form where "one line" is safe. Linking needs the session: without one the manual command is
419
- // the hint, never a hang; a failed link (bad id, no access) is a REAL error — the link is the
420
- // entire point of the flag — so it sets the exit code instead of pretending setup succeeded.
421
- if (opts.project) {
428
+ // --project / --create: bind this directory to a project inside the SAME process. Never split
429
+ // this back into `setup agent && insta project <cmd>` as one paste: no shell joiner survives
430
+ // every Windows shell, and in shells without bracketed paste the queued second line is eaten
431
+ // as the answer to the login prompt above (console PR #290). Both need the session: without
432
+ // one the manual command is the hint, never a hang; a failure (bad id, no access, name taken)
433
+ // is a REAL error — binding a project is the entire point of the flag — so it sets the exit
434
+ // code instead of pretending setup succeeded.
435
+ if (project.kind !== 'none') {
436
+ const linking = project.kind === 'link';
437
+ const retry = linking
438
+ ? `insta project link ${project.id}`
439
+ : `insta project create${project.name ? ` ${slugifyName(project.name)}` : ''}`;
422
440
  if (!loggedIn) {
423
- info(` not logged in — project not linked; run \`insta login\`, then \`insta project link ${opts.project}\``);
441
+ info(` not logged in — project not ${linking ? 'linked' : 'created'}; run \`insta login\`, then \`${retry}\``);
424
442
  }
425
443
  else {
426
444
  try {
427
- await link(opts.project);
445
+ if (project.kind === 'link')
446
+ await link(project.id);
447
+ else
448
+ await create(project.name);
428
449
  }
429
450
  catch (e) {
430
451
  // Stop here — like the skill-install failure above, finishing with the success summary
431
452
  // and a cheerful `next:` after an error is mixed messaging. Setup itself did succeed,
432
453
  // so say exactly that alongside the retry command.
433
- info(` project link failed (${e instanceof Error ? e.message : String(e)}) — agent setup itself is done; run \`insta project link ${opts.project}\` to retry the link`);
454
+ info(` project ${linking ? 'link' : 'create'} failed (${e instanceof Error ? e.message : String(e)}) — agent setup itself is done; run \`${retry}\` to retry the ${linking ? 'link' : 'create'}`);
434
455
  process.exitCode = 1;
435
456
  return;
436
457
  }
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(' | ')}`)
@@ -90,6 +91,7 @@ setupCmd.command('agent').description('Install the insta CLI (if missing), the i
90
91
  .option('--env <prod|staging>', 'deployment to set this machine up for (default: prod — switches and persists, like `insta env use`)')
91
92
  .option('--mcp-token', 'register the MCP server with a minted insta_ API token instead of OAuth (headless machines / CI)')
92
93
  .option('--project <id>', 'also link this directory to an existing project after setup (flows through login first if needed)')
94
+ .option('--create [name]', 'also create a new project and link this directory after setup (default name: this directory; mutually exclusive with --project)')
93
95
  .action(guard((o) => setup.setupAgent(o)));
94
96
  // ---- MCP server integration ----
95
97
  const mcpCmd = program.command('mcp').description('insta-cloud remote MCP server integration');
@@ -198,7 +200,7 @@ program.command('deploy [dir]').description('Deploy a source directory (built re
198
200
  // optional makes commander unable to hold that boundary itself).
199
201
  const { argv: computeArgv, command: execCommand, windowsFallback: execWindowsFallback, } = computeCmd.splitExecArgs(process.argv);
200
202
  // ---- compute (lifecycle control + custom domains) ----
201
- const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains');
203
+ const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/restart/status) + custom domains');
202
204
  compute.command('set-domain <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
203
205
  .option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o)));
204
206
  compute.command('check-domain <host>').description("Show a custom domain's cert status + required DNS records")
@@ -211,6 +213,8 @@ compute.command('stop [service]').description('Take a compute service offline; t
211
213
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStop(service, o)));
212
214
  compute.command('suspend [service]').description('Suspend a compute service (RAM snapshot); stays down until `start`')
213
215
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeSuspend(service, o)));
216
+ 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")
217
+ .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(service, o)));
214
218
  compute.command('status [service]').description("Show a compute service's desired vs. live state")
215
219
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o)));
216
220
  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 +232,8 @@ compute.command('volume [service]').description("Show, attach, grow, or delete a
228
232
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
229
233
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
230
234
  .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)');
235
+ // ---- db (postgres service controls + managed-DB query) ----
236
+ 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
237
  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
238
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
235
239
  .action(guard((o) => dbCmd.dbUrl(o)));
@@ -250,6 +254,11 @@ db.command('volume').description("Show or grow a postgres service's provisioned
250
254
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
251
255
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
252
256
  .action(guard((o) => dbCmd.dbVolume(o)));
257
+ 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')
258
+ .option('--database <db>', 'mongodb only — the database to run against (default admin)')
259
+ .option('--branch <branch>', 'branch (default: current)')
260
+ .option('--json')
261
+ .action(guard((service, args, o) => dbQueryCmd.dbQuery(service, args, o)));
253
262
  // ---- storage (bucket objects) ----
254
263
  const storage = program.command('storage').description("Browse, download, and delete a storage service's bucket objects");
255
264
  storage.command('list').description("List the bucket's objects. S3 filters by prefix only — there is no substring search")
@@ -300,7 +309,7 @@ program.command('usage').description('Usage for the current billing cycle by bil
300
309
  const bill = program.command('billing').description('Current billing cycle overview (tier / used / included / overage / credits / forecast + per-dimension & per-project breakdown)')
301
310
  .option('--org <id>', 'target org (default: linked project\'s org)').option('--json')
302
311
  .action(guard((o) => billing(o)));
303
- bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|enterprise) via Stripe Checkout')
312
+ bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout')
304
313
  .option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
305
314
  .action(guard((tier, o) => billingUpgrade(tier, o)));
306
315
  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.52",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [