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 +9 -3
- package/dist/api.js +1 -1
- package/dist/commands/auth.js +37 -14
- package/dist/commands/billing.js +42 -5
- package/dist/commands/compute.js +14 -1
- package/dist/commands/db-query.js +102 -0
- package/dist/commands/env.js +1 -1
- package/dist/commands/setup.js +39 -18
- package/dist/index.js +17 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ off with `insta autoupdate off`.
|
|
|
61
61
|
## Quickstart
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
insta login
|
|
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
|
|
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` |
|
|
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
|
|
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
|
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 —
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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(
|
|
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) {
|
package/dist/commands/billing.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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 });
|
package/dist/commands/compute.js
CHANGED
|
@@ -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(
|
|
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
|
package/dist/commands/env.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/commands/setup.js
CHANGED
|
@@ -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 {
|
|
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: () =>
|
|
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
|
|
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('
|
|
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:
|
|
415
|
-
//
|
|
416
|
-
// every Windows shell, and in shells without bracketed paste the queued
|
|
417
|
-
// the answer to the login prompt above (console PR #290).
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
if (
|
|
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
|
|
441
|
+
info(` not logged in — project not ${linking ? 'linked' : 'created'}; run \`insta login\`, then \`${retry}\``);
|
|
424
442
|
}
|
|
425
443
|
else {
|
|
426
444
|
try {
|
|
427
|
-
|
|
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
|
|
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
|
|
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:
|
|
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|
|
|
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)')
|