insta 0.0.35 → 0.0.36

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,256 @@
1
+ // `insta build` — pre-push verification of a source directory: the detection plan (what would
2
+ // build), the Dockerfile that would be used (yours, or nixpacks-generated), and static checks.
3
+ // Entirely local and offline: no login, no project link, nothing pushed or deployed. Phase 1 is
4
+ // static-only — no Docker daemon involved.
5
+ import { resolve, join } from 'node:path';
6
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
+ import { info, printJson, die } from '../util.js';
8
+ import { dockerfileExposedPort } from './deploy.js';
9
+ import { nixpacksPlan, nixpacksGeneratedDockerfile, nixpacksAvailable, quietRunner } from '../nixpacks.js';
10
+ // A failed critical sinks the build; a failed warning deserves attention; skips are not failures.
11
+ export function computeVerdict(checks) {
12
+ const failed = checks.filter((c) => c.status === 'fail');
13
+ if (failed.some((c) => c.severity === 'critical'))
14
+ return 'failed';
15
+ if (failed.length > 0)
16
+ return 'needs-attention';
17
+ return 'deployable';
18
+ }
19
+ // Port resolution mirrors deploy.ts: an explicit --port wins, else the Dockerfile's EXPOSE. The
20
+ // rationale string is part of the output — every plan line says why (the `fly launch` pattern).
21
+ export function inferPort(flag, dockerfile) {
22
+ if (flag !== undefined) {
23
+ const port = /^\d+$/.test(flag.trim()) ? Number(flag.trim()) : NaN;
24
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
25
+ throw new Error(`--port must be an integer between 1 and 65535, got: ${flag}`);
26
+ return { port, rationale: '--port flag' };
27
+ }
28
+ const exposed = dockerfile ? dockerfileExposedPort(dockerfile) : undefined;
29
+ if (exposed)
30
+ return { port: exposed, rationale: `Dockerfile EXPOSE ${exposed}` };
31
+ return { port: undefined, rationale: 'not detected — deploy defaults to 8080' };
32
+ }
33
+ // Keys the app expects, from .env.example — surfaced so an agent can `insta secrets set` them
34
+ // before the first deploy instead of discovering missing config from runtime crashes.
35
+ export function envKeysFromDotEnvExample(content) {
36
+ const keys = [];
37
+ for (const line of content.split('\n')) {
38
+ if (line.trim().startsWith('#'))
39
+ continue;
40
+ const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
41
+ if (key)
42
+ keys.push(key);
43
+ }
44
+ return keys;
45
+ }
46
+ const CONTEXT_WARN_BYTES = 100 * 1024 * 1024;
47
+ const WALK_CAP = 50_000; // entries; hitting it marks the stats truncated (size becomes a floor)
48
+ // Sizes what would actually ship: a dockerignored node_modules is skipped, not counted. (Only the
49
+ // node_modules pattern is honored — full .dockerignore glob semantics aren't reimplemented here.)
50
+ export function contextStats(dir, cap = WALK_CAP) {
51
+ const ignoreFile = join(dir, '.dockerignore');
52
+ const ignoreLines = existsSync(ignoreFile)
53
+ ? readFileSync(ignoreFile, 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
54
+ : [];
55
+ const nodeModulesIgnored = ignoreLines.some((l) => ['node_modules', 'node_modules/', '/node_modules', '**/node_modules'].includes(l));
56
+ let totalBytes = 0;
57
+ let nodeModulesBytes = 0;
58
+ let hasNodeModules = false;
59
+ let truncated = false;
60
+ let seen = 0;
61
+ const walk = (d, inNodeModules) => {
62
+ let entries;
63
+ try {
64
+ entries = readdirSync(d);
65
+ }
66
+ catch {
67
+ return;
68
+ }
69
+ for (const name of entries) {
70
+ if (seen++ >= cap) {
71
+ truncated = true;
72
+ return;
73
+ }
74
+ if (name === '.git')
75
+ continue;
76
+ const p = join(d, name);
77
+ let st;
78
+ try {
79
+ st = statSync(p);
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ if (name === 'node_modules' && st.isDirectory()) {
85
+ hasNodeModules = true;
86
+ if (nodeModulesIgnored)
87
+ continue; // excluded from the context — don't count it
88
+ }
89
+ const isNm = inNodeModules || name === 'node_modules';
90
+ if (st.isDirectory())
91
+ walk(p, isNm);
92
+ else {
93
+ totalBytes += st.size;
94
+ if (isNm)
95
+ nodeModulesBytes += st.size;
96
+ }
97
+ }
98
+ };
99
+ walk(dir, false);
100
+ return { totalBytes, nodeModulesBytes, hasNodeModules, nodeModulesIgnored, truncated };
101
+ }
102
+ const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
103
+ export function contextCheck(ctx) {
104
+ const shipsNodeModules = ctx.hasNodeModules && !ctx.nodeModulesIgnored;
105
+ const tooBig = ctx.totalBytes > CONTEXT_WARN_BYTES;
106
+ const size = `${mb(ctx.totalBytes)}${ctx.truncated ? '+' : ''}`;
107
+ const detail = shipsNodeModules
108
+ ? `node_modules (${mb(ctx.nodeModulesBytes)}) would ship in the ${size} build context`
109
+ : ctx.truncated
110
+ ? `over ${WALK_CAP.toLocaleString('en-US')} entries — scan truncated, ${size} is a floor`
111
+ : `${size}${tooBig ? ' — large contexts make remote builds slow' : ''}`;
112
+ const bad = shipsNodeModules || tooBig || ctx.truncated;
113
+ return {
114
+ id: 'context',
115
+ severity: 'warning',
116
+ status: bad ? 'fail' : 'pass',
117
+ title: 'build context',
118
+ detail,
119
+ ...(bad ? { nextAction: 'add a .dockerignore (node_modules, build artifacts, secrets)' } : {}),
120
+ };
121
+ }
122
+ export async function buildReport(dirArg, opts, deps) {
123
+ const dir = resolve(process.cwd(), dirArg);
124
+ const userDockerfilePath = join(dir, 'Dockerfile');
125
+ const hasUserDockerfile = existsSync(userDockerfilePath);
126
+ let dockerfile = { source: null };
127
+ let np = null;
128
+ let dockerfileDetail = '';
129
+ if (hasUserDockerfile) {
130
+ dockerfile = { source: 'user', path: userDockerfilePath, content: readFileSync(userDockerfilePath, 'utf8') };
131
+ dockerfileDetail = 'using the Dockerfile in the directory';
132
+ }
133
+ else if (!deps.nixpacksAvailable) {
134
+ dockerfileDetail = 'no Dockerfile in the directory, and nixpacks is not installed to generate one';
135
+ }
136
+ else {
137
+ np = await nixpacksPlan(dir, deps.runner);
138
+ if (!np) {
139
+ dockerfileDetail = 'no Dockerfile, and nixpacks matched no provider for this directory';
140
+ }
141
+ else {
142
+ const generated = await nixpacksGeneratedDockerfile(dir, deps.runner);
143
+ if (generated) {
144
+ dockerfile = { source: 'nixpacks', content: generated };
145
+ dockerfileDetail = `generated by nixpacks (providers: ${np.providers.join(', ') || 'none'})`;
146
+ }
147
+ else {
148
+ dockerfileDetail = 'nixpacks detected the app but could not generate a Dockerfile';
149
+ }
150
+ }
151
+ }
152
+ const builder = hasUserDockerfile ? 'dockerfile' : np ? 'nixpacks' : null;
153
+ const { port, rationale } = inferPort(opts.port, dockerfile.content);
154
+ const envExample = join(dir, '.env.example');
155
+ const envKeys = existsSync(envExample) ? envKeysFromDotEnvExample(readFileSync(envExample, 'utf8')) : [];
156
+ const checks = [];
157
+ checks.push({
158
+ id: 'dockerfile',
159
+ severity: 'critical',
160
+ status: dockerfile.source ? 'pass' : 'fail',
161
+ title: 'Dockerfile',
162
+ detail: dockerfileDetail,
163
+ ...(dockerfile.source ? {} : { nextAction: `add a Dockerfile at ${userDockerfilePath}, or install nixpacks (https://nixpacks.com/docs/install) so insta can generate one` }),
164
+ });
165
+ if (builder === 'dockerfile') {
166
+ const hasCmd = /^\s*(CMD|ENTRYPOINT)\s/im.test(dockerfile.content ?? '');
167
+ checks.push({
168
+ id: 'start-command',
169
+ severity: 'warning',
170
+ status: hasCmd ? 'pass' : 'fail',
171
+ title: 'start command',
172
+ detail: hasCmd ? 'Dockerfile has a CMD/ENTRYPOINT' : 'no CMD or ENTRYPOINT in the Dockerfile — the base image must supply one',
173
+ ...(hasCmd ? {} : { nextAction: 'add a CMD (or ENTRYPOINT) so the image starts your app' }),
174
+ });
175
+ }
176
+ else if (builder === 'nixpacks') {
177
+ checks.push({
178
+ id: 'start-command',
179
+ severity: 'critical',
180
+ status: np?.startCommand ? 'pass' : 'fail',
181
+ title: 'start command',
182
+ detail: np?.startCommand ?? 'nixpacks found no start command — the built image would not run',
183
+ ...(np?.startCommand ? {} : { nextAction: 'define one (e.g. a package.json "start" script, or a Procfile)' }),
184
+ });
185
+ }
186
+ else {
187
+ checks.push({ id: 'start-command', severity: 'critical', status: 'skip', title: 'start command', detail: 'skipped — no builder' });
188
+ }
189
+ checks.push({
190
+ id: 'port',
191
+ severity: 'warning',
192
+ status: port !== undefined ? 'pass' : 'fail',
193
+ title: 'port',
194
+ detail: port !== undefined ? `${port} (${rationale})` : rationale,
195
+ ...(port !== undefined ? {} : { nextAction: 'pass --port <n> (or add EXPOSE <n> to the Dockerfile) — a port mismatch is the #1 deploy mistake' }),
196
+ });
197
+ checks.push(contextCheck(contextStats(dir)));
198
+ return {
199
+ dir,
200
+ plan: { builder, providers: np?.providers ?? [], installCommand: np?.installCommand, buildCommand: np?.buildCommand, startCommand: np?.startCommand, port, portRationale: rationale, envKeys },
201
+ dockerfile,
202
+ checks,
203
+ verdict: computeVerdict(checks),
204
+ };
205
+ }
206
+ const MARK = { pass: '✓', fail: '✗', skip: '·' };
207
+ export function renderReport(r, explain) {
208
+ const lines = [];
209
+ lines.push(`plan for ${r.dir}:`);
210
+ lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}`);
211
+ if (r.plan.installCommand)
212
+ lines.push(` install: ${r.plan.installCommand}`);
213
+ if (r.plan.buildCommand)
214
+ lines.push(` build: ${r.plan.buildCommand}`);
215
+ if (r.plan.startCommand)
216
+ lines.push(` start: ${r.plan.startCommand}`);
217
+ lines.push(` port: ${r.plan.port ?? '?'} (${r.plan.portRationale})`);
218
+ if (r.plan.envKeys.length)
219
+ lines.push(` env keys (.env.example): ${r.plan.envKeys.join(', ')}`);
220
+ lines.push('checks:');
221
+ for (const c of r.checks) {
222
+ const mark = c.status === 'fail' && c.severity !== 'critical' ? '⚠' : MARK[c.status];
223
+ lines.push(` ${mark} ${c.title}${c.detail ? ` — ${c.detail}` : ''}`);
224
+ if (c.status === 'fail' && c.nextAction)
225
+ lines.push(` → ${c.nextAction}`);
226
+ }
227
+ if (explain && r.dockerfile.content) {
228
+ lines.push(`dockerfile (${r.dockerfile.source}):`);
229
+ for (const l of r.dockerfile.content.trimEnd().split('\n'))
230
+ lines.push(` ${l}`);
231
+ }
232
+ lines.push(`verdict: ${r.verdict}`);
233
+ return lines;
234
+ }
235
+ // Dockerfile content is included with --explain; without it the report stays small.
236
+ export function jsonReport(report, explain) {
237
+ return explain ? report : { ...report, dockerfile: { ...report.dockerfile, content: undefined } };
238
+ }
239
+ export async function build(dirArg, opts) {
240
+ const dir = dirArg ?? '.';
241
+ const abs = resolve(process.cwd(), dir);
242
+ if (!existsSync(abs) || !statSync(abs).isDirectory())
243
+ die(`no such directory: ${abs}`);
244
+ // Only probe for nixpacks when there is no Dockerfile to verify. The probe is silent and never
245
+ // installs anything — stdout must stay pure for --json, and a verifier must stay offline.
246
+ const available = existsSync(join(abs, 'Dockerfile')) ? false : await nixpacksAvailable();
247
+ const report = await buildReport(dir, opts, { runner: quietRunner, nixpacksAvailable: available });
248
+ if (opts.json)
249
+ printJson(jsonReport(report, !!opts.explain));
250
+ else
251
+ for (const line of renderReport(report, !!opts.explain))
252
+ info(line);
253
+ if (report.verdict === 'failed')
254
+ process.exitCode = 1;
255
+ }
256
+ //# sourceMappingURL=build.js.map
@@ -70,6 +70,96 @@ export async function computeStatus(serviceName, opts) {
70
70
  return printJson(r);
71
71
  info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`);
72
72
  }
73
+ // ---- exec (one-shot command; no interactive shell/PTY) ----
74
+ // `insta compute exec [service] -- <command> [args…]`: the command must reach the platform
75
+ // byte-for-byte and can itself contain dashes or another `--`, so it can't be a normal commander
76
+ // positional — with `service` optional, commander flattens everything past the literal `--` into
77
+ // one operand list and has no way to tell "no service, command starts here" apart from "service IS
78
+ // the first command token". Splitting argv on the first literal `--` after `compute exec`
79
+ // ourselves, before commander ever parses it, removes the ambiguity; this is the only place in the
80
+ // whole CLI a bare `--` has this meaning, so nothing else is affected. Exported for a direct,
81
+ // network-free unit test — this split is the seam most likely to regress.
82
+ export function splitExecArgs(argv) {
83
+ const i = argv.findIndex((a, idx) => a === 'compute' && argv[idx + 1] === 'exec');
84
+ if (i === -1)
85
+ return { argv };
86
+ const dash = argv.indexOf('--', i + 2);
87
+ if (dash === -1)
88
+ return { argv };
89
+ return { argv: argv.slice(0, dash), command: argv.slice(dash + 1) };
90
+ }
91
+ // The --timeout override, through a throwing parser like every other user-typed number in this
92
+ // repo (parseCpu, parseCount, parsePort): junk must fail locally instead of reaching the server as
93
+ // NaN, and the bounds mirror what the platform enforces (1-180s; server default 30 when omitted).
94
+ export function parseTimeoutSec(raw) {
95
+ const n = Number(raw);
96
+ if (!Number.isInteger(n) || n < 1 || n > 180)
97
+ throw new Error(`invalid timeout: ${raw} (1-180 seconds)`);
98
+ return n;
99
+ }
100
+ // Map exec inputs to the platform POST body. Pure, unit-tested without a network mock (mirrors
101
+ // deployRequestBody / servicesAddRequestBody). timeoutSec is omitted when not given so the server
102
+ // applies its own default (30s) rather than the client picking one on the wire.
103
+ export function execRequestBody(command, timeoutSec) {
104
+ return { command, ...(timeoutSec !== undefined ? { timeoutSec } : {}) };
105
+ }
106
+ // Renders the exec response and sets process.exitCode — split out of computeExec as a pure function
107
+ // of (res, json) so it's unit-testable without a network mock, same as handleApproval's own
108
+ // {status, body} shape.
109
+ //
110
+ // A 202 means the command has NOT run: unlike every other gated command (where "nothing happened"
111
+ // is the safe default), a caller chaining `insta compute exec … && next` must not see exit 0 here,
112
+ // or `next` runs believing the command succeeded. --json prints the raw envelope (so a scripted
113
+ // caller can inspect approvalId/action) instead of the human hint; either way exit 1.
114
+ export function applyExecResult(res, json) {
115
+ if (res.status === 202 && res.body?.status === 'approval_required') {
116
+ if (json)
117
+ printJson(res.body);
118
+ else
119
+ handleApproval(res);
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+ const { exitCode, stdout, stderr, truncated } = res.body;
124
+ if (json) {
125
+ printJson(res.body);
126
+ }
127
+ else {
128
+ process.stdout.write(stdout);
129
+ process.stderr.write(stderr);
130
+ if (truncated)
131
+ process.stderr.write('note: output truncated — the platform caps stdout/stderr at 1 MiB each\n');
132
+ }
133
+ // The platform sends -1 as an "unknown exit" sentinel, and nothing outside 0-255 is a valid POSIX
134
+ // exit code. Assigning it straight to process.exitCode risks Node's own DEP0164 (a negative code
135
+ // silently exits 255) — clamp out-of-range codes to 1 instead, with a one-line note so the cause is
136
+ // visible. Normal codes pass through untouched.
137
+ if (exitCode < 0 || exitCode > 255) {
138
+ process.stderr.write(`note: remote exit code ${exitCode} out of range — exiting 1\n`);
139
+ process.exitCode = 1;
140
+ }
141
+ else {
142
+ process.exitCode = exitCode;
143
+ }
144
+ }
145
+ // One HTTP round trip, not a shell session: no PTY, no interactivity, stdout/stderr come back as
146
+ // two whole strings (each capped at 1 MiB server-side) rather than a stream. They're written to
147
+ // this process's own stdout/stderr verbatim — no prefixes, no added newline — and the remote exit
148
+ // code becomes this process's own exit code (--json still passes it through, it just skips the
149
+ // split-stream output), since agents scripting this rely on it. Waking a scaled-to-zero machine is
150
+ // expected — it adds latency and bills as uptime, it is not an error.
151
+ export async function computeExec(serviceName, command, opts) {
152
+ if (!command || command.length === 0)
153
+ throw new Error('usage: insta compute exec [service] -- <command> [args…] (see --help)');
154
+ const timeoutSec = opts.timeout !== undefined ? parseTimeoutSec(opts.timeout) : undefined;
155
+ const api = await ApiClient.load();
156
+ const p = await requireProject();
157
+ const branch = opts.branch ?? p.branch;
158
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
159
+ const id = resolveComputeServiceId(services, serviceName);
160
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/exec`, execRequestBody(command, timeoutSec));
161
+ applyExecResult(res, opts.json);
162
+ }
73
163
  // ---- always-on (opt out of scale-to-zero; all plans; billing is actual usage either way) ----
74
164
  export async function computeAlwaysOn(mode, serviceName, opts) {
75
165
  if (mode !== 'on' && mode !== 'off')
@@ -1,8 +1,8 @@
1
1
  import { resolve, join } from 'node:path';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
- import { ApiClient, requireProject } from '../api.js';
3
+ import { ApiClient, ApiError, requireProject } from '../api.js';
4
4
  import { info, die, handleApproval, renderNextActions } from '../util.js';
5
- import { flyctlBuildAndPush, ensureFlyctl } from '../flyctl-build.js';
5
+ import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner } from '../flyctl-build.js';
6
6
  // Map CLI options to the platform deploy request body. Pure, so it's unit-tested. --websocket is only
7
7
  // sent when set (plain deploys unchanged).
8
8
  export function deployRequestBody(image, branch, opts) {
@@ -53,20 +53,50 @@ export async function deploy(dir, opts) {
53
53
  info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
54
54
  renderNextActions(res.body.nextActions);
55
55
  }
56
+ // The local image tag a daemon-side deploy runs: unique per build so a redeploy replaces, and
57
+ // legible in `docker images`. Pure, so it's unit-tested.
58
+ export function localImageTag(projectId, group, now = Date.now()) {
59
+ return `insta-src-${projectId.slice(0, 8)}-${group ?? 'default'}:${now}`;
60
+ }
61
+ // Local build for a local daemon (insta-oss): the CLI and the daemon share ONE docker, so a
62
+ // locally-built tag is directly runnable — no registry, no push. Same injectable-runner pattern
63
+ // as flyctl-build.ts.
64
+ export async function dockerBuildLocal(absDir, tag, run = defaultBuildRunner) {
65
+ const { code } = await run('docker', ['build', '-t', tag, '.'], { cwd: absDir, env: process.env });
66
+ if (code !== 0)
67
+ throw new Error(`docker build failed (exit ${code}). See output above.`);
68
+ return tag;
69
+ }
56
70
  // Source mode: mint a scoped Fly deploy token from the platform, then build+push <dir> (needs a
57
- // Dockerfile) with flyctl's remote builder, returning the pushed image ref to deploy.
58
- async function buildFromSource(api, projectId, dir, branch, opts) {
71
+ // Dockerfile) with flyctl's remote builder, returning the pushed image ref to deploy. Against a
72
+ // local daemon (insta-oss) the token mint answers 501 — build with docker instead, same contract.
73
+ // Exported with injectable pieces for tests (the repo's DI pattern; no global mocks).
74
+ export async function buildFromSource(api, projectId, dir, branch, opts, run = defaultBuildRunner) {
59
75
  const absDir = resolve(process.cwd(), dir);
60
76
  if (!existsSync(join(absDir, 'Dockerfile')))
61
77
  die(`no Dockerfile at ${join(absDir, 'Dockerfile')} — add one, or use --image <url>`);
62
- await ensureFlyctl();
63
- const port = opts.port ? Number(opts.port) : 8080;
64
- const tok = await api.rawRequest('POST', `/projects/${projectId}/deploy-token`, { branch, group: opts.group });
78
+ let tok;
79
+ try {
80
+ tok = await api.rawRequest('POST', `/projects/${projectId}/deploy-token`, { branch, group: opts.group });
81
+ }
82
+ catch (e) {
83
+ // 501 = no remote builder here (insta-oss is the only deployment that answers it) — the
84
+ // daemon deploys from the SAME docker this shell uses, so build locally and hand it the tag.
85
+ if (!(e instanceof ApiError) || e.status !== 501)
86
+ throw e;
87
+ const tag = localImageTag(projectId, opts.group);
88
+ info(`no remote builder on this daemon — building ${dir} locally with docker…`);
89
+ const built = await dockerBuildLocal(absDir, tag, run);
90
+ info(` built ${built}`);
91
+ return built;
92
+ }
65
93
  if (handleApproval(tok))
66
94
  die('deploy requires approval — get it approved, then re-run');
67
95
  const { token, flyApp } = tok.body;
96
+ await ensureFlyctl(); // cloud path only — the local path needs docker, which the daemon requires anyway
97
+ const port = opts.port ? Number(opts.port) : 8080;
68
98
  info(`building ${dir} for ${flyApp} (remote builder)…`);
69
- const { imageRef } = await flyctlBuildAndPush({ dir: absDir, flyApp, imageLabel: `insta-${Date.now()}`, token, port });
99
+ const { imageRef } = await flyctlBuildAndPush({ dir: absDir, flyApp, imageLabel: `insta-${Date.now()}`, token, port }, run);
70
100
  info(` pushed ${imageRef}`);
71
101
  return imageRef;
72
102
  }
@@ -60,7 +60,7 @@ function humanBytes(v, base) {
60
60
  const shown = i === 0 && Number.isInteger(value) ? String(value) : value.toFixed(1);
61
61
  return `${shown} ${units[i]}`;
62
62
  }
63
- // insta metrics <db|compute> [group]
63
+ // insta metrics <db|compute|redis|mysql|mongodb> [group]
64
64
  export async function metrics(component, group, opts) {
65
65
  const api = await ApiClient.load();
66
66
  const p = await requireProject();
@@ -129,23 +129,26 @@ export async function usage(opts) {
129
129
  info(` ${pr.name}: $${Number(pr.totalCostUsd ?? 0).toFixed(4)}`);
130
130
  }
131
131
  }
132
- // pure: platform path for a compute deploy-events request (used by `insta logs --deploy`).
132
+ // pure: platform path for a deploy-events request (used by `insta logs --deploy`). Any Fly-backed
133
+ // component (compute or a managed database) has machine lifecycle events; omitted → the platform
134
+ // defaults to compute.
133
135
  export function deployEventsPath(projectId, opts) {
134
- return `/projects/${projectId}/deploy-events${qs({ group: opts.group, branch: opts.branch, limit: opts.limit, instance: opts.instance })}`;
136
+ return `/projects/${projectId}/deploy-events${qs({ component: opts.component, group: opts.group, branch: opts.branch, limit: opts.limit, instance: opts.instance })}`;
135
137
  }
136
138
  // pure: render one deploy event as a log-style line.
137
139
  export function deployEventLine(ev) {
138
140
  const inst = ev.instance ? ` (${ev.instance})` : '';
139
141
  return `${ev.ts ?? ''} [${ev.origin ?? ''}] ${ev.type ?? ''}: ${ev.status ?? ''}${inst}`;
140
142
  }
141
- // insta logs <db|compute> [group]
143
+ // insta logs <db|compute|redis|mysql|mongodb> [group]
142
144
  export async function logs(component, group, opts) {
143
145
  const api = await ApiClient.load();
144
146
  const p = await requireProject();
145
147
  if (opts.deploy) {
146
- if (component !== 'compute')
147
- return info('deploy events are only available for compute');
148
- const res = await api.request('GET', deployEventsPath(p.projectId, { group, branch: opts.branch ?? p.branch, limit: opts.limit, instance: opts.instance }));
148
+ // Machine lifecycle events exist for every Fly-backed component; 'db' (postgres) has no machines.
149
+ if (component === 'db')
150
+ return info('deploy events are not available for db — use compute, redis, mysql or mongodb');
151
+ const res = await api.request('GET', deployEventsPath(p.projectId, { component, group, branch: opts.branch ?? p.branch, limit: opts.limit, instance: opts.instance }));
149
152
  if (opts.json)
150
153
  return printJson(res);
151
154
  if (res.note)
@@ -115,6 +115,69 @@ export async function secretsUnset(name, opts) {
115
115
  return;
116
116
  info(`unset ${name} (${opts.branch ? `branch ${opts.branch}` : 'project-wide'})`);
117
117
  }
118
+ export async function secretsBind(envName, source, opts) {
119
+ if (!opts.to)
120
+ die('--to <compute/name> is required');
121
+ const api = await ApiClient.load();
122
+ const p = await requireProject();
123
+ const branch = opts.branch ?? p.branch;
124
+ const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}`, {
125
+ branch,
126
+ target: opts.to,
127
+ source,
128
+ ...(opts.sourceName ? { sourceName: opts.sourceName } : {}),
129
+ });
130
+ if (handleApproval(res))
131
+ return;
132
+ if (opts.json)
133
+ return printJson({ ok: true });
134
+ info(`bound ${envName} on ${opts.to} to ${source}${opts.sourceName ? `.${opts.sourceName}` : ''} (branch ${branch})`);
135
+ }
136
+ export async function secretsUnbind(envName, opts) {
137
+ if (!opts.from)
138
+ die('--from <compute/name> is required');
139
+ const api = await ApiClient.load();
140
+ const p = await requireProject();
141
+ const branch = opts.branch ?? p.branch;
142
+ const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.from)}`);
143
+ if (handleApproval(res))
144
+ return;
145
+ if (opts.json)
146
+ return printJson({ ok: true });
147
+ info(`unbound ${envName} from ${opts.from} (branch ${branch})`);
148
+ }
149
+ export async function secretsBindings(opts) {
150
+ if (!opts.target)
151
+ die('--target <compute/name> is required');
152
+ const api = await ApiClient.load();
153
+ const p = await requireProject();
154
+ const branch = opts.branch ?? p.branch;
155
+ const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-bindings?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.target)}`);
156
+ if (handleApproval(res))
157
+ return;
158
+ const bindings = res.body.bindings ?? [];
159
+ if (opts.json)
160
+ return printJson(bindings);
161
+ if (!bindings.length)
162
+ return info(`(no secret bindings for ${opts.target} on ${branch})`);
163
+ for (const b of bindings)
164
+ info(`${b.envName} <- ${b.source.type}/${b.source.name}.${b.sourceName}`);
165
+ }
166
+ export async function secretsSources(opts) {
167
+ const api = await ApiClient.load();
168
+ const p = await requireProject();
169
+ const branch = opts.branch ?? p.branch;
170
+ const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-sources?branch=${encodeURIComponent(branch)}`);
171
+ if (handleApproval(res))
172
+ return;
173
+ const sources = res.body.sources ?? [];
174
+ if (opts.json)
175
+ return printJson(sources);
176
+ if (!sources.length)
177
+ return info(`(no credential sources on ${branch})`);
178
+ for (const s of sources)
179
+ info(`${s.service.type}/${s.service.name}: ${s.secrets.join(', ')}`);
180
+ }
118
181
  /** Gitignore the env file we just wrote (git repos only; idempotent). Returns true if added. */
119
182
  export function ensureIgnored(cwd, name) {
120
183
  if (!existsSync(join(cwd, '.git')))
@@ -1,7 +1,7 @@
1
- // `insta services` — manage a project's opt-in services (postgres | storage | compute | redis).
1
+ // `insta services` — manage a project's opt-in services (postgres | storage | compute | redis | mysql | mongodb).
2
2
  import { ApiClient, requireProject } from '../api.js';
3
3
  import { info, printJson, handleApproval, renderNextActions } from '../util.js';
4
- export const SERVICE_TYPES = ['postgres', 'storage', 'compute', 'redis'];
4
+ export const SERVICE_TYPES = ['postgres', 'storage', 'compute', 'redis', 'mysql', 'mongodb'];
5
5
  const SERVICE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,38}$/;
6
6
  export function q(branch) {
7
7
  return branch ? `?branch=${encodeURIComponent(branch)}` : '';
@@ -72,6 +72,9 @@ export function resolveSoleService(services, type, name) {
72
72
  export function resolveComputeServiceId(services, name) {
73
73
  return resolveSoleService(services, 'compute', name).id;
74
74
  }
75
+ function defaultDatabasePort(type) {
76
+ return type === 'mysql' ? 3306 : type === 'mongodb' ? 27017 : 6379;
77
+ }
75
78
  // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
76
79
  // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
77
80
  // type) stays in servicesAdd, ahead of any network/config access.
@@ -124,7 +127,7 @@ export async function servicesAdd(type, name, opts = {}) {
124
127
  export function serviceListLine(s) {
125
128
  const extra = s.type === 'compute'
126
129
  ? ` x${s.machine_count}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}`
127
- : s.type === 'redis' ? ` tcp/${s.port ?? 6379}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}`
130
+ : ['redis', 'mysql', 'mongodb'].includes(s.type) ? ` tcp/${s.port ?? defaultDatabasePort(s.type)}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}`
128
131
  : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '';
129
132
  return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`;
130
133
  }
@@ -136,7 +139,7 @@ export async function servicesList(opts) {
136
139
  if (opts.json)
137
140
  return printJson(services);
138
141
  if (!services.length)
139
- return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute|redis> <name>\`)`);
142
+ return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute|redis|mysql|mongodb> <name>\`)`);
140
143
  for (const s of services)
141
144
  info(serviceListLine(s));
142
145
  }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { resolveServiceArgs, serviceArgsDeps } from './resolve-service.js';
17
17
  import * as regions from './commands/regions.js';
18
18
  import * as secretsCmd from './commands/secrets.js';
19
19
  import { deploy } from './commands/deploy.js';
20
+ import { build } from './commands/build.js';
20
21
  import * as computeCmd from './commands/compute.js';
21
22
  import * as dbCmd from './commands/db.js';
22
23
  import * as storageCmd from './commands/storage.js';
@@ -109,14 +110,14 @@ br.command('switch <name>').action(guard((name) => branch.branchSwitch(name)));
109
110
  br.command('delete <name>').action(guard((name) => branch.branchDelete(name)));
110
111
  br.command('merge <source>').description('Merge a branch service set into another (structural, no data)')
111
112
  .option('--into <branch>', 'target branch (default: current)').action(guard((source, o) => branch.branchMerge(source, o)));
112
- // ---- services (opt-in postgres/storage/compute/redis) ----
113
- const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis)');
113
+ // ---- services (opt-in postgres/storage/compute/redis/mysql/mongodb) ----
114
+ const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis|mysql|mongodb)');
114
115
  // [type] [name] are optional so the command can answer "what can I add?" — a terminal is walked
115
116
  // through the dashboard's Add Service kinds, anything else gets that list back as an error
116
117
  // (resolve-service.ts). Picking Docker Image also fills in --image/--port from the answers.
117
118
  svc.command('add [type] [name]').description('Provision a service on demand (assigns a default domain for postgres/compute); with no type/name, a terminal picks from the service kinds')
118
119
  .option('--branch <branch>', 'target branch (default: current)')
119
- .option('--region <region>', 'region for postgres/compute/redis, e.g. us-east (see `insta regions`)')
120
+ .option('--region <region>', 'region for postgres/compute/managed databases, e.g. us-east (see `insta regions`)')
120
121
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
121
122
  .option('--image <url>', 'compute only: run this container image at creation')
122
123
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
@@ -153,13 +154,43 @@ sec.command('set <name> [value]').description('Set a user secret (project-wide;
153
154
  .action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o)));
154
155
  sec.command('unset <name>').description('Remove a user secret')
155
156
  .option('--branch <branch>', 'scope to one branch').action(guard((n, o) => secretsCmd.secretsUnset(n, o)));
157
+ sec.command('bind <env-name> <source>').description('Bind a service credential into a compute env var')
158
+ .option('--branch <branch>', 'branch (default: current)')
159
+ .option('--to <compute-service>', 'target compute service, e.g. compute/api')
160
+ .option('--source-name <name>', 'source credential name when the source exposes more than one')
161
+ .option('--json')
162
+ .action(guard((n, source, o) => secretsCmd.secretsBind(n, source, o)));
163
+ sec.command('unbind <env-name>').description('Remove a service credential binding from a compute env var')
164
+ .option('--branch <branch>', 'branch (default: current)')
165
+ .option('--from <compute-service>', 'target compute service, e.g. compute/api')
166
+ .option('--json')
167
+ .action(guard((n, o) => secretsCmd.secretsUnbind(n, o)));
168
+ sec.command('bindings').description('List service credential bindings for a compute service')
169
+ .option('--branch <branch>', 'branch (default: current)')
170
+ .option('--target <compute-service>', 'target compute service, e.g. compute/api')
171
+ .option('--json')
172
+ .action(guard((o) => secretsCmd.secretsBindings(o)));
173
+ sec.command('sources').description('List service credential sources available for binding')
174
+ .option('--branch <branch>', 'branch (default: current)')
175
+ .option('--json')
176
+ .action(guard((o) => secretsCmd.secretsSources(o)));
156
177
  sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json')
157
178
  .action(guard((o) => secretsCmd.secretsTree(o)));
179
+ // ---- build (pre-push verification — local, offline, deploys nothing) ----
180
+ program.command('build [dir]').description('Verify a source directory would build before deploying: detection plan + the Dockerfile that would be used (yours, or nixpacks-generated) + static checks. Local and offline — no login needed, nothing pushed. Exit 1 when the verdict is failed')
181
+ .option('--explain', 'include the Dockerfile content in the output')
182
+ .option('--port <p>', 'port the app listens on (else the Dockerfile EXPOSE)')
183
+ .option('--json')
184
+ .action(guard((dir, o) => build(dir, o)));
158
185
  // ---- deploy ----
159
186
  program.command('deploy [dir]').description('Deploy a source directory (built remotely on Fly) or a prebuilt --image to a branch compute group')
160
187
  .option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
161
188
  .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
162
189
  .action(guard((dir, o) => deploy(dir, o)));
190
+ // `insta compute exec` needs the command verbatim after a literal `--`; split it out of argv here,
191
+ // before commander parses anything (see splitExecArgs's own comment for why `service` being
192
+ // optional makes commander unable to hold that boundary itself).
193
+ const { argv: computeArgv, command: execCommand } = computeCmd.splitExecArgs(process.argv);
163
194
  // ---- compute (lifecycle control + custom domains) ----
164
195
  const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains');
165
196
  compute.command('set-domain <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
@@ -181,6 +212,9 @@ compute.command('limits [service]').description("Show or set a compute service's
181
212
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
182
213
  compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way')
183
214
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
215
+ compute.command('exec [service]').description("Run a one-shot command inside a compute service's machine (`insta compute exec [service] -- <command> [args…]`) — no interactive shell/PTY: `command` is argv, no shell is invoked (use [\"sh\", \"-c\", \"...\"] for shell features). Wakes the machine first if it's scaled to zero — expect a few seconds of latency, billed as uptime, not an error. Exits with the remote command's own exit code (agents rely on this)")
216
+ .option('--branch <b>').option('--timeout <sec>', 'command timeout in seconds, 1-180 (platform default: 30)').option('--json')
217
+ .action(guard((service, o) => computeCmd.computeExec(service, execCommand, o)));
184
218
  compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 1Gi; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
185
219
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
186
220
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
@@ -225,11 +259,11 @@ program.command('manifest').description('Print an agent-legible view of the proj
225
259
  // ---- regions ----
226
260
  program.command('regions').description('List regions available for postgres/compute services').option('--json').action(guard((o) => regions.regionsList(o)));
227
261
  // ---- observability ----
228
- program.command('metrics <target> [group]').description('Service metrics (target: db|compute)')
262
+ program.command('metrics <target> [group]').description('Service metrics (target: db|compute|redis|mysql|mongodb)')
229
263
  .option('--branch <b>').option('--from <unix>').option('--to <unix>').option('--step <s>').option('--json')
230
264
  .action(guard((target, group, o) => obs.metrics(target, group, o)));
231
- program.command('logs <target> [group]').description('Service logs (runtime by default; --deploy = compute deploy events; target: db|compute)')
232
- .option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--deploy', 'show compute deploy events (machine lifecycle) instead of runtime logs').option('--json')
265
+ program.command('logs <target> [group]').description('Service logs (runtime by default; --deploy = machine lifecycle events; target: db|compute|redis|mysql|mongodb)')
266
+ .option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--deploy', 'show deploy events (machine lifecycle) instead of runtime logs — Fly-backed targets only, not db').option('--json')
233
267
  .action(guard((target, group, o) => obs.logs(target, group, o)));
234
268
  program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)')
235
269
  .option('--from <unix>').option('--to <unix>').option('--proj [id]', 'show one project (the linked one, or a given id) instead of the whole org').option('--json')
@@ -284,5 +318,5 @@ program.command('autoupdate [mode]').description('Show or set auto-update: on |
284
318
  .action(guard((mode) => selfUpdate.autoupdate(mode)));
285
319
  program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck()));
286
320
  selfUpdate.maybeUpdate(resolveVersion(), process.argv);
287
- program.parseAsync(process.argv);
321
+ program.parseAsync(computeArgv);
288
322
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,69 @@
1
+ // nixpacks glue for `insta build`: framework detection (`nixpacks plan`) and Dockerfile
2
+ // generation (`nixpacks build --out`) — both static, neither touches a Docker daemon.
3
+ // Same injectable-runner pattern as flyctl-build.ts.
4
+ import { spawn } from 'node:child_process';
5
+ import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ // Capture-only runner: plan output is parsed (not shown), and a wedged binary must not hang the
9
+ // command — kill after 30s and let the caller degrade.
10
+ export const quietRunner = (cmd, args, opts) => new Promise((resolve) => {
11
+ const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['ignore', 'pipe', 'pipe'] });
12
+ let output = '';
13
+ const timer = setTimeout(() => child.kill('SIGKILL'), 30_000);
14
+ child.stdout?.on('data', (b) => { output += b.toString(); });
15
+ child.stderr?.on('data', (b) => { output += b.toString(); });
16
+ child.on('error', (err) => { clearTimeout(timer); resolve({ code: -1, output: `${output}\n${err.message}` }); });
17
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, output }); });
18
+ });
19
+ export function parseNixpacksPlan(text) {
20
+ try {
21
+ const j = JSON.parse(text);
22
+ // Real plans (nixpacks ≥1.x) leave `providers` empty and name the matched provider(s) in the
23
+ // NIXPACKS_METADATA build variable instead.
24
+ const listed = Array.isArray(j.providers) ? j.providers : [];
25
+ const meta = typeof j.variables?.NIXPACKS_METADATA === 'string'
26
+ ? j.variables.NIXPACKS_METADATA.split(',').map((s) => s.trim()).filter(Boolean)
27
+ : [];
28
+ return {
29
+ providers: listed.length ? listed : meta,
30
+ installCommand: j.phases?.install?.cmds?.join(' && ') || undefined,
31
+ buildCommand: j.phases?.build?.cmds?.join(' && ') || undefined,
32
+ startCommand: j.start?.cmd || undefined,
33
+ };
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export async function nixpacksPlan(dir, run = quietRunner) {
40
+ const { code, output } = await run('nixpacks', ['plan', dir], { cwd: dir, env: process.env });
41
+ if (code !== 0)
42
+ return null;
43
+ return parseNixpacksPlan(output);
44
+ }
45
+ // `nixpacks build --out <dir>` generates .nixpacks/Dockerfile and skips Docker entirely. The out
46
+ // dir is a temp dir so the user's source tree stays clean (the platform writes into the source
47
+ // dir because it builds from a scratch clone — a local verify must not).
48
+ export async function nixpacksGeneratedDockerfile(dir, run = quietRunner) {
49
+ const out = mkdtempSync(join(tmpdir(), 'insta-nixpacks-'));
50
+ try {
51
+ const { code } = await run('nixpacks', ['build', dir, '--out', out], { cwd: dir, env: process.env });
52
+ if (code !== 0)
53
+ return null;
54
+ const generated = join(out, '.nixpacks', 'Dockerfile');
55
+ return existsSync(generated) ? readFileSync(generated, 'utf8') : null;
56
+ }
57
+ finally {
58
+ rmSync(out, { recursive: true, force: true });
59
+ }
60
+ }
61
+ // Quiet probe — no install, no output. `insta build` advertises itself as local and offline, so
62
+ // unlike deploy's ensureFlyctl it must never download anything or write to stdout (which would
63
+ // corrupt --json); when nixpacks is missing the command degrades to Dockerfile-only checks and
64
+ // the report's nextAction says how to install it.
65
+ export async function nixpacksAvailable(run = quietRunner) {
66
+ const { code } = await run('nixpacks', ['--version'], { cwd: '.', env: process.env });
67
+ return code === 0;
68
+ }
69
+ //# sourceMappingURL=nixpacks.js.map
@@ -1,5 +1,5 @@
1
1
  // `insta services add` with no type (or no name): the kinds are otherwise only discoverable by
2
- // guessing wrong and reading `type must be postgres|storage|compute|redis`, so missing arguments answer
2
+ // guessing wrong and reading `type must be postgres|storage|compute|redis|mysql|mongodb`, so missing arguments answer
3
3
  // "what can I add?" instead. The list mirrors the dashboard's Add Service menu (frontend
4
4
  // `add-service-button.tsx`) — Docker Image sits BESIDE Empty Service, not under it, because
5
5
  // picking an image is a different intent rather than a compute flag. An agent gets the same list
@@ -12,6 +12,8 @@ export const SERVICE_KINDS = [
12
12
  { id: 'image', label: 'Docker Image', type: 'compute', hint: 'run an existing container image', needsImage: true },
13
13
  { id: 'postgres', label: 'Postgres', type: 'postgres', hint: 'relational DB, usable as soon as it is added', defaultName: 'main-db' },
14
14
  { id: 'redis', label: 'Redis', type: 'redis', hint: 'private Redis-compatible cache', defaultName: 'cache' },
15
+ { id: 'mysql', label: 'MySQL', type: 'mysql', hint: 'private MySQL database', defaultName: 'mysql-db' },
16
+ { id: 'mongodb', label: 'MongoDB', type: 'mongodb', hint: 'private MongoDB database', defaultName: 'mongo-db' },
15
17
  { id: 'storage', label: 'Storage', type: 'storage', hint: 'S3-compatible bucket, private by default', defaultName: 'assets' },
16
18
  { id: 'compute', label: 'Empty Service', type: 'compute', hint: 'an app to deploy code to (empty until `insta deploy`)', defaultName: 'compute' },
17
19
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.35",
3
+ "version": "0.0.36",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [