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 +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 +5 -5
- package/dist/index.js +16 -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,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 {
|
|
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: () =>
|
|
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
|
|
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('
|
|
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
|
|
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(' | ')}`)
|
|
@@ -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|
|
|
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)')
|