insta 0.0.48 → 0.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/build.js +34 -9
- package/dist/commands/compute.js +458 -45
- package/dist/commands/db-query.js +102 -0
- package/dist/commands/deploy.js +20 -1
- package/dist/commands/env.js +1 -1
- package/dist/commands/setup.js +9 -84
- package/dist/commands/upgrade.js +282 -54
- package/dist/index.js +31 -18
- package/dist/spawn.js +77 -0
- package/dist/util.js +17 -2
- package/package.json +1 -1
|
@@ -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/deploy.js
CHANGED
|
@@ -29,6 +29,25 @@ export function dockerfileExposedPort(dockerfile) {
|
|
|
29
29
|
}
|
|
30
30
|
return port;
|
|
31
31
|
}
|
|
32
|
+
// A directory deploy builds the Dockerfile IN the directory — there is no no-Dockerfile lane here.
|
|
33
|
+
// The nixpacks (no-Dockerfile) lane is real but server-side: it runs on the build gateway for
|
|
34
|
+
// GitHub-connected repos only, and nothing reachable from `insta deploy <dir>` can enter it. So the
|
|
35
|
+
// dead-end message names every way forward instead of the bare "add one".
|
|
36
|
+
//
|
|
37
|
+
// It deliberately does NOT say "save the Dockerfile `insta build --explain` prints": that file is
|
|
38
|
+
// not standalone — it COPYs `.nixpacks/nixpkgs-<hash>.nix` support files nixpacks writes beside it,
|
|
39
|
+
// which the source dir does not have. Pointing at it would swap one false promise for another. The
|
|
40
|
+
// detected install/start commands ARE reusable, so the message points at those.
|
|
41
|
+
// Pure, so it's unit-tested.
|
|
42
|
+
export function noDockerfileMessage(absDir) {
|
|
43
|
+
return [
|
|
44
|
+
`no Dockerfile at ${join(absDir, 'Dockerfile')} — a directory deploy builds the Dockerfile in the directory.`,
|
|
45
|
+
'Options:',
|
|
46
|
+
` - add a Dockerfile to ${absDir} (\`insta build ${absDir}\` prints the install/start commands nixpacks detected, as a starting point)`,
|
|
47
|
+
' - deploy a prebuilt image instead: `insta deploy --image <url>`',
|
|
48
|
+
" - connect the app's GitHub repo in the console — that lane builds Dockerfile-less repos with nixpacks server-side",
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
|
32
51
|
// Deploy either a prebuilt image (`--image`) or a source directory (positional `<dir>`, built
|
|
33
52
|
// remotely on Fly and pushed with a short-lived platform-minted token). Exactly one mode.
|
|
34
53
|
export async function deploy(dir, opts) {
|
|
@@ -80,7 +99,7 @@ export async function dockerBuildLocal(absDir, tag, run = defaultBuildRunner) {
|
|
|
80
99
|
export async function buildFromSource(api, projectId, dir, branch, opts, run = opts.json ? stderrBuildRunner : defaultBuildRunner) {
|
|
81
100
|
const absDir = resolve(process.cwd(), dir);
|
|
82
101
|
if (!existsSync(join(absDir, 'Dockerfile')))
|
|
83
|
-
die(
|
|
102
|
+
die(noDockerfileMessage(absDir));
|
|
84
103
|
const log = note(opts);
|
|
85
104
|
let tok;
|
|
86
105
|
try {
|
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
|
@@ -6,19 +6,21 @@
|
|
|
6
6
|
// Stack skills (tigris/better-auth) intentionally stay per-project: their presence in a
|
|
7
7
|
// project doubles as its stack manifest — that install happens on `project create|link`.
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
|
-
import { closeSync, createReadStream,
|
|
10
|
-
import {
|
|
9
|
+
import { closeSync, createReadStream, openSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
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';
|
|
17
|
-
import {
|
|
16
|
+
import { info, openUrl } from '../util.js';
|
|
17
|
+
import { isRunnableFile, resolveSpawnable } from '../spawn.js';
|
|
18
|
+
import { loginDevice } from './auth.js';
|
|
18
19
|
import { projectLink } from './project.js';
|
|
19
20
|
import { envUse } from './env.js';
|
|
20
21
|
import { installAgentConfigs } from './mcp.js';
|
|
21
22
|
import { detectChannel } from './upgrade.js';
|
|
23
|
+
export { resolveSpawnable, whichOnPath } from '../spawn.js';
|
|
22
24
|
// The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an
|
|
23
25
|
// "Installing to all N agents" banner, a full N-line install-path box, and a third-party
|
|
24
26
|
// "Security Risk Assessment" that flags our OWN first-party skill as "Critical Risk". Streamed
|
|
@@ -99,35 +101,6 @@ export function summarizeInstall(output) {
|
|
|
99
101
|
// access() answers "can THIS process exec it", which for root is always yes, so a root-run
|
|
100
102
|
// setup would wrongly treat a non-executable file as a durable install. On Windows execute
|
|
101
103
|
// permission is extension-driven, so existence of a regular file is the right check.
|
|
102
|
-
const isRunnableFile = (p, win) => {
|
|
103
|
-
try {
|
|
104
|
-
const st = statSync(p);
|
|
105
|
-
if (!st.isFile())
|
|
106
|
-
return false;
|
|
107
|
-
return win || (st.mode & 0o111) !== 0;
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
/** Resolve a bare command name to its absolute PATH location (PATHEXT-aware on Windows).
|
|
114
|
-
* cmd.exe searches the CURRENT DIRECTORY before PATH for bare names, so handing it a bare
|
|
115
|
-
* `claude` would let a claude.cmd planted in the project directory shadow the real CLI —
|
|
116
|
-
* the cmd.exe wrapper below only ever passes absolute paths. */
|
|
117
|
-
export function whichOnPath(bin, env = process.env, platform = process.platform) {
|
|
118
|
-
const win = platform === 'win32';
|
|
119
|
-
const exts = win ? [...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';'), ''] : [''];
|
|
120
|
-
for (const dir of (env.PATH ?? '').split(win ? ';' : ':')) {
|
|
121
|
-
if (!dir)
|
|
122
|
-
continue;
|
|
123
|
-
for (const ext of exts) {
|
|
124
|
-
const p = join(dir, bin + ext);
|
|
125
|
-
if (isRunnableFile(p, win))
|
|
126
|
-
return p;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
104
|
export function findDurableOnPath(bin, env = process.env, platform = process.platform) {
|
|
132
105
|
const win = platform === 'win32';
|
|
133
106
|
const dirs = (env.PATH ?? '').split(win ? ';' : ':');
|
|
@@ -182,54 +155,6 @@ export async function ensureCliInstalled(run, channel = detectChannel(), onPath
|
|
|
182
155
|
info(' (permission error: the npm prefix is system-owned — use a Node version manager, or elevate that one command)');
|
|
183
156
|
}
|
|
184
157
|
}
|
|
185
|
-
// ---- Windows-safe spawning for npm/npx ----
|
|
186
|
-
// On Windows `npm`/`npx` are .cmd shims, which spawn() without a shell refuses (Node docs:
|
|
187
|
-
// spawning .bat/.cmd needs a shell or cmd.exe). Rather than a shell (argument-quoting hazards),
|
|
188
|
-
// re-enter them as node scripts: the CLI script named by npm_execpath (swapped between
|
|
189
|
-
// npm-cli.js and npx-cli.js as needed), else the one shipped beside the running node, else the
|
|
190
|
-
// bare name (POSIX, where PATH shims resolve fine). Applied ONCE, inside the default runner,
|
|
191
|
-
// so every `run('npm'|'npx', …)` call site benefits and nothing is ever resolved twice.
|
|
192
|
-
export function resolveSpawnable(cmd, args, npmExecpath = process.env.npm_execpath, execPath = process.execPath, platform = process.platform, env = process.env) {
|
|
193
|
-
// Node re-entry is only valid when THIS process runs on node. On the native-binary channel
|
|
194
|
-
// execPath is the compiled `insta` executable — and npm scripts export npm_execpath to their
|
|
195
|
-
// children — so re-entering blindly would spawn `insta npx-cli.js …`. A non-node execPath
|
|
196
|
-
// sends npm/npx down the generic shim path below instead.
|
|
197
|
-
const execIsNode = /(^|[\\/])node(\.exe)?$/i.test(execPath);
|
|
198
|
-
if ((cmd === 'npm' || cmd === 'npx') && execIsNode) {
|
|
199
|
-
if (npmExecpath && /(^|[\\/])np[mx](-cli)?\.[cm]?js$/.test(npmExecpath)) {
|
|
200
|
-
const cli = npmExecpath.replace(/np[mx](-cli)?(\.[cm]?js)$/, `${cmd}$1$2`);
|
|
201
|
-
if (existsSync(cli))
|
|
202
|
-
return { cmd: execPath, args: [cli, ...args] };
|
|
203
|
-
}
|
|
204
|
-
const nodeDir = dirname(execPath);
|
|
205
|
-
const besideNode = platform === 'win32'
|
|
206
|
-
? join(nodeDir, 'node_modules', 'npm', 'bin', `${cmd}-cli.js`)
|
|
207
|
-
: join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', `${cmd}-cli.js`);
|
|
208
|
-
if (existsSync(besideNode))
|
|
209
|
-
return { cmd: execPath, args: [besideNode, ...args] };
|
|
210
|
-
}
|
|
211
|
-
// Generic shim path — every non-npm CLI we shell out to (claude), plus npm/npx themselves
|
|
212
|
-
// when node isn't resolvable (native binary channel). On Windows these are .cmd shims, which
|
|
213
|
-
// spawn() refuses without a shell, so route them through cmd.exe. Guards, in order:
|
|
214
|
-
// - BARE names only: an absolute path or anything .exe (node.exe from a resolved npm/npx
|
|
215
|
-
// invocation passing back through here) is directly spawnable and must NOT see cmd.exe.
|
|
216
|
-
// - The name is resolved to its ABSOLUTE PATH location first: cmd.exe searches the current
|
|
217
|
-
// directory before PATH, so a bare name would let a shim planted in the project dir
|
|
218
|
-
// shadow the real CLI. No PATH hit → pass through (spawn fails; callers degrade).
|
|
219
|
-
// - No manual quoting: libuv already wraps spaced args when building the child command
|
|
220
|
-
// line — pre-quoting would be quoted AGAIN and arrive as literal quote characters.
|
|
221
|
-
// - That leaves cmd.exe metacharacters unprotectable, so an arg carrying one (e.g. a
|
|
222
|
-
// custom INSTA_MCP_URL with `&`) skips the wrapper: the bare-shim spawn fails and every
|
|
223
|
-
// caller degrades gracefully (probe → not-installed; registration → manual-add
|
|
224
|
-
// fallback). Never hand metacharacters to a shell.
|
|
225
|
-
const bareShim = !/[\\/]/.test(cmd) && !/\.exe$/i.test(cmd);
|
|
226
|
-
if (platform === 'win32' && bareShim && !args.some((a) => /[&|<>^%"]/.test(a))) {
|
|
227
|
-
const abs = whichOnPath(cmd, env, platform);
|
|
228
|
-
if (abs)
|
|
229
|
-
return { cmd: 'cmd.exe', args: ['/d', '/s', '/c', abs, ...args] };
|
|
230
|
-
}
|
|
231
|
-
return { cmd, args };
|
|
232
|
-
}
|
|
233
158
|
// Capture stdout+stderr silently (don't stream) so we can print our own clean summary.
|
|
234
159
|
// stdin is 'ignore', NOT 'inherit': under the canonical `curl … | sh` install, stdin is the
|
|
235
160
|
// piped install script itself — a child that inherits it (npx/skills reads for keypresses even
|
|
@@ -422,7 +347,7 @@ const defaultAsk = async (question) => {
|
|
|
422
347
|
};
|
|
423
348
|
export async function setupAgent(opts, run = defaultRunner, mint, installConfigs = installAgentConfigs, ensure = (r) => ensureCliInstalled(r), readStored = readPersistedGlobal, switchEnv = (n) => envUse(n), loginFlow = {
|
|
424
349
|
ask: defaultAsk,
|
|
425
|
-
login: () =>
|
|
350
|
+
login: () => loginDevice({}, openUrl),
|
|
426
351
|
stdinTty: canPromptViaTty(),
|
|
427
352
|
stdoutTty: !!process.stdout.isTTY,
|
|
428
353
|
}, link = projectLink) {
|
|
@@ -473,7 +398,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
|
|
|
473
398
|
const stored = await readStored();
|
|
474
399
|
let loggedIn = !!(stored.accessToken || stored.user);
|
|
475
400
|
if (shouldOfferLogin(!!opts.yes, loggedIn, loginFlow.stdinTty, loginFlow.stdoutTty)) {
|
|
476
|
-
if (await loginFlow.ask('log in now
|
|
401
|
+
if (await loginFlow.ask('log in now in the browser? (Y/n) ')) {
|
|
477
402
|
try {
|
|
478
403
|
await loginFlow.login();
|
|
479
404
|
loggedIn = true;
|
|
@@ -482,7 +407,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
|
|
|
482
407
|
}
|
|
483
408
|
catch (e) {
|
|
484
409
|
info(` login did not complete (${e instanceof Error ? e.message : String(e)}) — no problem, setup itself is done.`);
|
|
485
|
-
info('
|
|
410
|
+
info(' run `insta login` to try again — the sign-in link it prints works from a browser on any device.');
|
|
486
411
|
}
|
|
487
412
|
}
|
|
488
413
|
}
|