@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,548 @@
1
+ /**
2
+ * `seq-studio repos <sub>` — basic management of platform git-service repos
3
+ * over the JSON API.
4
+ *
5
+ * Everything here is API-driven (Auth0 bearer via seqapi login), matching the
6
+ * rest of seq-studio. `pull` materializes a tree over the JSON API; `clone`
7
+ * prefers a real `git clone` when `ATLAS_GIT_PAT` is set (smart-HTTP URL from
8
+ * `show`), otherwise falls back to the same JSON materialize path and hints
9
+ * at `seq-studio auth pat create`.
10
+ *
11
+ * Repo addressing is `<namespace>/<name>` everywhere; the API is id-addressed,
12
+ * so commands resolve the path via the namespaces + repos endpoints (the same
13
+ * dance the Repositories UI does — there is no /repos/by-path endpoint).
14
+ */
15
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { resolve, sep } from 'node:path';
18
+ import { materializeRepo, resolveCommitSha, resolveRepo, } from '@sequenceholdings/artifact-studio/git-service-client';
19
+ import { deleteNoContent, getJson, postJson } from '../atlas-client.js';
20
+ import { printCliError } from '../cli-errors.js';
21
+ import { PREVIEW_DOMAIN } from '../preview.js';
22
+ import { confirmYes } from '../prompt.js';
23
+ import { buildContext, clientOptions, flagBool, LOG, } from '../functions/commands.js';
24
+ import { resolveGitPatFromEnv, runGitClone, } from './git-clone.js';
25
+ import { formatPatSetupHint } from '../pat-hints.js';
26
+ import { tryGetAccessToken } from '../auth.js';
27
+ import { readConfig, resolveEnv } from '../config.js';
28
+ const PAGE_SIZE = 200;
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+ export function parseRepoPath(input) {
33
+ const parts = (input ?? '').split('/');
34
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
35
+ throw new Error(`Expected a repo path of the form <namespace>/<name> (got "${input ?? ''}").`);
36
+ }
37
+ return { namespace: parts[0], name: parts[1] };
38
+ }
39
+ /**
40
+ * A value-taking flag given without a value parses as `true` (parseArgs
41
+ * semantics) — silently ignoring it would make the command do something other
42
+ * than what was asked (e.g. `list --namespace --mine` printing the FULL repo
43
+ * list, or `pull --ref --out x` fetching the default branch). Error instead.
44
+ */
45
+ export function stringFlag(flags, key) {
46
+ const value = flags[key];
47
+ if (value === undefined)
48
+ return undefined;
49
+ if (typeof value !== 'string')
50
+ throw new Error(`--${key} requires a value.`);
51
+ return value;
52
+ }
53
+ /**
54
+ * buildContext for repos commands: rejects a valueless -e/--env (which would
55
+ * silently fall back to the default environment — dangerous with `delete
56
+ * --yes`) and threads the Cloudflare preview WAF bypass header to
57
+ * artifact-studio's HTTP client, which resolveRepo/materializeRepo use
58
+ * (mirrors applyPreviewAccessHeader in ../artifact/delegate.ts — studio-cli's
59
+ * own atlas-client reads PREVIEW_ACCESS_HEADER directly).
60
+ */
61
+ async function reposContext(args) {
62
+ if (args.flags.env === true || args.flags.e === true) {
63
+ throw new Error('-e/--env requires a value (local|staging|production|banksouth|preview:<slug>).');
64
+ }
65
+ const ctx = await buildContext(args);
66
+ const secret = process.env.PREVIEW_ACCESS_HEADER?.trim();
67
+ if (secret && isPreviewHost(ctx.env.url)) {
68
+ process.env.ARTIFACT_STUDIO_EXTRA_HEADERS = JSON.stringify({ 'x-preview-access': secret });
69
+ }
70
+ return ctx;
71
+ }
72
+ function isPreviewHost(baseUrl) {
73
+ try {
74
+ return new URL(baseUrl).hostname.toLowerCase().endsWith(`.${PREVIEW_DOMAIN}`);
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ async function listAllPages(ctx, basePath) {
81
+ const sep = basePath.includes('?') ? '&' : '?';
82
+ const out = [];
83
+ for (let offset = 0;; offset += PAGE_SIZE) {
84
+ const page = await getJson({
85
+ ...clientOptions(ctx),
86
+ path: `${basePath}${sep}limit=${PAGE_SIZE}&offset=${offset}`,
87
+ });
88
+ out.push(...page.items);
89
+ if (page.items.length === 0 || out.length >= page.total)
90
+ return out;
91
+ }
92
+ }
93
+ /** Smart-HTTP clone base (aligned with PLA-83: `…/repos/<id>/git`). */
94
+ export function cloneUrl(ctx, repoId) {
95
+ return `${ctx.env.url}/api/git-service/repos/${repoId}/git`;
96
+ }
97
+ function shortDate(iso) {
98
+ return iso.slice(0, 10);
99
+ }
100
+ function assertDestWritable({ destDir, force, }) {
101
+ if (!existsSync(destDir))
102
+ return;
103
+ const entries = readdirSync(destDir);
104
+ if (entries.length === 0)
105
+ return;
106
+ if (!force) {
107
+ throw new Error(`${destDir} exists and is not empty — pass --force to write into it anyway ` +
108
+ '(existing files are overwritten; files absent at the pulled ref are NOT removed).');
109
+ }
110
+ }
111
+ /**
112
+ * `--force` may recursively delete `destDir`. Refuse cwd / home / filesystem
113
+ * roots / ancestors of cwd so a typo like `--out . --force` cannot wipe the
114
+ * working tree.
115
+ */
116
+ export function assertSafeForceDest(destDir) {
117
+ const resolved = resolve(destDir);
118
+ const cwd = resolve(process.cwd());
119
+ const home = resolve(homedir());
120
+ const forbidden = new Set([
121
+ resolved === '/' ? '/' : '',
122
+ cwd,
123
+ home,
124
+ resolve('/'),
125
+ resolve('/Users'),
126
+ resolve('/home'),
127
+ resolve('/tmp'),
128
+ resolve('/var'),
129
+ resolve('/etc'),
130
+ ].filter(Boolean));
131
+ if (forbidden.has(resolved)) {
132
+ throw new Error(`--force refuses to delete ${resolved} (cwd, home, or system path). ` +
133
+ 'Pass a dedicated empty subdirectory, e.g. --out ./my-repo.');
134
+ }
135
+ // Ancestor of cwd (e.g. --out .. --force while inside a project).
136
+ if (cwd !== resolved && cwd.startsWith(resolved.endsWith(sep) ? resolved : `${resolved}${sep}`)) {
137
+ throw new Error(`--force refuses to delete ${resolved} because it contains the current working directory.`);
138
+ }
139
+ // Too shallow to be a sensible clone target (e.g. /Users/you).
140
+ const depth = resolved.split(sep).filter(Boolean).length;
141
+ if (depth < 2) {
142
+ throw new Error(`--force refuses to delete shallow path ${resolved}. Pass a dedicated subdirectory.`);
143
+ }
144
+ }
145
+ /** Prepare destination for smart-HTTP clone; optionally wipe when --force. */
146
+ function prepareCloneDest({ destDir, force }) {
147
+ assertDestWritable({ destDir, force });
148
+ if (force && existsSync(destDir) && readdirSync(destDir).length > 0) {
149
+ assertSafeForceDest(destDir);
150
+ rmSync(destDir, { recursive: true, force: true });
151
+ }
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // Commands
155
+ // ---------------------------------------------------------------------------
156
+ export async function reposNamespacesCommand(args) {
157
+ const [action, slug] = args.positional;
158
+ const ctx = await reposContext(args);
159
+ if (action === 'create') {
160
+ if (!slug) {
161
+ console.error('usage: seq-studio repos namespaces create <slug> [-e env]');
162
+ return 1;
163
+ }
164
+ const created = await postJson({
165
+ ...clientOptions(ctx),
166
+ path: '/api/git-service/namespaces',
167
+ body: { slug },
168
+ });
169
+ console.log(`${LOG} created namespace ${created.slug} (id=${created.id}) on ${ctx.env.name}`);
170
+ return 0;
171
+ }
172
+ if (action !== undefined) {
173
+ console.error(`unknown namespaces action: ${action} (expected "create" or no action to list)`);
174
+ return 1;
175
+ }
176
+ const namespaces = await listAllPages(ctx, '/api/git-service/namespaces');
177
+ if (namespaces.length === 0) {
178
+ console.log(`${LOG} no namespaces on ${ctx.env.name}`);
179
+ return 0;
180
+ }
181
+ console.log(`${LOG} ${namespaces.length} namespace${namespaces.length === 1 ? '' : 's'} on ${ctx.env.name}:`);
182
+ for (const ns of namespaces.sort((a, b) => a.slug.localeCompare(b.slug))) {
183
+ console.log(` ${ns.slug} id=${ns.id} created=${shortDate(ns.createdAt)} by ${ns.createdBy}`);
184
+ }
185
+ return 0;
186
+ }
187
+ export async function reposListCommand(args) {
188
+ const namespaceFilter = stringFlag(args.flags, 'namespace');
189
+ const ctx = await reposContext(args);
190
+ const namespaces = await listAllPages(ctx, '/api/git-service/namespaces');
191
+ const slugById = new Map(namespaces.map((ns) => [ns.id, ns.slug]));
192
+ let basePath = '/api/git-service/repos';
193
+ const filters = [];
194
+ if (namespaceFilter) {
195
+ const ns = namespaces.find((candidate) => candidate.slug === namespaceFilter);
196
+ if (!ns) {
197
+ console.error(`${LOG} namespace "${namespaceFilter}" not found on ${ctx.env.name}`);
198
+ return 1;
199
+ }
200
+ filters.push(`namespaceId=${encodeURIComponent(ns.id)}`);
201
+ }
202
+ if (flagBool(args.flags, 'mine'))
203
+ filters.push('createdBy=me');
204
+ if (filters.length > 0)
205
+ basePath += `?${filters.join('&')}`;
206
+ const repos = await listAllPages(ctx, basePath);
207
+ if (repos.length === 0) {
208
+ console.log(`${LOG} no repos visible on ${ctx.env.name}`);
209
+ return 0;
210
+ }
211
+ const path = (repo) => `${slugById.get(repo.namespaceId) ?? repo.namespaceId}/${repo.name}`;
212
+ const width = Math.max(...repos.map((repo) => path(repo).length));
213
+ console.log(`${LOG} ${repos.length} repo${repos.length === 1 ? '' : 's'} on ${ctx.env.name}:`);
214
+ for (const repo of repos.sort((a, b) => path(a).localeCompare(path(b)))) {
215
+ console.log(` ${path(repo).padEnd(width)} ${repo.defaultBranch} updated=${shortDate(repo.updatedAt)} by ${repo.createdBy}`);
216
+ }
217
+ return 0;
218
+ }
219
+ export async function reposShowCommand(args) {
220
+ const { namespace, name } = parseRepoPath(args.positional[0]);
221
+ const ctx = await reposContext(args);
222
+ const resolved = await resolveRepo({ ...clientOptions(ctx), namespace, name });
223
+ const repo = await getJson({
224
+ ...clientOptions(ctx),
225
+ path: `/api/git-service/repos/${resolved.id}`,
226
+ });
227
+ const branches = await getJson({
228
+ ...clientOptions(ctx),
229
+ path: `/api/git-service/repos/${resolved.id}/refs?prefix=${encodeURIComponent('refs/heads/')}`,
230
+ });
231
+ console.log(`${LOG} repo on ${ctx.env.name}:`);
232
+ console.log(` path: ${namespace}/${name}`);
233
+ console.log(` id: ${repo.id}`);
234
+ console.log(` default branch: ${repo.defaultBranch}`);
235
+ console.log(` created: ${shortDate(repo.createdAt)} by ${repo.createdBy}`);
236
+ console.log(` updated: ${shortDate(repo.updatedAt)}`);
237
+ console.log(` clone (PAT as basic-auth password): ${cloneUrl(ctx, repo.id)}`);
238
+ // Best-effort cross-link to a matching Artifact Studio project on this env
239
+ // (same slug as the repo name). Failures are silent — not every repo is an
240
+ // artifact, and the projects endpoint may 403 for some principals.
241
+ try {
242
+ const projects = await getJson({
243
+ ...clientOptions(ctx),
244
+ path: '/api/artifact-studio/projects',
245
+ });
246
+ const match = projects.projects.find((p) => p.slug === name);
247
+ if (match) {
248
+ console.log(` artifactSlug: ${match.slug}`);
249
+ console.log(` artifactProjectId: ${match.id}`);
250
+ }
251
+ }
252
+ catch {
253
+ // ignore
254
+ }
255
+ if (branches.items.length > 0) {
256
+ console.log(` branches:`);
257
+ for (const branch of branches.items) {
258
+ console.log(` ${branch.ref.replace('refs/heads/', '')} @ ${branch.sha.slice(0, 12)}`);
259
+ }
260
+ }
261
+ return 0;
262
+ }
263
+ export async function reposCreateCommand(args) {
264
+ const { namespace, name } = parseRepoPath(args.positional[0]);
265
+ const ctx = await reposContext(args);
266
+ const defaultBranch = stringFlag(args.flags, 'default-branch');
267
+ const repo = await postJson({
268
+ ...clientOptions(ctx),
269
+ path: '/api/git-service/repos',
270
+ body: { namespaceSlug: namespace, name, ...(defaultBranch ? { defaultBranch } : {}) },
271
+ });
272
+ console.log(`${LOG} created ${namespace}/${name} (id=${repo.id}, default branch ${repo.defaultBranch}) on ${ctx.env.name}`);
273
+ return 0;
274
+ }
275
+ async function materializeTree({ ctx, namespace, name, destDir, ref, verb, }) {
276
+ const resolved = await resolveRepo({ ...clientOptions(ctx), namespace, name });
277
+ const requestedRef = ref ?? resolved.defaultBranch;
278
+ const sha = await resolveCommitSha({
279
+ ...clientOptions(ctx),
280
+ repoId: resolved.id,
281
+ ref: requestedRef,
282
+ });
283
+ const fileCount = await materializeRepo({
284
+ ...clientOptions(ctx),
285
+ repoId: resolved.id,
286
+ ref: sha,
287
+ destDir,
288
+ });
289
+ console.log(`${LOG} ${verb} ${namespace}/${name} @ ${sha.slice(0, 12)} (${requestedRef}) → ${destDir} (${fileCount} files)`);
290
+ return 0;
291
+ }
292
+ export async function reposPullCommand(args) {
293
+ const { namespace, name } = parseRepoPath(args.positional[0]);
294
+ const ctx = await reposContext(args);
295
+ const destDir = resolve(stringFlag(args.flags, 'out') ?? name);
296
+ assertDestWritable({ destDir, force: flagBool(args.flags, 'force') });
297
+ return materializeTree({
298
+ ctx,
299
+ namespace,
300
+ name,
301
+ destDir,
302
+ ref: stringFlag(args.flags, 'ref'),
303
+ verb: 'pulled',
304
+ });
305
+ }
306
+ /**
307
+ * Preferred clone verb.
308
+ *
309
+ * With `ATLAS_GIT_PAT`:
310
+ * - `--url <clone-url>` or `--id <repo-uuid>` → smart-HTTP clone with no seqapi
311
+ * - `<ns>/<name>` → needs Auth0 to resolve the UUID, then smart-HTTP clone
312
+ * Without a PAT: JSON materialize (needs Auth0) + PAT setup hints.
313
+ *
314
+ * `gitClone` is injectable for tests.
315
+ */
316
+ export async function reposCloneCommand(args, deps = {}) {
317
+ const force = flagBool(args.flags, 'force');
318
+ const ref = stringFlag(args.flags, 'ref');
319
+ const pat = resolveGitPatFromEnv();
320
+ const urlFlag = stringFlag(args.flags, 'url');
321
+ const idFlag = stringFlag(args.flags, 'id');
322
+ const positional = args.positional[0];
323
+ if (urlFlag && idFlag) {
324
+ throw new Error('Pass only one of --url or --id.');
325
+ }
326
+ if ((urlFlag || idFlag) && positional) {
327
+ throw new Error('Pass either <ns>/<name> or --url/--id, not both.');
328
+ }
329
+ // --url/--id are PAT-only (no JSON materialize for a bare UUID/URL).
330
+ if ((urlFlag || idFlag) && !pat) {
331
+ const env = await resolveEnvOnly(args);
332
+ const lines = [
333
+ `${LOG} --url/--id requires ATLAS_GIT_PAT (JSON materialize needs <ns>/<name>).`,
334
+ ...formatPatSetupHint({ envUrl: env.url, envName: env.name }),
335
+ ];
336
+ throw new Error(lines.join('\n'));
337
+ }
338
+ // PAT-only path: clone by URL or repo UUID without seqapi/Auth0.
339
+ if (pat && (urlFlag || idFlag)) {
340
+ const env = await resolveEnvOnly(args);
341
+ const targetUrl = urlFlag
342
+ ? normalizeCloneUrl(urlFlag, env)
343
+ : cloneUrl({ env, token: '' }, idFlag);
344
+ const destDir = resolve(stringFlag(args.flags, 'out') ?? defaultOutFromCloneTarget({ url: targetUrl, id: idFlag }));
345
+ prepareCloneDest({ destDir, force });
346
+ const clone = deps.gitClone ?? runGitClone;
347
+ await clone({
348
+ cloneUrl: targetUrl,
349
+ destDir,
350
+ pat,
351
+ ...(ref ? { ref } : {}),
352
+ });
353
+ console.log(`${LOG} cloned ${targetUrl} → ${destDir}` +
354
+ (ref ? ` (ref ${ref})` : '') +
355
+ ` via smart-HTTP`);
356
+ return 0;
357
+ }
358
+ // <ns>/<name> path — needs Auth0 for resolveRepo (JSON API) whether or not
359
+ // we then use smart-HTTP. Without Auth0, point users at --url from the UI.
360
+ if (!positional) {
361
+ console.error('usage: seq-studio repos clone <ns>/<name> | --url <clone-url> | --id <uuid> [-e env] [--ref r] [--out dir]');
362
+ return 1;
363
+ }
364
+ const { namespace, name } = parseRepoPath(positional);
365
+ const token = await tryGetAccessToken();
366
+ if (!token) {
367
+ const env = await resolveEnvOnly(args);
368
+ const lines = pat
369
+ ? [
370
+ `ATLAS_GIT_PAT is set, but resolving ${namespace}/${name} needs Auth0 (seqapi login).`,
371
+ ` No seqapi? Copy the clone URL from Repositories → Clone, then:`,
372
+ ` ATLAS_GIT_PAT=<token> seq-studio repos clone --url <https://…/repos/<id>/git> -e ${env.name}`,
373
+ ]
374
+ : [
375
+ `${LOG} no ATLAS_GIT_PAT and not logged in (seqapi).`,
376
+ ...formatPatSetupHint({ envUrl: env.url, envName: env.name }),
377
+ ` Or: seqapi login && seq-studio repos clone ${namespace}/${name} -e ${env.name}`,
378
+ ];
379
+ throw new Error(lines.join('\n'));
380
+ }
381
+ const ctx = await reposContext(args);
382
+ const destDir = resolve(stringFlag(args.flags, 'out') ?? name);
383
+ if (pat) {
384
+ prepareCloneDest({ destDir, force });
385
+ const resolved = await resolveRepo({ ...clientOptions(ctx), namespace, name });
386
+ const targetUrl = cloneUrl(ctx, resolved.id);
387
+ const clone = deps.gitClone ?? runGitClone;
388
+ await clone({
389
+ cloneUrl: targetUrl,
390
+ destDir,
391
+ pat,
392
+ ...(ref ? { ref } : {}),
393
+ });
394
+ console.log(`${LOG} cloned ${namespace}/${name} → ${destDir}` +
395
+ (ref ? ` (ref ${ref})` : '') +
396
+ ` via smart-HTTP`);
397
+ return 0;
398
+ }
399
+ assertDestWritable({ destDir, force });
400
+ console.log(`${LOG} no ATLAS_GIT_PAT — materializing via JSON API (no .git dir).`);
401
+ for (const line of formatPatSetupHint({ envUrl: ctx.env.url, envName: ctx.env.name })) {
402
+ console.log(line);
403
+ }
404
+ return materializeTree({
405
+ ctx,
406
+ namespace,
407
+ name,
408
+ destDir,
409
+ ref,
410
+ verb: 'cloned',
411
+ });
412
+ }
413
+ /** Resolve -e/--env without requiring Auth0 (for PAT-only --url/--id clones). */
414
+ async function resolveEnvOnly(args) {
415
+ if (args.flags.env === true || args.flags.e === true) {
416
+ throw new Error('-e/--env requires a value (local|staging|production|banksouth|preview:<slug>).');
417
+ }
418
+ const config = await readConfig();
419
+ const requested = (typeof args.flags.env === 'string' ? args.flags.env : undefined) ??
420
+ (typeof args.flags.e === 'string' ? args.flags.e : undefined);
421
+ return resolveEnv({ config, requested });
422
+ }
423
+ /**
424
+ * Validate clone URL shape and that the origin matches the resolved Atlas env
425
+ * so askpass never sends ATLAS_GIT_PAT to an attacker-controlled host.
426
+ */
427
+ export function normalizeCloneUrl(raw, env) {
428
+ let url;
429
+ try {
430
+ url = new URL(raw);
431
+ }
432
+ catch {
433
+ throw new Error(`--url must be a valid HTTPS clone URL (got "${raw}").`);
434
+ }
435
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
436
+ throw new Error(`--url must be http(s) (got ${url.protocol}).`);
437
+ }
438
+ // HTTPS everywhere except local (http://localhost:5001).
439
+ if (env.name !== 'local' && url.protocol !== 'https:') {
440
+ throw new Error(`--url must use HTTPS for ${env.name} (got ${url.protocol}).`);
441
+ }
442
+ url.username = '';
443
+ url.password = '';
444
+ if (!/\/api\/git-service\/repos\/[^/]+\/git\/?$/.test(url.pathname)) {
445
+ throw new Error(`--url must look like …/api/git-service/repos/<id>/git (got ${url.pathname}).`);
446
+ }
447
+ let expected;
448
+ try {
449
+ expected = new URL(env.url);
450
+ }
451
+ catch {
452
+ throw new Error(`Invalid Atlas env URL for ${env.name}: ${env.url}`);
453
+ }
454
+ if (url.origin !== expected.origin) {
455
+ throw new Error(`--url origin ${url.origin} does not match Atlas env ${env.name} (${expected.origin}). ` +
456
+ `Pass -e to select the matching environment, or use --id <uuid> instead of a foreign URL.`);
457
+ }
458
+ return url.toString().replace(/\/$/, '');
459
+ }
460
+ function defaultOutFromCloneTarget({ url, id, }) {
461
+ if (id)
462
+ return id;
463
+ const match = /\/repos\/([^/]+)\/git\/?$/.exec(new URL(url).pathname);
464
+ return match?.[1] ?? 'repo';
465
+ }
466
+ export async function reposDeleteCommand(args) {
467
+ const { namespace, name } = parseRepoPath(args.positional[0]);
468
+ const ctx = await reposContext(args);
469
+ const resolved = await resolveRepo({ ...clientOptions(ctx), namespace, name });
470
+ const ok = await confirmYes({
471
+ preview: [
472
+ `${LOG} about to DELETE ${namespace}/${name} (id=${resolved.id}) on ${ctx.env.name}.`,
473
+ `${LOG} this deletes the repo record — it becomes unreachable via the API and UI;`,
474
+ `${LOG} underlying git objects may be retained in storage until a future GC.`,
475
+ ],
476
+ confirmed: flagBool(args.flags, 'yes'),
477
+ });
478
+ if (!ok)
479
+ return 1;
480
+ await deleteNoContent({
481
+ ...clientOptions(ctx),
482
+ path: `/api/git-service/repos/${resolved.id}`,
483
+ });
484
+ console.log(`${LOG} deleted ${namespace}/${name} on ${ctx.env.name}`);
485
+ return 0;
486
+ }
487
+ // ---------------------------------------------------------------------------
488
+ // Router
489
+ // ---------------------------------------------------------------------------
490
+ export const REPOS_USAGE = `usage:
491
+ seq-studio repos list [-e env] [--namespace <slug>] [--mine] repos visible on the environment
492
+ seq-studio repos namespaces [create <slug>] [-e env] list or create namespaces
493
+ seq-studio repos show <ns>/<name> [-e env] repo detail (branches, clone URL)
494
+ seq-studio repos create <ns>/<name> [-e env] [--default-branch b] create an empty repo
495
+ seq-studio repos clone <ns>/<name> [-e env] [--ref r] [--out dir] [--force]
496
+ seq-studio repos clone --url <https://…/repos/<id>/git> [-e env] [--ref r] [--out dir]
497
+ seq-studio repos clone --id <uuid> [-e env] [--ref r] [--out dir]
498
+ smart-HTTP when ATLAS_GIT_PAT is set;
499
+ otherwise JSON materialize (<ns>/<name>)
500
+ seq-studio repos pull <ns>/<name> [-e env] [--ref r] [--out dir] [--force]
501
+ materialize the tree at a ref (JSON API)
502
+ seq-studio repos delete <ns>/<name> [-e env] [--yes] delete a repo (confirm prompt)
503
+
504
+ Flags: -e/--env <local|staging|production|banksouth>
505
+
506
+ clone prefers real git clone (PAT via askpass — never written into the remote
507
+ URL). Without ATLAS_GIT_PAT, <ns>/<name> falls back to the JSON API and prints
508
+ how to get a PAT (Atlas UI /settings/tokens — no seqapi required).
509
+ No seqapi + PAT: use --url from Repositories → Clone (or --id <uuid>).
510
+ --ref accepts a branch, tag, or commit SHA (SHA → clone then checkout).
511
+
512
+ Authenticate JSON API calls with: seqapi login
513
+ Authenticate git clone/push with: ATLAS_GIT_PAT (from Atlas Settings → Tokens)
514
+ `;
515
+ export async function runReposCommand(sub, args) {
516
+ try {
517
+ switch (sub) {
518
+ case 'list':
519
+ return await reposListCommand(args);
520
+ case 'namespaces':
521
+ return await reposNamespacesCommand(args);
522
+ case 'show':
523
+ return await reposShowCommand(args);
524
+ case 'create':
525
+ return await reposCreateCommand(args);
526
+ case 'clone':
527
+ return await reposCloneCommand(args);
528
+ case 'pull':
529
+ return await reposPullCommand(args);
530
+ case 'delete':
531
+ return await reposDeleteCommand(args);
532
+ case 'help':
533
+ case '--help':
534
+ case '-h':
535
+ case undefined:
536
+ console.log(REPOS_USAGE);
537
+ return sub ? 0 : 1;
538
+ default:
539
+ console.error(`unknown repos command: ${sub}`);
540
+ console.error(REPOS_USAGE);
541
+ return 1;
542
+ }
543
+ }
544
+ catch (error) {
545
+ printCliError(error);
546
+ return 1;
547
+ }
548
+ }
@@ -0,0 +1,10 @@
1
+ export { looksLikeCommitSha, redactCloneUrl, resolveGitPatFromEnv, runGitClone, withGitAskpass, type RunGitClone, } from '@sequenceholdings/artifact-studio/git-clone';
2
+ /**
3
+ * Write a credential via `git credential approve` so subsequent clones against
4
+ * the same host can authenticate without embedding the PAT in the URL.
5
+ */
6
+ export declare function storeGitCredentials({ cloneUrl, pat, username, }: {
7
+ cloneUrl: string;
8
+ pat: string;
9
+ username?: string;
10
+ }): Promise<void>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Smart-HTTP git clone helpers for platform git-service.
3
+ *
4
+ * The clone primitives (askpass, runGitClone, PAT/URL helpers) are the
5
+ * canonical implementation in `@sequenceholdings/artifact-studio/git-clone` so
6
+ * the `repos` tool and the artifact `--repo` deploy path share one code path.
7
+ * We re-export them here to keep the historical import site stable, and add
8
+ * `storeGitCredentials` (git credential-helper writer) which is studio-cli
9
+ * specific.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ export { looksLikeCommitSha, redactCloneUrl, resolveGitPatFromEnv, runGitClone, withGitAskpass, } from '@sequenceholdings/artifact-studio/git-clone';
13
+ /**
14
+ * Write a credential via `git credential approve` so subsequent clones against
15
+ * the same host can authenticate without embedding the PAT in the URL.
16
+ */
17
+ export async function storeGitCredentials({ cloneUrl, pat, username = 'git', }) {
18
+ const parsed = new URL(cloneUrl);
19
+ const protocol = parsed.protocol.replace(/:$/, '');
20
+ const host = parsed.host;
21
+ const path = parsed.pathname;
22
+ const payload = [
23
+ `protocol=${protocol}`,
24
+ `host=${host}`,
25
+ `path=${path}`,
26
+ `username=${username}`,
27
+ `password=${pat}`,
28
+ '',
29
+ ].join('\n');
30
+ await new Promise((resolve, reject) => {
31
+ const child = spawn('git', ['credential', 'approve'], {
32
+ stdio: ['pipe', 'ignore', 'pipe'],
33
+ });
34
+ let stderr = '';
35
+ child.stderr?.on('data', (chunk) => {
36
+ stderr += chunk.toString();
37
+ });
38
+ child.on('error', (err) => reject(err));
39
+ child.on('close', (code) => {
40
+ if (code === 0) {
41
+ resolve();
42
+ return;
43
+ }
44
+ reject(new Error(`git credential approve failed (exit ${code ?? 'null'}): ${stderr.trim() || 'no stderr'}`));
45
+ });
46
+ child.stdin?.write(payload);
47
+ child.stdin?.end();
48
+ });
49
+ }
@@ -0,0 +1,24 @@
1
+ import type { ParsedArgs } from '../process/commands.js';
2
+ export declare const LOG = "[seq-studio]";
3
+ export declare function secretsCreateCommand(args: ParsedArgs): Promise<number>;
4
+ export declare function secretsSetCommand(args: ParsedArgs): Promise<number>;
5
+ export declare function secretsListCommand(args: ParsedArgs): Promise<number>;
6
+ export declare function secretsAttachCommand(args: ParsedArgs): Promise<number>;
7
+ export declare function secretsDetachCommand(args: ParsedArgs): Promise<number>;
8
+ export declare function secretsApplyCommand(args: ParsedArgs): Promise<number>;
9
+ export declare function secretsVersionsCommand(args: ParsedArgs): Promise<number>;
10
+ /**
11
+ * Point the shared default at a prior version. Editor-tier, same as `set` —
12
+ * and with the same propagation model: nothing is redeployed, attached
13
+ * functions are flagged and pick the version up on their own next redeploy.
14
+ */
15
+ export declare function secretsSetDefaultCommand(args: ParsedArgs): Promise<number>;
16
+ /**
17
+ * Pin one attached function to a specific secret version (sticky — survives
18
+ * rotations until `--unpin`). Requires editor+ on the secret AND the
19
+ * function (server-enforced dual-editor gate). Three-way flow mirroring the
20
+ * UI: pin + redeploy the function's active version / pin only / cancel.
21
+ */
22
+ export declare function secretsPinCommand(args: ParsedArgs): Promise<number>;
23
+ export declare const SECRETS_USAGE = "usage:\n seq-studio secrets create <NAME> [--description text] register an org-owned secret\n seq-studio secrets set <NAME> set the shared default value (write-only)\n seq-studio secrets list [-e env] secrets you can see (never values)\n seq-studio secrets attach <NAME> --fn <slug> mount default on a function env var\n seq-studio secrets detach <NAME> --fn <slug> remove attachment\n seq-studio secrets apply --from-env-file .env [-e env] push defaults + attach (keys default from manifest)\n seq-studio secrets versions <NAME> value version history (never values)\n seq-studio secrets set-default <NAME> [version] point the default at a prior version (editor)\n seq-studio secrets pin <NAME> --fn <slug> [version] pin one function to a version (sticky; --unpin clears)\n\n Note: for the common deploy loop, secrets declared in managed-function.yml are\n reconciled automatically by `seq-studio functions deploy` using a local .env.\n Use `secrets` commands for CI (no .env), bulk/multi-function ops, or write-only\n value changes without a redeploy.\n\n Flags: -e/--env <local|staging|production|banksouth>\n --fn <slug> \u00B7 --env-var <NAME> \u00B7 --yes\n --functions <f1,f2> \u00B7 --keys <K1,K2> \u00B7 --all (override key selection)\n set-default: [version|version-row-id] (defaults to the most recent non-default) \u00B7 --yes\n pin: [version|version-row-id] (defaults to the current default) \u00B7 --unpin \u00B7 --yes (redeploy) \u00B7 --yes --no-redeploy\n";
24
+ export declare function runSecretsCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;