insta 0.1.8 → 0.1.11
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 +2 -2
- package/dist/commands/branch.js +5 -2
- package/dist/commands/build.js +17 -54
- package/dist/commands/cron.js +125 -28
- package/dist/commands/govern.js +14 -3
- package/dist/commands/manifest.js +8 -2
- package/dist/commands/postgres.js +28 -7
- package/dist/commands/project.js +10 -0
- package/dist/index.js +10 -7
- package/dist/pack.js +12 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -215,7 +215,7 @@ build never reaches a production installer.
|
|
|
215
215
|
| `insta login` · `logout` · `status` | Browser sign-in (default), `--email` + password, or `--oauth github\|google`; `status` shows the environment, login and linked project/branch |
|
|
216
216
|
| `insta org` | `list` · `create` (one free org per user) |
|
|
217
217
|
| `insta tokens` | API tokens for CI and agents: `list` · `create <name>` · `revoke <id>`. A new token binds to the current org by default (`--org <id>`, `--project <id>`, or an explicit `--account` for everything the account can do); `--read-only` (GET only); `--expires 30d\|90d\|1y\|never` (default 90d). The plaintext is printed once |
|
|
218
|
-
| `insta project` | `create` · `list` · `link` · `delete` |
|
|
218
|
+
| `insta project` | `create` · `list` · `link` · `rename` · `delete` |
|
|
219
219
|
| `insta branch` | `create` · `list` · `switch` · `delete` · `merge` |
|
|
220
220
|
| `insta service` (`services`, `svc`) | `add` · `list` · `remove` · `rename` |
|
|
221
221
|
| `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `bind` · `unbind` · `bindings` · `sources` · `tree` |
|
|
@@ -225,7 +225,7 @@ build never reaches a production installer.
|
|
|
225
225
|
| `insta redis` · `mysql` · `mongodb` | `query` · `status` · `limits` · `volume` · `always-on` · `logs` · `metrics` |
|
|
226
226
|
| `insta storage` | `list` · `get` · `delete` · `set-access` |
|
|
227
227
|
| `insta build [dir]` · `deploy [dir]` | Verify a source dir would build; deploy a source directory (built remotely) or `--image <url>`. `insta build logs <id>` reads source-build output: `--source archive` (default) uses the deploy operation ID, `--source github` uses a GitHub build ID; `--follow` watches output, `--json` returns one snapshot |
|
|
228
|
-
| `insta cron` | Branch-scoped HTTP schedules: `list` · `create` · `show` · `edit` · `pause` · `resume` · `delete` · `run` · `runs` · `preview`. Expressions are UTC and so is every time printed; `run` is one EXTRA execution and does not consume the next scheduled tick; header values are write-only, so `show` lists
|
|
228
|
+
| `insta cron` | Branch-scoped HTTP schedules: `list` · `create` · `show` · `edit` · `pause` · `resume` · `delete` · `run` · `runs` · `preview`. Expressions are UTC and so is every time printed; `run` is one EXTRA execution and does not consume the next scheduled tick; literal header values are write-only, so `show` lists their names only — a header backed by `--secret-ref` is resolved from a project secret at send time and `show` names that secret too |
|
|
229
229
|
| `insta run <cmd>` | Run a command with the branch bundle injected, nothing written to disk |
|
|
230
230
|
| `insta template` | `list` · `info` · `deploy` |
|
|
231
231
|
| `insta billing` | Current cycle overview; `subscribe <tier>` · `portal` · `usage` |
|
package/dist/commands/branch.js
CHANGED
|
@@ -7,8 +7,11 @@ export async function branchCreate(name, opts) {
|
|
|
7
7
|
const out = await api.request('POST', `/projects/${p.projectId}/branches`, { name, from: opts.from ?? p.branch });
|
|
8
8
|
if (opts.json)
|
|
9
9
|
return printJson(out);
|
|
10
|
-
info(`created branch ${out.branch.name} (${out.branch.id})`);
|
|
11
|
-
|
|
10
|
+
info(`created branch ${out.branch.name} (${out.branch.id}) [${out.branch.status}]`);
|
|
11
|
+
const creating = out.branch.status === 'creating';
|
|
12
|
+
if (creating)
|
|
13
|
+
info(' still provisioning — deploy once `insta branch list` shows it active (a deploy before then answers 404); if it shows error, re-run this command');
|
|
14
|
+
renderNextActions(creating ? out.nextActions?.filter((a) => a.op !== 'deploy') : out.nextActions);
|
|
12
15
|
}
|
|
13
16
|
export async function branchList(opts) {
|
|
14
17
|
const api = await ApiClient.load();
|
package/dist/commands/build.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// Entirely local and offline: no login, no project link, nothing pushed or deployed. Phase 1 is
|
|
4
4
|
// static-only — no Docker daemon involved.
|
|
5
5
|
import { resolve, join } from 'node:path';
|
|
6
|
-
import { existsSync, readFileSync,
|
|
6
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
7
7
|
import { info, printJson, die } from '../util.js';
|
|
8
8
|
import { dockerfileExposedPort } from './deploy.js';
|
|
9
|
+
import { contextEntries } from '../pack.js';
|
|
9
10
|
import { nixpacksPlan, nixpacksGeneratedDockerfile, nixpacksAvailable, quietRunner } from '../nixpacks.js';
|
|
10
11
|
// A failed critical sinks the build; a failed warning deserves attention; skips are not failures.
|
|
11
12
|
export function computeVerdict(checks) {
|
|
@@ -45,63 +46,20 @@ export function envKeysFromDotEnvExample(content) {
|
|
|
45
46
|
}
|
|
46
47
|
const CONTEXT_WARN_BYTES = 100 * 1024 * 1024;
|
|
47
48
|
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
49
|
export function contextStats(dir, cap = WALK_CAP) {
|
|
51
|
-
const
|
|
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));
|
|
50
|
+
const entries = contextEntries(dir, cap + 1);
|
|
56
51
|
let totalBytes = 0;
|
|
57
52
|
let nodeModulesBytes = 0;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 };
|
|
53
|
+
for (const e of entries.slice(0, cap)) {
|
|
54
|
+
totalBytes += e.size;
|
|
55
|
+
if (e.path.split('/').includes('node_modules'))
|
|
56
|
+
nodeModulesBytes += e.size;
|
|
57
|
+
}
|
|
58
|
+
return { totalBytes, nodeModulesBytes, truncated: entries.length > cap, hasDockerignore: existsSync(join(dir, '.dockerignore')) };
|
|
101
59
|
}
|
|
102
60
|
const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
103
61
|
export function contextCheck(ctx) {
|
|
104
|
-
const shipsNodeModules = ctx.
|
|
62
|
+
const shipsNodeModules = ctx.nodeModulesBytes > 0;
|
|
105
63
|
const tooBig = ctx.totalBytes > CONTEXT_WARN_BYTES;
|
|
106
64
|
const size = `${mb(ctx.totalBytes)}${ctx.truncated ? '+' : ''}`;
|
|
107
65
|
const detail = shipsNodeModules
|
|
@@ -116,7 +74,7 @@ export function contextCheck(ctx) {
|
|
|
116
74
|
status: bad ? 'fail' : 'pass',
|
|
117
75
|
title: 'build context',
|
|
118
76
|
detail,
|
|
119
|
-
...(bad ? { nextAction: 'add a .dockerignore (node_modules, build artifacts, secrets)
|
|
77
|
+
...(bad ? { nextAction: `${ctx.hasDockerignore ? 'extend your' : 'add a'} .dockerignore (node_modules, build artifacts, secrets)` } : {}),
|
|
120
78
|
};
|
|
121
79
|
}
|
|
122
80
|
export async function buildReport(dirArg, opts, deps) {
|
|
@@ -211,7 +169,12 @@ export async function buildReport(dirArg, opts, deps) {
|
|
|
211
169
|
detail: port !== undefined ? `${port} (${rationale})` : rationale,
|
|
212
170
|
...(port !== undefined ? {} : { nextAction: 'pass --port <n> (or add EXPOSE <n> to the Dockerfile) — a port mismatch is the #1 deploy mistake' }),
|
|
213
171
|
});
|
|
214
|
-
|
|
172
|
+
try {
|
|
173
|
+
checks.push(contextCheck(contextStats(dir)));
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
checks.push({ id: 'context', severity: 'warning', status: 'fail', title: 'build context', detail: err.message, nextAction: 'make it readable, or exclude it in .dockerignore — deploy cannot pack it either' });
|
|
177
|
+
}
|
|
215
178
|
return {
|
|
216
179
|
dir,
|
|
217
180
|
plan: { builder, providers: np?.providers ?? [], installCommand: np?.installCommand, buildCommand: np?.buildCommand, startCommand: np?.startCommand, port, portRationale: rationale, envKeys },
|
package/dist/commands/cron.js
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
// so a localised column would read correctly today and lie twice a year, in both directions.
|
|
12
12
|
// 3. Header VALUES are write-only — the request config is encrypted at rest and no read decrypts
|
|
13
13
|
// it. `show` can therefore list header NAMES and nothing else, and `edit` REPLACES the request
|
|
14
|
-
// rather than merging into one it cannot read (see cronEditWarnings).
|
|
14
|
+
// rather than merging into one it cannot read (see cronEditWarnings). The one readable part is
|
|
15
|
+
// `secretRefs` (header → project secret NAME, resolved at send time), which `edit` carries
|
|
16
|
+
// forward because it can (see buildRequest).
|
|
15
17
|
import { randomUUID } from 'node:crypto';
|
|
16
18
|
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
17
19
|
import { die, handleApproval, info, printJson, refuse } from '../util.js';
|
|
@@ -53,13 +55,13 @@ export function parseRunLimit(raw) {
|
|
|
53
55
|
* contains one (`authorization=Bearer a=b`, any base64 padding), so splitting on every `=` would
|
|
54
56
|
* quietly truncate exactly the credentials this flag exists to carry.
|
|
55
57
|
*/
|
|
56
|
-
export function parseHeader(raw) {
|
|
58
|
+
export function parseHeader(raw, flag = '--header') {
|
|
57
59
|
const at = raw.indexOf('=');
|
|
58
60
|
if (at < 1)
|
|
59
|
-
throw new Error(
|
|
61
|
+
throw new Error(`${flag} must be name=value, got: ${raw}`);
|
|
60
62
|
const name = raw.slice(0, at).trim();
|
|
61
63
|
if (!name)
|
|
62
|
-
throw new Error(
|
|
64
|
+
throw new Error(`${flag} must be name=value, got: ${raw}`);
|
|
63
65
|
return [name, raw.slice(at + 1)];
|
|
64
66
|
}
|
|
65
67
|
/**
|
|
@@ -67,18 +69,50 @@ export function parseHeader(raw) {
|
|
|
67
69
|
* request carries one value per header, and silently dropping one of two credentials an agent
|
|
68
70
|
* passed is the failure that shows up later as a 401 nobody can explain.
|
|
69
71
|
*/
|
|
70
|
-
export function parseHeaders(list) {
|
|
71
|
-
|
|
72
|
+
export function parseHeaders(list, flag = '--header') {
|
|
73
|
+
// `__proto__` is syntactically a valid HTTP header name. A `{}` literal is not a safe map for
|
|
74
|
+
// it: `out['__proto__'] = value` hits Object.prototype's __proto__ SETTER instead of creating
|
|
75
|
+
// an own property, so a string value is silently swallowed — the header would vanish with no
|
|
76
|
+
// error. A null-prototype object has no such setter, so the assignment below behaves like an
|
|
77
|
+
// ordinary map entry for every syntactically valid name, `__proto__` included.
|
|
78
|
+
const out = Object.create(null);
|
|
72
79
|
for (const raw of list) {
|
|
73
|
-
const [name, value] = parseHeader(raw);
|
|
80
|
+
const [name, value] = parseHeader(raw, flag);
|
|
74
81
|
// Header names are case-insensitive on the wire, so a case-varied repeat is the same repeat.
|
|
75
|
-
const clash =
|
|
82
|
+
const clash = findHeader(out, name);
|
|
76
83
|
if (clash !== undefined)
|
|
77
|
-
throw new Error(
|
|
84
|
+
throw new Error(`${flag} ${name} given twice (also as ${clash}) — a header carries one value`);
|
|
78
85
|
out[name] = value;
|
|
79
86
|
}
|
|
80
87
|
return out;
|
|
81
88
|
}
|
|
89
|
+
/** The key of `rec` naming header `name`, case-insensitively (header names are, on the wire). */
|
|
90
|
+
function findHeader(rec, name) {
|
|
91
|
+
return Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Collect repeated `--secret-ref header=SECRET_NAME`. Same grammar as `--header`, and an EMPTY secret
|
|
95
|
+
* name means "remove this header's ref" on an edit — a secret cannot be named "", so the spelling is
|
|
96
|
+
* unambiguous, and it keeps removal on the flag that sets refs rather than adding a second one.
|
|
97
|
+
*/
|
|
98
|
+
export function parseSecretRefs(list) {
|
|
99
|
+
const out = parseHeaders(list, '--secret-ref');
|
|
100
|
+
for (const k of Object.keys(out)) {
|
|
101
|
+
const raw = out[k];
|
|
102
|
+
const trimmed = raw.trim();
|
|
103
|
+
// Only a LITERALLY empty value means "remove" — that's what makes the spelling unambiguous.
|
|
104
|
+
// A value that is merely whitespace is a fat-fingered secret name, not that same intent, and
|
|
105
|
+
// trimming it to '' would silently take the removal branch instead of naming the typo.
|
|
106
|
+
if (raw !== '' && trimmed === '')
|
|
107
|
+
throw new Error(`--secret-ref ${k}=${raw} names a blank secret — a secret name cannot be blank (use --secret-ref ${k}= with nothing after '=' to remove the ref)`);
|
|
108
|
+
out[k] = trimmed;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/** A job's refs, `{}` for a row written before refs existed. */
|
|
113
|
+
export function refsOf(r) {
|
|
114
|
+
return r.secretRefs ?? {};
|
|
115
|
+
}
|
|
82
116
|
/** Resolve a cron job by the name the CLI addresses it with (mirrors resolveServiceId). */
|
|
83
117
|
export function resolveJob(jobs, name) {
|
|
84
118
|
const job = jobs.find((j) => j.name === name);
|
|
@@ -127,11 +161,19 @@ export function jobShowLines(j, serviceName) {
|
|
|
127
161
|
` target ${targetLine(j.target, serviceName)}`,
|
|
128
162
|
` request ${j.request.method} timeout ${j.request_timeout_ms}ms`,
|
|
129
163
|
];
|
|
130
|
-
//
|
|
131
|
-
// beats an empty "value" column that reads as "this header is set to
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
164
|
+
// Literal headers: names, never values — the request config is encrypted at rest and no read
|
|
165
|
+
// decrypts it. Saying so beats an empty "value" column that reads as "this header is set to
|
|
166
|
+
// nothing". Secret-backed headers get a row each, because the secret NAME is readable and is the
|
|
167
|
+
// thing an operator needs when a rotation or a missing secret is the question.
|
|
168
|
+
const refs = Object.entries(refsOf(j.request));
|
|
169
|
+
const literal = j.request.headerNames.filter((n) => findHeader(refsOf(j.request), n) === undefined);
|
|
170
|
+
const rows = [
|
|
171
|
+
...(literal.length ? [`${literal.join(', ')} (names only — values are encrypted at rest and never returned)`] : []),
|
|
172
|
+
...refs.map(([h, s]) => `${h} ← secret ${s} (resolved at send time)`),
|
|
173
|
+
];
|
|
174
|
+
if (!rows.length)
|
|
175
|
+
lines.push(' headers (none)');
|
|
176
|
+
rows.forEach((r, i) => lines.push(` ${i ? ' ' : 'headers '} ${r}`));
|
|
135
177
|
lines.push(` retry ${retryLine(j.retry_policy)}`);
|
|
136
178
|
lines.push(` revision ${j.revision} (the If-Match an edit is conditioned on)`);
|
|
137
179
|
lines.push(` created ${fmtUtc(j.created_at)} updated ${fmtUtc(j.updated_at)}`);
|
|
@@ -185,7 +227,7 @@ export function conflictLines(name, revision, what) {
|
|
|
185
227
|
}
|
|
186
228
|
/** True when any flag that shapes the stored request was given. */
|
|
187
229
|
export function namesRequest(o) {
|
|
188
|
-
return o.method !== undefined || o.body !== undefined || (o.header?.length ?? 0) > 0;
|
|
230
|
+
return o.method !== undefined || o.body !== undefined || (o.header?.length ?? 0) > 0 || (o.secretRef?.length ?? 0) > 0;
|
|
189
231
|
}
|
|
190
232
|
/** True when any flag that names a target was given. */
|
|
191
233
|
export function namesTarget(o) {
|
|
@@ -208,8 +250,52 @@ export function assertTargetFlags(o, partial = false) {
|
|
|
208
250
|
if (o.path !== undefined && !o.path.startsWith('/'))
|
|
209
251
|
throw new Error(`--path must start with /, got: ${o.path}`);
|
|
210
252
|
}
|
|
211
|
-
/**
|
|
212
|
-
|
|
253
|
+
/**
|
|
254
|
+
* The stored request. `undefined` when no flag shaped one.
|
|
255
|
+
*
|
|
256
|
+
* `current` is the request of the job being edited (omit it on create). Its secret refs are carried
|
|
257
|
+
* forward — unlike literal values they are readable, so dropping them would be a choice rather than
|
|
258
|
+
* a limitation — except for a header the flags re-supply, via `--secret-ref` (a new secret) or
|
|
259
|
+
* `--header` (a literal replacing the ref). `--secret-ref h=` removes one.
|
|
260
|
+
*/
|
|
261
|
+
export function buildRequest(o, current) {
|
|
262
|
+
const flags = parseRequestFlags(o);
|
|
263
|
+
if (!flags)
|
|
264
|
+
return undefined;
|
|
265
|
+
const { method, headers, refFlags } = flags;
|
|
266
|
+
const had = current ? refsOf(current) : {};
|
|
267
|
+
// Same reasoning as parseHeaders: `had` may carry a stored `__proto__` ref (it came off the
|
|
268
|
+
// wire through JSON.parse, which — unlike a `{}` literal assignment — gives it a real own
|
|
269
|
+
// property), and a `{}` accumulator here would silently drop it while carrying every other ref
|
|
270
|
+
// forward, breaking the one guarantee this function exists to keep.
|
|
271
|
+
const refs = Object.create(null);
|
|
272
|
+
for (const [h, s] of Object.entries(had)) {
|
|
273
|
+
if (findHeader(headers ?? {}, h) === undefined && findHeader(refFlags, h) === undefined)
|
|
274
|
+
refs[h] = s;
|
|
275
|
+
}
|
|
276
|
+
for (const [h, s] of Object.entries(refFlags)) {
|
|
277
|
+
if (s) {
|
|
278
|
+
refs[h] = s;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// A removal that removes nothing is a typo'd header name, and the ref it meant is still live.
|
|
282
|
+
if (!current)
|
|
283
|
+
throw new Error(`--secret-ref ${h}= removes a secret ref, and a new job has none — name the secret: --secret-ref ${h}=SECRET_NAME`);
|
|
284
|
+
if (findHeader(had, h) === undefined) {
|
|
285
|
+
const names = Object.keys(had);
|
|
286
|
+
throw new Error(`--secret-ref ${h}= removes a secret ref, and the job has none on ${h}${names.length ? ` (it has: ${names.join(', ')})` : ''}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
method,
|
|
291
|
+
...(headers ? { headers } : {}),
|
|
292
|
+
...(o.body !== undefined ? { body: o.body } : {}),
|
|
293
|
+
...(Object.keys(refs).length ? { secretRefs: refs } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/** The request flags parsed and checked against each other — no job needed, so an edit runs this
|
|
297
|
+
* before any network access. `undefined` when no flag shaped a request. */
|
|
298
|
+
export function parseRequestFlags(o) {
|
|
213
299
|
if (!namesRequest(o))
|
|
214
300
|
return undefined;
|
|
215
301
|
const method = o.method ? parseMethod(o.method) : o.body !== undefined ? 'POST' : 'GET';
|
|
@@ -219,23 +305,31 @@ export function buildRequest(o) {
|
|
|
219
305
|
if (o.body !== undefined && method !== 'POST')
|
|
220
306
|
throw new Error('--body is sent on POST only — drop --method GET, or drop --body');
|
|
221
307
|
const headers = o.header?.length ? parseHeaders(o.header) : undefined;
|
|
222
|
-
|
|
308
|
+
const refFlags = o.secretRef?.length ? parseSecretRefs(o.secretRef) : {};
|
|
309
|
+
// The platform refuses a header in both maps; saying so here names the flags that did it.
|
|
310
|
+
for (const h of Object.keys(refFlags)) {
|
|
311
|
+
const both = findHeader(headers ?? {}, h);
|
|
312
|
+
if (both !== undefined)
|
|
313
|
+
throw new Error(`${h} is given as both --header and --secret-ref — a header is a literal value or a secret, not both`);
|
|
314
|
+
}
|
|
315
|
+
return { method, ...(headers ? { headers } : {}), refFlags };
|
|
223
316
|
}
|
|
224
317
|
/**
|
|
225
318
|
* What an edit that reshapes the request is about to LOSE.
|
|
226
319
|
*
|
|
227
320
|
* The API replaces `request` wholesale — it cannot merge, because a deep merge cannot express
|
|
228
|
-
* "remove this header", and the CLI could not merge either even if the API did: header
|
|
229
|
-
* write-only, so the values currently stored are unreadable here. Anything the new flags
|
|
230
|
-
* re-supply is therefore gone, and the one thing this command must not do is drop a
|
|
231
|
-
* without saying which one.
|
|
321
|
+
* "remove this header", and the CLI could not merge either even if the API did: literal header
|
|
322
|
+
* values are write-only, so the values currently stored are unreadable here. Anything the new flags
|
|
323
|
+
* do not re-supply is therefore gone, and the one thing this command must not do is drop a
|
|
324
|
+
* credential without saying which one. Secret refs are readable and buildRequest carries them into
|
|
325
|
+
* `next`, so only a ref the flags removed or replaced leaves — on purpose, and not warned about.
|
|
232
326
|
*/
|
|
233
327
|
export function cronEditWarnings(current, next) {
|
|
234
328
|
const out = [];
|
|
235
|
-
const kept = new Set(Object.keys(next.headers ?? {}).map((h) => h.toLowerCase()));
|
|
236
|
-
const dropped = current.headerNames.filter((n) => !kept.has(n.toLowerCase()));
|
|
329
|
+
const kept = new Set([...Object.keys(next.headers ?? {}), ...Object.keys(next.secretRefs ?? {})].map((h) => h.toLowerCase()));
|
|
330
|
+
const dropped = current.headerNames.filter((n) => !kept.has(n.toLowerCase()) && findHeader(refsOf(current), n) === undefined);
|
|
237
331
|
if (dropped.length) {
|
|
238
|
-
out.push(`warning: the stored request is replaced, not merged (header values are write-only and cannot be read back) — these headers are dropped: ${dropped.join(', ')}`);
|
|
332
|
+
out.push(`warning: the stored request is replaced, not merged (literal header values are write-only and cannot be read back) — these headers are dropped: ${dropped.join(', ')}`);
|
|
239
333
|
}
|
|
240
334
|
// The METHOD is part of the request being replaced, and changing it is the quietest way to break
|
|
241
335
|
// a job: `--header` alone on a POST job re-shapes the whole request, and the default method for a
|
|
@@ -375,13 +469,16 @@ export async function cronShow(name, opts = {}, injected) {
|
|
|
375
469
|
}
|
|
376
470
|
export async function cronEdit(name, opts = {}, injected) {
|
|
377
471
|
assertTargetFlags(opts, true);
|
|
378
|
-
|
|
472
|
+
// Contradictory flags fail before any network access; only whether a `--secret-ref h=` removal
|
|
473
|
+
// names a ref the job has waits for the job (buildRequest, below).
|
|
474
|
+
parseRequestFlags(opts);
|
|
379
475
|
const timeout = opts.timeout === undefined ? undefined : parseTimeout(opts.timeout);
|
|
380
|
-
if (!namesTarget(opts) && !
|
|
381
|
-
throw new Error('nothing to change — pass --expression, --url/--service/--path, --method/--header/--body, --timeout or --name');
|
|
476
|
+
if (!namesTarget(opts) && !namesRequest(opts) && timeout === undefined && opts.expression === undefined && opts.name === undefined) {
|
|
477
|
+
throw new Error('nothing to change — pass --expression, --url/--service/--path, --method/--header/--secret-ref/--body, --timeout or --name');
|
|
382
478
|
}
|
|
383
479
|
const c = await context(opts, injected);
|
|
384
480
|
const job = await findJob(c, name);
|
|
481
|
+
const request = buildRequest(opts, job.request);
|
|
385
482
|
const target = namesTarget(opts)
|
|
386
483
|
// An edit that moves a service target keeps the path it had unless --path says otherwise.
|
|
387
484
|
? await targetOf(c, opts, job.target.kind === 'service' ? job.target.path : '/')
|
package/dist/commands/govern.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
-
import { info, printJson } from '../util.js';
|
|
2
|
+
import { die, info, printJson } from '../util.js';
|
|
3
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
4
|
export async function events(opts) {
|
|
4
5
|
const api = await ApiClient.load();
|
|
5
6
|
const p = await requireProject();
|
|
6
7
|
const qs = [];
|
|
7
|
-
if (opts.branch)
|
|
8
|
-
|
|
8
|
+
if (opts.branch) {
|
|
9
|
+
let id = opts.branch;
|
|
10
|
+
// An id passes through unlooked-up: a deleted branch keeps its events but leaves the branch list.
|
|
11
|
+
if (!UUID.test(id)) {
|
|
12
|
+
const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
|
|
13
|
+
const b = branches.find((x) => x.name === id);
|
|
14
|
+
if (!b)
|
|
15
|
+
die(`branch not found: ${id}`);
|
|
16
|
+
id = b.id;
|
|
17
|
+
}
|
|
18
|
+
qs.push(`branch=${encodeURIComponent(id)}`);
|
|
19
|
+
}
|
|
9
20
|
if (opts.limit)
|
|
10
21
|
qs.push(`limit=${opts.limit}`);
|
|
11
22
|
const { events } = await api.request('GET', `/projects/${p.projectId}/events${qs.length ? `?${qs.join('&')}` : ''}`);
|
|
@@ -25,6 +25,13 @@ export function resourceLine(r) {
|
|
|
25
25
|
const pg = r.kind === 'insta-db' || r.kind === 'neon' ? pgBadge(r.ref?.pgVersion) : '';
|
|
26
26
|
return ` - ${resourceLabel(r)} ${where}${pg} [${r.status}]`;
|
|
27
27
|
}
|
|
28
|
+
// An origin postgres writes a root row and a default-branch row for one instance.
|
|
29
|
+
export function branchResources(resources, b) {
|
|
30
|
+
const own = resources.filter((r) => r.branchId === b.id);
|
|
31
|
+
if (!b.is_default)
|
|
32
|
+
return own;
|
|
33
|
+
return resources.filter((r) => r.branchId === b.id || (r.branchId === null && !own.some((o) => o.kind === r.kind && o.name === r.name)));
|
|
34
|
+
}
|
|
28
35
|
// Agent-legible view of each environment's databases / storage / compute.
|
|
29
36
|
export async function manifest(opts) {
|
|
30
37
|
const api = await ApiClient.load();
|
|
@@ -35,8 +42,7 @@ export async function manifest(opts) {
|
|
|
35
42
|
info(`project ${detail.project.name} (${detail.project.id}) [${detail.project.status}]`);
|
|
36
43
|
for (const b of detail.branches) {
|
|
37
44
|
info(` branch ${b.name}${b.is_default ? ' *' : ''} [${b.status}]`);
|
|
38
|
-
const
|
|
39
|
-
for (const r of rs)
|
|
45
|
+
for (const r of branchResources(detail.resources, b))
|
|
40
46
|
info(resourceLine(r));
|
|
41
47
|
}
|
|
42
48
|
}
|
|
@@ -3,13 +3,22 @@ import { constants as osConstants } from 'node:os';
|
|
|
3
3
|
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
4
4
|
import { info, printJson, handleApproval, relayExitCode } from '../util.js';
|
|
5
5
|
import { parseVolumeGib, q, resolveSoleService } from './services.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export async function dbAlwaysOn(mode, service, opts) {
|
|
11
|
-
if (mode !== 'on' && mode !== 'off')
|
|
6
|
+
export function alwaysOnArgs(first, second) {
|
|
7
|
+
if (first === 'on' || first === 'off')
|
|
8
|
+
return { mode: first, service: second };
|
|
9
|
+
if (second !== undefined)
|
|
12
10
|
throw new Error('mode must be on|off');
|
|
11
|
+
return { service: first };
|
|
12
|
+
}
|
|
13
|
+
export function alwaysOnLine(group, scaleToZero) {
|
|
14
|
+
return `postgres ${group}: always-on ${scaleToZero === false ? 'on — instance stays warm' : scaleToZero === true ? 'off — scales to zero when idle' : 'unknown — the provider did not report it'}`;
|
|
15
|
+
}
|
|
16
|
+
// Show or set a postgres service's idle mode: scale-to-zero (the default: instance suspends when
|
|
17
|
+
// idle, cold-starts on the next connection) or always-on (instance stays warm; idle RAM bills at
|
|
18
|
+
// actual usage). Reads GET /database/instance, sets PATCH /database/settings {scaleToZero} —
|
|
19
|
+
// insta-db-backed postgres only.
|
|
20
|
+
export async function dbAlwaysOn(first, second, opts) {
|
|
21
|
+
const { mode, service } = alwaysOnArgs(first, second);
|
|
13
22
|
const api = await ApiClient.load();
|
|
14
23
|
const p = await requireProject();
|
|
15
24
|
const qs = new URLSearchParams();
|
|
@@ -18,7 +27,19 @@ export async function dbAlwaysOn(mode, service, opts) {
|
|
|
18
27
|
qs.set('branch', branch);
|
|
19
28
|
if (service)
|
|
20
29
|
qs.set('group', service);
|
|
21
|
-
const
|
|
30
|
+
const suffix = qs.toString() ? `?${qs}` : '';
|
|
31
|
+
if (!mode) {
|
|
32
|
+
const read = await fetchDbInstance(api, p.projectId, suffix);
|
|
33
|
+
if (read.kind === 'no-instance') {
|
|
34
|
+
info(`postgres ${service ?? 'default'}: no manageable instance (this service manages its own resources)`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (opts.json)
|
|
38
|
+
return printJson(read.body);
|
|
39
|
+
info(alwaysOnLine(service ?? 'default', read.body?.scaleToZero));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${suffix}`, { scaleToZero: mode !== 'on' });
|
|
22
43
|
if (handleApproval(res, opts.json))
|
|
23
44
|
return;
|
|
24
45
|
if (opts.json)
|
package/dist/commands/project.js
CHANGED
|
@@ -124,6 +124,16 @@ export async function projectLink(id, opts = {}) {
|
|
|
124
124
|
await tryInstallObserve(opts.json);
|
|
125
125
|
await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) });
|
|
126
126
|
}
|
|
127
|
+
export async function projectRename(name, opts) {
|
|
128
|
+
const api = await ApiClient.load();
|
|
129
|
+
const projectId = opts.project ?? (await requireProject()).projectId;
|
|
130
|
+
const res = await api.rawRequest('PATCH', `/projects/${projectId}`, { name });
|
|
131
|
+
if (handleApproval(res, opts.json))
|
|
132
|
+
return;
|
|
133
|
+
if (opts.json)
|
|
134
|
+
return printJson(res.body);
|
|
135
|
+
info(`renamed project ${projectId} to ${res.body.project?.name ?? name}`);
|
|
136
|
+
}
|
|
127
137
|
export async function projectDelete(opts) {
|
|
128
138
|
const api = await ApiClient.load();
|
|
129
139
|
const projectId = opts.project ?? (await requireProject()).projectId;
|
package/dist/index.js
CHANGED
|
@@ -133,6 +133,7 @@ const pj = program.command('project').description('Manage projects');
|
|
|
133
133
|
pj.command('create [name]').option('--org <id>', 'org to create under (default: personal)').option('--json').action(guard((name, o) => project.projectCreate(name, o)));
|
|
134
134
|
pj.command('list').option('--org <id>').option('--json').action(guard((o) => project.projectList(o)));
|
|
135
135
|
pj.command('link <id>').description('Link a project to this directory').option('--json').action(guard((id, o) => project.projectLink(id, o)));
|
|
136
|
+
pj.command('rename <name>').description('Rename a project (display name only)').option('--project <id>').option('--json').action(guard((name, o) => project.projectRename(name, o)));
|
|
136
137
|
pj.command('delete').option('--project <id>').option('--json').action(guard((o) => project.projectDelete(o)));
|
|
137
138
|
// ---- branch ----
|
|
138
139
|
const br = program.command('branch').description('Manage branch environments');
|
|
@@ -147,7 +148,7 @@ const svc = program.command('service').aliases(['services', 'svc']).description(
|
|
|
147
148
|
// [type] [name] are optional so the command can answer "what can I add?" — a terminal is walked
|
|
148
149
|
// through the dashboard's Add Service kinds, anything else gets that list back as an error
|
|
149
150
|
// (resolve-service.ts). Picking Docker Image also fills in --image/--port from the answers.
|
|
150
|
-
svc.command('add [type] [name]').description('Provision a service on demand (
|
|
151
|
+
svc.command('add [type] [name]').description('Provision a service on demand (compute gets a default URL); with no type/name, a terminal picks from the service kinds')
|
|
151
152
|
.option('--branch <branch>', 'target branch (default: current)')
|
|
152
153
|
.option('--region <region>', 'region for postgres/compute/managed databases, e.g. us-east (see `insta config regions`)')
|
|
153
154
|
.option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
|
|
@@ -213,9 +214,11 @@ sec.command('tree').description('Show secrets as project → branch → service
|
|
|
213
214
|
// Every expression is evaluated in UTC and every time printed is UTC: a cron pinned to UTC does not
|
|
214
215
|
// keep a fixed local time, so a localised column would quietly lie across a DST boundary.
|
|
215
216
|
const cron = program.command('cron').description('Schedule HTTP calls on a branch — a compute service of the project, or an external URL. Expressions and every printed time are UTC');
|
|
216
|
-
// The repeatable --header
|
|
217
|
-
// edit, and two copies is how they drift apart.
|
|
218
|
-
|
|
217
|
+
// The repeatable --header / --secret-ref collectors, declared once: commander needs the same
|
|
218
|
+
// reducers on create and edit, and two copies is how they drift apart. `--secret-ref h=` (empty
|
|
219
|
+
// secret) is the removal, not a `--no-secret-ref`: commander reads a `--no-` twin as a boolean
|
|
220
|
+
// negation of the whole option, so it could not carry the header name.
|
|
221
|
+
const headerOption = (c) => c.option('--header <name=value>', 'non-secret request header, repeatable (split on the first = so a value may contain one). Values are encrypted at rest and are NEVER returned by a read — `cron show` lists header names only. For a credential use --secret-ref', (v, prev) => [...prev, v], []).option('--secret-ref <name=SECRET>', 'request header whose value is the project secret SECRET (set with `insta secrets set`), repeatable. The way to pass a credential: resolved at send time on every attempt, so rotating the secret takes effect on the next run with no edit to the job. User-set secrets only — platform-minted credentials (DATABASE_URL, …) do not resolve', (v, prev) => [...prev, v], []);
|
|
219
222
|
cron.command('list').description('List the branch\'s cron jobs: state, expression, next UTC run and target')
|
|
220
223
|
.option('--branch <branch>', 'branch (default: current)').option('--json')
|
|
221
224
|
.action(guard((o) => cronCmd.cronList(o)));
|
|
@@ -229,11 +232,11 @@ headerOption(cron.command('create <name> <expression>'))
|
|
|
229
232
|
.option('--timeout <ms>', 'request timeout, 1000..300000 ms; excludes cold start (the wake is timed separately)')
|
|
230
233
|
.option('--branch <branch>', 'branch (default: current)').option('--json')
|
|
231
234
|
.action(guard((name, expression, o) => cronCmd.cronCreate(name, expression, o)));
|
|
232
|
-
cron.command('show <name>').description('Show one cron job: schedule, next UTC run, target, request (header NAMES only — values are write-only), retry policy and the revision an edit is conditioned on')
|
|
235
|
+
cron.command('show <name>').description('Show one cron job: schedule, next UTC run, target, request (header NAMES only — literal values are write-only; a secret-backed header shows the secret it reads), retry policy and the revision an edit is conditioned on')
|
|
233
236
|
.option('--branch <branch>', 'branch (default: current)').option('--json')
|
|
234
237
|
.action(guard((name, o) => cronCmd.cronShow(name, o)));
|
|
235
238
|
headerOption(cron.command('edit <name>'))
|
|
236
|
-
.description('Change a cron job. The request is REPLACED, not merged
|
|
239
|
+
.description('Change a cron job. The request is REPLACED, not merged: literal header values cannot be read back, so any --header not re-supplied is dropped (the command names what it drops); secret refs ARE carried forward unless --header/--secret-ref names that header again — `--secret-ref <name>=` removes one. Conditioned on the revision just read: a concurrent edit fails rather than clobbering')
|
|
237
240
|
.option('--expression <expr>', 'new 5-field cron expression (UTC; quote it)')
|
|
238
241
|
.option('--name <new-name>', 'rename the job')
|
|
239
242
|
.option('--url <url>', 'external target: an absolute http(s) URL')
|
|
@@ -435,7 +438,7 @@ pg.command('volume [service]').description("Show or grow a postgres service's pr
|
|
|
435
438
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
436
439
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
437
440
|
.action(guard((service, o) => pgCmd.dbVolume(service, o)));
|
|
438
|
-
pg.command('always-on
|
|
441
|
+
pg.command('always-on [mode] [service]').description('Show or set a postgres service always-on (mode: on|off; no mode = print the current setting). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
|
|
439
442
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
440
443
|
.action(guard((mode, service, o) => pgCmd.dbAlwaysOn(mode, service, o)));
|
|
441
444
|
addObservability(pg, 'db', 'postgres');
|
package/dist/pack.js
CHANGED
|
@@ -72,7 +72,7 @@ const ALWAYS_SKIP = new Set(['.git', '.insta']);
|
|
|
72
72
|
// docker keeps these whatever the ignore file says; avoids a remote-only "Dockerfile not found".
|
|
73
73
|
const KEPT_AT_ROOT = new Set(['Dockerfile', '.dockerignore']);
|
|
74
74
|
// One global sort emits a parent before its children, since a dir name prefixes everything inside.
|
|
75
|
-
function walk(root, rel, out, links, ig, files, flavour) {
|
|
75
|
+
function walk(root, rel, out, links, ig, files, flavour, cap = Infinity) {
|
|
76
76
|
const dirAbs = join(root, rel === '' ? '.' : rel.split('/').join(sep));
|
|
77
77
|
const names = readdirSync(dirAbs).sort();
|
|
78
78
|
// A nested .gitignore extends its own subtree; recompiled only where one exists. docker has none.
|
|
@@ -81,6 +81,8 @@ function walk(root, rel, out, links, ig, files, flavour) {
|
|
|
81
81
|
ig = compileIgnore(files, 'git');
|
|
82
82
|
}
|
|
83
83
|
for (const name of names) {
|
|
84
|
+
if (out.length >= cap)
|
|
85
|
+
return;
|
|
84
86
|
if (ALWAYS_SKIP.has(name))
|
|
85
87
|
continue;
|
|
86
88
|
const relPath = rel === '' ? name : `${rel}/${name}`;
|
|
@@ -98,7 +100,7 @@ function walk(root, rel, out, links, ig, files, flavour) {
|
|
|
98
100
|
continue;
|
|
99
101
|
// Emitted whenever we descend, so a re-included child has its parent.
|
|
100
102
|
out.push({ path: `${relPath}/`, mode: 0o755, size: 0, dir: true });
|
|
101
|
-
walk(root, relPath, out, links, ig, files, flavour);
|
|
103
|
+
walk(root, relPath, out, links, ig, files, flavour, cap);
|
|
102
104
|
}
|
|
103
105
|
else if (st.isFile()) {
|
|
104
106
|
if (!keep && ig.excludes(relPath, false))
|
|
@@ -165,6 +167,14 @@ function rootIgnore(absDir) {
|
|
|
165
167
|
const files = existsSync(join(absDir, '.gitignore')) ? [{ base: '', text: read('.gitignore') }] : [];
|
|
166
168
|
return { ig: compileIgnore(files, 'git'), files, flavour: 'git' };
|
|
167
169
|
}
|
|
170
|
+
// .dockerignore only: the docker and flyctl lanes never read .gitignore, and `insta build` cannot know the lane.
|
|
171
|
+
export function contextEntries(absDir, cap = Infinity) {
|
|
172
|
+
const entries = [];
|
|
173
|
+
const file = join(absDir, '.dockerignore');
|
|
174
|
+
const files = existsSync(file) ? [{ base: '', text: readFileSync(file, 'utf8') }] : [];
|
|
175
|
+
walk(absDir, '', entries, [], compileIgnore(files, 'docker'), files, 'docker', cap);
|
|
176
|
+
return entries;
|
|
177
|
+
}
|
|
168
178
|
const mib = (n) => `${(n / (1024 * 1024)).toFixed(1)} MiB`;
|
|
169
179
|
// Windows has no POSIX exec bit for lstat to report, so a script packs as 0644 and the image
|
|
170
180
|
// cannot run it. Same as `docker build` from Windows; say so rather than let it fail at start-up.
|