insta 0.0.34 → 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.
- package/dist/commands/build.js +256 -0
- package/dist/commands/compute.js +90 -0
- package/dist/commands/deploy.js +38 -8
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/metrics.js +10 -7
- package/dist/commands/secrets.js +63 -0
- package/dist/commands/services.js +8 -4
- package/dist/index.js +41 -7
- package/dist/nixpacks.js +69 -0
- package/dist/resolve-service.js +4 -1
- package/package.json +1 -1
|
@@ -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
|
package/dist/commands/compute.js
CHANGED
|
@@ -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')
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
}
|
|
@@ -35,7 +35,7 @@ export const LIMITS = {
|
|
|
35
35
|
// control is server-side (per-IP rate limit + weekly dedup). Env overrides are for tests and
|
|
36
36
|
// emergency rotation.
|
|
37
37
|
const FEEDBACK_ENDPOINT = process.env.INSTA_FEEDBACK_URL ||
|
|
38
|
-
'https://
|
|
38
|
+
'https://feedback.instacloud.com/v1/feedback';
|
|
39
39
|
const FEEDBACK_INGEST_TOKEN = process.env.INSTA_FEEDBACK_TOKEN || 'insta-feedback-public-v1';
|
|
40
40
|
const FEEDBACK_TIMEOUT_MS = 10_000;
|
|
41
41
|
const MAX_FILE_BYTES = 256 * 1024;
|
package/dist/commands/metrics.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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)
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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).
|
|
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'];
|
|
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,8 @@ 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
|
-
:
|
|
130
|
+
: ['redis', 'mysql', 'mongodb'].includes(s.type) ? ` tcp/${s.port ?? defaultDatabasePort(s.type)}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}`
|
|
131
|
+
: s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '';
|
|
128
132
|
return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`;
|
|
129
133
|
}
|
|
130
134
|
export async function servicesList(opts) {
|
|
@@ -135,7 +139,7 @@ export async function servicesList(opts) {
|
|
|
135
139
|
if (opts.json)
|
|
136
140
|
return printJson(services);
|
|
137
141
|
if (!services.length)
|
|
138
|
-
return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute> <name>\`)`);
|
|
142
|
+
return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute|redis|mysql|mongodb> <name>\`)`);
|
|
139
143
|
for (const s of services)
|
|
140
144
|
info(serviceListLine(s));
|
|
141
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) ----
|
|
113
|
-
const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute)');
|
|
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, 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 =
|
|
232
|
-
.option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--deploy', 'show
|
|
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(
|
|
321
|
+
program.parseAsync(computeArgv);
|
|
288
322
|
//# sourceMappingURL=index.js.map
|
package/dist/nixpacks.js
ADDED
|
@@ -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
|
package/dist/resolve-service.js
CHANGED
|
@@ -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`, 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
|
|
@@ -11,6 +11,9 @@ import { SERVICE_TYPES, assertServiceName, parsePort } from './commands/services
|
|
|
11
11
|
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
|
+
{ 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' },
|
|
14
17
|
{ id: 'storage', label: 'Storage', type: 'storage', hint: 'S3-compatible bucket, private by default', defaultName: 'assets' },
|
|
15
18
|
{ id: 'compute', label: 'Empty Service', type: 'compute', hint: 'an app to deploy code to (empty until `insta deploy`)', defaultName: 'compute' },
|
|
16
19
|
];
|