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 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 header names only |
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` |
@@ -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
- renderNextActions(out.nextActions);
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();
@@ -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, readdirSync, statSync } from 'node:fs';
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 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));
50
+ const entries = contextEntries(dir, cap + 1);
56
51
  let totalBytes = 0;
57
52
  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 };
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.hasNodeModules && !ctx.nodeModulesIgnored;
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
- checks.push(contextCheck(contextStats(dir)));
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 },
@@ -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(`--header must be name=value, got: ${raw}`);
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(`--header must be name=value, got: ${raw}`);
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
- const out = {};
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 = Object.keys(out).find((k) => k.toLowerCase() === name.toLowerCase());
82
+ const clash = findHeader(out, name);
76
83
  if (clash !== undefined)
77
- throw new Error(`--header ${name} given twice (also as ${clash}) — a header carries one value`);
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
- // Names, never values: the request config is encrypted at rest and no read decrypts it. Saying so
131
- // beats an empty "value" column that reads as "this header is set to nothing".
132
- lines.push(j.request.headerNames.length
133
- ? ` headers ${j.request.headerNames.join(', ')} (names only — values are encrypted at rest and never returned)`
134
- : ' headers (none)');
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
- /** The stored request, from the flags alone. `undefined` when no flag shaped one. */
212
- export function buildRequest(o) {
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
- return { method, ...(headers ? { headers } : {}), ...(o.body !== undefined ? { body: o.body } : {}) };
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 values are
229
- * write-only, so the values currently stored are unreadable here. Anything the new flags do not
230
- * re-supply is therefore gone, and the one thing this command must not do is drop a credential
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
- const request = buildRequest(opts);
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) && !request && timeout === undefined && opts.expression === undefined && opts.name === undefined) {
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 : '/')
@@ -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
- qs.push(`branch=${encodeURIComponent(opts.branch)}`);
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 rs = detail.resources.filter((r) => r.branchId === b.id || (b.is_default && r.branchId === null));
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
- // Toggle a postgres service between scale-to-zero (the default: instance suspends when idle,
7
- // cold-starts on the next connection) and always-on (instance stays warm; idle RAM bills at
8
- // actual usage). Thin wrapper over PATCH /database/settings {scaleToZero} — insta-db-backed
9
- // postgres only.
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 res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${qs.toString() ? `?${qs}` : ''}`, { scaleToZero: mode !== 'on' });
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)
@@ -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 (assigns a default domain for postgres/compute); with no type/name, a terminal picks from the service kinds')
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 collector, declared once: commander needs the same reducer on create and
217
- // edit, and two copies is how they drift apart.
218
- const headerOption = (c) => c.option('--header <name=value>', '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', (v, prev) => [...prev, v], []);
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 (header values cannot be read back, so anything --header does not re-supply is dropped — the command names what it drops). Conditioned on the revision just read: a concurrent edit fails rather than clobbering')
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 <mode> [service]').description('Set a postgres service always-on (mode: on|off). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.8",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [