insta 0.0.39 → 0.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -5,9 +5,13 @@ import { autoResolveProject, promptChoice } from './resolve-project.js';
5
5
  import { die } from './util.js';
6
6
  export class ApiError extends Error {
7
7
  status;
8
- constructor(status, msg) {
8
+ body;
9
+ // body carries the parsed error payload for callers that branch on machine-readable errors
10
+ // (e.g. template deploy's missing_variables); the message stays the human line.
11
+ constructor(status, msg, body) {
9
12
  super(msg);
10
13
  this.status = status;
14
+ this.body = body;
11
15
  this.name = 'ApiError';
12
16
  }
13
17
  }
@@ -47,14 +51,14 @@ export class ApiClient {
47
51
  async request(method, path, body, opts = {}) {
48
52
  const res = await this.raw(method, path, body, opts.auth ?? true);
49
53
  if (res.status >= 400)
50
- throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`);
54
+ throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body);
51
55
  return res.body;
52
56
  }
53
57
  // Like request but returns {status, body} so callers can branch on 202 (approval_required).
54
58
  async rawRequest(method, path, body, opts = {}) {
55
59
  const res = await this.raw(method, path, body, opts.auth ?? true);
56
60
  if (res.status >= 400)
57
- throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`);
61
+ throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body);
58
62
  return res;
59
63
  }
60
64
  async raw(method, path, body, auth) {
@@ -0,0 +1,357 @@
1
+ // `insta template` — browse the platform template registry and deploy a template (by registry
2
+ // code, or from a local directory carrying insta.template.yaml) onto a branch. The deploy is a
3
+ // platform-side pipeline (create services → write variables → deploy → health check); the CLI
4
+ // submits it and renders progress by polling the deployment resource.
5
+ import { join, resolve } from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import * as clack from '@clack/prompts';
9
+ import { ApiClient, ApiError, requireProject } from '../api.js';
10
+ import { info, printJson, handleApproval, renderNextActions } from '../util.js';
11
+ import { MANIFEST_FILE, collectManifestVariables, loadTemplateManifest } from '../template-manifest.js';
12
+ // One aligned row per template; numeric columns right-aligned. Plain padded columns, as the rest
13
+ // of the CLI (storage list, compute check-domain) — no table library.
14
+ export function templateListLines(templates) {
15
+ if (!templates.length)
16
+ return ['(no templates published yet)'];
17
+ const head = ['CODE', 'VERSION', 'CATEGORY', 'VARS', 'DEPLOYS', 'NAME'];
18
+ const numeric = [false, false, false, true, true, false];
19
+ const rows = templates.map((t) => [
20
+ t.code, t.version ?? '', t.category ?? '-',
21
+ String(t.requiredVarCount ?? 0), String(t.deployCount ?? 0),
22
+ t.tagline ? `${t.name} — ${t.tagline}` : (t.name ?? ''),
23
+ ]);
24
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
25
+ return [head, ...rows].map((r) => r.map((c, i) => (i === r.length - 1 ? c : numeric[i] ? c.padStart(widths[i]) : c.padEnd(widths[i]))).join(' ').trimEnd());
26
+ }
27
+ // The info endpoint may list services as an array or keep the manifest's map shape — render both.
28
+ export function normalizeInfoServices(raw) {
29
+ if (Array.isArray(raw)) {
30
+ return raw.map((s) => ({ name: s.name ?? '?', type: s.type, port: s.port, volumeGib: s.volumeGib ?? s.volume?.size }));
31
+ }
32
+ if (raw && typeof raw === 'object') {
33
+ return Object.entries(raw).map(([name, s]) => ({ name, type: s?.type, port: s?.port, volumeGib: s?.volumeGib ?? s?.volume?.size }));
34
+ }
35
+ return [];
36
+ }
37
+ // Variables may arrive as one array with a `required` flag, or pre-grouped {required, optional}
38
+ // (the registry detail endpoint's shape).
39
+ export function normalizeInfoVariables(raw) {
40
+ const one = (v, required) => ({
41
+ name: v.name, required, description: v.description, default: v.default, generate: v.generate,
42
+ });
43
+ if (Array.isArray(raw))
44
+ return raw.map((v) => one(v, !!v.required));
45
+ if (raw && typeof raw === 'object') {
46
+ const g = raw;
47
+ return [...(g.required ?? []).map((v) => one(v, true)), ...(g.optional ?? []).map((v) => one(v, false))];
48
+ }
49
+ return [];
50
+ }
51
+ // `bold` is injected so the renderer stays pure (tests pass identity; the command passes ANSI
52
+ // bold on a TTY).
53
+ export function templateInfoLines(t, bold = (s) => s) {
54
+ const lines = [`${t.code}${t.name ? ` — ${t.name}` : ''}`];
55
+ if (t.tagline)
56
+ lines.push(` ${t.tagline}`);
57
+ const field = (label, value) => { if (value)
58
+ lines.push(` ${label.padEnd(11)} ${value}`); };
59
+ field('version', t.version);
60
+ field('maintainer', t.maintainer);
61
+ field('source', t.source);
62
+ field('license', t.license);
63
+ field('upstream', t.upstream?.pinned ?? t.upstream?.image ?? t.upstream?.repo);
64
+ const services = normalizeInfoServices(t.services);
65
+ if (services.length) {
66
+ const summary = services.map((s) => {
67
+ const bits = [s.type && s.type !== 'compute' ? s.type : undefined, s.port ? `port ${s.port}` : undefined, s.volumeGib ? `${s.volumeGib}Gi volume` : undefined].filter(Boolean);
68
+ return `${s.name}${bits.length ? ` (${bits.join(', ')})` : ''}`;
69
+ });
70
+ lines.push(`services (${services.length}): ${summary.join(', ')}`);
71
+ }
72
+ const vars = normalizeInfoVariables(t.variables);
73
+ if (vars.length) {
74
+ lines.push('variables:');
75
+ const render = (v, emph) => {
76
+ const extra = [v.generate ? `generated: ${v.generate}` : undefined, v.default !== undefined ? `default: ${v.default}` : undefined].filter(Boolean);
77
+ lines.push(` ${emph(v.name.padEnd(24))} ${v.description ?? ''}${extra.length ? ` (${extra.join(', ')})` : ''}`.trimEnd());
78
+ };
79
+ const required = vars.filter((v) => v.required);
80
+ const optional = vars.filter((v) => !v.required);
81
+ if (required.length) {
82
+ lines.push(' required:');
83
+ for (const v of required)
84
+ render(v, bold);
85
+ }
86
+ if (optional.length) {
87
+ lines.push(' optional:');
88
+ for (const v of optional)
89
+ render(v, (s) => s);
90
+ }
91
+ }
92
+ return lines;
93
+ }
94
+ // Parse repeated --set K=V flags. Names must be platform env-var names (the same rule the
95
+ // manifest's env maps live under), so a typo fails here instead of surviving to a server 400.
96
+ // Later occurrences of a name win (shell-override semantics).
97
+ export function parseSetFlags(pairs) {
98
+ const values = {};
99
+ for (const pair of pairs) {
100
+ const m = /^([A-Z][A-Z0-9_]{0,63})=([\s\S]*)$/.exec(pair);
101
+ if (!m)
102
+ throw new Error(`--set expects NAME=value (NAME matching ^[A-Z][A-Z0-9_]{0,63}$), got: ${pair}`);
103
+ values[m[1]] = m[2];
104
+ }
105
+ return values;
106
+ }
107
+ export function missingVariablesMessage(missing) {
108
+ return [
109
+ 'missing required template variables:',
110
+ ...missing.map((v) => ` ${v.name.padEnd(24)} ${v.description ?? ''}`.trimEnd()),
111
+ 'supply them with --set NAME=value (repeatable)',
112
+ ].join('\n');
113
+ }
114
+ /**
115
+ * Decide which deploy-time variables to SEND. The platform's own resolution order is
116
+ * provided → generator → default (templateManifest.ts resolveVariables), so anything a generator
117
+ * or default answers is left OFF the wire — the executor generates secrets itself (they never
118
+ * transit) and applies defaults. What remains: --set wins; required vars with no machine answer
119
+ * are prompted on a TTY and are an error anywhere else. Unknown --set names pass through — the
120
+ * platform's variable set may be newer than local parsing.
121
+ */
122
+ export async function resolveVariables(vars, given, opts = {}) {
123
+ const values = { ...given };
124
+ const missing = [];
125
+ for (const v of vars) {
126
+ if (values[v.name] !== undefined)
127
+ continue;
128
+ if (v.generate || v.default !== undefined) {
129
+ opts.onAutoResolved?.(v);
130
+ continue;
131
+ }
132
+ if (!v.required)
133
+ continue;
134
+ if (opts.tty && opts.ask) {
135
+ values[v.name] = await opts.ask(v);
136
+ continue;
137
+ }
138
+ missing.push(v);
139
+ }
140
+ if (missing.length)
141
+ throw new Error(missingVariablesMessage(missing));
142
+ return values;
143
+ }
144
+ // The platform's machine-readable "you forgot these" answer to the POST (error=missing_variables,
145
+ // missing: [{name, key, description}] with a missingVariables alias) — turned back into promptable
146
+ // variables. null = some other error, not ours to interpret.
147
+ export function missingVariablesFrom(body) {
148
+ if ((body?.error ?? body?.code) !== 'missing_variables')
149
+ return null;
150
+ const list = body?.missing ?? body?.missingVariables ?? [];
151
+ if (!Array.isArray(list))
152
+ return [];
153
+ return list.map((v) => ({ name: String(v.name ?? v.key ?? v), required: true, description: v.description }));
154
+ }
155
+ // A deploy target that reads as a filesystem path must resolve as one — a typo'd directory should
156
+ // not fall through to a registry lookup that 404s with a confusing "no such template".
157
+ export function looksLikePath(target) {
158
+ return target.startsWith('.') || target.startsWith('/') || target.startsWith('~') || target.includes('/') || target.includes('\\');
159
+ }
160
+ // Expanding `~` is normally the shell's job, but it only does it unquoted — `deploy "~/tpl"`, or a
161
+ // target assembled by an agent, arrives literally, and path.resolve() would then look for a
162
+ // directory actually named "~". looksLikePath advertises `~` as a local path, so honour it here.
163
+ // `~user` is left alone: resolving another user's home needs a passwd lookup, which no shell-less
164
+ // tool should fake.
165
+ const expandHome = (target) => target.replace(/^~(?=[/\\]|$)/, () => homedir());
166
+ /**
167
+ * Which deploy mode a target selects. Local mode is OPTED INTO by a path-looking target (./dir,
168
+ * /abs, sub/dir); a bare word is ALWAYS a registry code — even when a same-named directory with a
169
+ * manifest sits in the working directory, deploying it must be explicit (./plausible), never a
170
+ * cwd coincidence. And a path-looking target with no manifest is a mistake, never a registry code.
171
+ */
172
+ export function deployMode(target, hasManifest = (d) => existsSync(join(d, MANIFEST_FILE))) {
173
+ if (!looksLikePath(target))
174
+ return { kind: 'registry', code: target };
175
+ const dir = resolve(process.cwd(), expandHome(target));
176
+ if (!hasManifest(dir))
177
+ throw new Error(`no ${MANIFEST_FILE} at ${join(dir, MANIFEST_FILE)}`);
178
+ return { kind: 'local', dir };
179
+ }
180
+ // ---- deployment progress ----
181
+ // The platform pipeline (insta-platform TemplateDeployment): status is running|succeeded|failed|
182
+ // partial, and `step` names where the run is (or stopped) — create_services → write_variables →
183
+ // deploy → health_check.
184
+ export const DEPLOY_STEPS = ['create services', 'write variables', 'deploy', 'health check'];
185
+ const STEP_KEYS = ['create_services', 'write_variables', 'deploy', 'health_check'];
186
+ /** The index of the step a deployment is on, or null when it reports none (or one this CLI does
187
+ * not know) — the watcher then holds progress instead of guessing. */
188
+ export function stepIndexFor(step) {
189
+ const i = STEP_KEYS.indexOf(step ?? '');
190
+ return i >= 0 ? i : null;
191
+ }
192
+ /** Success URLs, one line each: per-service `name: url` (plus bare urls, defensively). */
193
+ export function deploymentUrls(dep) {
194
+ const lines = [];
195
+ for (const u of dep?.urls ?? [])
196
+ lines.push(String(u));
197
+ for (const s of dep?.services ?? [])
198
+ if (s?.url)
199
+ lines.push(`${s.name ?? 'service'}: ${s.url}`);
200
+ return lines;
201
+ }
202
+ // One line per service with its terminal state — the anatomy of a partial/failed run.
203
+ export function serviceStateLines(dep) {
204
+ return (dep?.services ?? []).map((s) => {
205
+ const mark = s?.state === 'healthy' ? '✓' : s?.state === 'failed' ? '✗' : '•';
206
+ return ` ${mark} ${s?.name ?? 'service'}${s?.url ? ` — ${s.url}` : ''}${s?.state && s.state !== 'healthy' ? ` [${s.state}]` : ''}`;
207
+ });
208
+ }
209
+ // `partial` is TERMINAL: some services came up healthy, others failed, and the created resources
210
+ // are kept either way — so the message must say what stands and how to move (retry re-running the
211
+ // deploy, or clean up), not just that something went wrong.
212
+ export function partialMessage(dep) {
213
+ const services = dep?.services ?? [];
214
+ const healthy = services.filter((s) => s?.state === 'healthy').length;
215
+ return [
216
+ `template deployment finished partial: ${healthy}/${services.length} services healthy`,
217
+ ...serviceStateLines(dep),
218
+ ...(dep?.error ? [` ${dep.error}`] : []),
219
+ ...(dep?.logsTail ? ['--- log tail ---', String(dep.logsTail).trimEnd()] : []),
220
+ 'created services are kept — inspect with `insta logs compute <name>`, re-run the deploy to retry, or remove them with `insta services remove <type> <name>`',
221
+ ].join('\n');
222
+ }
223
+ export function failureMessage(dep, fallbackStep) {
224
+ const at = DEPLOY_STEPS[Math.min(stepIndexFor(dep?.step) ?? fallbackStep, DEPLOY_STEPS.length - 1)];
225
+ return [
226
+ `template deployment failed during ${at}${dep?.error ? `: ${dep.error}` : ''}`,
227
+ ...serviceStateLines(dep),
228
+ ...(dep?.logsTail ? ['--- log tail ---', String(dep.logsTail).trimEnd()] : []),
229
+ ].join('\n');
230
+ }
231
+ const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
232
+ /**
233
+ * Poll a template deployment until it settles, emitting each step exactly once as it completes
234
+ * (✓) or becomes active (…). Terminal states: succeeded (returns), failed and partial (throw —
235
+ * partial would otherwise poll forever, the platform never leaves it). Injectable getter/output/
236
+ * wait keep this testable without a network or real timers (the deviceGrant pattern in auth.ts).
237
+ */
238
+ export async function watchDeployment(getDeployment, id, out = info, wait = sleepSeconds, timeoutMs = 15 * 60_000) {
239
+ let done = 0; // steps already reported ✓
240
+ let active = -1; // step already reported …
241
+ const deadline = Date.now() + timeoutMs;
242
+ while (Date.now() < deadline) {
243
+ const dep = await getDeployment(id);
244
+ const status = String(dep?.status ?? '');
245
+ const idx = stepIndexFor(dep?.step);
246
+ const completed = status === 'succeeded' ? DEPLOY_STEPS.length : (idx ?? done);
247
+ for (; done < completed; done++)
248
+ out(` ✓ ${DEPLOY_STEPS[done]}`);
249
+ if (status === 'failed')
250
+ throw new Error(failureMessage(dep, done));
251
+ if (status === 'partial')
252
+ throw new Error(partialMessage(dep));
253
+ if (status === 'succeeded')
254
+ return dep;
255
+ // No step named (or one this CLI doesn't know): HOLD — announcing `create services` off a
256
+ // payload that never said so would be a guess, and the run may well be somewhere else.
257
+ if (idx !== null) {
258
+ const current = Math.min(idx, DEPLOY_STEPS.length - 1);
259
+ if (active !== current) {
260
+ out(` … ${DEPLOY_STEPS[current]}`);
261
+ active = current;
262
+ }
263
+ }
264
+ await wait(2);
265
+ }
266
+ throw new Error(`timed out after ${Math.round(timeoutMs / 60_000)}m waiting for template deployment ${id} — check \`insta events\``);
267
+ }
268
+ // ---- commands ----
269
+ export async function templateList(opts) {
270
+ const api = await ApiClient.load();
271
+ const { templates } = await api.request('GET', '/templates');
272
+ if (opts.json)
273
+ return printJson(templates);
274
+ for (const line of templateListLines(templates ?? []))
275
+ info(line);
276
+ }
277
+ export async function templateInfo(code, opts) {
278
+ const api = await ApiClient.load();
279
+ const tpl = await api.request('GET', `/templates/${encodeURIComponent(code)}`);
280
+ if (opts.json)
281
+ return printJson(tpl);
282
+ const bold = process.stdout.isTTY ? (s) => `\x1b[1m${s}\x1b[0m` : (s) => s;
283
+ for (const line of templateInfoLines(tpl.template ?? tpl, bold))
284
+ info(line);
285
+ }
286
+ /** Real prompt (clack, as feedback.ts); cancelling exits without deploying anything. */
287
+ async function promptVariable(v) {
288
+ const answer = await clack.text({
289
+ message: `${v.name}${v.description ? ` — ${v.description}` : ''}:`,
290
+ validate: (s) => (s.trim() ? undefined : 'required'),
291
+ });
292
+ if (clack.isCancel(answer))
293
+ process.exit(0);
294
+ return answer.trim();
295
+ }
296
+ export async function templateDeploy(target, opts = {}, deps = {}) {
297
+ const given = parseSetFlags(opts.set ?? []); // a typo'd --set fails before any network access
298
+ // --json asked for parseable output: every human progress line is suppressed so stdout carries
299
+ // exactly one JSON document (the repo's --json convention).
300
+ const quiet = !!opts.json;
301
+ const api = deps.api ?? (await ApiClient.load());
302
+ const p = deps.project ?? (await requireProject());
303
+ const branchName = opts.branch ?? p.branch;
304
+ const ask = deps.ask ?? promptVariable;
305
+ // Local directory mode is opted into by a path-looking target; a bare word is always a registry
306
+ // code, so a same-named local directory can never shadow the registry template.
307
+ const mode = deployMode(target);
308
+ let manifest;
309
+ let vars;
310
+ if (mode.kind === 'local') {
311
+ manifest = loadTemplateManifest(mode.dir); // parse + local validation (pinned images, described vars)
312
+ vars = collectManifestVariables(manifest);
313
+ if (!quiet)
314
+ info(`deploying local template ${manifest.code}@${manifest.version}`);
315
+ }
316
+ else {
317
+ // Learn the variable set up front from the registry so prompting happens before the POST.
318
+ const tpl = await api.request('GET', `/templates/${encodeURIComponent(mode.code)}`);
319
+ vars = normalizeInfoVariables((tpl.template ?? tpl).variables);
320
+ }
321
+ // --json asked for parseable output, so a caller that happens to own a TTY still gets the error.
322
+ const tty = !opts.json && !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
323
+ const onAutoResolved = quiet ? undefined : (v) => info(` ${v.name}: ${v.generate ? `platform-generated (${v.generate})` : `default (${v.default})`}`);
324
+ const variables = await resolveVariables(vars, given, { tty, ask, onAutoResolved });
325
+ // The endpoint takes the branch NAME directly (branchId is its uuid alias) — no lookup needed.
326
+ const body = { ...(mode.kind === 'local' ? { manifest } : { templateCode: mode.code }), branch: branchName, variables };
327
+ let res;
328
+ try {
329
+ res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments`, body);
330
+ }
331
+ catch (e) {
332
+ // The platform's own variable check is the authority; when it names what is missing in a
333
+ // machine-readable way, prompt from that and retry once instead of parroting an opaque 4xx.
334
+ const missing = e instanceof ApiError ? missingVariablesFrom(e.body) : null;
335
+ if (!missing?.length)
336
+ throw e;
337
+ Object.assign(variables, await resolveVariables(missing, {}, { tty, ask }));
338
+ res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments`, { ...body, variables });
339
+ }
340
+ // handleApproval owns the whole 202 contract (hint on stderr, raw envelope on stdout under
341
+ // --json, exit code 2) — pass the flag through as every other gated command does.
342
+ if (handleApproval(res, opts.json))
343
+ return;
344
+ const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id;
345
+ const codeLabel = manifest?.code ?? target;
346
+ if (!quiet)
347
+ info(`deploying template ${codeLabel} to branch ${branchName} (${deploymentId})`);
348
+ const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`), deploymentId, quiet ? () => { } : info, deps.wait);
349
+ if (opts.json)
350
+ return printJson(dep);
351
+ info(`template ${codeLabel} deployed to branch ${branchName}`);
352
+ for (const u of deploymentUrls(dep))
353
+ info(` ${u}`);
354
+ info('next: run `insta secrets` to refresh .env with the new service credentials');
355
+ renderNextActions(dep.nextActions);
356
+ }
357
+ //# sourceMappingURL=template.js.map
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import * as computeCmd from './commands/compute.js';
22
22
  import * as dbCmd from './commands/db.js';
23
23
  import * as storageCmd from './commands/storage.js';
24
24
  import { manifest } from './commands/manifest.js';
25
+ import * as template from './commands/template.js';
25
26
  import * as govern from './commands/govern.js';
26
27
  import * as observe from './commands/observe.js';
27
28
  import * as obs from './commands/metrics.js';
@@ -256,6 +257,17 @@ storage.command('delete <key>').description('DELETES one object from the bucket
256
257
  .option('--service <name>', 'storage service (default: the sole one on the branch)')
257
258
  .option('--branch <b>', 'branch (default: current)').option('--json')
258
259
  .action(guard((key, o) => storageCmd.storageDelete(key, o)));
260
+ // ---- templates (registry + local insta.template.yaml deploys) ----
261
+ const tpl = program.command('template').description('Browse and deploy app templates (registry, or a local dir with insta.template.yaml)');
262
+ tpl.command('list').description('List templates in the platform registry').option('--json').action(guard((o) => template.templateList(o)));
263
+ tpl.command('info <code>').description('Show a template: version, upstream pin, services, and its required/optional variables')
264
+ .option('--json').action(guard((code, o) => template.templateInfo(code, o)));
265
+ tpl.command('deploy <code-or-dir>').description('Deploy a template onto a branch — a registry code, or a local directory containing insta.template.yaml (a path-looking target is always read as a directory). Missing required variables are prompted for on a terminal; generator-backed (secret:N) and defaulted ones are resolved by the platform')
266
+ .option('--branch <b>', 'target branch (default: current)')
267
+ .option('--set <NAME=value>', 'set a template variable (repeatable)', (v, prev) => [...prev, v], [])
268
+ .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting')
269
+ .option('--json')
270
+ .action(guard((target, o) => template.templateDeploy(target, o)));
259
271
  // ---- manifest ----
260
272
  program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o)));
261
273
  // ---- regions ----
@@ -0,0 +1,190 @@
1
+ // Local insta.template.yaml parsing + validation for `insta template deploy ./dir` — the CLI
2
+ // twin of the platform's src/provisioning/templateManifest.ts (THE authority; the executor
3
+ // revalidates every manifest). Local checks exist to fail fast with a file the author can act
4
+ // on, before anything travels, so the rules here mirror the server's exactly — plus two
5
+ // authoring lints the server does not enforce: images must be pinned, and required variables
6
+ // need a description unless a generator answers for the user. Everything here is pure over the
7
+ // parsed document (unit-tested); only loadTemplateManifest touches disk.
8
+ import { join, resolve } from 'node:path';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import YAML from 'yaml';
11
+ export const MANIFEST_FILE = 'insta.template.yaml';
12
+ // The platform's own shapes (templateManifest.ts): codes and service names become branch/service
13
+ // names; env vars are user-secret names; the one generator family is secret:N.
14
+ const CODE_RE = /^[a-z0-9][a-z0-9-]{0,38}$/;
15
+ export const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
16
+ const GENERATOR_RE = /^secret:([1-9]\d{0,2})$/;
17
+ // What counts as a digest is the PLATFORM's call, not ours: registry.ts's DIGEST regex, verbatim.
18
+ const DIGEST = /^sha256:[a-f0-9]{64}$/;
19
+ // Why an image must carry a pin: a template is a reproducible deploy, and a tagless ref (implicit
20
+ // :latest) or an explicit :latest re-resolves on every deploy — two runs of the same template
21
+ // version would ship different bytes. Digest-pinned refs (…@sha256:…) are the strongest pin.
22
+ //
23
+ // The reference grammar mirrors the platform's parseImageRef (src/provisioning/registry.ts, THE
24
+ // authority): everything after the LAST `@` is the reference, and `@` wins over `:` — so a `@`
25
+ // reference is a pin only when it is a real digest, and a malformed one is never re-read as a tag.
26
+ export function imageTagIssue(image) {
27
+ const at = image.lastIndexOf('@');
28
+ if (at !== -1) {
29
+ if (DIGEST.test(image.slice(at + 1)))
30
+ return null; // digest-pinned, the strongest pin
31
+ return `image ${image} carries an @ reference that is not a sha256 digest — use @sha256:<64 hex> (or a version tag)`;
32
+ }
33
+ const lastSegment = image.split('/').pop() ?? '';
34
+ const tag = lastSegment.includes(':') ? lastSegment.split(':').pop() : undefined;
35
+ if (!tag)
36
+ return `image ${image} has no tag — pin a version (or a @sha256 digest)`;
37
+ if (tag === 'latest')
38
+ return `image ${image} is pinned to :latest, which is not a pin — use a version tag (or a @sha256 digest)`;
39
+ return null;
40
+ }
41
+ // The platform runs every scalar manifest field through scalarString
42
+ // (src/provisioning/templateManifest.ts): a string passes, a number/boolean coerces, anything else
43
+ // is a manifest error naming the field. Same verdict here, so a YAML typo is reported rather than
44
+ // crashing a lint that assumed a string.
45
+ function scalarString(v) {
46
+ if (typeof v === 'string')
47
+ return v;
48
+ if (typeof v === 'number' || typeof v === 'boolean')
49
+ return String(v);
50
+ return null;
51
+ }
52
+ function asVarSpec(v) {
53
+ if (typeof v === 'string')
54
+ return { description: v };
55
+ return v ?? {};
56
+ }
57
+ /** All local validation problems, empty when the manifest is deployable. */
58
+ export function validateManifest(m) {
59
+ const problems = [];
60
+ if (!m || typeof m !== 'object')
61
+ return ['manifest is not a YAML mapping'];
62
+ if (!m.code || typeof m.code !== 'string')
63
+ problems.push('code is required');
64
+ else if (!CODE_RE.test(m.code))
65
+ problems.push(`code must be lower-kebab (a-z, 0-9, -, max 39 chars), got: ${m.code}`);
66
+ if (m.version === undefined || m.version === null || m.version === '')
67
+ problems.push('version is required');
68
+ const generators = new Set();
69
+ for (const [name, spec] of Object.entries(m.generated ?? {})) {
70
+ if (!GENERATOR_RE.test(String(spec)))
71
+ problems.push(`generated.${name}: unknown generator '${spec}' (the platform knows secret:N, N 1-999)`);
72
+ generators.add(name);
73
+ }
74
+ const services = m.services ?? {};
75
+ const names = Object.keys(services);
76
+ if (names.length === 0)
77
+ problems.push('services: at least one service is required');
78
+ for (const name of names) {
79
+ const svc = services[name] ?? {};
80
+ const where = `services.${name}`;
81
+ if (svc.type !== 'web' && svc.type !== 'worker')
82
+ problems.push(`${where}.type must be web or worker`);
83
+ if (svc.image && svc.build)
84
+ problems.push(`${where}: image and build are mutually exclusive`);
85
+ if (!svc.image && !svc.build)
86
+ problems.push(`${where}: one of image or build is required`);
87
+ // A parsed YAML document holds whatever the author typed, so both scalars are type-checked the
88
+ // platform's way BEFORE any rule reads them as strings.
89
+ const image = svc.image !== undefined ? scalarString(svc.image) : undefined;
90
+ if (svc.image !== undefined && image === null)
91
+ problems.push(`${where}.image must be a string`);
92
+ if (svc.build !== undefined && scalarString(svc.build) === null)
93
+ problems.push(`${where}.build must be a string`);
94
+ if (image) {
95
+ const issue = imageTagIssue(image);
96
+ if (issue)
97
+ problems.push(`${where}: ${issue}`);
98
+ }
99
+ if (svc.port !== undefined && (!Number.isInteger(svc.port) || svc.port < 1 || svc.port > 65535)) {
100
+ problems.push(`${where}: port must be an integer between 1 and 65535, got: ${svc.port}`);
101
+ }
102
+ if (svc.type === 'web' && !svc.healthcheck)
103
+ problems.push(`${where}: web services must declare a healthcheck path`);
104
+ if (svc.healthcheck && !String(svc.healthcheck).startsWith('/'))
105
+ problems.push(`${where}: healthcheck must be an absolute path (start with /)`);
106
+ if (svc.volume !== undefined && (!Number.isInteger(svc.volume?.size) || svc.volume.size < 1)) {
107
+ problems.push(`${where}: volume.size must be a whole Gi ≥ 1, got: ${svc.volume?.size}`);
108
+ }
109
+ const env = svc.env ?? {};
110
+ for (const group of ['fixed', 'generated', 'required', 'optional']) {
111
+ for (const varName of Object.keys(env[group] ?? {})) {
112
+ if (!ENV_NAME_RE.test(varName))
113
+ problems.push(`${where}.env.${group}.${varName}: env names must match ^[A-Z][A-Z0-9_]{0,63}$`);
114
+ }
115
+ }
116
+ for (const [varName, ref] of Object.entries(env.generated ?? {})) {
117
+ const match = /^\$\{([a-zA-Z0-9_-]+)\}$/.exec(String(ref));
118
+ if (!match)
119
+ problems.push(`${where}.env.generated.${varName} must reference a declared generator like \${name}`);
120
+ else if (!generators.has(match[1]))
121
+ problems.push(`${where}.env.generated.${varName} references undeclared generator '${match[1]}'`);
122
+ }
123
+ // The generator rule is the server's, so it holds wherever a var declares one — an invalid
124
+ // generate on an OPTIONAL var is just as fatal at deploy time as on a required one.
125
+ for (const group of ['required', 'optional']) {
126
+ for (const [varName, raw] of Object.entries(env[group] ?? {})) {
127
+ const spec = asVarSpec(raw);
128
+ if (spec.generate && !GENERATOR_RE.test(spec.generate))
129
+ problems.push(`${where}.env.${group}.${varName}: generate must be secret:N (1-999), got: ${spec.generate}`);
130
+ // A required var is a question put to the deployer — without a description (or a generator
131
+ // that answers it for them) there is nothing to ask with. Authoring lint, CLI-only.
132
+ if (group === 'required' && !spec.description && !spec.generate)
133
+ problems.push(`${where}.env.required.${varName}: a description is required (unless generate is set)`);
134
+ }
135
+ }
136
+ }
137
+ return problems;
138
+ }
139
+ /**
140
+ * Flatten the manifest's prompt-relevant variables: every service's env.required and env.optional
141
+ * merged into the global variable namespace the POST's `variables` map addresses — the same name
142
+ * declared by two services is ONE variable, required if required anywhere, later mentions
143
+ * backfilling fields earlier ones left unset (the platform's collectVariables merge). env.fixed /
144
+ * env.generated (and top-level `generated`) are filled in by the executor, so they are not
145
+ * deploy-time questions.
146
+ */
147
+ export function collectManifestVariables(m) {
148
+ const byName = new Map();
149
+ const add = (name, raw, required) => {
150
+ const spec = asVarSpec(raw);
151
+ const prev = byName.get(name);
152
+ byName.set(name, {
153
+ name,
154
+ description: prev?.description ?? spec.description,
155
+ required: (prev?.required ?? false) || required,
156
+ default: prev?.default ?? spec.default,
157
+ generate: prev?.generate ?? spec.generate,
158
+ });
159
+ };
160
+ for (const svc of Object.values(m.services ?? {})) {
161
+ for (const [name, spec] of Object.entries(svc.env?.required ?? {}))
162
+ add(name, spec, true);
163
+ for (const [name, spec] of Object.entries(svc.env?.optional ?? {}))
164
+ add(name, spec, false);
165
+ }
166
+ return [...byName.values()];
167
+ }
168
+ /** Parse manifest YAML text. Throws with every validation problem listed, not just the first. */
169
+ export function parseManifestYaml(text, source = MANIFEST_FILE) {
170
+ let doc;
171
+ try {
172
+ doc = YAML.parse(text);
173
+ }
174
+ catch (e) {
175
+ throw new Error(`${source}: ${e instanceof Error ? e.message : String(e)}`);
176
+ }
177
+ const manifest = (doc ?? {});
178
+ const problems = validateManifest(manifest);
179
+ if (problems.length)
180
+ throw new Error(`${source} is not deployable:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
181
+ return manifest;
182
+ }
183
+ /** Read + validate <dir>/insta.template.yaml. */
184
+ export function loadTemplateManifest(dir) {
185
+ const path = join(resolve(process.cwd(), dir), MANIFEST_FILE);
186
+ if (!existsSync(path))
187
+ throw new Error(`no ${MANIFEST_FILE} at ${path}`);
188
+ return parseManifestYaml(readFileSync(path, 'utf8'), path);
189
+ }
190
+ //# sourceMappingURL=template-manifest.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [
@@ -43,7 +43,8 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@clack/prompts": "^0.9.1",
46
- "commander": "^12.1.0"
46
+ "commander": "^12.1.0",
47
+ "yaml": "^2.9.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^20.14.0",