insta 0.0.39 → 0.0.41

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) {
@@ -285,39 +285,35 @@ const defaultMinter = async () => {
285
285
  return null;
286
286
  }
287
287
  };
288
- // Register the insta-cloud remote MCP server with Claude Code (user scope, so it follows the
289
- // machine like the skill install above). Default is OAuth: register with NO credential — the
290
- // platform's Better Auth MCP authorization server is discovered via RFC 9728 and Claude runs
291
- // the browser flow on first `/mcp` use, so no static token ever lands on disk. `--mcp-token`
292
- // is the headless fallback (CI, no browser): mint a durable token into the header instead.
293
- // Idempotent — an existing registration is left alone. Best-effort: the skill install is the
294
- // primary outcome; agents without an MCP registry are covered by the skill alone.
295
- export async function registerMcp(run = defaultRunner, mint = defaultMinter, useToken = false) {
288
+ export async function registerMcp(run = defaultRunner, mint = defaultMinter, useToken = false, announce = true) {
296
289
  const { name, url } = await resolveMcpTarget();
297
290
  if (!(await run('claude', ['--version'])).ok)
298
- return; // no Claude Code on this machine
291
+ return 'no-claude'; // no Claude Code on this machine
299
292
  if ((await run('claude', ['mcp', 'get', name])).ok) {
300
- info(`✓ MCP — ${name} already registered with Claude Code`);
301
- return;
293
+ if (announce)
294
+ info(`✓ MCP — ${name} already registered with Claude Code`);
295
+ return 'existing';
302
296
  }
303
297
  const args = ['mcp', 'add', '--transport', 'http', '--scope', 'user', name, url];
304
298
  if (useToken) {
305
299
  const token = await mint();
306
300
  if (!token) {
307
301
  info(' MCP not registered (--mcp-token needs a login) — run `insta login`, then `insta setup agent --mcp-token` again');
308
- return;
302
+ return 'no-token';
309
303
  }
310
304
  args.push('--header', `Authorization: Bearer ${token}`);
311
305
  }
312
306
  const res = await run('claude', args);
313
- if (res.ok) {
307
+ if (!res.ok) {
308
+ info(` MCP registration failed — add manually:\n claude mcp add --transport http ${name} ${url}`);
309
+ return 'failed';
310
+ }
311
+ if (announce) {
314
312
  info(`✓ MCP — ${name} registered with Claude Code (\`claude mcp list\` to verify)`);
315
313
  if (!useToken)
316
314
  info(' first use: run `/mcp` in Claude Code and authorize in the browser (headless machines: `insta setup agent --mcp-token`)');
317
315
  }
318
- else {
319
- info(` MCP registration failed — add manually:\n claude mcp add --transport http ${name} ${url}`);
320
- }
316
+ return 'new';
321
317
  }
322
318
  /** The environment `setup agent` should target, and whether the machine must be switched to it
323
319
  * first. Pure — decides only; the caller performs the switch.
@@ -414,31 +410,23 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
414
410
  return;
415
411
  }
416
412
  info(summarizeInstall(res.output ?? ''));
417
- info(' every coding agent on this machine now knows InstaCloud (review skills before use — they run with full permissions).');
418
- await registerMcp(run, mint, !!opts.mcpToken);
413
+ // Register everything first, summarize ONCE below: Claude Code via `claude mcp add`, every other
414
+ // agent via a config-file entry — different mechanisms, one outcome, one line.
415
+ let claude = await registerMcp(run, mint, !!opts.mcpToken, false);
419
416
  const others = await installConfigs();
420
- if (others.length)
421
- info(`✓ MCP — also configured for ${others.join(', ')} (restart those tools to pick it up)`);
422
- // The checkmarks end the install, but the user's next move shouldn't be guesswork. Local state
423
- // only (no network): a config with a session means logged in — good enough for a hint.
424
- // Deliberately prod-hosted prompt.md even on a staging setup: it is generic onboarding (the
425
- // login/create commands in it are env-aware at runtime), and staging serves no equivalent.
426
- const stored = await readStored();
427
- const loggedIn = !!(stored.accessToken || stored.user);
428
- const nextCreate = 'next: `insta project create <name>` in your app repo — or tell your agent: "Fetch https://instacloud.com/prompt.md and follow it"';
429
- if (loggedIn)
430
- return info(nextCreate);
431
- // Default into login on an interactive terminal — see shouldOfferLogin. Best-effort: a declined
417
+ // Default into login on an interactive terminal (see shouldOfferLogin) BEFORE the MCP summary:
418
+ // a --mcp-token registration needs the session to mint, so a post-login retry must land in the
419
+ // same combined line instead of announcing Claude Code separately. Best-effort: a declined
432
420
  // prompt or a failed browser flow leaves a completed setup plus the manual hint, never an error.
421
+ const stored = await readStored();
422
+ let loggedIn = !!(stored.accessToken || stored.user);
433
423
  if (shouldOfferLogin(!!opts.yes, loggedIn, loginFlow.stdinTty, loginFlow.stdoutTty)) {
434
424
  if (await loginFlow.ask('log in now with GitHub? (Y/n) ')) {
435
425
  try {
436
426
  await loginFlow.login();
437
- // --mcp-token needs a session to mint: the registration above ran logged-out and skipped
438
- // itself with the login hint — now that the session exists, do the token registration.
427
+ loggedIn = true;
439
428
  if (opts.mcpToken)
440
- await registerMcp(run, mint, true);
441
- return info(nextCreate);
429
+ claude = await registerMcp(run, mint, true, false);
442
430
  }
443
431
  catch (e) {
444
432
  info(` login did not complete (${e instanceof Error ? e.message : String(e)}) — no problem, setup itself is done.`);
@@ -446,6 +434,18 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
446
434
  }
447
435
  }
448
436
  }
449
- info('next: `insta login --oauth github` (headless: `insta login --device`), then `insta project create <name>` — or tell your agent: "Fetch https://instacloud.com/prompt.md and follow it"');
437
+ // The one MCP line. The restart note exists because config-file agents only read their config
438
+ // at startup; Claude Code picks it up live.
439
+ const mcpTargets = [...(claude === 'new' || claude === 'existing' ? ['Claude Code'] : []), ...others];
440
+ if (mcpTargets.length) {
441
+ info(`✓ MCP — ${mcpTargets.join(', ')}${others.length ? ' (already-running tools load it on restart)' : ''}`);
442
+ }
443
+ if (claude === 'new' && !opts.mcpToken) {
444
+ info(' Claude Code first use: run `/mcp` and authorize in the browser (headless machines: `insta setup agent --mcp-token`)');
445
+ }
446
+ // The checkmarks end the install, but the user's next move shouldn't be guesswork.
447
+ info(loggedIn
448
+ ? 'next: run `insta project create` inside your app directory (existing project: `insta project link <id>`)'
449
+ : 'next: `insta login --oauth github` (headless: `insta login --device`), then `insta project create` inside your app directory');
450
450
  }
451
451
  //# sourceMappingURL=setup.js.map
@@ -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.41",
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",