@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.
- package/README.md +258 -0
- package/dist/artifact/delegate.d.ts +25 -0
- package/dist/artifact/delegate.js +263 -0
- package/dist/atlas-client.d.ts +44 -0
- package/dist/atlas-client.js +173 -0
- package/dist/auth-cmds/commands.d.ts +15 -0
- package/dist/auth-cmds/commands.js +249 -0
- package/dist/auth.d.ts +26 -0
- package/dist/auth.js +171 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +8 -0
- package/dist/cli-errors.d.ts +5 -0
- package/dist/cli-errors.js +78 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.js +103 -0
- package/dist/env-flags.d.ts +8 -0
- package/dist/env-flags.js +47 -0
- package/dist/functions/bundle.d.ts +30 -0
- package/dist/functions/bundle.js +137 -0
- package/dist/functions/commands.d.ts +86 -0
- package/dist/functions/commands.js +999 -0
- package/dist/functions/egress-preview.d.ts +32 -0
- package/dist/functions/egress-preview.js +54 -0
- package/dist/functions/lockfile-origin.d.ts +16 -0
- package/dist/functions/lockfile-origin.js +45 -0
- package/dist/functions/manifest.d.ts +89 -0
- package/dist/functions/manifest.js +586 -0
- package/dist/functions/secret-reconcile.d.ts +79 -0
- package/dist/functions/secret-reconcile.js +86 -0
- package/dist/main.d.ts +14 -0
- package/dist/main.js +129 -0
- package/dist/orm/delegate.d.ts +8 -0
- package/dist/orm/delegate.js +61 -0
- package/dist/pat-hints.d.ts +17 -0
- package/dist/pat-hints.js +28 -0
- package/dist/preview.d.ts +89 -0
- package/dist/preview.js +291 -0
- package/dist/process/agent-loader.d.ts +24 -0
- package/dist/process/agent-loader.js +57 -0
- package/dist/process/build.d.ts +14 -0
- package/dist/process/build.js +368 -0
- package/dist/process/codegen.d.ts +18 -0
- package/dist/process/codegen.js +270 -0
- package/dist/process/commands.d.ts +47 -0
- package/dist/process/commands.js +786 -0
- package/dist/process/discover.d.ts +32 -0
- package/dist/process/discover.js +131 -0
- package/dist/process/lint.d.ts +39 -0
- package/dist/process/lint.js +485 -0
- package/dist/process/local-bundle.d.ts +17 -0
- package/dist/process/local-bundle.js +65 -0
- package/dist/process/plan-diff.d.ts +82 -0
- package/dist/process/plan-diff.js +333 -0
- package/dist/process/resolve-process-pin.d.ts +11 -0
- package/dist/process/resolve-process-pin.js +63 -0
- package/dist/process/simulate.d.ts +50 -0
- package/dist/process/simulate.js +328 -0
- package/dist/prompt.d.ts +35 -0
- package/dist/prompt.js +65 -0
- package/dist/repos/commands.d.ts +49 -0
- package/dist/repos/commands.js +548 -0
- package/dist/repos/git-clone.d.ts +10 -0
- package/dist/repos/git-clone.js +49 -0
- package/dist/secrets/commands.d.ts +24 -0
- package/dist/secrets/commands.js +704 -0
- package/dist/templates/process/example-process/process.ts +43 -0
- package/dist/templates/process/package.json +23 -0
- package/dist/templates/process/pnpm-workspace.yaml +21 -0
- package/dist/templates/process/tsconfig.json +17 -0
- package/package.json +78 -0
- package/templates/process/example-process/process.ts +43 -0
- package/templates/process/package.json +23 -0
- package/templates/process/pnpm-workspace.yaml +21 -0
- 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
|
+
}
|