insta 0.0.35 → 0.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -6
- package/dist/api.js +4 -2
- package/dist/commands/branch.js +12 -4
- package/dist/commands/build.js +256 -0
- package/dist/commands/compute.js +99 -8
- package/dist/commands/db.js +3 -3
- package/dist/commands/deploy.js +53 -15
- package/dist/commands/env.js +18 -1
- package/dist/commands/govern.js +9 -3
- package/dist/commands/metrics.js +45 -8
- package/dist/commands/org.js +3 -1
- package/dist/commands/project.js +33 -15
- package/dist/commands/run.js +7 -5
- package/dist/commands/secrets.js +72 -5
- package/dist/commands/services.js +16 -11
- package/dist/commands/setup.js +164 -3
- package/dist/commands/storage.js +3 -3
- package/dist/ensure-skills.js +16 -4
- package/dist/flyctl-build.js +25 -18
- package/dist/index.js +62 -24
- package/dist/nixpacks.js +69 -0
- package/dist/resolve-service.js +3 -1
- package/dist/util.js +10 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,14 +26,25 @@ npm install -g insta
|
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
For coding agents. Installs the CLI, the `insta` skill for every agent on the machine, and
|
|
29
|
-
registers the MCP server
|
|
29
|
+
registers the MCP server — one command for macOS, Linux, WSL, and native Windows shells
|
|
30
|
+
(PowerShell/cmd). Needs Node 18+ with a writable npm global prefix (a Node version manager
|
|
31
|
+
qualifies; if the global install can't write, setup continues and prints the exact
|
|
32
|
+
version-pinned `npm install -g` fallback to run yourself). E2e-validated on macOS/Linux; the
|
|
33
|
+
Windows spawn paths are unit-tested:
|
|
30
34
|
|
|
31
35
|
```bash
|
|
32
|
-
|
|
36
|
+
npx -y insta setup agent
|
|
33
37
|
```
|
|
34
38
|
|
|
35
|
-
On
|
|
36
|
-
|
|
39
|
+
On macOS/Linux without Node, the native-binary installer puts the `insta` CLI on PATH (the
|
|
40
|
+
skill + MCP steps it then runs still need Node — the skills tool runs via npx). Never run it
|
|
41
|
+
on native Windows — PowerShell's `curl` alias and the WSL `bash` shim break it; use npx
|
|
42
|
+
above, or download `insta-windows-x64.exe` from the
|
|
43
|
+
[releases page](https://github.com/InsForge/insta-cli/releases):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
curl -fsSL agents.instacloud.com | sh
|
|
47
|
+
```
|
|
37
48
|
|
|
38
49
|
Pin a version with `INSTA_VERSION=v0.0.22`; change the install directory with
|
|
39
50
|
`INSTA_INSTALL_DIR`. While the CLI is pre-1.0 it updates itself on new releases. Turn that
|
|
@@ -101,7 +112,8 @@ prints an approval id for an admin to grant with `insta approvals approve <id>`.
|
|
|
101
112
|
|
|
102
113
|
`insta manifest` prints an agent-legible view of every branch and its URLs. `insta setup
|
|
103
114
|
agent` installs the InstaCloud skill and registers the remote MCP server for the coding
|
|
104
|
-
agents on the machine
|
|
115
|
+
agents on the machine — and, when running from the npx cache with no durable `insta` on
|
|
116
|
+
PATH, first installs the CLI itself globally.
|
|
105
117
|
|
|
106
118
|
## Environments
|
|
107
119
|
|
|
@@ -167,7 +179,7 @@ build never reaches a production installer.
|
|
|
167
179
|
|---|---|
|
|
168
180
|
| `insta login` · `logout` · `status` | Email/password or `--oauth github\|google`; `status` shows the environment, login and linked project/branch |
|
|
169
181
|
| `insta env` | `show` · `use <prod\|staging>` |
|
|
170
|
-
| `insta setup` | `agent` — install the skill and
|
|
182
|
+
| `insta setup` | `agent` — install the CLI (if missing), the skill, and MCP for every coding agent |
|
|
171
183
|
| `insta mcp` | `install` — register the remote MCP server only |
|
|
172
184
|
| `insta org` | `list` · `create` (one free org per user) |
|
|
173
185
|
| `insta project` | `create` · `list` · `link` · `delete` |
|
package/dist/api.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 2xx (including 202 approval_required) returns the parsed body; >=400 throws ApiError.
|
|
3
3
|
import { readGlobal, writeGlobal, readProject, writeProject } from './config.js';
|
|
4
4
|
import { autoResolveProject, promptChoice } from './resolve-project.js';
|
|
5
|
-
import { die
|
|
5
|
+
import { die } from './util.js';
|
|
6
6
|
export class ApiError extends Error {
|
|
7
7
|
status;
|
|
8
8
|
constructor(status, msg) {
|
|
@@ -117,7 +117,9 @@ export async function requireProject() {
|
|
|
117
117
|
promptChoice,
|
|
118
118
|
save: async (c) => {
|
|
119
119
|
await writeProject(c);
|
|
120
|
-
|
|
120
|
+
// stderr: this is a diagnostic that can precede ANY command's output — under --json,
|
|
121
|
+
// stdout must stay one parseable document.
|
|
122
|
+
process.stderr.write(`auto-linked project ${c.projectId} → ./.insta/project.json\n`);
|
|
121
123
|
},
|
|
122
124
|
tty: !!process.stdin.isTTY && !!process.stderr.isTTY,
|
|
123
125
|
});
|
package/dist/commands/branch.js
CHANGED
|
@@ -5,6 +5,8 @@ export async function branchCreate(name, opts) {
|
|
|
5
5
|
const api = await ApiClient.load();
|
|
6
6
|
const p = await requireProject();
|
|
7
7
|
const out = await api.request('POST', `/projects/${p.projectId}/branches`, { name, from: opts.from ?? p.branch });
|
|
8
|
+
if (opts.json)
|
|
9
|
+
return printJson(out);
|
|
8
10
|
info(`created branch ${out.branch.name} (${out.branch.id})`);
|
|
9
11
|
renderNextActions(out.nextActions);
|
|
10
12
|
}
|
|
@@ -17,16 +19,18 @@ export async function branchList(opts) {
|
|
|
17
19
|
for (const b of branches)
|
|
18
20
|
info(`${b.is_default ? '*' : ' '} ${b.name} [${b.status}] ${b.id}`);
|
|
19
21
|
}
|
|
20
|
-
export async function branchSwitch(name) {
|
|
22
|
+
export async function branchSwitch(name, opts = {}) {
|
|
21
23
|
const api = await ApiClient.load();
|
|
22
24
|
const p = await requireProject();
|
|
23
25
|
const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
|
|
24
26
|
if (!branches.some((b) => b.name === name))
|
|
25
27
|
die(`branch not found: ${name}`);
|
|
26
28
|
await writeProject({ ...p, branch: name });
|
|
29
|
+
if (opts.json)
|
|
30
|
+
return printJson({ projectId: p.projectId, branch: name });
|
|
27
31
|
info(`switched to branch ${name} — run \`insta secrets\` to refresh .env`);
|
|
28
32
|
}
|
|
29
|
-
export async function branchDelete(name) {
|
|
33
|
+
export async function branchDelete(name, opts = {}) {
|
|
30
34
|
const api = await ApiClient.load();
|
|
31
35
|
const p = await requireProject();
|
|
32
36
|
const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
|
|
@@ -34,8 +38,10 @@ export async function branchDelete(name) {
|
|
|
34
38
|
if (!b)
|
|
35
39
|
die(`branch not found: ${name}`);
|
|
36
40
|
const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/branches/${b.id}`);
|
|
37
|
-
if (handleApproval(res))
|
|
41
|
+
if (handleApproval(res, opts.json))
|
|
38
42
|
return;
|
|
43
|
+
if (opts.json)
|
|
44
|
+
return printJson({ ok: true, branch: { id: b.id, name: b.name } });
|
|
39
45
|
info(`deleted branch ${name}`);
|
|
40
46
|
}
|
|
41
47
|
// insta branch merge <source> [--into <target>] — structurally merge source's services into target
|
|
@@ -47,8 +53,10 @@ export async function branchMerge(source, opts = {}) {
|
|
|
47
53
|
if (!target)
|
|
48
54
|
throw new Error('no target branch — pass --into <branch> (or link a branch first)');
|
|
49
55
|
const res = await api.rawRequest('POST', `/projects/${p.projectId}/branches/${encodeURIComponent(target)}/merge`, { from: source });
|
|
50
|
-
if (handleApproval(res))
|
|
56
|
+
if (handleApproval(res, opts.json))
|
|
51
57
|
return;
|
|
58
|
+
if (opts.json)
|
|
59
|
+
return printJson(res.body ?? {});
|
|
52
60
|
const { created = [], skipped = [] } = (res.body ?? {});
|
|
53
61
|
info(`merged ${source} → ${target}: ${created.length} created, ${skipped.length} skipped`);
|
|
54
62
|
for (const c of created)
|
|
@@ -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
|
@@ -7,7 +7,7 @@ export async function setDomain(host, opts) {
|
|
|
7
7
|
const api = await ApiClient.load();
|
|
8
8
|
const p = await requireProject();
|
|
9
9
|
const res = await api.rawRequest('POST', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group });
|
|
10
|
-
if (handleApproval(res))
|
|
10
|
+
if (handleApproval(res, opts.json))
|
|
11
11
|
return;
|
|
12
12
|
printDomain(res.body, opts.json);
|
|
13
13
|
}
|
|
@@ -26,9 +26,16 @@ export async function removeDomain(host, opts) {
|
|
|
26
26
|
const api = await ApiClient.load();
|
|
27
27
|
const p = await requireProject();
|
|
28
28
|
const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group });
|
|
29
|
-
if (handleApproval(res))
|
|
29
|
+
if (handleApproval(res, opts.json))
|
|
30
30
|
return;
|
|
31
|
-
|
|
31
|
+
renderRemoveDomain(res.body, opts.json);
|
|
32
|
+
}
|
|
33
|
+
// Split out (same pattern as applyExecResult) so the --json contract — stdout carries the platform
|
|
34
|
+
// response, never prose — is unit-testable without a network mock.
|
|
35
|
+
export function renderRemoveDomain(body, json) {
|
|
36
|
+
if (json)
|
|
37
|
+
return printJson(body);
|
|
38
|
+
info(`removed custom domain ${body.hostname} from ${body.flyApp}`);
|
|
32
39
|
}
|
|
33
40
|
function printDomain(r, json) {
|
|
34
41
|
if (json)
|
|
@@ -50,7 +57,7 @@ async function lifecycle(verb, serviceName, opts) {
|
|
|
50
57
|
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
|
|
51
58
|
const id = resolveComputeServiceId(services, serviceName);
|
|
52
59
|
const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/${verb}`);
|
|
53
|
-
if (handleApproval(res))
|
|
60
|
+
if (handleApproval(res, opts.json))
|
|
54
61
|
return;
|
|
55
62
|
if (opts.json)
|
|
56
63
|
return printJson(res.body);
|
|
@@ -70,6 +77,90 @@ export async function computeStatus(serviceName, opts) {
|
|
|
70
77
|
return printJson(r);
|
|
71
78
|
info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`);
|
|
72
79
|
}
|
|
80
|
+
// ---- exec (one-shot command; no interactive shell/PTY) ----
|
|
81
|
+
// `insta compute exec [service] -- <command> [args…]`: the command must reach the platform
|
|
82
|
+
// byte-for-byte and can itself contain dashes or another `--`, so it can't be a normal commander
|
|
83
|
+
// positional — with `service` optional, commander flattens everything past the literal `--` into
|
|
84
|
+
// one operand list and has no way to tell "no service, command starts here" apart from "service IS
|
|
85
|
+
// the first command token". Splitting argv on the first literal `--` after `compute exec`
|
|
86
|
+
// ourselves, before commander ever parses it, removes the ambiguity; this is the only place in the
|
|
87
|
+
// whole CLI a bare `--` has this meaning, so nothing else is affected. Exported for a direct,
|
|
88
|
+
// network-free unit test — this split is the seam most likely to regress.
|
|
89
|
+
export function splitExecArgs(argv) {
|
|
90
|
+
const i = argv.findIndex((a, idx) => a === 'compute' && argv[idx + 1] === 'exec');
|
|
91
|
+
if (i === -1)
|
|
92
|
+
return { argv };
|
|
93
|
+
const dash = argv.indexOf('--', i + 2);
|
|
94
|
+
if (dash === -1)
|
|
95
|
+
return { argv };
|
|
96
|
+
return { argv: argv.slice(0, dash), command: argv.slice(dash + 1) };
|
|
97
|
+
}
|
|
98
|
+
// The --timeout override, through a throwing parser like every other user-typed number in this
|
|
99
|
+
// repo (parseCpu, parseCount, parsePort): junk must fail locally instead of reaching the server as
|
|
100
|
+
// NaN, and the bounds mirror what the platform enforces (1-180s; server default 30 when omitted).
|
|
101
|
+
export function parseTimeoutSec(raw) {
|
|
102
|
+
const n = Number(raw);
|
|
103
|
+
if (!Number.isInteger(n) || n < 1 || n > 180)
|
|
104
|
+
throw new Error(`invalid timeout: ${raw} (1-180 seconds)`);
|
|
105
|
+
return n;
|
|
106
|
+
}
|
|
107
|
+
// Map exec inputs to the platform POST body. Pure, unit-tested without a network mock (mirrors
|
|
108
|
+
// deployRequestBody / servicesAddRequestBody). timeoutSec is omitted when not given so the server
|
|
109
|
+
// applies its own default (30s) rather than the client picking one on the wire.
|
|
110
|
+
export function execRequestBody(command, timeoutSec) {
|
|
111
|
+
return { command, ...(timeoutSec !== undefined ? { timeoutSec } : {}) };
|
|
112
|
+
}
|
|
113
|
+
// Renders the exec response and sets process.exitCode — split out of computeExec as a pure function
|
|
114
|
+
// of (res, json) so it's unit-testable without a network mock, same as handleApproval's own
|
|
115
|
+
// {status, body} shape.
|
|
116
|
+
//
|
|
117
|
+
// A 202 means the command has NOT run: handleApproval owns the whole contract (hint on stderr,
|
|
118
|
+
// raw envelope on stdout with --json, exit 2), so a caller chaining `insta compute exec … && next`
|
|
119
|
+
// can never mistake a pending gate for the command having succeeded — and exit 2 stays
|
|
120
|
+
// distinguishable from the remote command's own exit 1.
|
|
121
|
+
export function applyExecResult(res, json) {
|
|
122
|
+
if (handleApproval(res, json))
|
|
123
|
+
return;
|
|
124
|
+
const { exitCode, stdout, stderr, truncated } = res.body;
|
|
125
|
+
if (json) {
|
|
126
|
+
printJson(res.body);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
process.stdout.write(stdout);
|
|
130
|
+
process.stderr.write(stderr);
|
|
131
|
+
if (truncated)
|
|
132
|
+
process.stderr.write('note: output truncated — the platform caps stdout/stderr at 1 MiB each\n');
|
|
133
|
+
}
|
|
134
|
+
// The platform sends -1 as an "unknown exit" sentinel, and nothing outside 0-255 is a valid POSIX
|
|
135
|
+
// exit code. Assigning it straight to process.exitCode risks Node's own DEP0164 (a negative code
|
|
136
|
+
// silently exits 255) — clamp out-of-range codes to 1 instead, with a one-line note so the cause is
|
|
137
|
+
// visible. Normal codes pass through untouched.
|
|
138
|
+
if (exitCode < 0 || exitCode > 255) {
|
|
139
|
+
process.stderr.write(`note: remote exit code ${exitCode} out of range — exiting 1\n`);
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
process.exitCode = exitCode;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// One HTTP round trip, not a shell session: no PTY, no interactivity, stdout/stderr come back as
|
|
147
|
+
// two whole strings (each capped at 1 MiB server-side) rather than a stream. They're written to
|
|
148
|
+
// this process's own stdout/stderr verbatim — no prefixes, no added newline — and the remote exit
|
|
149
|
+
// code becomes this process's own exit code (--json still passes it through, it just skips the
|
|
150
|
+
// split-stream output), since agents scripting this rely on it. Waking a scaled-to-zero machine is
|
|
151
|
+
// expected — it adds latency and bills as uptime, it is not an error.
|
|
152
|
+
export async function computeExec(serviceName, command, opts) {
|
|
153
|
+
if (!command || command.length === 0)
|
|
154
|
+
throw new Error('usage: insta compute exec [service] -- <command> [args…] (see --help)');
|
|
155
|
+
const timeoutSec = opts.timeout !== undefined ? parseTimeoutSec(opts.timeout) : undefined;
|
|
156
|
+
const api = await ApiClient.load();
|
|
157
|
+
const p = await requireProject();
|
|
158
|
+
const branch = opts.branch ?? p.branch;
|
|
159
|
+
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
|
|
160
|
+
const id = resolveComputeServiceId(services, serviceName);
|
|
161
|
+
const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/exec`, execRequestBody(command, timeoutSec));
|
|
162
|
+
applyExecResult(res, opts.json);
|
|
163
|
+
}
|
|
73
164
|
// ---- always-on (opt out of scale-to-zero; all plans; billing is actual usage either way) ----
|
|
74
165
|
export async function computeAlwaysOn(mode, serviceName, opts) {
|
|
75
166
|
if (mode !== 'on' && mode !== 'off')
|
|
@@ -80,7 +171,7 @@ export async function computeAlwaysOn(mode, serviceName, opts) {
|
|
|
80
171
|
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
|
|
81
172
|
const id = resolveComputeServiceId(services, serviceName);
|
|
82
173
|
const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/always-on`, { enabled: mode === 'on' });
|
|
83
|
-
if (handleApproval(res))
|
|
174
|
+
if (handleApproval(res, opts.json))
|
|
84
175
|
return;
|
|
85
176
|
if (opts.json)
|
|
86
177
|
return printJson(res.body);
|
|
@@ -181,7 +272,7 @@ export async function computeVolume(serviceName, opts) {
|
|
|
181
272
|
catch (e) {
|
|
182
273
|
throw volumeDeleteError(e);
|
|
183
274
|
}
|
|
184
|
-
if (handleApproval(res))
|
|
275
|
+
if (handleApproval(res, opts.json))
|
|
185
276
|
return;
|
|
186
277
|
if (opts.json)
|
|
187
278
|
return printJson(res.body);
|
|
@@ -198,7 +289,7 @@ export async function computeVolume(serviceName, opts) {
|
|
|
198
289
|
}
|
|
199
290
|
const sizeGib = parseVolumeGib(opts.size);
|
|
200
291
|
const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/volume`, { sizeGib });
|
|
201
|
-
if (handleApproval(res))
|
|
292
|
+
if (handleApproval(res, opts.json))
|
|
202
293
|
return;
|
|
203
294
|
if (opts.json)
|
|
204
295
|
return printJson(res.body);
|
|
@@ -227,7 +318,7 @@ export async function computeLimits(serviceName, opts) {
|
|
|
227
318
|
if (opts.cpu)
|
|
228
319
|
body.cpu = parseCpu(opts.cpu);
|
|
229
320
|
const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/limits`, body);
|
|
230
|
-
if (handleApproval(res))
|
|
321
|
+
if (handleApproval(res, opts.json))
|
|
231
322
|
return;
|
|
232
323
|
if (opts.json)
|
|
233
324
|
return printJson(res.body);
|
package/dist/commands/db.js
CHANGED
|
@@ -19,7 +19,7 @@ export async function dbAlwaysOn(mode, opts) {
|
|
|
19
19
|
if (opts.group)
|
|
20
20
|
qs.set('group', opts.group);
|
|
21
21
|
const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${qs.toString() ? `?${qs}` : ''}`, { scaleToZero: mode !== 'on' });
|
|
22
|
-
if (handleApproval(res))
|
|
22
|
+
if (handleApproval(res, opts.json))
|
|
23
23
|
return;
|
|
24
24
|
if (opts.json)
|
|
25
25
|
return printJson(res.body);
|
|
@@ -111,7 +111,7 @@ export async function dbLimits(opts) {
|
|
|
111
111
|
throw new Error(`setting the ceiling failed (${e.status}): ${e.message}`);
|
|
112
112
|
throw e;
|
|
113
113
|
}
|
|
114
|
-
if (handleApproval(res))
|
|
114
|
+
if (handleApproval(res, opts.json))
|
|
115
115
|
return;
|
|
116
116
|
if (opts.json)
|
|
117
117
|
return printJson(res.body);
|
|
@@ -230,7 +230,7 @@ export async function dbVolume(opts) {
|
|
|
230
230
|
throw new Error(`growing the volume failed (${e.status}): ${e.message}`);
|
|
231
231
|
throw e;
|
|
232
232
|
}
|
|
233
|
-
if (handleApproval(res))
|
|
233
|
+
if (handleApproval(res, opts.json))
|
|
234
234
|
return;
|
|
235
235
|
if (opts.json)
|
|
236
236
|
return printJson(res.body);
|