insta 0.1.1 → 0.1.3

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
@@ -224,6 +224,7 @@ build never reaches a production installer.
224
224
  | `insta redis` · `mysql` · `mongodb` | `query` · `status` · `limits` · `volume` · `always-on` · `logs` · `metrics` |
225
225
  | `insta storage` | `list` · `get` · `delete` · `set-access` |
226
226
  | `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 |
227
+ | `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 |
227
228
  | `insta run <cmd>` | Run a command with the branch bundle injected, nothing written to disk |
228
229
  | `insta template` | `list` · `info` · `deploy` |
229
230
  | `insta billing` | Current cycle overview; `subscribe <tier>` · `portal` · `usage` |
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, readPersistedGlobal, writeGlobal, readProject, persistAutoLink, resolveProjectLink, foreignLinkMessage } from './config.js';
4
4
  import { autoResolveProject, promptChoice } from './resolve-project.js';
5
- import { die } from './util.js';
5
+ import { approvalHint, die } from './util.js';
6
6
  import { USER_AGENT } from './version.js';
7
7
  import { agentHeaders, agentMode } from './agent.js';
8
8
  export class ApiError extends Error {
@@ -19,8 +19,9 @@ export class ApiError extends Error {
19
19
  }
20
20
  export class AgentApprovalRequired extends Error {
21
21
  body;
22
+ // The platform's message already carries the review link; a runtime that sends none gets the CLI's own hint.
22
23
  constructor(body) {
23
- super(body.message ?? `approval required: ${body.approvalId}`);
24
+ super(body.message ?? approvalHint(body));
24
25
  this.body = body;
25
26
  }
26
27
  }
@@ -104,6 +105,8 @@ export class ApiClient {
104
105
  }
105
106
  async fetch(method, path, body, auth, scope = {}) {
106
107
  const headers = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT };
108
+ if (scope.headers)
109
+ Object.assign(headers, scope.headers);
107
110
  if (auth && this.cfg.accessToken)
108
111
  headers.Authorization = `Bearer ${this.cfg.accessToken}`;
109
112
  const payload = body === undefined ? undefined : JSON.stringify(body);
@@ -653,13 +653,18 @@ export function volumeLines(name, volume, cap, type = 'compute') {
653
653
  : 'grow with --size (grow-only); the volume cannot be deleted — remove the service instead';
654
654
  return [
655
655
  `${type} ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`,
656
+ ...(volume.pending && volume.appliedMountPath != null ? [` pending: ${volume.appliedMountPath ?? '(not deployed)'} → ${volume.mountPath}; deploy to apply (restarts the service). Application configuration is not updated automatically.`] : []),
656
657
  ` billing is actual data stored — the size is a cap, not a price; ${grow}`,
657
658
  ];
658
659
  }
659
660
  // Render the PUT result. Pure, exported for tests. `attached` comes from the backend and is what
660
661
  // tells a FIRST attach (no disk yet — it mounts on the next deploy) apart from a grow (the live
661
662
  // disk was already extended); the wire size is authoritative in both cases.
662
- export function volumeWriteLine(name, body, type = 'compute') {
663
+ export function volumeWriteLine(name, body, type = 'compute', sizeRequested = true) {
664
+ const pending = body.volume.pending && body.volume.appliedMountPath != null
665
+ ? `; mount path ${body.volume.appliedMountPath} → ${body.volume.mountPath} pending — deploy or restart to apply. Update application paths and startup commands yourself.` : '';
666
+ if (body.changed === false && !body.attached)
667
+ return `${type} ${name}: volume unchanged: ${body.volume.sizeGib}Gi at ${body.volume.mountPath}${pending}`;
663
668
  if (body.attached) {
664
669
  // Only a compute service has a deploy step for the mount to wait on; a managed database has
665
670
  // no deploy, so its disk is simply mounted.
@@ -668,7 +673,7 @@ export function volumeWriteLine(name, body, type = 'compute') {
668
673
  : `mounts at ${body.volume.mountPath}`;
669
674
  return `${type} ${name}: volume ${body.volume.sizeGib}Gi attached — ${mounts} (plan max ${body.cap.volumeGib}Gi)`;
670
675
  }
671
- return `${type} ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)`;
676
+ return `${type} ${name}: volume ${sizeRequested ? 'grown to ' : ''}${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)${pending}`;
672
677
  }
673
678
  // Render the DELETE result. Pure, exported for tests. Deleting is the only way off the volume
674
679
  // path (there is no detach), so the line says what came back with it: the two constraints the
@@ -702,10 +707,10 @@ export function volumeDeleteError(e) {
702
707
  // 403/400 messages carry the upgrade hints and must reach the user verbatim (the guard prints
703
708
  // ApiError messages as-is).
704
709
  export async function serviceVolume(type, serviceName, opts) {
710
+ if (opts.size !== undefined && !opts.size.trim())
711
+ throw new Error('--size must not be empty; specify a whole Gi value or omit --size for a path-only edit');
705
712
  if (opts.delete && opts.mountPath !== undefined)
706
713
  throw new Error('--delete cannot be combined with --mount-path');
707
- if (opts.mountPath !== undefined && !opts.size)
708
- throw new Error('--mount-path requires --size when attaching a volume');
709
714
  if (opts.delete && opts.size)
710
715
  throw new Error('--delete cannot be combined with --size (one changes the volume, the other destroys it)');
711
716
  const api = await ApiClient.load();
@@ -728,7 +733,7 @@ export async function serviceVolume(type, serviceName, opts) {
728
733
  info(volumeDeleteLine(res.body.service?.name ?? svc.name, type));
729
734
  return;
730
735
  }
731
- if (!opts.size) {
736
+ if (!opts.size && opts.mountPath === undefined) {
732
737
  const r = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/volume`);
733
738
  if (opts.json)
734
739
  return printJson(r);
@@ -736,13 +741,18 @@ export async function serviceVolume(type, serviceName, opts) {
736
741
  info(line);
737
742
  return;
738
743
  }
739
- const sizeGib = parseVolumeGib(opts.size);
744
+ if (opts.mountPath !== undefined && opts.size === undefined) {
745
+ const current = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/volume`);
746
+ if (!current.volume)
747
+ throw new Error(`no volume attached; attach one with insta compute volume ${svc.name} --size <gi> --mount-path ${opts.mountPath}`);
748
+ }
749
+ const sizeGib = opts.size === undefined ? undefined : parseVolumeGib(opts.size);
740
750
  const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/volume`, { sizeGib, ...(opts.mountPath !== undefined ? { mountPath: opts.mountPath } : {}) });
741
751
  if (handleApproval(res, opts.json))
742
752
  return;
743
753
  if (opts.json)
744
754
  return printJson(res.body);
745
- info(volumeWriteLine(res.body.service?.name ?? svc.name, res.body, type));
755
+ info(volumeWriteLine(res.body.service?.name ?? svc.name, res.body, type, opts.size !== undefined));
746
756
  }
747
757
  export const computeVolume = (serviceName, opts) => serviceVolume('compute', serviceName, opts);
748
758
  // Show or set a compute service's ceiling. With no --memory it PRINTS the current limits and the
@@ -1972,4 +1982,24 @@ export function hostPatternFor(host, suffixes) {
1972
1982
  const parts = host.split('.');
1973
1983
  return [parts[0], '*', ...parts.slice(2)].join('.');
1974
1984
  }
1985
+ export async function computeStartCommand(serviceName, opts) {
1986
+ if (opts.set !== undefined && opts.clear)
1987
+ throw new Error('use --set or --clear, not both');
1988
+ const api = await ApiClient.load();
1989
+ const p = await requireProject();
1990
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(opts.branch ?? p.branch)}`);
1991
+ const svc = resolveSoleService(services, 'compute', serviceName);
1992
+ if (opts.set === undefined && !opts.clear) {
1993
+ if (opts.json)
1994
+ return printJson({ service: svc });
1995
+ info(`compute ${svc.name}: startup command ${svc.start_command || '(image default)'}`);
1996
+ return;
1997
+ }
1998
+ const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/services/${svc.id}`, { startCommand: opts.clear ? '' : opts.set });
1999
+ if (handleApproval(res, opts.json))
2000
+ return;
2001
+ if (opts.json)
2002
+ return printJson(res.body);
2003
+ info('Startup command saved; deploy or restart after staging the volume path. CLI secrets changes deploy immediately; use Console to combine variable, command and path changes.');
2004
+ }
1975
2005
  //# sourceMappingURL=compute.js.map
@@ -0,0 +1,519 @@
1
+ // `insta cron` — schedules that send one HTTP request on a cron expression, to a compute service of
2
+ // the project or to an external URL.
3
+ //
4
+ // Three properties of the platform API shape this whole module, and none of them is plumbing:
5
+ //
6
+ // 1. A job is addressed by NAME here and by ID on the wire (names are unique per project+branch),
7
+ // so every subcommand resolves name → id from the branch listing first, exactly as
8
+ // resolve-service.ts resolves a service.
9
+ // 2. Times are UTC. The expression is evaluated in UTC by the platform and this prints every
10
+ // timestamp as UTC with the Z visible: a job pinned to UTC does NOT keep a fixed local time,
11
+ // so a localised column would read correctly today and lie twice a year, in both directions.
12
+ // 3. Header VALUES are write-only — the request config is encrypted at rest and no read decrypts
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).
15
+ import { randomUUID } from 'node:crypto';
16
+ import { ApiClient, ApiError, requireProject } from '../api.js';
17
+ import { die, handleApproval, info, printJson, refuse } from '../util.js';
18
+ import { q, resolveSoleService } from './services.js';
19
+ // ---- pure, unit-tested helpers (throw plain Errors; the CLI guard turns them into clean output) ----
20
+ // The API's own bounds (openapi/schemas/cron.ts), enforced here so junk fails before any network or
21
+ // config access — the parsePort lesson: `Number('5s')` is NaN and NaN serializes to null on the wire.
22
+ const MIN_TIMEOUT_MS = 1000;
23
+ const MAX_TIMEOUT_MS = 300_000;
24
+ const MAX_RUNS_PAGE = 100;
25
+ export const CRON_METHODS = ['GET', 'POST'];
26
+ /** Parse `--method`. Case-insensitive, because a method is conventionally written in caps and a
27
+ * shell user types what they read in the docs. */
28
+ export function parseMethod(raw) {
29
+ const m = raw.trim().toUpperCase();
30
+ if (!CRON_METHODS.includes(m))
31
+ throw new Error(`method must be ${CRON_METHODS.join('|')}, got: ${raw}`);
32
+ return m;
33
+ }
34
+ /** Parse `--timeout <ms>`. Decimal digits only, as parsePort: `0x1388` is a typo, not 5000. */
35
+ export function parseTimeout(raw) {
36
+ const m = /^\s*(\d+)\s*$/.exec(raw);
37
+ const n = m ? Number(m[1]) : NaN;
38
+ if (!Number.isInteger(n) || n < MIN_TIMEOUT_MS || n > MAX_TIMEOUT_MS) {
39
+ throw new Error(`--timeout must be an integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} ms, got: ${raw}`);
40
+ }
41
+ return n;
42
+ }
43
+ /** Parse `--limit <n>` for run history, inside the page the API serves. */
44
+ export function parseRunLimit(raw) {
45
+ const m = /^\s*(\d+)\s*$/.exec(raw);
46
+ const n = m ? Number(m[1]) : NaN;
47
+ if (!Number.isInteger(n) || n < 1 || n > MAX_RUNS_PAGE)
48
+ throw new Error(`--limit must be an integer 1..${MAX_RUNS_PAGE}, got: ${raw}`);
49
+ return n;
50
+ }
51
+ /**
52
+ * Parse one `--header k=v`. Split on the FIRST `=` only: a header value is free-form and routinely
53
+ * contains one (`authorization=Bearer a=b`, any base64 padding), so splitting on every `=` would
54
+ * quietly truncate exactly the credentials this flag exists to carry.
55
+ */
56
+ export function parseHeader(raw) {
57
+ const at = raw.indexOf('=');
58
+ if (at < 1)
59
+ throw new Error(`--header must be name=value, got: ${raw}`);
60
+ const name = raw.slice(0, at).trim();
61
+ if (!name)
62
+ throw new Error(`--header must be name=value, got: ${raw}`);
63
+ return [name, raw.slice(at + 1)];
64
+ }
65
+ /**
66
+ * Collect repeated `--header` flags. A name given twice is an error rather than last-wins: the
67
+ * request carries one value per header, and silently dropping one of two credentials an agent
68
+ * passed is the failure that shows up later as a 401 nobody can explain.
69
+ */
70
+ export function parseHeaders(list) {
71
+ const out = {};
72
+ for (const raw of list) {
73
+ const [name, value] = parseHeader(raw);
74
+ // 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());
76
+ if (clash !== undefined)
77
+ throw new Error(`--header ${name} given twice (also as ${clash}) — a header carries one value`);
78
+ out[name] = value;
79
+ }
80
+ return out;
81
+ }
82
+ /** Resolve a cron job by the name the CLI addresses it with (mirrors resolveServiceId). */
83
+ export function resolveJob(jobs, name) {
84
+ const job = jobs.find((j) => j.name === name);
85
+ if (!job)
86
+ throw new Error(`cron job not found: ${name}`);
87
+ return job;
88
+ }
89
+ /**
90
+ * A timestamp as UTC, with the marker visible: `2026-09-17 14:05:00Z`.
91
+ *
92
+ * Never localised. The expression is evaluated in UTC, so `0 3 * * *` fires at 03:00Z year-round —
93
+ * rendering that as a local time would show two different wall clocks across a DST boundary for a
94
+ * schedule that never moved, and the operator reading the column would blame the scheduler.
95
+ */
96
+ export function fmtUtc(iso) {
97
+ if (!iso)
98
+ return '—';
99
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(iso);
100
+ return m ? `${m[1]} ${m[2]}Z` : iso;
101
+ }
102
+ /**
103
+ * One-line rendering of where a job sends its request. The service and its path are separated by an
104
+ * arrow rather than concatenated: `compute/api/api/cron` hides where the service name ends, and the
105
+ * service half is the part a reader has to match against `insta services list`.
106
+ */
107
+ export function targetLine(t, serviceName) {
108
+ return t.kind === 'external' ? t.url : `${serviceName ? `compute/${serviceName}` : `service ${t.serviceId}`} → ${t.path}`;
109
+ }
110
+ /** One `cron list` row. Pure, so the columns are unit-tested (a template string nothing asserts on
111
+ * silently loses a segment — the serviceListLine lesson). */
112
+ export function jobListLine(j, serviceName) {
113
+ return `${j.name} [${j.enabled ? 'enabled' : 'paused'}] ${j.expression} next ${fmtUtc(j.next_run_at)} ${targetLine(j.target, serviceName)} ${j.id}`;
114
+ }
115
+ /** The retry policy in one cell, with the platform's own defaults named when the job sets none. */
116
+ export function retryLine(p) {
117
+ const statuses = p.retryableStatuses?.length ? ` retry on ${p.retryableStatuses.join(',')}` : '';
118
+ const age = p.maxRunAgeMs !== undefined ? ` give up after ${p.maxRunAgeMs}ms` : '';
119
+ return `platform ${p.platformMaxRetries ?? 2}, application ${p.applicationMaxRetries ?? 0}${statuses}${age}`;
120
+ }
121
+ /** `cron show`, as lines. */
122
+ export function jobShowLines(j, serviceName) {
123
+ const lines = [
124
+ `cron/${j.name} [${j.enabled ? 'enabled' : 'paused'}] on ${j.branch} ${j.id}`,
125
+ ` expression ${j.expression} (${j.timezone})`,
126
+ ` next run ${fmtUtc(j.next_run_at)}`,
127
+ ` target ${targetLine(j.target, serviceName)}`,
128
+ ` request ${j.request.method} timeout ${j.request_timeout_ms}ms`,
129
+ ];
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)');
135
+ lines.push(` retry ${retryLine(j.retry_policy)}`);
136
+ lines.push(` revision ${j.revision} (the If-Match an edit is conditioned on)`);
137
+ lines.push(` created ${fmtUtc(j.created_at)} updated ${fmtUtc(j.updated_at)}`);
138
+ return lines;
139
+ }
140
+ /**
141
+ * One `cron runs` row. This is the surface an operator (or an agent) reads when a job is
142
+ * misbehaving, so it carries the facts that separate the possible causes: the wake and the request
143
+ * are timed apart because a slow cold start is the platform's problem and a slow response is the
144
+ * target's, and the HTTP status distinguishes "your endpoint said no" from "nothing was ever sent".
145
+ */
146
+ export function runListLine(row) {
147
+ const { run, attempts } = row;
148
+ const last = attempts?.length ? attempts[attempts.length - 1] : undefined;
149
+ // Fall back to the counters when the attempts could not be read: a retried run has made
150
+ // 1 + retries attempts, and the count is still worth printing without them.
151
+ const count = attempts ? attempts.length : run.platform_retry_count + run.application_retry_count + 1;
152
+ const timing = last
153
+ ? `wake ${last.wake_ms ?? '—'}ms req ${last.request_ms ?? '—'}ms`
154
+ : attempts ? 'wake — req —' : 'wake ? req ?';
155
+ const outcome = last?.http_status != null
156
+ ? `http ${last.http_status}`
157
+ : last?.failure_kind
158
+ ? `${last.failure_kind}${last.error_code ? ` (${last.error_code})` : ''}`
159
+ : run.skip_reason ? `skipped: ${run.skip_reason}` : '—';
160
+ return [
161
+ fmtUtc(run.scheduled_at),
162
+ // 10, the width of the longest status the platform has (`retry_wait`): a column padded to the
163
+ // second-longest is a column that jumps exactly when the history gets interesting.
164
+ run.status.padEnd(10),
165
+ run.trigger_type.padEnd(9),
166
+ `${count} attempt${count === 1 ? '' : 's'}`.padEnd(11),
167
+ timing.padEnd(24),
168
+ outcome.padEnd(16),
169
+ run.id,
170
+ ].join(' ');
171
+ }
172
+ /** `cron preview`, as lines. Invalid is an ANSWER here, not a transport error (the API says so too). */
173
+ export function previewLines(expression, p) {
174
+ if (!p.valid)
175
+ return [`invalid cron expression: ${p.error ?? 'unparseable'}`];
176
+ return [`${expression} ${p.description} (UTC)`, ...p.next.map((n) => ` ${fmtUtc(n)}`)];
177
+ }
178
+ /** What to say when a PATCH loses the If-Match race. Never a silent re-read: the schedule moved
179
+ * under this command, and re-reading to retry is how one of the two edits disappears. */
180
+ export function conflictLines(name, revision, what) {
181
+ return [
182
+ `refusing to ${what}: cron job ${name} changed since revision ${revision} — someone (or something) else edited it while this command was running.`,
183
+ `nothing was written. read it again and decide: insta cron show ${name}`,
184
+ ];
185
+ }
186
+ /** True when any flag that shapes the stored request was given. */
187
+ export function namesRequest(o) {
188
+ return o.method !== undefined || o.body !== undefined || (o.header?.length ?? 0) > 0;
189
+ }
190
+ /** True when any flag that names a target was given. */
191
+ export function namesTarget(o) {
192
+ return o.url !== undefined || o.service !== undefined || o.path !== undefined;
193
+ }
194
+ /**
195
+ * Validate the target flags. Not merged with target building: building needs the service listing
196
+ * (a network round trip), and a command whose flags contradict each other must fail before it.
197
+ * `partial` is the edit case, where naming no target at all means "leave it alone".
198
+ */
199
+ export function assertTargetFlags(o, partial = false) {
200
+ if (o.url && o.service)
201
+ throw new Error('--url and --service name two different targets — pass one');
202
+ if (o.path !== undefined && !o.service)
203
+ throw new Error('--path applies to --service (an external target carries its path in the --url)');
204
+ if (!partial && !o.url && !o.service)
205
+ throw new Error('name a target: --url <https://…> or --service <name> [--path /api/cron]');
206
+ if (o.url !== undefined && !/^https?:\/\//i.test(o.url))
207
+ throw new Error(`--url must be an absolute http(s) URL, got: ${o.url}`);
208
+ if (o.path !== undefined && !o.path.startsWith('/'))
209
+ throw new Error(`--path must start with /, got: ${o.path}`);
210
+ }
211
+ /** The stored request, from the flags alone. `undefined` when no flag shaped one. */
212
+ export function buildRequest(o) {
213
+ if (!namesRequest(o))
214
+ return undefined;
215
+ const method = o.method ? parseMethod(o.method) : o.body !== undefined ? 'POST' : 'GET';
216
+ // A body on a GET is a typo with a plausible-looking outcome: the platform would send it and most
217
+ // targets would ignore it, so the job would run "fine" and do nothing. `--body` alone implies
218
+ // POST; `--body` with an explicit `--method GET` is a contradiction and says so.
219
+ if (o.body !== undefined && method !== 'POST')
220
+ throw new Error('--body is sent on POST only — drop --method GET, or drop --body');
221
+ const headers = o.header?.length ? parseHeaders(o.header) : undefined;
222
+ return { method, ...(headers ? { headers } : {}), ...(o.body !== undefined ? { body: o.body } : {}) };
223
+ }
224
+ /**
225
+ * What an edit that reshapes the request is about to LOSE.
226
+ *
227
+ * 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.
232
+ */
233
+ export function cronEditWarnings(current, next) {
234
+ 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()));
237
+ 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(', ')}`);
239
+ }
240
+ // The METHOD is part of the request being replaced, and changing it is the quietest way to break
241
+ // a job: `--header` alone on a POST job re-shapes the whole request, and the default method for a
242
+ // request with no --body is GET. The job then runs, answers 200, and does nothing.
243
+ if (current.method !== next.method) {
244
+ out.push(`warning: the request method changes ${current.method} → ${next.method} — pass --method ${current.method} to keep it`);
245
+ }
246
+ // The BODY cannot be read back either, so its loss cannot be detected by comparing; what CAN be
247
+ // said is when the stored request was one that carries a body and the new flags supply none.
248
+ // Silence here was the actual gap: the command promised to name what it drops and named only
249
+ // headers, so a POST job edited with `--header` alone lost its payload without a word.
250
+ if (current.method === 'POST' && next.body === undefined) {
251
+ out.push('warning: the stored request body (write-only, and not readable here) is dropped — re-supply it with --body, or pass --method GET if the job should stop sending one');
252
+ }
253
+ return out;
254
+ }
255
+ // ---- API plumbing ----
256
+ const jobsPath = (projectId) => `/projects/${projectId}/cron-jobs`;
257
+ /** Injected whole in tests; resolved from the config and the linked project otherwise. */
258
+ async function context(opts, injected) {
259
+ if (injected)
260
+ return injected;
261
+ const api = await ApiClient.load();
262
+ const p = await requireProject();
263
+ return { api, projectId: p.projectId, branch: opts.branch ?? p.branch };
264
+ }
265
+ async function listJobs(c) {
266
+ const { jobs } = await c.api.request('GET', `${jobsPath(c.projectId)}${q(c.branch)}`);
267
+ return jobs;
268
+ }
269
+ /** Name → the job itself: the listing is the resolution step, as it is for services. */
270
+ async function findJob(c, name) {
271
+ return resolveJob(await listJobs(c), name);
272
+ }
273
+ /** The compute service a `--service <name>` target names, resolved on the same branch as the job. */
274
+ async function computeTarget(c, name, path) {
275
+ const { services } = await c.api.request('GET', `/projects/${c.projectId}/services${q(c.branch)}`);
276
+ return { kind: 'service', serviceId: resolveSoleService(services, 'compute', name).id, path };
277
+ }
278
+ /** Service id → name, so a target reads as `compute/api/cron` instead of a uuid. Best effort: a
279
+ * target in another project of the org is legal and simply has no name on this branch. */
280
+ async function serviceNames(c, jobs) {
281
+ if (!jobs.some((j) => j.target.kind === 'service'))
282
+ return new Map();
283
+ try {
284
+ const { services } = await c.api.request('GET', `/projects/${c.projectId}/services${q(c.branch)}`);
285
+ return new Map(services.map((s) => [s.id, s.name]));
286
+ }
287
+ catch {
288
+ return new Map();
289
+ }
290
+ }
291
+ function targetOf(c, o, fallbackPath = '/') {
292
+ return o.url ? { kind: 'external', url: o.url } : computeTarget(c, o.service, o.path ?? fallbackPath);
293
+ }
294
+ /**
295
+ * PATCH conditioned on the revision that was just read. A 409 is reported, never retried: the
296
+ * schedule changed between the read and the write, and re-reading to apply the patch on top is
297
+ * precisely how the other edit disappears.
298
+ */
299
+ async function patchJob(c, job, patch, what, json) {
300
+ try {
301
+ const res = await c.api.rawRequest('PATCH', `${jobsPath(c.projectId)}/${job.id}`, patch, {
302
+ headers: { 'If-Match': String(job.revision) },
303
+ });
304
+ if (handleApproval(res, json))
305
+ return null;
306
+ return res.body.job;
307
+ }
308
+ catch (e) {
309
+ if (e instanceof ApiError && e.status === 409)
310
+ refuse(conflictLines(job.name, job.revision, what));
311
+ throw e;
312
+ }
313
+ }
314
+ /** Run detail, one request per run — bounded, so a 100-run page cannot open 100 sockets at once. */
315
+ async function mapLimit(items, limit, fn) {
316
+ const out = new Array(items.length);
317
+ let cursor = 0;
318
+ const worker = async () => {
319
+ for (;;) {
320
+ const i = cursor++;
321
+ const item = items[i];
322
+ if (item === undefined)
323
+ return;
324
+ out[i] = await fn(item);
325
+ }
326
+ };
327
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
328
+ return out;
329
+ }
330
+ const RUN_DETAIL_CONCURRENCY = 5;
331
+ // ---- commands ----
332
+ export async function cronList(opts = {}, injected) {
333
+ const c = await context(opts, injected);
334
+ const jobs = await listJobs(c);
335
+ if (opts.json)
336
+ return printJson(jobs);
337
+ if (!jobs.length)
338
+ return info(`(no cron jobs on ${c.branch ?? 'default'} — add one with \`insta cron create <name> "<expression>" --url <https://…>\`)`);
339
+ const names = await serviceNames(c, jobs);
340
+ for (const j of jobs)
341
+ info(jobListLine(j, j.target.kind === 'service' ? names.get(j.target.serviceId) : undefined));
342
+ }
343
+ export async function cronCreate(name, expression, opts = {}, injected) {
344
+ // Flag validation first, before any config or network access: a contradictory command must not
345
+ // reach the point where half of it has happened.
346
+ assertTargetFlags(opts);
347
+ const request = buildRequest(opts);
348
+ const timeout = opts.timeout === undefined ? undefined : parseTimeout(opts.timeout);
349
+ const c = await context(opts, injected);
350
+ const target = await targetOf(c, opts);
351
+ const res = await c.api.rawRequest('POST', jobsPath(c.projectId), {
352
+ name,
353
+ expression,
354
+ ...(c.branch ? { branch: c.branch } : {}),
355
+ target,
356
+ ...(request ? { request } : {}),
357
+ ...(timeout !== undefined ? { requestTimeoutMs: timeout } : {}),
358
+ });
359
+ if (handleApproval(res, opts.json))
360
+ return;
361
+ const job = res.body.job;
362
+ if (opts.json)
363
+ return printJson(job);
364
+ info(`created cron job ${job.name} on ${job.branch} (${job.id})`);
365
+ info(` ${job.expression} (${job.timezone}) — next run ${fmtUtc(job.next_run_at)}`);
366
+ }
367
+ export async function cronShow(name, opts = {}, injected) {
368
+ const c = await context(opts, injected);
369
+ const job = await findJob(c, name);
370
+ if (opts.json)
371
+ return printJson(job);
372
+ const names = await serviceNames(c, [job]);
373
+ for (const line of jobShowLines(job, job.target.kind === 'service' ? names.get(job.target.serviceId) : undefined))
374
+ info(line);
375
+ }
376
+ export async function cronEdit(name, opts = {}, injected) {
377
+ assertTargetFlags(opts, true);
378
+ const request = buildRequest(opts);
379
+ 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');
382
+ }
383
+ const c = await context(opts, injected);
384
+ const job = await findJob(c, name);
385
+ const target = namesTarget(opts)
386
+ // An edit that moves a service target keeps the path it had unless --path says otherwise.
387
+ ? await targetOf(c, opts, job.target.kind === 'service' ? job.target.path : '/')
388
+ : undefined;
389
+ if (request)
390
+ for (const w of cronEditWarnings(job.request, request))
391
+ process.stderr.write(`${w}\n`);
392
+ const updated = await patchJob(c, job, {
393
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
394
+ ...(opts.expression !== undefined ? { expression: opts.expression } : {}),
395
+ ...(target ? { target } : {}),
396
+ ...(request ? { request } : {}),
397
+ ...(timeout !== undefined ? { requestTimeoutMs: timeout } : {}),
398
+ }, 'edit', opts.json);
399
+ if (!updated)
400
+ return;
401
+ if (opts.json)
402
+ return printJson(updated);
403
+ info(`updated cron job ${updated.name} (revision ${updated.revision}) — next run ${fmtUtc(updated.next_run_at)}`);
404
+ }
405
+ export async function cronPause(name, opts = {}, injected) {
406
+ await setEnabled(name, false, opts, injected);
407
+ }
408
+ export async function cronResume(name, opts = {}, injected) {
409
+ await setEnabled(name, true, opts, injected);
410
+ }
411
+ async function setEnabled(name, enabled, opts, injected) {
412
+ const c = await context(opts, injected);
413
+ const job = await findJob(c, name);
414
+ const updated = await patchJob(c, job, { enabled }, enabled ? 'resume' : 'pause', opts.json);
415
+ if (!updated)
416
+ return;
417
+ if (opts.json)
418
+ return printJson(updated);
419
+ info(enabled
420
+ ? `resumed cron job ${updated.name} — next run ${fmtUtc(updated.next_run_at)}`
421
+ : `paused cron job ${updated.name} — it will not fire until \`insta cron resume ${updated.name}\``);
422
+ }
423
+ export async function cronDelete(name, opts = {}, injected) {
424
+ const c = await context(opts, injected);
425
+ const job = await findJob(c, name);
426
+ // No prompt, a refusal: there is no terminal in the caller an agent or CI runs from, and a
427
+ // question asked into a pipe either hangs or is answered by whatever byte arrives next.
428
+ if (!opts.yes) {
429
+ refuse([
430
+ `refusing to delete cron job ${job.name} on ${job.branch} (${job.expression} ${job.timezone}) without --yes.`,
431
+ `its run history is retained, but the schedule stops firing immediately: insta cron delete ${name} --yes`,
432
+ ]);
433
+ }
434
+ const res = await c.api.rawRequest('DELETE', `${jobsPath(c.projectId)}/${job.id}`);
435
+ if (handleApproval(res, opts.json))
436
+ return;
437
+ if (opts.json)
438
+ return printJson({ ok: true, deleted: { id: job.id, name: job.name, branch: job.branch } });
439
+ info(`deleted cron job ${job.name} from ${job.branch} — its run history is retained`);
440
+ }
441
+ export async function cronRun(name, opts = {}, injected) {
442
+ const c = await context(opts, injected);
443
+ const job = await findJob(c, name);
444
+ // Minted ONCE per invocation, outside the request call. The API client replays a request after a
445
+ // 401 refresh, and a key minted per HTTP attempt would make that replay a second execution of the
446
+ // job — which is the exact duplicate the platform requires this header to prevent.
447
+ const idempotencyKey = randomUUID();
448
+ const res = await c.api.rawRequest('POST', `${jobsPath(c.projectId)}/${job.id}/runs`, undefined, {
449
+ headers: { 'Idempotency-Key': idempotencyKey },
450
+ });
451
+ if (handleApproval(res, opts.json))
452
+ return;
453
+ const runId = res.body.runId;
454
+ if (opts.json)
455
+ return printJson({ runId, job: { id: job.id, name: job.name }, idempotencyKey });
456
+ info(`triggered cron job ${job.name} — run ${runId} accepted (read it with \`insta cron runs ${name}\`)`);
457
+ // A manual run is an EXTRA execution: the platform does not move next_run_at for it, and an
458
+ // operator firing a job by hand at 02:59 should not expect the 03:00 tick to have been consumed.
459
+ //
460
+ // Only when there IS a next run. A paused job accepts a manual run — pause governs the clock, not
461
+ // the operator — and it has no next_run_at, so the sentence below would promise a scheduled
462
+ // execution that is not coming and print the em dash `fmtUtc` uses for "none" in the middle of it.
463
+ if (job.enabled) {
464
+ info(` the scheduled run at ${fmtUtc(job.next_run_at)} still happens — a manual run is an extra execution`);
465
+ }
466
+ else {
467
+ info(` ${job.name} is paused and stays paused — this run is the only one (\`insta cron resume ${name}\` starts the schedule again)`);
468
+ }
469
+ }
470
+ export async function cronRuns(name, opts = {}, injected) {
471
+ const limit = opts.limit === undefined ? undefined : parseRunLimit(opts.limit);
472
+ const c = await context(opts, injected);
473
+ const job = await findJob(c, name);
474
+ const { runs } = await c.api.request('GET', `${jobsPath(c.projectId)}/${job.id}/runs${limit === undefined ? '' : `?limit=${limit}`}`);
475
+ // The listing carries the run rows; the wake/request split and the HTTP status live on the
476
+ // ATTEMPTS, which are only readable one run at a time. That is an extra request per row, which is
477
+ // worth it: without them this command answers "it failed" and an operator still has to go and ask
478
+ // what failed. A run whose detail cannot be read degrades to counters rather than failing the page.
479
+ const rows = await mapLimit(runs, RUN_DETAIL_CONCURRENCY, async (run) => {
480
+ try {
481
+ const d = await c.api.request('GET', `${jobsPath(c.projectId)}/${job.id}/runs/${run.id}`);
482
+ return { run, attempts: d.attempts };
483
+ }
484
+ catch {
485
+ return { run, attempts: null };
486
+ }
487
+ });
488
+ if (opts.json)
489
+ return printJson(rows.map((r) => ({ ...r.run, attempts: r.attempts })));
490
+ if (!rows.length)
491
+ return info(`(no runs yet for ${job.name} — trigger one with \`insta cron run ${name}\`)`);
492
+ info(`runs of cron/${job.name} on ${job.branch} — times are UTC`);
493
+ for (const row of rows)
494
+ info(runListLine(row));
495
+ }
496
+ export async function cronPreview(expression, opts = {}, injected) {
497
+ // The answer depends on nothing but the expression, but the route is project-scoped (membership is
498
+ // what keeps an open parser endpoint off the internet) — so this resolves a project like every
499
+ // other command, and asks the SAME parser that create() validates with rather than shipping a
500
+ // second cron implementation in the CLI that could disagree with the materializer.
501
+ const c = await context({}, injected);
502
+ const res = await c.api.rawRequest('POST', `${jobsPath(c.projectId)}/preview`, { expression });
503
+ if (handleApproval(res, opts.json))
504
+ return;
505
+ const preview = res.body;
506
+ if (opts.json) {
507
+ printJson(preview);
508
+ // An invalid expression is a legitimate answer from the API and a failed command here: a script
509
+ // that pipes this into `insta cron create` must be able to branch on the exit code.
510
+ if (!preview.valid)
511
+ process.exitCode = 1;
512
+ return;
513
+ }
514
+ if (!preview.valid)
515
+ die(`invalid cron expression: ${preview.error ?? 'unparseable'}`);
516
+ for (const line of previewLines(expression, preview))
517
+ info(line);
518
+ }
519
+ //# sourceMappingURL=cron.js.map
@@ -1,4 +1,5 @@
1
1
  import { ApiClient } from '../api.js';
2
+ import { readProject } from '../config.js';
2
3
  import { info, printJson, handleApproval, die } from '../util.js';
3
4
  import { presentUrl, resolveOrgId } from './billing.js';
4
5
  import { domainDeps, domainTarget, setDomain, checkDomain, removeDomain } from './compute.js';
@@ -101,11 +102,16 @@ function domainLines(d, linked = true) {
101
102
  }
102
103
  // The zone answers elsewhere, so nothing published here resolves and an attach is refused. The
103
104
  // repair is a next action, so it follows `linked` like the others: under --org it would name this org.
105
+ // Managed custody is the opposite case — the zone answers from InstaCloud's own nameservers, and
106
+ // attach works exactly as under the registrar — so `delegated` stays false there and only the
107
+ // custody line says where the zone lives.
104
108
  if (d.delegated) {
105
109
  out.push(` delegated to ${d.nameservers.join(', ')}${linked ? '' : ' — attach is refused'}`);
106
110
  if (linked)
107
111
  out.push(` attach is refused until: insta domain nameservers reset ${d.domainName}`);
108
112
  }
113
+ if (d.custody === 'managed')
114
+ out.push(` zone managed by InstaCloud (${d.nameservers.join(', ')}) — attach works as usual`);
109
115
  const w = Math.max(0, ...d.hostnames.map((x) => x.hostname.length));
110
116
  for (const h of d.hostnames)
111
117
  out.push(` ${h.hostname.padEnd(w)} ${h.state}${h.service ? ` → ${h.service}` : ''}${h.reason ? ` — ${h.reason}` : ''}`);
@@ -146,6 +152,43 @@ export async function domainStatus(name, opts, deps) {
146
152
  }
147
153
  // ---- nameservers and transferring out ----
148
154
  const domainPath = (orgId, domainName) => `/orgs/${orgId}/domains/${encodeURIComponent(domainName)}`;
155
+ /**
156
+ * Move a bought domain's DNS onto an InstaCloud-managed zone. This is what makes an APEX serve:
157
+ * under the registrar's nameservers the apex flattens to shared proxy addresses no certificate
158
+ * authority will vouch for, and the hostname can never verify. Delegation copies every record —
159
+ * the platform's and yours — into the managed zone first, then switches the nameservers, so a
160
+ * hostname that was serving keeps serving; a hostname that failed BECAUSE the zone had been
161
+ * delegated away is revived to pending on its own. 202 approval_required in agent mode
162
+ * (domain.delegate); the platform requires org admin either way.
163
+ */
164
+ export async function domainDelegate(domainName, opts, deps) {
165
+ const { api, orgId } = await orgDeps(opts, deps);
166
+ // The org route still signs for a PROJECT in agent mode — `domain.delegate` is read there, the
167
+ // same precedent `buy` documents above — but a user token needs none, so the link stays optional
168
+ // and the verb keeps working from an unlinked directory. The link only counts when it belongs to
169
+ // the org being mutated: under `--org` naming ANOTHER org, signing with this directory's project
170
+ // would evaluate authorization against the wrong project's policy, so the call goes projectless
171
+ // and the platform's org-administration path judges it instead.
172
+ const link = deps?.project ?? (await readProject()) ?? undefined;
173
+ const projectId = link && link.orgId === orgId ? link.projectId : undefined;
174
+ const res = await api.rawRequest('POST', `${domainPath(orgId, domainName)}/delegate`, undefined, projectId ? { projectId } : undefined);
175
+ if (handleApproval(res, opts.json))
176
+ return;
177
+ if (opts.json)
178
+ return printJson(res.body);
179
+ const d = res.body;
180
+ for (const line of domainLines(d, !opts.org))
181
+ info(line);
182
+ // Hostname re-verification is the platform's own loop; the reader's next move is to watch it —
183
+ // in the org the delegate just acted on, so an explicit --org rides along. But the platform
184
+ // revives only delegation-caused failures: when every hostname is still failed in this very
185
+ // answer, nothing is converging and the watch hint would contradict the `nothing serving —
186
+ // attach` line domainLines just printed, which IS the remedy there (attach works as usual
187
+ // under managed custody).
188
+ if (!d.hostnames.length || d.hostnames.some((h) => h.state !== 'failed')) {
189
+ info(`hostnames re-verify on the managed zone by themselves — watch: insta domain status ${d.domainName}${opts.org ? ` --org ${opts.org}` : ''}`);
190
+ }
191
+ }
149
192
  export async function domainNameserversSet(domainName, hosts, opts, deps) {
150
193
  const nameservers = hosts.flatMap((h) => h.split(/[\s,]+/)).map((h) => h.replace(/\.$/, '')).filter(Boolean);
151
194
  if (!nameservers.length)
@@ -41,7 +41,7 @@ export function parseWatchPaths(raw) {
41
41
  throw new Error("watch paths need at least one pattern, e.g. 'apps/web/**,packages/ui/**' (quote them, or the shell expands the *)");
42
42
  return out;
43
43
  }
44
- // Build/start come from detection only: the platform's nixpacks lane fails a build whose commands differ from it.
44
+ // Persisted commands constrain builder selection; automatic detection must leave them unset.
45
45
  // autoDeploy rides along only when switched off: a public repo 400s on autoDeploy: true.
46
46
  export function sourceBody(src, c, o) {
47
47
  const repo = src.source === 'app'
@@ -50,8 +50,6 @@ export function sourceBody(src, c, o) {
50
50
  return {
51
51
  ...repo,
52
52
  rootDir: c.rootDir,
53
- buildCommand: c.buildCommand,
54
- startCommand: c.startCommand,
55
53
  port: o.port !== undefined ? parsePort(o.port) : c.port,
56
54
  ...(o.repoBranch ? { branch: o.repoBranch } : {}),
57
55
  ...(o.autoDeploy === false ? { autoDeploy: false } : {}),
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import * as services from './commands/services.js';
20
20
  import { resolveServiceArgs, serviceArgsDeps } from './resolve-service.js';
21
21
  import * as regions from './commands/regions.js';
22
22
  import * as secretsCmd from './commands/secrets.js';
23
+ import * as cronCmd from './commands/cron.js';
23
24
  import { deploy } from './commands/deploy.js';
24
25
  import { build } from './commands/build.js';
25
26
  import { buildLogs } from './commands/build-logs.js';
@@ -136,7 +137,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass
136
137
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
137
138
  .option('--always-on', 'compute only: create as always-on — never scales to zero (the default for new compute services; all plans; billing is actual usage either way)')
138
139
  .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
139
- .option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data; fixed after attachment)')
140
+ .option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
140
141
  .option('--volume <gi>', 'compute only: attach a persistent volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`). Any plan may attach up to its own plan cap (10Gi free, 50Gi paid by default; the bare `insta compute volume <name>` read prints it as plan max); a size above the free cap is paid. Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
141
142
  .option('--json')
142
143
  .action(guard(async (type, name, o) => {
@@ -190,6 +191,60 @@ sec.command('sources').description('List service credential sources available fo
190
191
  .action(guard((o) => secretsCmd.secretsSources(o)));
191
192
  sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json')
192
193
  .action(guard((o) => secretsCmd.secretsTree(o)));
194
+ // ---- cron (branch-scoped schedules that send one HTTP request) ----
195
+ // Every expression is evaluated in UTC and every time printed is UTC: a cron pinned to UTC does not
196
+ // keep a fixed local time, so a localised column would quietly lie across a DST boundary.
197
+ 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');
198
+ // The repeatable --header collector, declared once: commander needs the same reducer on create and
199
+ // edit, and two copies is how they drift apart.
200
+ 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], []);
201
+ cron.command('list').description('List the branch\'s cron jobs: state, expression, next UTC run and target')
202
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
203
+ .action(guard((o) => cronCmd.cronList(o)));
204
+ headerOption(cron.command('create <name> <expression>'))
205
+ .description('Create a cron job. The expression is a 5-field cron expression in UTC (quote it — the shell eats the *). Name a target with --url or --service')
206
+ .option('--url <url>', 'external target: an absolute http(s) URL')
207
+ .option('--service <name>', "internal target: a compute service on this branch — the worker resolves its live route at send time, so a redeploy can't leave the job firing at a dead host")
208
+ .option('--path <path>', 'request path on the --service target, leading slash (default /)')
209
+ .option('--method <method>', 'GET or POST (default GET, or POST when --body is given)')
210
+ .option('--body <json>', 'request body — POST only')
211
+ .option('--timeout <ms>', 'request timeout, 1000..300000 ms; excludes cold start (the wake is timed separately)')
212
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
213
+ .action(guard((name, expression, o) => cronCmd.cronCreate(name, expression, o)));
214
+ 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')
215
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
216
+ .action(guard((name, o) => cronCmd.cronShow(name, o)));
217
+ headerOption(cron.command('edit <name>'))
218
+ .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')
219
+ .option('--expression <expr>', 'new 5-field cron expression (UTC; quote it)')
220
+ .option('--name <new-name>', 'rename the job')
221
+ .option('--url <url>', 'external target: an absolute http(s) URL')
222
+ .option('--service <name>', 'internal target: a compute service on this branch')
223
+ .option('--path <path>', 'request path on the --service target (default: the path it already had)')
224
+ .option('--method <method>', 'GET or POST')
225
+ .option('--body <json>', 'request body — POST only')
226
+ .option('--timeout <ms>', 'request timeout, 1000..300000 ms')
227
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
228
+ .action(guard((name, o) => cronCmd.cronEdit(name, o)));
229
+ cron.command('pause <name>').description('Stop a cron job firing, keeping its definition and history')
230
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
231
+ .action(guard((name, o) => cronCmd.cronPause(name, o)));
232
+ cron.command('resume <name>').description('Let a paused cron job fire again from the next tick')
233
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
234
+ .action(guard((name, o) => cronCmd.cronResume(name, o)));
235
+ cron.command('delete <name>').description('Delete a cron job — it stops firing immediately; its run history is retained')
236
+ .option('-y, --yes', 'required: confirm the deletion (there is no prompt, in a terminal or out of one)')
237
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
238
+ .action(guard((name, o) => cronCmd.cronDelete(name, o)));
239
+ cron.command('run <name>').description('Trigger one run now. An EXTRA execution — the next scheduled run still happens. Sent with an Idempotency-Key minted once, so a retried request cannot fire the job twice')
240
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
241
+ .action(guard((name, o) => cronCmd.cronRun(name, o)));
242
+ cron.command('runs <name>').description('Run history, most recent first: status, trigger, attempts, the wake/request split (a slow wake is the platform, a slow request is your endpoint) and the HTTP status')
243
+ .option('--limit <n>', 'number of runs, 1..100 (default 20)')
244
+ .option('--branch <branch>', 'branch (default: current)').option('--json')
245
+ .action(guard((name, o) => cronCmd.cronRuns(name, o)));
246
+ cron.command('preview <expression>').description('Validate an expression and print its next five UTC fire times — answered by the same parser the scheduler uses. Exits 1 when the expression is invalid')
247
+ .option('--json').action(guard((expression, o) => cronCmd.cronPreview(expression, o)));
193
248
  // ---- domain (bought here, or bring your own; hostnames on compute services; DNS of bought zones) ----
194
249
  const dom = program.command('domain').description('Domains: buy through InstaCloud or bring your own — attach / check / detach hostnames on compute services; DNS records of bought domains');
195
250
  dom.command('search <keyword>').description('Search purchasable names with prices (a label like "myapp" or a full name like "myapp.com")')
@@ -214,11 +269,14 @@ dom.command('list').description("Domains bought through InstaCloud in this org
214
269
  dom.command('status <name>').description("A bought domain's order and attach state")
215
270
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
216
271
  .action(guard((name, o) => domainCmd.domainStatus(name, o)));
217
- const ns = dom.command('nameservers').description("Delegate a bought domain's zone to nameservers you name, or put it back on InstaCloud's registrar");
218
- ns.command('set <domain> <nameservers...>').description("Delegate the zone — the nameservers must already host it. Every hostname the domain serves stops answering, unless they are the registrar's own: the records an attach published live in the zone you are leaving")
272
+ dom.command('delegate <domain>').description("Move a bought domain's DNS onto an InstaCloud-managed zone — the way an apex hostname gets a certificate and serves. Every record is copied first and the nameservers switch after, so serving hostnames stay up and ones that failed by delegating away revive on their own. While managed, `domain records` answers 409 for every verb (managed-zone record editing is not covered yet); `nameservers reset` is the way back (org admin; gated: domain.delegate — agent mode gates from a linked project, an unlinked --org call falls under org administration instead)")
273
+ .option('--org <id>', "target org (default: linked project's org)").option('--json')
274
+ .action(guard((domain, o) => domainCmd.domainDelegate(domain, o)));
275
+ const ns = dom.command('nameservers').description("Delegate a bought domain's zone to nameservers you name, or put it back on InstaCloud's registrar — for InstaCloud's own managed zone, use `insta domain delegate`");
276
+ ns.command('set <domain> <nameservers...>').description("Delegate the zone to nameservers of YOURS — they must already host it. Every hostname the domain serves stops answering, unless they are the registrar's own: the records an attach published live in the zone you are leaving. To keep hostnames serving on InstaCloud-run nameservers instead, use `insta domain delegate`")
219
277
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
220
278
  .action(guard((domain, hosts, o) => domainCmd.domainNameserversSet(domain, hosts, o)));
221
- ns.command('reset <domain>').description("Put the zone back on the registrar's own nameservers; a hostname it took down is re-attached with `insta domain attach`")
279
+ ns.command('reset <domain>').description("Put the zone back on the registrar's own nameservers — from your own delegation or from an InstaCloud-managed zone alike; a hostname `set` took down is re-attached with `insta domain attach`, one a managed zone was serving re-verifies on its own")
222
280
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
223
281
  .action(guard((domain, o) => domainCmd.domainNameserversReset(domain, o)));
224
282
  const xfer = dom.command('transfer').description('Take a bought domain to another registrar — open the lock, then read the code (all three need org admin)');
@@ -315,8 +373,13 @@ compute.command('watch-paths [service]').description("Show or change which paths
315
373
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeWatchPaths(service, o)));
316
374
  compute.command('disconnect-repo [service]').description('Disconnect the GitHub repository from a compute service. The service keeps running its current image; pushes no longer deploy it, and its build history stays')
317
375
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o)));
318
- compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at --mount-path (default /data) on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
319
- .option('--mount-path <path>', 'container mount path for a new volume (requires --size; default /data; fixed after attachment)')
376
+ compute.command('start-command [service]').description('Show or stage a compute startup command for the next deployment. Runs through sh -c; --clear restores the image default. Stage the volume path and command before deploying. CLI secrets writes redeploy immediately; use Console to combine variables, path and command in one deployment.')
377
+ .option('--set <command>', 'startup command to use on the next deploy')
378
+ .option('--clear', 'use the image default command on the next deploy')
379
+ .option('--json').option('--branch <branch>', 'branch (default: current)')
380
+ .action(guard((service, o) => computeCmd.computeStartCommand(service, o)));
381
+ compute.command('volume [service]').description("Show, attach, grow, remount, or delete a compute service's persistent volume. --mount-path alone stages an existing volume path change, pending until deploy or restart; an unchanged normalized path is a no-op readback. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at --mount-path (default /data) on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
382
+ .option('--mount-path <path>', 'configure mount path; existing volume changes apply on the next deploy (restarts the service)')
320
383
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
321
384
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
322
385
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
@@ -14,6 +14,9 @@ export const MANIFEST_FILE = 'insta.template.yaml';
14
14
  const CODE_RE = /^[a-z0-9][a-z0-9-]{0,38}$/;
15
15
  export const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
16
16
  const GENERATOR_RE = /^secret:([1-9]\d{0,2})$/;
17
+ // The platform provisions these and owns everything about them (platform templateManifest.ts).
18
+ const MANAGED_TYPES = ['postgres', 'redis', 'mysql', 'mongodb'];
19
+ const MANIFEST_TYPES = ['web', 'worker', ...MANAGED_TYPES];
17
20
  // What counts as a digest is the PLATFORM's call, not ours: registry.ts's DIGEST regex, verbatim.
18
21
  const DIGEST = /^sha256:[a-f0-9]{64}$/;
19
22
  // Why an image must carry a pin: a template is a reproducible deploy, and a tagless ref (implicit
@@ -78,17 +81,19 @@ export function validateManifest(m) {
78
81
  for (const name of names) {
79
82
  const svc = services[name] ?? {};
80
83
  const where = `services.${name}`;
81
- if (svc.type !== 'web' && svc.type !== 'worker' && svc.type !== 'postgres') {
82
- problems.push(`${where}.type must be web, worker or postgres`);
84
+ // typeof, not String(): a YAML array like [redis] would otherwise stringify to its lone element.
85
+ const type = typeof svc.type === 'string' ? svc.type : undefined;
86
+ if (!type || !MANIFEST_TYPES.includes(type)) {
87
+ problems.push(`${where}.type must be one of ${MANIFEST_TYPES.join(', ')}`);
83
88
  }
84
- // A managed postgres service is BARE: the platform owns its image, port, sizing, credentials
85
- // and env, so every other rule below would be asking about fields it must not carry. Mirrors
86
- // the platform's own check (provisioning/templateManifest.ts) so an author hears it here.
87
- if (svc.type === 'postgres') {
89
+ // A managed datastore is BARE: the platform owns its image, port, sizing, credentials and env,
90
+ // so every other rule below would be asking about fields it must not carry. Mirrors the
91
+ // platform's own check (provisioning/templateManifest.ts) so an author hears it here.
92
+ if (type && MANAGED_TYPES.includes(type)) {
88
93
  const bare = svc;
89
94
  for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn']) {
90
95
  if (bare[field] !== undefined) {
91
- problems.push(`${where}.${field}: a postgres service is platform-managed and carries no ${field} — declare it bare ({ type: postgres })`);
96
+ problems.push(`${where}.${field}: a ${type} service is platform-managed and carries no ${field} — declare it bare ({ type: ${type} })`);
92
97
  }
93
98
  }
94
99
  const groups = ['fixed', 'generated', 'platform', 'required', 'optional'];
@@ -97,7 +102,7 @@ export function validateManifest(m) {
97
102
  const emptyShell = !!envShell && typeof envShell === 'object' && !Array.isArray(envShell)
98
103
  && Object.entries(envShell).every(([g, v]) => groups.includes(g) && !!v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0);
99
104
  if (!emptyShell) {
100
- problems.push(`${where}.env: a postgres service is platform-managed and carries no env — declare it bare ({ type: postgres })`);
105
+ problems.push(`${where}.env: a ${type} service is platform-managed and carries no env — declare it bare ({ type: ${type} })`);
101
106
  }
102
107
  }
103
108
  continue;
@@ -121,6 +126,16 @@ export function validateManifest(m) {
121
126
  if (svc.port !== undefined && (!Number.isInteger(svc.port) || svc.port < 1 || svc.port > 65535)) {
122
127
  problems.push(`${where}: port must be an integer between 1 and 65535, got: ${svc.port}`);
123
128
  }
129
+ // A worker is PORTLESS: the platform deploys it as its own port-0 service, so nothing is routed
130
+ // to it and nothing probes it. The server's three refusals, said here before the upload.
131
+ if (svc.type === 'worker') {
132
+ if (svc.port !== undefined)
133
+ problems.push(`${where}.port: a worker has no routed port — remove it (a worker is portless; declare type: web to serve HTTP)`);
134
+ if (svc.healthcheck !== undefined)
135
+ problems.push(`${where}.healthcheck: a worker has no HTTP endpoint to probe — its health is the machine's state; remove it (or declare type: web)`);
136
+ if (svc.alwaysOn === false)
137
+ problems.push(`${where}.alwaysOn: a worker cannot scale to zero — nothing is routed to it, so nothing would wake it; remove alwaysOn or set it true`);
138
+ }
124
139
  if (svc.type === 'web' && !svc.healthcheck)
125
140
  problems.push(`${where}: web services must declare a healthcheck path`);
126
141
  if (svc.healthcheck && !String(svc.healthcheck).startsWith('/'))
@@ -164,6 +179,23 @@ export function validateManifest(m) {
164
179
  }
165
180
  }
166
181
  }
182
+ // A managed datastore has no url or host to address, mirroring the platform's env.fixed check.
183
+ for (const name of names) {
184
+ const svc = services[name] ?? {};
185
+ for (const [varName, raw] of Object.entries(svc.env?.fixed ?? {})) {
186
+ for (const m of String(raw).matchAll(/\$\{([^}]+)\}/g)) {
187
+ const ref = m[1].trim();
188
+ const svcRef = /^services\.([a-z0-9-]+)\.(url|host)$/.exec(ref);
189
+ if (!svcRef)
190
+ continue;
191
+ const target = services[svcRef[1]];
192
+ const targetType = target && typeof target.type === 'string' ? target.type : undefined;
193
+ if (targetType && MANAGED_TYPES.includes(targetType)) {
194
+ problems.push(`services.${name}.env.fixed.${varName}: '${svcRef[1]}' is a managed ${targetType}, so it has no url or host. Use its platform credentials instead: \${{services.${svcRef[1]}.<KEY>}} under env.platform`);
195
+ }
196
+ }
197
+ }
198
+ }
167
199
  return problems;
168
200
  }
169
201
  /**
package/dist/util.js CHANGED
@@ -204,12 +204,20 @@ export function handleApproval(res, json) {
204
204
  if (res.status === 202 && res.body?.status === 'approval_required') {
205
205
  if (json)
206
206
  printJson(res.body);
207
- process.stderr.write(`approval required for ${res.body.action} — run: insta agent approvals approve ${res.body.approvalId}\n`);
207
+ process.stderr.write(`${approvalHint(res.body)}\n`);
208
208
  process.exitCode = 2;
209
209
  return true;
210
210
  }
211
211
  return false;
212
212
  }
213
+ // The one-line hint for a gated request. `url` is the console page for THIS request, built by the
214
+ // platform (only it knows which console fronts it — prod, staging, self-hosted); it comes first
215
+ // because a click is the fastest route to approval and terminals link it. An older platform or the
216
+ // OSS runtime sends no url, and the CLI command alone still stands.
217
+ export function approvalHint(body) {
218
+ const review = body.url ? `review it at ${body.url} or ` : '';
219
+ return `approval required for ${body.action} — ${review}run: insta agent approvals approve ${body.approvalId}`;
220
+ }
213
221
  // Neutral op → an `insta` command string. Unknown ops fall back to reason-only (no crash). The
214
222
  // platform names an observability target with its component word (`db` for postgres); the CLI
215
223
  // groups by service type, so the target becomes the parent command.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [