@sequenceholdings/studio-cli 0.1.9

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.
Files changed (74) hide show
  1. package/README.md +258 -0
  2. package/dist/artifact/delegate.d.ts +25 -0
  3. package/dist/artifact/delegate.js +263 -0
  4. package/dist/atlas-client.d.ts +44 -0
  5. package/dist/atlas-client.js +173 -0
  6. package/dist/auth-cmds/commands.d.ts +15 -0
  7. package/dist/auth-cmds/commands.js +249 -0
  8. package/dist/auth.d.ts +26 -0
  9. package/dist/auth.js +171 -0
  10. package/dist/bin.d.ts +2 -0
  11. package/dist/bin.js +8 -0
  12. package/dist/cli-errors.d.ts +5 -0
  13. package/dist/cli-errors.js +78 -0
  14. package/dist/config.d.ts +44 -0
  15. package/dist/config.js +103 -0
  16. package/dist/env-flags.d.ts +8 -0
  17. package/dist/env-flags.js +47 -0
  18. package/dist/functions/bundle.d.ts +30 -0
  19. package/dist/functions/bundle.js +137 -0
  20. package/dist/functions/commands.d.ts +86 -0
  21. package/dist/functions/commands.js +999 -0
  22. package/dist/functions/egress-preview.d.ts +32 -0
  23. package/dist/functions/egress-preview.js +54 -0
  24. package/dist/functions/lockfile-origin.d.ts +16 -0
  25. package/dist/functions/lockfile-origin.js +45 -0
  26. package/dist/functions/manifest.d.ts +89 -0
  27. package/dist/functions/manifest.js +586 -0
  28. package/dist/functions/secret-reconcile.d.ts +79 -0
  29. package/dist/functions/secret-reconcile.js +86 -0
  30. package/dist/main.d.ts +14 -0
  31. package/dist/main.js +129 -0
  32. package/dist/orm/delegate.d.ts +8 -0
  33. package/dist/orm/delegate.js +61 -0
  34. package/dist/pat-hints.d.ts +17 -0
  35. package/dist/pat-hints.js +28 -0
  36. package/dist/preview.d.ts +89 -0
  37. package/dist/preview.js +291 -0
  38. package/dist/process/agent-loader.d.ts +24 -0
  39. package/dist/process/agent-loader.js +57 -0
  40. package/dist/process/build.d.ts +14 -0
  41. package/dist/process/build.js +368 -0
  42. package/dist/process/codegen.d.ts +18 -0
  43. package/dist/process/codegen.js +270 -0
  44. package/dist/process/commands.d.ts +47 -0
  45. package/dist/process/commands.js +786 -0
  46. package/dist/process/discover.d.ts +32 -0
  47. package/dist/process/discover.js +131 -0
  48. package/dist/process/lint.d.ts +39 -0
  49. package/dist/process/lint.js +485 -0
  50. package/dist/process/local-bundle.d.ts +17 -0
  51. package/dist/process/local-bundle.js +65 -0
  52. package/dist/process/plan-diff.d.ts +82 -0
  53. package/dist/process/plan-diff.js +333 -0
  54. package/dist/process/resolve-process-pin.d.ts +11 -0
  55. package/dist/process/resolve-process-pin.js +63 -0
  56. package/dist/process/simulate.d.ts +50 -0
  57. package/dist/process/simulate.js +328 -0
  58. package/dist/prompt.d.ts +35 -0
  59. package/dist/prompt.js +65 -0
  60. package/dist/repos/commands.d.ts +49 -0
  61. package/dist/repos/commands.js +548 -0
  62. package/dist/repos/git-clone.d.ts +10 -0
  63. package/dist/repos/git-clone.js +49 -0
  64. package/dist/secrets/commands.d.ts +24 -0
  65. package/dist/secrets/commands.js +704 -0
  66. package/dist/templates/process/example-process/process.ts +43 -0
  67. package/dist/templates/process/package.json +23 -0
  68. package/dist/templates/process/pnpm-workspace.yaml +21 -0
  69. package/dist/templates/process/tsconfig.json +17 -0
  70. package/package.json +78 -0
  71. package/templates/process/example-process/process.ts +43 -0
  72. package/templates/process/package.json +23 -0
  73. package/templates/process/pnpm-workspace.yaml +21 -0
  74. package/templates/process/tsconfig.json +17 -0
@@ -0,0 +1,999 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { basename, isAbsolute, join, resolve } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { createInterface } from 'node:readline';
7
+ import { Writable } from 'node:stream';
8
+ import { load as parseYaml } from 'js-yaml';
9
+ import { getAccessToken } from '../auth.js';
10
+ import { readConfig, resolveEnv } from '../config.js';
11
+ import { AtlasApiError, deleteJson, getJson, postJson } from '../atlas-client.js';
12
+ import { clarifyApplyFailureReason, printCliError } from '../cli-errors.js';
13
+ import { confirmYes } from '../prompt.js';
14
+ import { collectBundleFiles, isDirectory, validateLocalBundle, } from './bundle.js';
15
+ import { managedFunctionManifestSchema, MF_MANIFEST_FILENAME, manifestEgressHosts, manifestEgressIpRanges, } from './manifest.js';
16
+ import { buildEgressPreviewLines, egressPropagationNote, formatEgressSummary, printFunctionEgressHosts, } from './egress-preview.js';
17
+ import { parseSourceSpec, resolveArtifactSource, } from '@sequenceholdings/artifact-studio/source-resolver';
18
+ import { buildSecretPreviewLines, classifySecrets, } from './secret-reconcile.js';
19
+ const execFileAsync = promisify(execFile);
20
+ export const LOG = '[seq-studio]';
21
+ const POLL_INTERVAL_MS = 5_000;
22
+ const POLL_TIMEOUT_MS = 20 * 60 * 1000;
23
+ export function flagBool(flags, ...keys) {
24
+ return keys.some((key) => flags[key] === true || flags[key] === 'true');
25
+ }
26
+ export async function buildContext(args) {
27
+ const config = await readConfig();
28
+ const requested = (typeof args.flags.env === 'string' ? args.flags.env : undefined) ??
29
+ (typeof args.flags.e === 'string' ? args.flags.e : undefined);
30
+ const env = resolveEnv({ config, requested });
31
+ const token = await getAccessToken();
32
+ return { env, token };
33
+ }
34
+ export function clientOptions(ctx) {
35
+ return { baseUrl: ctx.env.url, token: ctx.token };
36
+ }
37
+ export async function readManifestOptional(dir) {
38
+ const path = join(dir, MF_MANIFEST_FILENAME);
39
+ if (!existsSync(path))
40
+ return null;
41
+ const raw = parseYaml(await readFile(path, 'utf8'));
42
+ return managedFunctionManifestSchema.parse(raw);
43
+ }
44
+ async function readManifest(dir) {
45
+ const manifest = await readManifestOptional(dir);
46
+ if (!manifest) {
47
+ throw new Error(`No ${MF_MANIFEST_FILENAME} in ${dir}. Run \`seq-studio functions init\` to scaffold one, or pass --dir.`);
48
+ }
49
+ return manifest;
50
+ }
51
+ export function workDir(args) {
52
+ const dir = typeof args.flags.dir === 'string' ? args.flags.dir : '.';
53
+ return resolve(dir);
54
+ }
55
+ /**
56
+ * Source selection for build/deploy: --dir (local, default '.'), a platform
57
+ * git-service repo (--repo <ns>/<name>), or any git URL (--git-url <url>);
58
+ * --ref picks a branch/tag/commit. Reuses the artifact-studio resolver —
59
+ * functions name their local dir with --dir rather than a positional, so map
60
+ * it onto the spec parser's positional slot.
61
+ */
62
+ export function parseFunctionsSourceSpec(args) {
63
+ const dir = typeof args.flags.dir === 'string' ? args.flags.dir : undefined;
64
+ if (dir !== undefined && (args.flags.repo !== undefined || args.flags['git-url'] !== undefined)) {
65
+ throw new Error('--dir cannot be combined with --repo / --git-url.');
66
+ }
67
+ return parseSourceSpec({ positional: dir === undefined ? [] : [dir], flags: args.flags });
68
+ }
69
+ /** Manifest read with a source-aware error for the missing case. */
70
+ async function readSourceManifest(dir, spec) {
71
+ const manifest = await readManifestOptional(dir);
72
+ if (manifest)
73
+ return manifest;
74
+ throw new Error(spec.kind === 'local'
75
+ ? `No ${MF_MANIFEST_FILENAME} in ${dir}. Run \`seq-studio functions init\` to scaffold one, or pass --dir.`
76
+ : `No ${MF_MANIFEST_FILENAME} at the root of the source repo — a managed-function repo keeps its manifest at the top level.`);
77
+ }
78
+ /**
79
+ * Resolve the target function: --fn <slug> wins, else the manifest in the
80
+ * working directory names it. Returns null when it doesn't exist remotely.
81
+ */
82
+ async function resolveFunction({ ctx, args, required = true, }) {
83
+ let slug = typeof args.flags.fn === 'string' ? args.flags.fn : undefined;
84
+ if (!slug) {
85
+ const manifest = await readManifest(workDir(args));
86
+ slug = manifest.function.id;
87
+ }
88
+ const { functions } = await getJson({
89
+ ...clientOptions(ctx),
90
+ path: `/api/managed-functions?slug=${encodeURIComponent(slug)}`,
91
+ });
92
+ const fn = functions.find((f) => f.slug === slug) ?? null;
93
+ if (!fn && required) {
94
+ throw new Error(`Managed function "${slug}" not found on ${ctx.env.name} (or you don't have access). Run \`seq-studio functions list -e ${ctx.env.name}\` to see what exists.`);
95
+ }
96
+ return fn;
97
+ }
98
+ export async function resolveOrRegisterFunction({ ctx, slug, title, description, }) {
99
+ const { functions } = await getJson({
100
+ ...clientOptions(ctx),
101
+ path: `/api/managed-functions?slug=${encodeURIComponent(slug)}`,
102
+ });
103
+ const existing = functions.find((f) => f.slug === slug);
104
+ if (existing)
105
+ return { fn: existing, created: false };
106
+ const created = await postJson({
107
+ ...clientOptions(ctx),
108
+ path: '/api/managed-functions',
109
+ body: {
110
+ slug,
111
+ title,
112
+ description: description ?? null,
113
+ },
114
+ });
115
+ return { fn: created.function, created: true };
116
+ }
117
+ export function predictNextVersion(latest) {
118
+ const parsed = latest ? Number.parseInt(latest.replace(/^v/, ''), 10) : 0;
119
+ return `v${(Number.isFinite(parsed) ? parsed : 0) + 1}`;
120
+ }
121
+ async function gitMetadata(dir) {
122
+ try {
123
+ const [commit, branch, status] = await Promise.all([
124
+ execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: dir }),
125
+ execFileAsync('git', ['branch', '--show-current'], { cwd: dir }),
126
+ execFileAsync('git', ['status', '--porcelain'], { cwd: dir }),
127
+ ]);
128
+ return {
129
+ gitCommit: commit.stdout.trim() || null,
130
+ gitBranch: branch.stdout.trim() || null,
131
+ gitDirty: status.stdout.trim().length > 0,
132
+ };
133
+ }
134
+ catch {
135
+ return { gitCommit: null, gitBranch: null, gitDirty: null };
136
+ }
137
+ }
138
+ /** Prompt on the TTY with echo suppressed — secret values are never displayed. */
139
+ export async function promptHidden(question) {
140
+ if (!process.stdin.isTTY) {
141
+ // Piped stdin: read it all (`echo "$VALUE" | seq-studio secrets set KEY`).
142
+ const chunks = [];
143
+ for await (const chunk of process.stdin)
144
+ chunks.push(chunk);
145
+ return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
146
+ }
147
+ const muted = new Writable({
148
+ write(_chunk, _encoding, callback) {
149
+ callback();
150
+ },
151
+ });
152
+ const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
153
+ process.stderr.write(question);
154
+ const answer = await new Promise((resolvePrompt) => {
155
+ rl.question('', (value) => resolvePrompt(value));
156
+ });
157
+ rl.close();
158
+ process.stderr.write('\n');
159
+ return answer;
160
+ }
161
+ export function printError(error) {
162
+ printCliError(error);
163
+ }
164
+ // ---------------------------------------------------------------------------
165
+ // init
166
+ // ---------------------------------------------------------------------------
167
+ const TEMPLATE_MANIFEST = (id) => `schema_version: 1
168
+
169
+ function:
170
+ id: ${id}
171
+ title: ${id}
172
+ description: Describe what this function does.
173
+
174
+ runtime: nodejs20
175
+ entrypoint: handler
176
+
177
+ limits:
178
+ memory_mb: 256
179
+ timeout_seconds: 60
180
+ max_instances: 3
181
+ invoke_rate_per_minute: 60
182
+
183
+ # Env-var names the function expects (UPPER_SNAKE_CASE). Values are read
184
+ # from a local .env file at deploy time — they are never committed or bundled.
185
+ secrets: []
186
+
187
+ # JSON Schema for the invoke payload — Chat agents read this to know the shape.
188
+ input_schema:
189
+ type: object
190
+ properties:
191
+ name:
192
+ type: string
193
+ required: [name]
194
+
195
+ output_schema:
196
+ type: object
197
+ properties:
198
+ message:
199
+ type: string
200
+ `;
201
+ const TEMPLATE_HANDLER = `import { http, type Request, type Response } from '@google-cloud/functions-framework'
202
+
203
+ // The entrypoint named in managed-function.yml. Atlas proxies authorized
204
+ // POSTs here with a JSON body matching input_schema. Shared secrets are
205
+ // env-mounted at deploy time — read them via process.env.<NAME>.
206
+ http('handler', (req: Request, res: Response) => {
207
+ const { name } = (req.body ?? {}) as { name?: string }
208
+ res.json({ message: \`Hello, \${name ?? 'world'}!\` })
209
+ })
210
+ `;
211
+ // TypeScript is compiled server-side by the deploy worker (\`tsc\` runs with
212
+ // devDependencies installed, then the tree is pruned to \`--prod\`), so the
213
+ // bundle ships \`index.ts\` and the worker emits the \`dist/index.js\` that
214
+ // \`main\` points at. \`outDir\`/\`rootDir\` keep that output predictable.
215
+ const TEMPLATE_TSCONFIG = JSON.stringify({
216
+ compilerOptions: {
217
+ target: 'es2022',
218
+ module: 'commonjs',
219
+ moduleResolution: 'node',
220
+ outDir: 'dist',
221
+ rootDir: '.',
222
+ strict: true,
223
+ esModuleInterop: true,
224
+ skipLibCheck: true,
225
+ resolveJsonModule: true,
226
+ },
227
+ include: ['index.ts'],
228
+ exclude: ['node_modules', 'dist'],
229
+ }, null, 2) + '\n';
230
+ const TEMPLATE_PACKAGE_JSON = (id) => JSON.stringify({
231
+ name: id,
232
+ version: '0.0.1',
233
+ private: true,
234
+ // The deploy worker compiles index.ts -> dist/index.js; `main` points at
235
+ // the compiled output Cloud Functions loads.
236
+ main: 'dist/index.js',
237
+ scripts: {
238
+ build: 'tsc',
239
+ },
240
+ dependencies: {
241
+ '@google-cloud/functions-framework': '^3.4.0',
242
+ },
243
+ devDependencies: {
244
+ '@types/node': '^20.11.0',
245
+ typescript: '^5.6.0',
246
+ },
247
+ }, null, 2) + '\n';
248
+ const TEMPLATE_GITIGNORE = `node_modules/
249
+ dist/
250
+ .env
251
+ .env.*
252
+ *.log
253
+ `;
254
+ // Local-only: makes `pnpm install` resolve against Chainguard so the lockfile
255
+ // matches what the deploy worker installs from (the fast, version-pinned
256
+ // path). Auth comes from your ~/.npmrc (scripts/chainguard-bootstrap). This
257
+ // file is NEVER bundled — collectBundleFiles excludes it and the server
258
+ // rejects it; the deploy worker pins the registry itself. Authors without
259
+ // Chainguard credentials can delete it and install from public npm — see the
260
+ // comment written into the scaffolded file.
261
+ const TEMPLATE_NPMRC = `# Pins installs to Chainguard, Sequence's internal package registry, so your
262
+ # lockfile matches what the deploy worker installs (fast, version-pinned
263
+ # deploys). Requires Chainguard credentials in ~/.npmrc (Sequence engineers:
264
+ # scripts/chainguard-bootstrap).
265
+ #
266
+ # No Chainguard credentials? \`pnpm install\` will fail with a 401 — DELETE THIS
267
+ # FILE and install from public npm instead. That is safe: the deploy worker
268
+ # always re-resolves dependencies against Chainguard server-side, and this
269
+ # file is never included in the deployed bundle either way.
270
+ registry=https://libraries.cgr.dev/javascript/
271
+ //libraries.cgr.dev/javascript/:always-auth=true
272
+ //libraries.cgr.dev/javascript-upstream/:always-auth=true
273
+ `;
274
+ export async function functionsInitCommand(args) {
275
+ const target = args.positional[0];
276
+ if (!target) {
277
+ console.error('usage: seq-studio functions init <dir>');
278
+ return 1;
279
+ }
280
+ const dir = resolve(target);
281
+ // Derive the slug from the resolved directory name so `init .` and
282
+ // trailing-slash paths work (the raw argument would yield an empty id).
283
+ const id = basename(dir)
284
+ .toLowerCase()
285
+ .replace(/[^a-z0-9-]+/g, '-')
286
+ .replace(/^-+|-+$/g, '');
287
+ if (!id) {
288
+ console.error(`${LOG} could not derive a function id from "${target}" — the directory name needs at least one alphanumeric character.`);
289
+ return 1;
290
+ }
291
+ if (existsSync(join(dir, MF_MANIFEST_FILENAME))) {
292
+ console.error(`${LOG} ${dir} already contains ${MF_MANIFEST_FILENAME}`);
293
+ return 1;
294
+ }
295
+ await mkdir(dir, { recursive: true });
296
+ await writeFile(join(dir, MF_MANIFEST_FILENAME), TEMPLATE_MANIFEST(id));
297
+ await writeFile(join(dir, 'index.ts'), TEMPLATE_HANDLER);
298
+ await writeFile(join(dir, 'tsconfig.json'), TEMPLATE_TSCONFIG);
299
+ await writeFile(join(dir, 'package.json'), TEMPLATE_PACKAGE_JSON(id));
300
+ await writeFile(join(dir, '.gitignore'), TEMPLATE_GITIGNORE);
301
+ await writeFile(join(dir, '.npmrc'), TEMPLATE_NPMRC);
302
+ console.log(`${LOG} scaffolded managed function "${id}" (TypeScript) in ${dir}`);
303
+ console.log('');
304
+ console.log('Next steps (init is only needed when creating a function from scratch):');
305
+ console.log(` cd ${target}`);
306
+ console.log(' pnpm install # installs deps for local dev + pins versions via Chainguard.');
307
+ console.log(' # Requires Chainguard credentials (Sequence-internal). Without');
308
+ console.log(' # them this 401s — delete the scaffolded .npmrc and install from');
309
+ console.log(' # public npm instead (the deploy worker re-resolves server-side).');
310
+ console.log(' seq-studio functions build # local pre-flight checks');
311
+ console.log(' # Add secret names to managed-function.yml (secrets: [MY_SECRET]) and values to .env');
312
+ console.log(' seq-studio functions deploy -e staging # upload, apply secrets, then deploy');
313
+ return 0;
314
+ }
315
+ // ---------------------------------------------------------------------------
316
+ // build (local pre-flight)
317
+ // ---------------------------------------------------------------------------
318
+ export async function functionsBuildCommand(args) {
319
+ const spec = parseFunctionsSourceSpec(args);
320
+ // Local and git-url sources build offline; only a git-service repo needs
321
+ // the target environment + token to fetch its tree.
322
+ const remote = spec.kind === 'git-service' ? clientOptions(await buildContext(args)) : {};
323
+ const source = await resolveArtifactSource(spec, remote);
324
+ try {
325
+ return await buildFromResolvedSource({ spec, source });
326
+ }
327
+ finally {
328
+ await source.cleanup();
329
+ }
330
+ }
331
+ async function buildFromResolvedSource({ spec, source, }) {
332
+ const dir = source.dir;
333
+ if (!(await isDirectory(dir))) {
334
+ console.error(`${LOG} not a directory: ${dir}`);
335
+ return 1;
336
+ }
337
+ const manifest = await readSourceManifest(dir, spec);
338
+ const { files, excludedEnvFiles } = await collectBundleFiles(dir);
339
+ const issues = await validateLocalBundle({ rootDir: dir, files });
340
+ console.log(`${LOG} function: ${manifest.function.id} (${manifest.function.title})`);
341
+ console.log(`${LOG} runtime: ${manifest.runtime}, entrypoint: ${manifest.entrypoint}`);
342
+ console.log(`${LOG} files: ${files.length}`);
343
+ console.log(`${LOG} secrets: ${manifest.secrets.length > 0 ? manifest.secrets.join(', ') : '(none declared)'}`);
344
+ console.log(`${LOG} egress: ${formatEgressSummary([
345
+ ...manifestEgressHosts(manifest.egress),
346
+ ...manifestEgressIpRanges(manifest.egress),
347
+ ])}`);
348
+ for (const excluded of excludedEnvFiles) {
349
+ console.warn(`${LOG} excluded ${excluded} — dotenv files never enter bundles; use .env as a value source for \`seq-studio functions deploy\``);
350
+ }
351
+ for (const issue of issues) {
352
+ const log = issue.level === 'error' ? console.error : issue.level === 'warning' ? console.warn : console.log;
353
+ log(`${LOG} ${issue.level}: ${issue.message}`);
354
+ }
355
+ const errors = issues.filter((i) => i.level === 'error');
356
+ if (errors.length > 0) {
357
+ console.error(`${LOG} build check failed (${errors.length} error${errors.length === 1 ? '' : 's'})`);
358
+ return 1;
359
+ }
360
+ console.log(`${LOG} bundle OK — next: \`functions deploy\` (reads .env and reconciles secrets automatically)`);
361
+ return 0;
362
+ }
363
+ // ---------------------------------------------------------------------------
364
+ // deploy
365
+ // ---------------------------------------------------------------------------
366
+ async function getFunctionDetail(ctx, functionId) {
367
+ try {
368
+ return await getJson({
369
+ ...clientOptions(ctx),
370
+ path: `/api/managed-functions/${functionId}`,
371
+ });
372
+ }
373
+ catch (error) {
374
+ if (error instanceof AtlasApiError && error.status === 404)
375
+ return null;
376
+ throw error;
377
+ }
378
+ }
379
+ export async function functionsDeployCommand(args) {
380
+ const spec = parseFunctionsSourceSpec(args);
381
+ // Only a --repo source needs env + auth up front — materializing it fetches
382
+ // the tree from the target environment's git service. Local / git-url
383
+ // sources defer auth until after local validation, so bad input (missing
384
+ // manifest, bad --from-env-file) fails fast instead of failing on auth.
385
+ const ctx = spec.kind === 'git-service' ? await buildContext(args) : null;
386
+ const source = await resolveArtifactSource(spec, ctx ? clientOptions(ctx) : {});
387
+ try {
388
+ return await deployFromResolvedSource({ args, spec, ctx, source });
389
+ }
390
+ finally {
391
+ await source.cleanup();
392
+ }
393
+ }
394
+ async function deployFromResolvedSource({ args, spec, ctx: earlyCtx, source, }) {
395
+ const dir = source.dir;
396
+ const manifest = await readSourceManifest(dir, spec);
397
+ const { files, excludedEnvFiles } = await collectBundleFiles(dir);
398
+ for (const excluded of excludedEnvFiles) {
399
+ console.warn(`${LOG} excluded ${excluded} — dotenv files never enter bundles`);
400
+ }
401
+ const issues = await validateLocalBundle({ rootDir: dir, files });
402
+ const errors = issues.filter((i) => i.level === 'error');
403
+ for (const issue of issues) {
404
+ const log = issue.level === 'error' ? console.error : issue.level === 'warning' ? console.warn : console.log;
405
+ log(`${LOG} ${issue.level}: ${issue.message}`);
406
+ }
407
+ if (errors.length > 0)
408
+ return 1;
409
+ // Read the local .env for secret value sourcing.
410
+ // An explicitly supplied --from-env-file must exist; the default .env is
411
+ // optional (CI path: values must already exist server-side). For a remote
412
+ // source (--repo / --git-url) there is no implicit .env — repo contents are
413
+ // never used as secret-value sources — and a relative --from-env-file
414
+ // resolves against the invoking cwd, not the materialized temp tree.
415
+ const envFileFlag = typeof args.flags['from-env-file'] === 'string' ? args.flags['from-env-file'] : null;
416
+ const envFilePath = envFileFlag
417
+ ? isAbsolute(envFileFlag)
418
+ ? envFileFlag
419
+ : resolve(spec.kind === 'local' ? dir : process.cwd(), envFileFlag)
420
+ : spec.kind === 'local'
421
+ ? join(dir, '.env')
422
+ : null;
423
+ let envValues = {};
424
+ if (envFileFlag) {
425
+ if (!envFilePath || !existsSync(envFilePath)) {
426
+ console.error(`${LOG} --from-env-file: file not found: ${envFilePath}`);
427
+ return 1;
428
+ }
429
+ envValues = parseDotenv(await readFile(envFilePath, 'utf8'));
430
+ }
431
+ else if (envFilePath && existsSync(envFilePath)) {
432
+ envValues = parseDotenv(await readFile(envFilePath, 'utf8'));
433
+ }
434
+ // A --repo source authenticated before materialization; local / git-url
435
+ // sources authenticate only now, after local validation passed.
436
+ const ctx = earlyCtx ?? (await buildContext(args));
437
+ const slug = manifest.function.id;
438
+ const { functions } = await getJson({
439
+ ...clientOptions(ctx),
440
+ path: `/api/managed-functions?slug=${encodeURIComponent(slug)}`,
441
+ });
442
+ const existingShell = functions.find((f) => f.slug === slug) ?? null;
443
+ const shellAction = existingShell
444
+ ? `existing shell on ${ctx.env.name}`
445
+ : `will register new shell on ${ctx.env.name}`;
446
+ let versions = [];
447
+ if (existingShell) {
448
+ try {
449
+ versions = await listVersions(ctx, existingShell.id);
450
+ }
451
+ catch (error) {
452
+ if (error instanceof AtlasApiError && error.status === 404) {
453
+ versions = [];
454
+ }
455
+ else {
456
+ throw error;
457
+ }
458
+ }
459
+ }
460
+ const latestVersion = versions[0]?.version;
461
+ const nextVersion = predictNextVersion(latestVersion);
462
+ const deployMessage = (typeof args.flags.message === 'string' ? args.flags.message : undefined) ??
463
+ (typeof args.flags.m === 'string' ? args.flags.m : undefined) ??
464
+ null;
465
+ // Local deploys record best-effort working-tree provenance; remote sources
466
+ // carry the resolver's pinned commit (gitCommit = resolved SHA, never dirty).
467
+ const git = spec.kind === 'local' ? await gitMetadata(dir) : source.provenance;
468
+ // Classify each declared secret so the deploy preview and reconcile step
469
+ // know exactly what action to take for each one.
470
+ let classification = null;
471
+ if (manifest.secrets.length > 0) {
472
+ const { secrets: serverSecrets } = await getJson({
473
+ ...clientOptions(ctx),
474
+ path: '/api/managed-secrets',
475
+ });
476
+ const serverInfoByName = new Map(serverSecrets.map((s) => [
477
+ s.name,
478
+ { id: s.id, hasDefaultValue: s.hasDefaultValue, attachmentCount: s.attachmentCount },
479
+ ]));
480
+ // Build envVar → secretId map so the classifier can detect when an env
481
+ // var is already attached to a *different* secret and not skip reconcile.
482
+ const attachedByEnvVar = new Map();
483
+ if (existingShell) {
484
+ const detail = await getFunctionDetail(ctx, existingShell.id);
485
+ for (const a of detail?.attachments ?? []) {
486
+ if (a.secretId)
487
+ attachedByEnvVar.set(a.envVarName, a.secretId);
488
+ }
489
+ }
490
+ classification = classifySecrets({
491
+ manifestSecrets: manifest.secrets,
492
+ envValues,
493
+ serverInfoByName,
494
+ attachedByEnvVar,
495
+ });
496
+ // Hard-stop before any writes if any declared secret cannot be satisfied.
497
+ if (classification.blocked.length > 0) {
498
+ for (const c of classification.blocked) {
499
+ console.error(`${LOG} secret "${c.name}" is declared but has no value — ` +
500
+ `add it to .env, run \`seq-studio secrets set ${c.name} -e ${ctx.env.name}\`, or set it in the Sequence app`);
501
+ }
502
+ console.error(`${LOG} resolve the above before deploying`);
503
+ return 1;
504
+ }
505
+ }
506
+ // Surface .env keys that aren't in the manifest so users know they're ignored.
507
+ const undeclaredEnvKeys = Object.keys(envValues).filter((k) => envValues[k]?.length && !manifest.secrets.includes(k));
508
+ const preview = [
509
+ `${LOG} deploy preview:`,
510
+ `${LOG} environment: ${ctx.env.name}`,
511
+ `${LOG} function: ${slug} (${manifest.function.title})`,
512
+ `${LOG} shell: ${shellAction}`,
513
+ `${LOG} version: ${nextVersion}`,
514
+ `${LOG} bundle files: ${files.length}`,
515
+ ];
516
+ if (deployMessage)
517
+ preview.push(`${LOG} message: ${deployMessage}`);
518
+ if (git.gitCommit) {
519
+ preview.push(`${LOG} git: ${git.gitBranch ?? '?'}@${git.gitCommit.slice(0, 8)}${git.gitDirty ? ' (dirty)' : ''}`);
520
+ }
521
+ if (classification) {
522
+ preview.push(...buildSecretPreviewLines(LOG, classification.secrets));
523
+ if (classification.secrets.some((c) => c.category === 'OVERWRITE')) {
524
+ preview.push(`${LOG} note: overwriting a shared default changes it for all attached functions`);
525
+ }
526
+ }
527
+ else {
528
+ preview.push(`${LOG} secrets: (none declared)`);
529
+ }
530
+ preview.push(...buildEgressPreviewLines(LOG, [
531
+ ...manifestEgressHosts(manifest.egress),
532
+ ...manifestEgressIpRanges(manifest.egress),
533
+ ]));
534
+ if (undeclaredEnvKeys.length > 0) {
535
+ preview.push(`${LOG} note: .env has ${undeclaredEnvKeys.join(', ')} — not declared in manifest, ignored`);
536
+ }
537
+ // --no-provision (the CI sweep): update-only deploy. Anything that would
538
+ // create or reconcile state beyond the version upload — registering a new
539
+ // shell, writing secret values, attaching secrets — is an error instead of
540
+ // a write: those routes stay per-principal FGA-gated (no trusted-M2M path),
541
+ // and first-time wiring is deliberately a human step. See
542
+ // atlas/processes/ROLLOUT.md.
543
+ if (flagBool(args.flags, 'no-provision')) {
544
+ const problems = [];
545
+ if (!existingShell) {
546
+ problems.push(`function "${slug}" is not registered on ${ctx.env.name} — run ` +
547
+ `\`seq-studio functions deploy -e ${ctx.env.name}\` once with your own login (seqapi login) to register it`);
548
+ }
549
+ for (const c of classification?.secrets ?? []) {
550
+ if (c.category === 'UPLOAD_NEW' || c.category === 'OVERWRITE') {
551
+ problems.push(`secret "${c.name}" would be written from a local value — provision it server-side first: ` +
552
+ `\`seq-studio secrets set ${c.name} -e ${ctx.env.name}\``);
553
+ }
554
+ else if (c.category === 'USE_EXISTING' && !c.attachedToFn) {
555
+ problems.push(`secret "${c.name}" exists but is not attached to ${slug} — attach it once: ` +
556
+ `\`seq-studio secrets attach ${c.name} --fn ${slug} -e ${ctx.env.name}\``);
557
+ }
558
+ }
559
+ if (problems.length > 0) {
560
+ for (const problem of problems)
561
+ console.error(`${LOG} --no-provision: ${problem}`);
562
+ console.error(`${LOG} resolve the above (human one-time wiring), then re-run the deploy`);
563
+ return 1;
564
+ }
565
+ }
566
+ const confirmed = flagBool(args.flags, 'yes');
567
+ const ok = await confirmYes({ preview, confirmed });
568
+ if (!ok)
569
+ return 1;
570
+ // Ensure the function shell exists before reconciling secrets (which need the fn id).
571
+ let fn = existingShell;
572
+ let createdShell = false;
573
+ if (!fn) {
574
+ console.log(`${LOG} registering new managed function "${slug}" on ${ctx.env.name}`);
575
+ const registered = await resolveOrRegisterFunction({
576
+ ctx,
577
+ slug,
578
+ title: manifest.function.title,
579
+ description: manifest.function.description ?? null,
580
+ });
581
+ fn = registered.fn;
582
+ createdShell = registered.created;
583
+ }
584
+ // Reconcile secrets after confirm and after the shell exists.
585
+ if (classification) {
586
+ // UPLOAD_NEW + OVERWRITE: delegate to /apply which handles create/set/attach atomically.
587
+ const applyEntries = classification.secrets
588
+ .filter((c) => c.category === 'UPLOAD_NEW' || c.category === 'OVERWRITE')
589
+ .map((c) => ({ name: c.name, value: envValues[c.name] ?? '' }));
590
+ if (applyEntries.length > 0) {
591
+ console.log(`${LOG} applying ${applyEntries.length} secret(s) to ${ctx.env.name}…`);
592
+ const result = await postJson({
593
+ ...clientOptions(ctx),
594
+ path: '/api/managed-secrets/apply',
595
+ body: { entries: applyEntries, functionSlugs: [slug] },
596
+ });
597
+ if (result.summary.failed > 0) {
598
+ for (const row of result.failed) {
599
+ console.error(`${LOG} secret "${row.secret}": ${clarifyApplyFailureReason(row.reason)}`);
600
+ }
601
+ console.error(`${LOG} could not apply all secrets — resolve the above before deploying`);
602
+ return 1;
603
+ }
604
+ }
605
+ // USE_EXISTING not yet attached: attach only (no value change).
606
+ const toAttach = classification.secrets.filter((c) => c.category === 'USE_EXISTING' && !c.attachedToFn && c.secretId !== null);
607
+ for (const c of toAttach) {
608
+ console.log(`${LOG} attaching existing secret "${c.name}" to ${slug}…`);
609
+ try {
610
+ await postJson({
611
+ ...clientOptions(ctx),
612
+ path: `/api/managed-secrets/${c.secretId}/attachments`,
613
+ body: { functionId: fn.id, envVarName: c.name },
614
+ });
615
+ }
616
+ catch (error) {
617
+ if (error instanceof AtlasApiError && error.status === 409) {
618
+ console.error(`${LOG} secret "${c.name}": env var is already attached to a different managed secret — ` +
619
+ `detach the conflicting attachment first: \`seq-studio secrets detach <NAME> --fn ${slug} -e ${ctx.env.name}\``);
620
+ return 1;
621
+ }
622
+ throw error;
623
+ }
624
+ }
625
+ }
626
+ if (git.gitDirty) {
627
+ console.warn(`${LOG} working tree is dirty — recording gitDirty=true on this version`);
628
+ }
629
+ console.log(`${LOG} uploading bundle (${files.length} files) to ${ctx.env.name}…`);
630
+ let version;
631
+ try {
632
+ ;
633
+ ({ version } = await postJson({
634
+ ...clientOptions(ctx),
635
+ path: `/api/managed-functions/${fn.id}/versions`,
636
+ body: {
637
+ manifest,
638
+ files,
639
+ version: nextVersion,
640
+ deployMessage,
641
+ ...git,
642
+ },
643
+ }));
644
+ }
645
+ catch (error) {
646
+ if (error instanceof AtlasApiError && error.status === 409) {
647
+ console.error(`${LOG} version ${nextVersion} already exists — another deploy may have raced. Re-run deploy to allocate the next version.`);
648
+ return 1;
649
+ }
650
+ throw error;
651
+ }
652
+ if (createdShell) {
653
+ console.log(`${LOG} registered shell for "${slug}"`);
654
+ }
655
+ console.log(`${LOG} version ${version.version} uploaded — building (server-side install + Cloud Functions deploy)`);
656
+ if (args.flags['no-wait']) {
657
+ console.log(`${LOG} not waiting (--no-wait). Poll with: seq-studio functions list -e ${ctx.env.name}`);
658
+ return 0;
659
+ }
660
+ return pollVersion({
661
+ ctx,
662
+ functionId: fn.id,
663
+ versionId: version.id,
664
+ egressNote: manifestEgressHosts(manifest.egress).length > 0 ||
665
+ manifestEgressIpRanges(manifest.egress).length > 0,
666
+ });
667
+ }
668
+ export async function pollVersion({ ctx, functionId, versionId, egressNote = false, }) {
669
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
670
+ let lastStatus = '';
671
+ for (;;) {
672
+ const { version } = await getJson({
673
+ ...clientOptions(ctx),
674
+ path: `/api/managed-functions/${functionId}/versions/${versionId}`,
675
+ });
676
+ if (version.status !== lastStatus) {
677
+ lastStatus = version.status;
678
+ console.log(`${LOG} status: ${version.status}`);
679
+ }
680
+ if (version.status === 'deployed') {
681
+ console.log(`${LOG} ✓ ${version.version} is live`);
682
+ if (egressNote) {
683
+ console.log(egressPropagationNote(LOG));
684
+ }
685
+ return 0;
686
+ }
687
+ if (version.status === 'failed') {
688
+ console.error(`${LOG} ✗ deploy failed: ${version.statusDetail ?? 'unknown error'}`);
689
+ return 1;
690
+ }
691
+ if (Date.now() > deadline) {
692
+ console.error(`${LOG} timed out waiting for deploy (still "${version.status}")`);
693
+ return 1;
694
+ }
695
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, POLL_INTERVAL_MS));
696
+ }
697
+ }
698
+ // ---------------------------------------------------------------------------
699
+ // list
700
+ // ---------------------------------------------------------------------------
701
+ export async function functionsListCommand(args) {
702
+ const ctx = await buildContext(args);
703
+ const { functions } = await getJson({
704
+ ...clientOptions(ctx),
705
+ path: '/api/managed-functions',
706
+ });
707
+ if (flagBool(args.flags, 'match-local')) {
708
+ const dir = workDir(args);
709
+ let manifest = null;
710
+ try {
711
+ manifest = await readManifestOptional(dir);
712
+ }
713
+ catch (error) {
714
+ const detail = error instanceof Error ? error.message : String(error);
715
+ console.warn(`${LOG} --match-local: invalid ${MF_MANIFEST_FILENAME} in ${dir}: ${detail}`);
716
+ }
717
+ if (manifest) {
718
+ const localSlug = manifest.function.id;
719
+ const match = functions.find((f) => f.slug === localSlug);
720
+ console.log(`${LOG} local manifest function.id "${localSlug}": ${match ? `exists on ${ctx.env.name}` : `not found on ${ctx.env.name}`}`);
721
+ }
722
+ else if (!existsSync(join(dir, MF_MANIFEST_FILENAME))) {
723
+ console.warn(`${LOG} --match-local: no ${MF_MANIFEST_FILENAME} in ${dir}`);
724
+ }
725
+ }
726
+ if (functions.length === 0) {
727
+ console.log(`${LOG} no managed functions visible on ${ctx.env.name}`);
728
+ return 0;
729
+ }
730
+ console.log(`${LOG} ${functions.length} function${functions.length === 1 ? '' : 's'} on ${ctx.env.name}:`);
731
+ for (const fn of functions) {
732
+ const state = fn.deployed ? 'deployed' : 'not deployed';
733
+ const redeploy = fn.secretsNeedDeploy ? ', secrets pending redeploy' : '';
734
+ const access = fn.currentUserAccess ?? (fn.canInvoke ? 'invoke-only' : 'none');
735
+ console.log(` ${fn.slug} id=${fn.id} [${state}${redeploy}] access=${access} ${fn.title}`);
736
+ }
737
+ return 0;
738
+ }
739
+ async function printFunctionShowDetails({ ctx, fn, }) {
740
+ const versions = await listVersions(ctx, fn.id);
741
+ const active = versions.find((v) => v.isActive);
742
+ if (active) {
743
+ console.log(` active: ${active.version} (${active.status})`);
744
+ }
745
+ const recent = versions.slice(0, 5);
746
+ if (recent.length > 0) {
747
+ console.log(`${LOG} recent versions:`);
748
+ for (const v of recent) {
749
+ const marker = v.isActive ? ' (active)' : '';
750
+ console.log(` ${v.version} [${v.status}]${marker}`);
751
+ }
752
+ }
753
+ const detail = await getFunctionDetail(ctx, fn.id);
754
+ if (detail && detail.attachments.length > 0) {
755
+ console.log(`${LOG} attached secrets:`);
756
+ for (const row of detail.attachments) {
757
+ const mounted = row.deployed ? 'mounted' : 'pending redeploy';
758
+ console.log(` ${row.envVarName} [${mounted}]`);
759
+ }
760
+ }
761
+ printFunctionEgressHosts({
762
+ log: LOG,
763
+ hosts: detail?.function.egressHosts ?? fn.egressHosts ?? [],
764
+ ipRanges: detail?.function.egressIpRanges ?? fn.egressIpRanges ?? [],
765
+ egressEnforced: detail?.function.egressEnforced ?? fn.egressEnforced ?? false,
766
+ });
767
+ }
768
+ export async function functionsShowCommand(args) {
769
+ const ctx = await buildContext(args);
770
+ const fn = await resolveFunction({ ctx, args, required: true });
771
+ if (!fn)
772
+ return 1;
773
+ console.log(`${LOG} function on ${ctx.env.name}:`);
774
+ console.log(` id: ${fn.id}`);
775
+ console.log(` slug: ${fn.slug}`);
776
+ console.log(` title: ${fn.title}`);
777
+ console.log(` deployed: ${fn.deployed ? 'yes' : 'no'}`);
778
+ if (fn.currentUserAccess) {
779
+ console.log(` access: ${fn.currentUserAccess}`);
780
+ if (fn.secretsNeedDeploy) {
781
+ console.log(` secrets: pending redeploy`);
782
+ }
783
+ }
784
+ else if (fn.canInvoke) {
785
+ console.log(` access: invoke-only`);
786
+ }
787
+ if (fn.currentUserAccess) {
788
+ try {
789
+ await printFunctionShowDetails({ ctx, fn });
790
+ }
791
+ catch (error) {
792
+ printError(error);
793
+ return 1;
794
+ }
795
+ }
796
+ return 0;
797
+ }
798
+ // ---------------------------------------------------------------------------
799
+ // logs
800
+ // ---------------------------------------------------------------------------
801
+ export async function functionsLogsCommand(args) {
802
+ const ctx = await buildContext(args);
803
+ const fn = (await resolveFunction({ ctx, args }));
804
+ const limit = typeof args.flags.limit === 'string' ? Number(args.flags.limit) : 100;
805
+ const since = typeof args.flags.since === 'string' ? args.flags.since : undefined;
806
+ const query = new URLSearchParams({ limit: String(limit) });
807
+ if (since)
808
+ query.set('since', since);
809
+ const { entries } = await getJson({
810
+ ...clientOptions(ctx),
811
+ path: `/api/managed-functions/${fn.id}/logs?${query.toString()}`,
812
+ });
813
+ if (entries.length === 0) {
814
+ console.log(`${LOG} no log entries for ${fn.slug}`);
815
+ return 0;
816
+ }
817
+ // Cloud Logging returns newest-first; print oldest-first for readability.
818
+ for (const entry of [...entries].reverse()) {
819
+ console.log(`${entry.timestamp} ${entry.severity.padEnd(8)} ${entry.message}`);
820
+ }
821
+ return 0;
822
+ }
823
+ // ---------------------------------------------------------------------------
824
+ // promote / rollback
825
+ // ---------------------------------------------------------------------------
826
+ export async function resolveLiveActiveVersionId(ctx, functionId) {
827
+ const versions = await listVersions(ctx, functionId);
828
+ return versions.find((v) => v.isActive)?.id ?? null;
829
+ }
830
+ async function listVersions(ctx, functionId) {
831
+ const { versions } = await getJson({
832
+ ...clientOptions(ctx),
833
+ path: `/api/managed-functions/${functionId}/versions`,
834
+ });
835
+ return versions;
836
+ }
837
+ export async function functionsPromoteCommand(args) {
838
+ const versionName = args.positional[0];
839
+ if (!versionName) {
840
+ console.error('usage: seq-studio functions promote <version> [-e env] [--fn slug]');
841
+ return 1;
842
+ }
843
+ const ctx = await buildContext(args);
844
+ const fn = (await resolveFunction({ ctx, args }));
845
+ const versions = await listVersions(ctx, fn.id);
846
+ const target = versions.find((v) => v.version === versionName);
847
+ if (!target) {
848
+ console.error(`${LOG} version "${versionName}" not found. Known: ${versions.map((v) => v.version).join(', ')}`);
849
+ return 1;
850
+ }
851
+ console.log(`${LOG} promoting ${fn.slug} ${target.version}…`);
852
+ await postJson({
853
+ ...clientOptions(ctx),
854
+ path: `/api/managed-functions/${fn.id}/versions/${target.id}/promote`,
855
+ });
856
+ // The target version's manifest (and therefore its egress) isn't in the
857
+ // summary, so always surface the propagation hint after the flip.
858
+ return pollVersion({ ctx, functionId: fn.id, versionId: target.id, egressNote: true });
859
+ }
860
+ export async function functionsRollbackCommand(args) {
861
+ const ctx = await buildContext(args);
862
+ const fn = (await resolveFunction({ ctx, args }));
863
+ const versions = await listVersions(ctx, fn.id);
864
+ let target;
865
+ const versionName = args.positional[0];
866
+ if (versionName) {
867
+ target = versions.find((v) => v.version === versionName);
868
+ if (!target) {
869
+ console.error(`${LOG} version "${versionName}" not found. Known: ${versions.map((v) => v.version).join(', ')}`);
870
+ return 1;
871
+ }
872
+ }
873
+ else {
874
+ // No explicit target: the most recent previously-deployed version
875
+ // that isn't the active one.
876
+ target = versions.find((v) => !v.isActive && v.deployedAt !== null);
877
+ if (!target) {
878
+ console.error(`${LOG} no prior deployed version to roll back to`);
879
+ return 1;
880
+ }
881
+ }
882
+ console.log(`${LOG} rolling back ${fn.slug} to ${target.version}…`);
883
+ await postJson({
884
+ ...clientOptions(ctx),
885
+ path: `/api/managed-functions/${fn.id}/versions/${target.id}/rollback`,
886
+ });
887
+ // Same as promote: the target manifest's egress isn't known here.
888
+ return pollVersion({ ctx, functionId: fn.id, versionId: target.id, egressNote: true });
889
+ }
890
+ // ---------------------------------------------------------------------------
891
+ // secrets
892
+ // ---------------------------------------------------------------------------
893
+ /** Minimal dotenv parser — KEY=VALUE lines, quotes stripped, comments skipped. */
894
+ export function parseDotenv(content) {
895
+ const out = {};
896
+ for (const rawLine of content.split('\n')) {
897
+ const line = rawLine.trim();
898
+ if (!line || line.startsWith('#'))
899
+ continue;
900
+ const eq = line.indexOf('=');
901
+ if (eq <= 0)
902
+ continue;
903
+ const key = line.slice(0, eq).trim().replace(/^export\s+/, '');
904
+ let value = line.slice(eq + 1).trim();
905
+ if ((value.startsWith('"') && value.endsWith('"')) ||
906
+ (value.startsWith("'") && value.endsWith("'"))) {
907
+ value = value.slice(1, -1);
908
+ }
909
+ if (key)
910
+ out[key] = value;
911
+ }
912
+ return out;
913
+ }
914
+ // ---------------------------------------------------------------------------
915
+ // delete
916
+ // ---------------------------------------------------------------------------
917
+ export async function functionsDeleteCommand(args) {
918
+ const ctx = await buildContext(args);
919
+ const fn = (await resolveFunction({ ctx, args }));
920
+ if (!args.flags.yes) {
921
+ console.error(`${LOG} this archives "${fn.slug}" on ${ctx.env.name} and tears down its Cloud Function, service account, and source bundles (history is retained).\n` +
922
+ `${LOG} re-run with --yes to confirm`);
923
+ return 1;
924
+ }
925
+ await deleteJson({ ...clientOptions(ctx), path: `/api/managed-functions/${fn.id}` });
926
+ console.log(`${LOG} archived ${fn.slug} on ${ctx.env.name}`);
927
+ return 0;
928
+ }
929
+ // ---------------------------------------------------------------------------
930
+ // dispatcher
931
+ // ---------------------------------------------------------------------------
932
+ export const FUNCTIONS_USAGE = `usage:
933
+ seq-studio functions init <dir> scaffold manifest + TypeScript hello-world handler
934
+ seq-studio functions build [--dir d] local pre-flight (manifest, lockfile, size)
935
+ seq-studio functions deploy [-e env] [-m msg] preview + confirm secrets + upload and deploy
936
+ seq-studio functions list [-e env] [--match-local] functions visible on the environment
937
+ seq-studio functions show [-e env] [--fn slug] detail for one function (versions, secrets)
938
+ seq-studio functions logs [-e env] [--limit N] [--since t] Cloud Logging snapshot (reader-gated)
939
+ seq-studio functions promote <version> [-e env] make a version live
940
+ seq-studio functions rollback [<version>] [-e env] redeploy a prior version
941
+ seq-studio functions delete [--yes] archive function + tear down GCP resources
942
+ (version history is retained)
943
+
944
+ Flags: -e/--env <local|staging|production|banksouth|preview:<slug>> · --fn <slug> · --dir <path>
945
+ --from-env-file <path> (default: .env) source file for secret values
946
+ --no-wait · --yes
947
+ --no-provision (deploy) update-only: error instead of registering a new
948
+ shell, writing secret values, or attaching secrets (CI sweep)
949
+
950
+ Source for build/deploy: a local --dir (default .), a platform git-service
951
+ repo (--repo <ns>/<name>), or any git URL (--git-url <url>). --ref selects a
952
+ branch/tag/commit (default: the repo's default branch). Remote sources record
953
+ the pinned commit as provenance (never dirty) and NEVER read a repo-committed
954
+ .env for secret values — provision secrets server-side or pass a local
955
+ --from-env-file (resolved against your cwd).
956
+
957
+ --repo clones over smart-HTTP and REQUIRES a git PAT in ATLAS_GIT_PAT
958
+ (repo:read scope — \`seq-studio auth pat create --scopes repo:read\`, or
959
+ Atlas → Settings → Tokens). It's the same token you clone the repo with;
960
+ --env + seqapi login are still needed to resolve the repo and deploy.
961
+ `;
962
+ export async function runFunctionsCommand(sub, args) {
963
+ try {
964
+ switch (sub) {
965
+ case 'init':
966
+ return await functionsInitCommand(args);
967
+ case 'build':
968
+ return await functionsBuildCommand(args);
969
+ case 'deploy':
970
+ return await functionsDeployCommand(args);
971
+ case 'list':
972
+ return await functionsListCommand(args);
973
+ case 'show':
974
+ return await functionsShowCommand(args);
975
+ case 'logs':
976
+ return await functionsLogsCommand(args);
977
+ case 'promote':
978
+ return await functionsPromoteCommand(args);
979
+ case 'rollback':
980
+ return await functionsRollbackCommand(args);
981
+ case 'delete':
982
+ return await functionsDeleteCommand(args);
983
+ case 'help':
984
+ case '--help':
985
+ case '-h':
986
+ case undefined:
987
+ console.log(FUNCTIONS_USAGE);
988
+ return sub ? 0 : 1;
989
+ default:
990
+ console.error(`unknown functions command: ${sub}`);
991
+ console.error(FUNCTIONS_USAGE);
992
+ return 1;
993
+ }
994
+ }
995
+ catch (error) {
996
+ printError(error);
997
+ return 1;
998
+ }
999
+ }