@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,704 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolve, isAbsolute } from 'node:path';
|
|
4
|
+
import { buildContext, clientOptions, flagBool, parseDotenv, pollVersion, printError, promptHidden, readManifestOptional, resolveLiveActiveVersionId, resolveOrRegisterFunction, workDir, } from '../functions/commands.js';
|
|
5
|
+
import { clarifyApplyFailureReason } from '../cli-errors.js';
|
|
6
|
+
import { confirmChoice, confirmYes } from '../prompt.js';
|
|
7
|
+
import { AtlasApiError, deleteJson, getJson, patchJson, postJson, putJson } from '../atlas-client.js';
|
|
8
|
+
export const LOG = '[seq-studio]';
|
|
9
|
+
const VERSION_ROW_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
10
|
+
/** Match a version arg by stable row id, or by gcpVersion when unambiguous. */
|
|
11
|
+
function resolveVersionTarget(versions, versionArg) {
|
|
12
|
+
if (VERSION_ROW_ID_RE.test(versionArg)) {
|
|
13
|
+
const byId = versions.find((v) => v.id === versionArg);
|
|
14
|
+
if (!byId)
|
|
15
|
+
return { error: `version id "${versionArg}" not found` };
|
|
16
|
+
return { target: byId };
|
|
17
|
+
}
|
|
18
|
+
const wanted = versionArg.replace(/^v/, '');
|
|
19
|
+
const matches = versions.filter((v) => v.gcpVersion === wanted);
|
|
20
|
+
if (matches.length === 0) {
|
|
21
|
+
return {
|
|
22
|
+
error: `version "${versionArg}" not found. Known: ${versions.map((v) => `v${v.gcpVersion}`).join(', ')}`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (matches.length > 1) {
|
|
26
|
+
return {
|
|
27
|
+
error: `version "${versionArg}" is ambiguous (${matches.length} rows share v${wanted}). ` +
|
|
28
|
+
`Pass the version row id from "seq-studio secrets versions": ${matches.map((v) => v.id).join(', ')}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return { target: matches[0] };
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
function parseCommaList(value) {
|
|
37
|
+
if (!value)
|
|
38
|
+
return [];
|
|
39
|
+
return value
|
|
40
|
+
.split(',')
|
|
41
|
+
.map((part) => part.trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
async function listSecrets(ctx) {
|
|
45
|
+
const { secrets } = await getJson({
|
|
46
|
+
...clientOptions(ctx),
|
|
47
|
+
path: '/api/managed-secrets',
|
|
48
|
+
});
|
|
49
|
+
return secrets;
|
|
50
|
+
}
|
|
51
|
+
async function resolveSecretByName({ ctx, name, }) {
|
|
52
|
+
const secrets = await listSecrets(ctx);
|
|
53
|
+
const secret = secrets.find((row) => row.name === name);
|
|
54
|
+
if (!secret) {
|
|
55
|
+
throw new Error(`Managed secret "${name}" not found on ${ctx.env.name} (or you don't have access).`);
|
|
56
|
+
}
|
|
57
|
+
return secret;
|
|
58
|
+
}
|
|
59
|
+
function canAdminFunction(fn) {
|
|
60
|
+
return fn.currentUserAccess === 'admin' || fn.currentUserAccess === 'owner';
|
|
61
|
+
}
|
|
62
|
+
async function resolvePinRedeployChoice({ args, fnSlug, preview, canRedeploy, }) {
|
|
63
|
+
if (flagBool(args.flags, 'yes')) {
|
|
64
|
+
const redeploy = !flagBool(args.flags, 'no-redeploy');
|
|
65
|
+
if (redeploy && !canRedeploy) {
|
|
66
|
+
console.error(`${LOG} redeploy requires admin access on ${fnSlug} — use --yes --no-redeploy to pin only`);
|
|
67
|
+
return { ok: false };
|
|
68
|
+
}
|
|
69
|
+
for (const line of preview)
|
|
70
|
+
console.log(line);
|
|
71
|
+
console.log(redeploy
|
|
72
|
+
? `${LOG} pinning and redeploying ${fnSlug} (--yes)`
|
|
73
|
+
: `${LOG} pinning WITHOUT redeploy (--yes --no-redeploy)`);
|
|
74
|
+
return { ok: true, redeploy };
|
|
75
|
+
}
|
|
76
|
+
const choices = canRedeploy
|
|
77
|
+
? [
|
|
78
|
+
{ key: 'y', label: `yes — pin and redeploy ${fnSlug} now`, value: 'redeploy' },
|
|
79
|
+
{ key: 'p', label: 'pin only (mounts on the next redeploy)', value: 'pin-only' },
|
|
80
|
+
{ key: 'n', label: 'no / back', value: 'cancel' },
|
|
81
|
+
]
|
|
82
|
+
: [
|
|
83
|
+
{ key: 'p', label: 'pin only (mounts on the next redeploy)', value: 'pin-only' },
|
|
84
|
+
{ key: 'n', label: 'no / back', value: 'cancel' },
|
|
85
|
+
];
|
|
86
|
+
const choice = await confirmChoice({
|
|
87
|
+
preview,
|
|
88
|
+
prompt: canRedeploy ? 'Choose [y/p/n]: ' : 'Choose [p/n]: ',
|
|
89
|
+
choices,
|
|
90
|
+
});
|
|
91
|
+
if (choice === null || choice === 'cancel') {
|
|
92
|
+
console.error(`${LOG} aborted`);
|
|
93
|
+
return { ok: false };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, redeploy: choice === 'redeploy' };
|
|
96
|
+
}
|
|
97
|
+
async function resolveFunctionBySlug({ ctx, slug, }) {
|
|
98
|
+
const { functions } = await getJson({
|
|
99
|
+
...clientOptions(ctx),
|
|
100
|
+
path: `/api/managed-functions?slug=${encodeURIComponent(slug)}`,
|
|
101
|
+
});
|
|
102
|
+
const fn = functions.find((row) => row.slug === slug);
|
|
103
|
+
if (!fn) {
|
|
104
|
+
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.`);
|
|
105
|
+
}
|
|
106
|
+
return fn;
|
|
107
|
+
}
|
|
108
|
+
async function functionExistsBySlug({ ctx, slug, }) {
|
|
109
|
+
const { functions } = await getJson({
|
|
110
|
+
...clientOptions(ctx),
|
|
111
|
+
path: `/api/managed-functions?slug=${encodeURIComponent(slug)}`,
|
|
112
|
+
});
|
|
113
|
+
return functions.some((row) => row.slug === slug);
|
|
114
|
+
}
|
|
115
|
+
async function functionExistsBySlugEventually({ ctx, slug, attempts = 3, retryDelayMs = 250, }) {
|
|
116
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
117
|
+
if (await functionExistsBySlug({ ctx, slug }))
|
|
118
|
+
return true;
|
|
119
|
+
if (attempt < attempts - 1) {
|
|
120
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, retryDelayMs));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// create
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
export async function secretsCreateCommand(args) {
|
|
129
|
+
const name = args.positional[0];
|
|
130
|
+
if (!name) {
|
|
131
|
+
console.error('usage: seq-studio secrets create <NAME> [-e env] [--description text]');
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const ctx = await buildContext(args);
|
|
135
|
+
const description = typeof args.flags.description === 'string' ? args.flags.description : undefined;
|
|
136
|
+
const secret = await postJson({
|
|
137
|
+
...clientOptions(ctx),
|
|
138
|
+
path: '/api/managed-secrets',
|
|
139
|
+
body: {
|
|
140
|
+
name,
|
|
141
|
+
description: description ?? null,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
console.log(`${LOG} created managed secret "${secret.name}" (${secret.id}) on ${ctx.env.name}`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// set (default value)
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
export async function secretsSetCommand(args) {
|
|
151
|
+
const name = args.positional[0];
|
|
152
|
+
if (!name) {
|
|
153
|
+
console.error('usage: seq-studio secrets set <NAME> [-e env]');
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
const ctx = await buildContext(args);
|
|
157
|
+
const secret = await resolveSecretByName({ ctx, name });
|
|
158
|
+
const value = await promptHidden(`Default value for ${name} (input hidden): `);
|
|
159
|
+
if (!value) {
|
|
160
|
+
console.error(`${LOG} empty value — aborted`);
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
await putJson({
|
|
164
|
+
...clientOptions(ctx),
|
|
165
|
+
path: `/api/managed-secrets/${secret.id}/value`,
|
|
166
|
+
body: { value },
|
|
167
|
+
});
|
|
168
|
+
console.log(`${LOG} set default value for "${name}"`);
|
|
169
|
+
console.log(`${LOG} functions with this secret attached need redeploy to mount the new version`);
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// list
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
export async function secretsListCommand(args) {
|
|
176
|
+
const ctx = await buildContext(args);
|
|
177
|
+
const secrets = await listSecrets(ctx);
|
|
178
|
+
if (secrets.length === 0) {
|
|
179
|
+
console.log(`${LOG} no managed secrets visible on ${ctx.env.name}`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
console.log(`${LOG} ${secrets.length} secret${secrets.length === 1 ? '' : 's'} on ${ctx.env.name}:`);
|
|
183
|
+
for (const secret of secrets) {
|
|
184
|
+
const defaultState = secret.hasDefaultValue ? 'default set' : 'no default';
|
|
185
|
+
const access = secret.currentUserAccess ?? 'none';
|
|
186
|
+
console.log(` ${secret.name} [${defaultState}] access=${access} attachments=${secret.attachmentCount}`);
|
|
187
|
+
}
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// attach / detach
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
export async function secretsAttachCommand(args) {
|
|
194
|
+
const name = args.positional[0];
|
|
195
|
+
const fnSlug = typeof args.flags.fn === 'string' ? args.flags.fn : args.positional[1];
|
|
196
|
+
if (!name || !fnSlug) {
|
|
197
|
+
console.error('usage: seq-studio secrets attach <NAME> --fn <slug> [--env-var NAME]');
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
const ctx = await buildContext(args);
|
|
201
|
+
const secret = await resolveSecretByName({ ctx, name });
|
|
202
|
+
const fn = await resolveFunctionBySlug({ ctx, slug: fnSlug });
|
|
203
|
+
const envVarName = (typeof args.flags['env-var'] === 'string' ? args.flags['env-var'] : undefined) ?? name;
|
|
204
|
+
if (secret.hasDefaultValue) {
|
|
205
|
+
const preview = [
|
|
206
|
+
`${LOG} attach preview:`,
|
|
207
|
+
`${LOG} secret: ${name}`,
|
|
208
|
+
`${LOG} function: ${fnSlug}`,
|
|
209
|
+
`${LOG} env var: ${envVarName}`,
|
|
210
|
+
`${LOG} note: shared default — anyone who can invoke "${fnSlug}" receives it at runtime`,
|
|
211
|
+
];
|
|
212
|
+
const ok = await confirmYes({ preview, confirmed: flagBool(args.flags, 'yes') });
|
|
213
|
+
if (!ok)
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
await postJson({
|
|
217
|
+
...clientOptions(ctx),
|
|
218
|
+
path: `/api/managed-secrets/${secret.id}/attachments`,
|
|
219
|
+
body: {
|
|
220
|
+
functionId: fn.id,
|
|
221
|
+
envVarName,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
console.log(`${LOG} attached "${name}" to ${fnSlug} as env var ${envVarName}`);
|
|
225
|
+
console.log(`${LOG} redeploy ${fnSlug} to mount the secret`);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
export async function secretsDetachCommand(args) {
|
|
229
|
+
const name = args.positional[0];
|
|
230
|
+
const fnSlug = typeof args.flags.fn === 'string' ? args.flags.fn : args.positional[1];
|
|
231
|
+
if (!name || !fnSlug) {
|
|
232
|
+
console.error('usage: seq-studio secrets detach <NAME> --fn <slug>');
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
const ctx = await buildContext(args);
|
|
236
|
+
const secret = await resolveSecretByName({ ctx, name });
|
|
237
|
+
const fn = await resolveFunctionBySlug({ ctx, slug: fnSlug });
|
|
238
|
+
await deleteJson({
|
|
239
|
+
...clientOptions(ctx),
|
|
240
|
+
path: `/api/managed-secrets/${secret.id}/attachments`,
|
|
241
|
+
body: { functionId: fn.id },
|
|
242
|
+
});
|
|
243
|
+
console.log(`${LOG} detached "${name}" from ${fnSlug}. Redeploy to unmount.`);
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// apply
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
function printApplyResult(result) {
|
|
250
|
+
const { summary, succeeded, failed } = result;
|
|
251
|
+
console.log(`${LOG} apply: ${summary.succeeded}/${summary.attempted} succeeded, ${summary.failed} failed`);
|
|
252
|
+
if (succeeded.length > 0) {
|
|
253
|
+
console.log(`${LOG} succeeded:`);
|
|
254
|
+
for (const row of succeeded) {
|
|
255
|
+
console.log(` ${row.secret} → ${row.function}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (failed.length > 0) {
|
|
259
|
+
console.error(`${LOG} failed:`);
|
|
260
|
+
for (const row of failed) {
|
|
261
|
+
console.error(` ${row.secret} → ${row.function}: ${clarifyApplyFailureReason(row.reason)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function resolveApplyKeys({ parsed, manifest, explicitKeys, applyAll, }) {
|
|
266
|
+
if (explicitKeys.length > 0) {
|
|
267
|
+
return { keys: explicitKeys, mode: 'explicit --keys' };
|
|
268
|
+
}
|
|
269
|
+
if (applyAll) {
|
|
270
|
+
return {
|
|
271
|
+
keys: Object.keys(parsed).filter((key) => (parsed[key]?.length ?? 0) > 0),
|
|
272
|
+
mode: '--all',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (manifest) {
|
|
276
|
+
const keys = manifest.secrets.filter((key) => parsed[key] && parsed[key].length > 0);
|
|
277
|
+
return {
|
|
278
|
+
keys,
|
|
279
|
+
mode: manifest.secrets.length > 0 ? 'manifest secrets ∩ .env' : 'manifest secrets (none declared)',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return { keys: [], mode: 'none (no manifest)' };
|
|
283
|
+
}
|
|
284
|
+
export async function secretsApplyCommand(args) {
|
|
285
|
+
const fromEnvFile = args.flags['from-env-file'];
|
|
286
|
+
if (typeof fromEnvFile !== 'string') {
|
|
287
|
+
console.error('usage: seq-studio secrets apply --from-env-file .env [-e env] [--yes]');
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
const dir = workDir(args);
|
|
291
|
+
const envPath = isAbsolute(fromEnvFile) ? fromEnvFile : resolve(dir, fromEnvFile);
|
|
292
|
+
if (!existsSync(envPath)) {
|
|
293
|
+
console.error(`${LOG} file not found: ${envPath}`);
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
const explicitKeys = parseCommaList(typeof args.flags.keys === 'string' ? args.flags.keys : undefined);
|
|
297
|
+
const applyAll = flagBool(args.flags, 'all');
|
|
298
|
+
let functionSlugs = parseCommaList(typeof args.flags.functions === 'string' ? args.flags.functions : undefined);
|
|
299
|
+
let manifest = null;
|
|
300
|
+
let manifestReadError = null;
|
|
301
|
+
try {
|
|
302
|
+
manifest = await readManifestOptional(dir);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
manifestReadError = error;
|
|
306
|
+
}
|
|
307
|
+
const fullyExplicitTarget = functionSlugs.length > 0 && (explicitKeys.length > 0 || applyAll);
|
|
308
|
+
if (manifestReadError && !fullyExplicitTarget) {
|
|
309
|
+
throw manifestReadError;
|
|
310
|
+
}
|
|
311
|
+
if (manifestReadError && fullyExplicitTarget) {
|
|
312
|
+
console.warn(`${LOG} ignoring invalid ${'managed-function.yml'} in ${dir} because --functions and --keys/--all were provided`);
|
|
313
|
+
}
|
|
314
|
+
if (functionSlugs.length === 0) {
|
|
315
|
+
if (manifest) {
|
|
316
|
+
functionSlugs = [manifest.function.id];
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
console.error(`${LOG} --functions is required when no ${'managed-function.yml'} is present in ${dir}`);
|
|
320
|
+
return 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!manifest && explicitKeys.length === 0 && !applyAll) {
|
|
324
|
+
console.error(`${LOG} refusing to apply every key in ${envPath} without managed-function.yml — pass --keys K1,K2 to select secrets, or --all to apply the whole file`);
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
const parsed = parseDotenv(await readFile(envPath, 'utf8'));
|
|
328
|
+
const { keys: selectedKeys, mode: keyMode } = resolveApplyKeys({
|
|
329
|
+
parsed,
|
|
330
|
+
manifest,
|
|
331
|
+
explicitKeys,
|
|
332
|
+
applyAll,
|
|
333
|
+
});
|
|
334
|
+
const entries = selectedKeys
|
|
335
|
+
.filter((name) => parsed[name] && parsed[name].length > 0)
|
|
336
|
+
.map((name) => ({ name, value: parsed[name] }));
|
|
337
|
+
if (explicitKeys.length > 0) {
|
|
338
|
+
const missing = explicitKeys.filter((key) => !(parsed[key] && parsed[key].length > 0));
|
|
339
|
+
if (missing.length > 0) {
|
|
340
|
+
console.warn(`${LOG} --keys not found (or empty) in ${envPath}: ${missing.join(', ')}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (entries.length === 0) {
|
|
344
|
+
console.error(`${LOG} nothing to apply — no matching non-empty KEY=VALUE lines in ${envPath}`);
|
|
345
|
+
if (manifest && manifest.secrets.length > 0 && !applyAll && explicitKeys.length === 0) {
|
|
346
|
+
console.error(`${LOG} manifest declares [${manifest.secrets.join(', ')}] but none have values in ${envPath}`);
|
|
347
|
+
}
|
|
348
|
+
if (manifest && manifest.secrets.length === 0 && !applyAll && explicitKeys.length === 0) {
|
|
349
|
+
console.error(`${LOG} managed-function.yml declares no secrets — add secrets there, or pass --keys/--all explicitly`);
|
|
350
|
+
}
|
|
351
|
+
return 1;
|
|
352
|
+
}
|
|
353
|
+
const ctx = await buildContext(args);
|
|
354
|
+
const shellsToRegister = [];
|
|
355
|
+
for (const slug of functionSlugs) {
|
|
356
|
+
const exists = await functionExistsBySlug({ ctx, slug });
|
|
357
|
+
if (!exists)
|
|
358
|
+
shellsToRegister.push(slug);
|
|
359
|
+
}
|
|
360
|
+
const preview = [
|
|
361
|
+
`${LOG} secrets apply preview:`,
|
|
362
|
+
`${LOG} environment: ${ctx.env.name}`,
|
|
363
|
+
`${LOG} env file: ${envPath}`,
|
|
364
|
+
`${LOG} key selection: ${keyMode}`,
|
|
365
|
+
`${LOG} functions: ${functionSlugs.join(', ')}`,
|
|
366
|
+
`${LOG} secrets: ${entries.map((e) => e.name).join(', ')}`,
|
|
367
|
+
];
|
|
368
|
+
if (shellsToRegister.length > 0) {
|
|
369
|
+
preview.push(`${LOG} shell: will register missing function shell(s): ${shellsToRegister.join(', ')}`);
|
|
370
|
+
}
|
|
371
|
+
const confirmed = flagBool(args.flags, 'yes');
|
|
372
|
+
const ok = await confirmYes({ preview, confirmed });
|
|
373
|
+
if (!ok)
|
|
374
|
+
return 1;
|
|
375
|
+
const unavailableFunctionSlugs = new Set();
|
|
376
|
+
for (const slug of shellsToRegister) {
|
|
377
|
+
const title = manifest && manifest.function.id === slug ? manifest.function.title : slug;
|
|
378
|
+
const description = manifest && manifest.function.id === slug ? (manifest.function.description ?? null) : null;
|
|
379
|
+
console.log(`${LOG} registering function shell "${slug}" on ${ctx.env.name}`);
|
|
380
|
+
try {
|
|
381
|
+
await resolveOrRegisterFunction({ ctx, slug, title, description });
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
if (error instanceof AtlasApiError && error.status === 409) {
|
|
385
|
+
const visibleNow = await functionExistsBySlugEventually({ ctx, slug });
|
|
386
|
+
if (visibleNow) {
|
|
387
|
+
// Conflict can happen on a read-after-write race; continue once visible.
|
|
388
|
+
console.warn(`${LOG} function shell "${slug}" already exists on ${ctx.env.name}; continuing`);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// Slug exists but remains unavailable (for example archived or inaccessible).
|
|
392
|
+
unavailableFunctionSlugs.add(slug);
|
|
393
|
+
console.warn(`${LOG} function shell "${slug}" already exists but is not available on ${ctx.env.name}; skipping this function`);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const applyFunctionSlugs = functionSlugs.filter((slug) => !unavailableFunctionSlugs.has(slug));
|
|
400
|
+
if (applyFunctionSlugs.length === 0) {
|
|
401
|
+
console.error(`${LOG} no available functions remain to apply secrets to on ${ctx.env.name}; check function slugs and access`);
|
|
402
|
+
return 1;
|
|
403
|
+
}
|
|
404
|
+
const result = await postJson({
|
|
405
|
+
...clientOptions(ctx),
|
|
406
|
+
path: '/api/managed-secrets/apply',
|
|
407
|
+
body: {
|
|
408
|
+
entries,
|
|
409
|
+
functionSlugs: applyFunctionSlugs,
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
printApplyResult(result);
|
|
413
|
+
if (result.summary.succeeded === 0)
|
|
414
|
+
return 1;
|
|
415
|
+
if (result.summary.failed > 0) {
|
|
416
|
+
console.log(`${LOG} partial success — redeploy affected functions to mount new values`);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
console.log(`${LOG} redeploy affected functions to mount new values`);
|
|
420
|
+
}
|
|
421
|
+
return 0;
|
|
422
|
+
}
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// versions / set-default
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
async function listSecretVersions(ctx, secretId) {
|
|
427
|
+
const all = [];
|
|
428
|
+
let cursor;
|
|
429
|
+
do {
|
|
430
|
+
const params = new URLSearchParams({ limit: '100' });
|
|
431
|
+
if (cursor)
|
|
432
|
+
params.set('cursor', cursor);
|
|
433
|
+
const { versions, nextCursor } = await getJson({
|
|
434
|
+
...clientOptions(ctx),
|
|
435
|
+
path: `/api/managed-secrets/${secretId}/versions?${params}`,
|
|
436
|
+
});
|
|
437
|
+
all.push(...versions);
|
|
438
|
+
cursor = nextCursor ?? undefined;
|
|
439
|
+
} while (cursor);
|
|
440
|
+
return all;
|
|
441
|
+
}
|
|
442
|
+
/** Unpinned attachment slugs, for the set-default confirmation preview. */
|
|
443
|
+
async function attachedFunctionSlugs(ctx, secretId) {
|
|
444
|
+
const detail = await getJson({
|
|
445
|
+
...clientOptions(ctx),
|
|
446
|
+
path: `/api/managed-secrets/${secretId}`,
|
|
447
|
+
});
|
|
448
|
+
const unpinned = detail.attachments.filter((a) => !a.pinnedVersionId);
|
|
449
|
+
if (unpinned.length === 0)
|
|
450
|
+
return [];
|
|
451
|
+
try {
|
|
452
|
+
const { functions } = await getJson({
|
|
453
|
+
...clientOptions(ctx),
|
|
454
|
+
path: '/api/managed-functions',
|
|
455
|
+
});
|
|
456
|
+
const slugById = new Map(functions.map((fn) => [fn.id, fn.slug]));
|
|
457
|
+
return unpinned.map((a) => slugById.get(a.functionId) ?? a.functionId.slice(0, 8));
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
// Slug lookup needs managed-functions viewer; set-default only needs secret editor.
|
|
461
|
+
return unpinned.map((a) => a.functionId.slice(0, 8));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
export async function secretsVersionsCommand(args) {
|
|
465
|
+
const name = args.positional[0];
|
|
466
|
+
if (!name) {
|
|
467
|
+
console.error('usage: seq-studio secrets versions <NAME> [-e env]');
|
|
468
|
+
return 1;
|
|
469
|
+
}
|
|
470
|
+
const ctx = await buildContext(args);
|
|
471
|
+
const secret = await resolveSecretByName({ ctx, name });
|
|
472
|
+
const versions = await listSecretVersions(ctx, secret.id);
|
|
473
|
+
if (versions.length === 0) {
|
|
474
|
+
console.log(`${LOG} no versions recorded for "${name}" on ${ctx.env.name}`);
|
|
475
|
+
return 0;
|
|
476
|
+
}
|
|
477
|
+
console.log(`${LOG} ${versions.length} version${versions.length === 1 ? '' : 's'} for "${name}" on ${ctx.env.name}:`);
|
|
478
|
+
const ambiguousVersions = new Set(versions
|
|
479
|
+
.filter((v, i, arr) => arr.some((o, j) => j !== i && o.gcpVersion === v.gcpVersion))
|
|
480
|
+
.map((v) => v.gcpVersion));
|
|
481
|
+
for (const v of versions) {
|
|
482
|
+
const idHint = ambiguousVersions.has(v.gcpVersion) ? ` ${v.id}` : '';
|
|
483
|
+
console.log(` v${v.gcpVersion}${idHint} ${v.createdBy} ${v.createdAt}${v.isActive ? ' (active)' : ''}`);
|
|
484
|
+
}
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Point the shared default at a prior version. Editor-tier, same as `set` —
|
|
489
|
+
* and with the same propagation model: nothing is redeployed, attached
|
|
490
|
+
* functions are flagged and pick the version up on their own next redeploy.
|
|
491
|
+
*/
|
|
492
|
+
export async function secretsSetDefaultCommand(args) {
|
|
493
|
+
const name = args.positional[0];
|
|
494
|
+
if (!name) {
|
|
495
|
+
console.error('usage: seq-studio secrets set-default <NAME> [version] [-e env] [--yes]');
|
|
496
|
+
return 1;
|
|
497
|
+
}
|
|
498
|
+
const ctx = await buildContext(args);
|
|
499
|
+
const secret = await resolveSecretByName({ ctx, name });
|
|
500
|
+
const versions = await listSecretVersions(ctx, secret.id);
|
|
501
|
+
if (versions.length === 0) {
|
|
502
|
+
console.error(`${LOG} "${name}" has no recorded versions`);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
const active = versions.find((v) => v.isActive);
|
|
506
|
+
let target;
|
|
507
|
+
const versionArg = args.positional[1];
|
|
508
|
+
if (versionArg) {
|
|
509
|
+
const resolved = resolveVersionTarget(versions, versionArg);
|
|
510
|
+
if (resolved.error) {
|
|
511
|
+
console.error(`${LOG} ${resolved.error}`);
|
|
512
|
+
return 1;
|
|
513
|
+
}
|
|
514
|
+
target = resolved.target;
|
|
515
|
+
if (!target)
|
|
516
|
+
return 1;
|
|
517
|
+
if (target.isActive) {
|
|
518
|
+
console.error(`${LOG} v${target.gcpVersion} is already the default`);
|
|
519
|
+
return 1;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
target = versions.find((v) => !v.isActive);
|
|
524
|
+
if (!target) {
|
|
525
|
+
console.error(`${LOG} no prior version to point the default at`);
|
|
526
|
+
return 1;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const slugs = await attachedFunctionSlugs(ctx, secret.id);
|
|
530
|
+
const activeLabel = active ? `v${active.gcpVersion}` : 'current';
|
|
531
|
+
const preview = [
|
|
532
|
+
`${LOG} set-default ${name} ${activeLabel} -> v${target.gcpVersion}`,
|
|
533
|
+
slugs.length > 0
|
|
534
|
+
? `${LOG} nothing redeploys — ${slugs.join(', ')} pick it up on their next redeploy`
|
|
535
|
+
: `${LOG} no default-tracking functions attached`,
|
|
536
|
+
];
|
|
537
|
+
const ok = await confirmYes({ preview, confirmed: flagBool(args.flags, 'yes') });
|
|
538
|
+
if (!ok)
|
|
539
|
+
return 1;
|
|
540
|
+
const result = await postJson({
|
|
541
|
+
...clientOptions(ctx),
|
|
542
|
+
path: `/api/managed-secrets/${secret.id}/versions/${target.id}/set-default`,
|
|
543
|
+
});
|
|
544
|
+
console.log(`${LOG} ${name} default set to v${result.gcpVersion}`);
|
|
545
|
+
if (result.flagged.length > 0) {
|
|
546
|
+
console.log(`${LOG} flagged for redeploy: ${result.flagged.map((f) => f.slug).join(', ')} — ` +
|
|
547
|
+
`redeploy to mount v${result.gcpVersion}`);
|
|
548
|
+
}
|
|
549
|
+
return 0;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Pin one attached function to a specific secret version (sticky — survives
|
|
553
|
+
* rotations until `--unpin`). Requires editor+ on the secret AND the
|
|
554
|
+
* function (server-enforced dual-editor gate). Three-way flow mirroring the
|
|
555
|
+
* UI: pin + redeploy the function's active version / pin only / cancel.
|
|
556
|
+
*/
|
|
557
|
+
export async function secretsPinCommand(args) {
|
|
558
|
+
const name = args.positional[0];
|
|
559
|
+
const fnSlug = typeof args.flags.fn === 'string' ? args.flags.fn : undefined;
|
|
560
|
+
if (!name || !fnSlug) {
|
|
561
|
+
console.error('usage: seq-studio secrets pin <NAME> --fn <slug> [version] [--unpin] [-e env] [--yes] [--no-redeploy]');
|
|
562
|
+
return 1;
|
|
563
|
+
}
|
|
564
|
+
const ctx = await buildContext(args);
|
|
565
|
+
const secret = await resolveSecretByName({ ctx, name });
|
|
566
|
+
const fn = await resolveFunctionBySlug({ ctx, slug: fnSlug });
|
|
567
|
+
if (flagBool(args.flags, 'unpin')) {
|
|
568
|
+
const preview = [
|
|
569
|
+
`${LOG} unpin ${fnSlug} from ${name} — back to the shared default`,
|
|
570
|
+
`${LOG} redeploy ${fnSlug} afterwards to apply`,
|
|
571
|
+
];
|
|
572
|
+
const ok = await confirmYes({ preview, confirmed: flagBool(args.flags, 'yes') });
|
|
573
|
+
if (!ok)
|
|
574
|
+
return 1;
|
|
575
|
+
const result = await patchJson({
|
|
576
|
+
...clientOptions(ctx),
|
|
577
|
+
path: `/api/managed-secrets/${secret.id}/attachments`,
|
|
578
|
+
body: { functionId: fn.id, versionId: null },
|
|
579
|
+
});
|
|
580
|
+
console.log(`${LOG} ${result.detail}`);
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
const versions = await listSecretVersions(ctx, secret.id);
|
|
584
|
+
if (versions.length === 0) {
|
|
585
|
+
console.error(`${LOG} "${name}" has no recorded versions to pin`);
|
|
586
|
+
return 1;
|
|
587
|
+
}
|
|
588
|
+
let target;
|
|
589
|
+
const versionArg = args.positional[1];
|
|
590
|
+
if (versionArg) {
|
|
591
|
+
const resolved = resolveVersionTarget(versions, versionArg);
|
|
592
|
+
if (resolved.error) {
|
|
593
|
+
console.error(`${LOG} ${resolved.error}`);
|
|
594
|
+
return 1;
|
|
595
|
+
}
|
|
596
|
+
target = resolved.target;
|
|
597
|
+
if (!target)
|
|
598
|
+
return 1;
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
target = versions.find((v) => v.isActive);
|
|
602
|
+
if (!target) {
|
|
603
|
+
console.error(`${LOG} "${name}" has no default version — pass an explicit version to pin`);
|
|
604
|
+
return 1;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const preview = [
|
|
608
|
+
`${LOG} pin ${fnSlug} to ${name} v${target.gcpVersion} (sticky — survives rotations until --unpin)`,
|
|
609
|
+
];
|
|
610
|
+
const canRedeploy = canAdminFunction(fn);
|
|
611
|
+
const redeployChoice = await resolvePinRedeployChoice({
|
|
612
|
+
args,
|
|
613
|
+
fnSlug,
|
|
614
|
+
preview,
|
|
615
|
+
canRedeploy,
|
|
616
|
+
});
|
|
617
|
+
if (!redeployChoice.ok)
|
|
618
|
+
return 1;
|
|
619
|
+
const { redeploy } = redeployChoice;
|
|
620
|
+
const result = await patchJson({
|
|
621
|
+
...clientOptions(ctx),
|
|
622
|
+
path: `/api/managed-secrets/${secret.id}/attachments`,
|
|
623
|
+
body: { functionId: fn.id, versionId: target.id },
|
|
624
|
+
});
|
|
625
|
+
console.log(`${LOG} pinned ${fnSlug} to ${name} v${result.gcpVersion}`);
|
|
626
|
+
if (!redeploy) {
|
|
627
|
+
console.log(`${LOG} redeploy ${fnSlug} to mount v${result.gcpVersion}`);
|
|
628
|
+
return 0;
|
|
629
|
+
}
|
|
630
|
+
const activeVersionId = await resolveLiveActiveVersionId(ctx, fn.id);
|
|
631
|
+
if (!activeVersionId) {
|
|
632
|
+
console.warn(`${LOG} ${fnSlug} has no deployed version — nothing to redeploy`);
|
|
633
|
+
return 0;
|
|
634
|
+
}
|
|
635
|
+
console.log(`${LOG} redeploying ${fnSlug}…`);
|
|
636
|
+
await postJson({
|
|
637
|
+
...clientOptions(ctx),
|
|
638
|
+
path: `/api/managed-functions/${fn.id}/versions/${activeVersionId}/promote`,
|
|
639
|
+
});
|
|
640
|
+
return pollVersion({ ctx, functionId: fn.id, versionId: activeVersionId });
|
|
641
|
+
}
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// dispatcher
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
export const SECRETS_USAGE = `usage:
|
|
646
|
+
seq-studio secrets create <NAME> [--description text] register an org-owned secret
|
|
647
|
+
seq-studio secrets set <NAME> set the shared default value (write-only)
|
|
648
|
+
seq-studio secrets list [-e env] secrets you can see (never values)
|
|
649
|
+
seq-studio secrets attach <NAME> --fn <slug> mount default on a function env var
|
|
650
|
+
seq-studio secrets detach <NAME> --fn <slug> remove attachment
|
|
651
|
+
seq-studio secrets apply --from-env-file .env [-e env] push defaults + attach (keys default from manifest)
|
|
652
|
+
seq-studio secrets versions <NAME> value version history (never values)
|
|
653
|
+
seq-studio secrets set-default <NAME> [version] point the default at a prior version (editor)
|
|
654
|
+
seq-studio secrets pin <NAME> --fn <slug> [version] pin one function to a version (sticky; --unpin clears)
|
|
655
|
+
|
|
656
|
+
Note: for the common deploy loop, secrets declared in managed-function.yml are
|
|
657
|
+
reconciled automatically by \`seq-studio functions deploy\` using a local .env.
|
|
658
|
+
Use \`secrets\` commands for CI (no .env), bulk/multi-function ops, or write-only
|
|
659
|
+
value changes without a redeploy.
|
|
660
|
+
|
|
661
|
+
Flags: -e/--env <local|staging|production|banksouth>
|
|
662
|
+
--fn <slug> · --env-var <NAME> · --yes
|
|
663
|
+
--functions <f1,f2> · --keys <K1,K2> · --all (override key selection)
|
|
664
|
+
set-default: [version|version-row-id] (defaults to the most recent non-default) · --yes
|
|
665
|
+
pin: [version|version-row-id] (defaults to the current default) · --unpin · --yes (redeploy) · --yes --no-redeploy
|
|
666
|
+
`;
|
|
667
|
+
export async function runSecretsCommand(sub, args) {
|
|
668
|
+
try {
|
|
669
|
+
switch (sub) {
|
|
670
|
+
case 'create':
|
|
671
|
+
return await secretsCreateCommand(args);
|
|
672
|
+
case 'set':
|
|
673
|
+
return await secretsSetCommand(args);
|
|
674
|
+
case 'list':
|
|
675
|
+
return await secretsListCommand(args);
|
|
676
|
+
case 'attach':
|
|
677
|
+
return await secretsAttachCommand(args);
|
|
678
|
+
case 'detach':
|
|
679
|
+
return await secretsDetachCommand(args);
|
|
680
|
+
case 'apply':
|
|
681
|
+
return await secretsApplyCommand(args);
|
|
682
|
+
case 'versions':
|
|
683
|
+
return await secretsVersionsCommand(args);
|
|
684
|
+
case 'set-default':
|
|
685
|
+
return await secretsSetDefaultCommand(args);
|
|
686
|
+
case 'pin':
|
|
687
|
+
return await secretsPinCommand(args);
|
|
688
|
+
case 'help':
|
|
689
|
+
case '--help':
|
|
690
|
+
case '-h':
|
|
691
|
+
case undefined:
|
|
692
|
+
console.log(SECRETS_USAGE);
|
|
693
|
+
return sub ? 0 : 1;
|
|
694
|
+
default:
|
|
695
|
+
console.error(`unknown secrets command: ${sub}`);
|
|
696
|
+
console.error(SECRETS_USAGE);
|
|
697
|
+
return 1;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
printError(error);
|
|
702
|
+
return 1;
|
|
703
|
+
}
|
|
704
|
+
}
|