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.
@@ -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
@@ -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(`no Dockerfile at ${join(absDir, 'Dockerfile')} — add one, or use --image <url>`);
102
+ die(noDockerfileMessage(absDir));
84
103
  const log = note(opts);
85
104
  let tok;
86
105
  try {
@@ -70,7 +70,7 @@ export async function envUse(name, opts = {}) {
70
70
  info(` api: ${nextApi}`);
71
71
  info(` mcp: ${ENVS[target].mcp} (registers as \`${mcpServerName(target)}\`)`);
72
72
  if (hadSession)
73
- info(' previous session dropped (separate deployment) — run `insta login --oauth github`');
73
+ info(' previous session dropped (separate deployment) — run `insta login`');
74
74
  // Switching the CLI does NOT re-point already-installed agents: their MCP registration and skill
75
75
  // files were written for the previous environment and are keyed by a different server name, so
76
76
  // they keep talking to it until setup is re-run. --env is REQUIRED in the hint: since 0.0.38 a
@@ -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, existsSync, openSync, readFileSync, statSync } from 'node:fs';
10
- import { dirname, join } from 'node:path';
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 { loginOauth } from './auth.js';
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: () => loginOauth('github', {}),
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 with GitHub? (Y/n) ')) {
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(' on a remote/SSH machine the browser flow cannot call back here — use `insta login --device` instead.');
410
+ info(' run `insta login` to try again — the sign-in link it prints works from a browser on any device.');
486
411
  }
487
412
  }
488
413
  }