@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,173 @@
1
+ /**
2
+ * Minimal authenticated HTTP client for Atlas. Same retry-on-503 contract
3
+ * as `shared/services/artifact-studio/src/api.ts` so behavior is
4
+ * consistent across both Sequence CLIs. Token + baseUrl are passed in by
5
+ * the caller — this file knows nothing about token files or config TOMLs.
6
+ */
7
+ import { PREVIEW_DOMAIN } from './preview.js';
8
+ const MAX_503_RETRIES = 5;
9
+ const DEFAULT_RETRY_AFTER_SECONDS = 2;
10
+ const LOG_PREFIX = '[seq-studio]';
11
+ export class AtlasApiError extends Error {
12
+ status;
13
+ path;
14
+ statusText;
15
+ body;
16
+ name = 'AtlasApiError';
17
+ constructor(status, message, path, statusText = '', body = null) {
18
+ super(message);
19
+ this.status = status;
20
+ this.path = path;
21
+ this.statusText = statusText;
22
+ this.body = body;
23
+ }
24
+ }
25
+ async function fetchWith503Retry(input, init) {
26
+ let attempt = 0;
27
+ for (;;) {
28
+ const response = await fetch(input, init);
29
+ if (response.status !== 503 || attempt >= MAX_503_RETRIES)
30
+ return response;
31
+ const retryAfter = Number(response.headers.get('Retry-After')) || DEFAULT_RETRY_AFTER_SECONDS;
32
+ attempt += 1;
33
+ console.warn(`${LOG_PREFIX} backend warming up (503); retrying in ${retryAfter}s (attempt ${attempt}/${MAX_503_RETRIES})`);
34
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
35
+ }
36
+ }
37
+ function previewAccessHeaders(baseUrl) {
38
+ const secret = process.env.PREVIEW_ACCESS_HEADER?.trim();
39
+ if (!secret)
40
+ return {};
41
+ try {
42
+ const parsed = new URL(baseUrl);
43
+ if (parsed.protocol !== 'https:')
44
+ return {};
45
+ if (!parsed.hostname.toLowerCase().endsWith(`.${PREVIEW_DOMAIN}`))
46
+ return {};
47
+ return { 'x-preview-access': secret };
48
+ }
49
+ catch {
50
+ return {};
51
+ }
52
+ }
53
+ function messageFromBody(body, status, statusText) {
54
+ if (body && typeof body === 'object') {
55
+ const record = body;
56
+ if (typeof record.detail === 'string' && record.detail.length > 0)
57
+ return record.detail;
58
+ if (typeof record.error === 'string' && record.error.length > 0)
59
+ return record.error;
60
+ if (typeof record.message === 'string' && record.message.length > 0)
61
+ return record.message;
62
+ }
63
+ if (typeof body === 'string' && body.trim().length > 0) {
64
+ const trimmed = body.trim();
65
+ return trimmed.length > 500 ? `${trimmed.slice(0, 500)}…` : trimmed;
66
+ }
67
+ if (statusText)
68
+ return statusText;
69
+ return `HTTP ${status}`;
70
+ }
71
+ function parseErrorBody(rawText, contentType) {
72
+ if (rawText.length === 0)
73
+ return null;
74
+ const looksLikeJson = contentType.toLowerCase().includes('json') || /^\s*[{[]/.test(rawText);
75
+ if (!looksLikeJson)
76
+ return rawText;
77
+ try {
78
+ return JSON.parse(rawText);
79
+ }
80
+ catch {
81
+ return rawText;
82
+ }
83
+ }
84
+ async function responseError(response, path) {
85
+ const contentType = response.headers.get('content-type') ?? '';
86
+ const rawText = await response.text();
87
+ const body = parseErrorBody(rawText, contentType);
88
+ const message = messageFromBody(body, response.status, response.statusText);
89
+ return new AtlasApiError(response.status, message, path, response.statusText, body);
90
+ }
91
+ export async function getJson({ baseUrl, token, path, }) {
92
+ const response = await fetchWith503Retry(`${baseUrl}${path}`, {
93
+ headers: { ...previewAccessHeaders(baseUrl), Authorization: `Bearer ${token}` },
94
+ });
95
+ if (!response.ok)
96
+ throw await responseError(response, path);
97
+ return response.json();
98
+ }
99
+ export async function getJsonOr404({ baseUrl, token, path, }) {
100
+ const response = await fetchWith503Retry(`${baseUrl}${path}`, {
101
+ headers: { ...previewAccessHeaders(baseUrl), Authorization: `Bearer ${token}` },
102
+ });
103
+ if (response.status === 404)
104
+ return null;
105
+ if (!response.ok)
106
+ throw await responseError(response, path);
107
+ return response.json();
108
+ }
109
+ export async function postJson({ baseUrl, token, path, body, }) {
110
+ const response = await fetchWith503Retry(`${baseUrl}${path}`, {
111
+ method: 'POST',
112
+ headers: {
113
+ ...previewAccessHeaders(baseUrl),
114
+ Authorization: `Bearer ${token}`,
115
+ 'Content-Type': 'application/json',
116
+ },
117
+ body: body === undefined ? undefined : JSON.stringify(body),
118
+ });
119
+ if (!response.ok)
120
+ throw await responseError(response, path);
121
+ return response.json();
122
+ }
123
+ export async function putJson({ baseUrl, token, path, body, }) {
124
+ const response = await fetchWith503Retry(`${baseUrl}${path}`, {
125
+ method: 'PUT',
126
+ headers: {
127
+ ...previewAccessHeaders(baseUrl),
128
+ Authorization: `Bearer ${token}`,
129
+ 'Content-Type': 'application/json',
130
+ },
131
+ body: body === undefined ? undefined : JSON.stringify(body),
132
+ });
133
+ if (!response.ok)
134
+ throw await responseError(response, path);
135
+ return response.json();
136
+ }
137
+ export async function patchJson({ baseUrl, token, path, body, }) {
138
+ const response = await fetchWith503Retry(`${baseUrl}${path}`, {
139
+ method: 'PATCH',
140
+ headers: {
141
+ ...previewAccessHeaders(baseUrl),
142
+ Authorization: `Bearer ${token}`,
143
+ 'Content-Type': 'application/json',
144
+ },
145
+ body: body === undefined ? undefined : JSON.stringify(body),
146
+ });
147
+ if (!response.ok)
148
+ throw await responseError(response, path);
149
+ return response.json();
150
+ }
151
+ export async function deleteJson({ baseUrl, token, path, body, }) {
152
+ const response = await fetchWith503Retry(`${baseUrl}${path}`, {
153
+ method: 'DELETE',
154
+ headers: {
155
+ ...previewAccessHeaders(baseUrl),
156
+ Authorization: `Bearer ${token}`,
157
+ 'Content-Type': 'application/json',
158
+ },
159
+ body: body === undefined ? undefined : JSON.stringify(body),
160
+ });
161
+ if (!response.ok)
162
+ throw await responseError(response, path);
163
+ return response.json();
164
+ }
165
+ /** DELETE for endpoints that return 204 with an empty body (deleteJson would choke on it). */
166
+ export async function deleteNoContent({ baseUrl, token, path, }) {
167
+ const response = await fetchWith503Retry(`${baseUrl}${path}`, {
168
+ method: 'DELETE',
169
+ headers: { ...previewAccessHeaders(baseUrl), Authorization: `Bearer ${token}` },
170
+ });
171
+ if (!response.ok)
172
+ throw await responseError(response, path);
173
+ }
@@ -0,0 +1,15 @@
1
+ import type { ParsedArgs } from '../process/commands.js';
2
+ declare const PAT_SCOPES: readonly ["repo:read", "repo:write", "repo:admin"];
3
+ type PatScope = (typeof PAT_SCOPES)[number];
4
+ export declare const AUTH_USAGE = "usage:\n seq-studio auth pat create --name <n> [--scopes repo:read,repo:write] [-e env]\n [--expires 7d|30d|90d|1y|never] [--store-credentials]\n seq-studio auth pat list [-e env]\n seq-studio auth pat revoke <id> [-e env] [--yes]\n\n Issue a personal access token for git clone / git push against the platform\n git service.\n\n Most users (no seqapi): open Atlas \u2192 Settings \u2192 Tokens, or:\n https://<atlas-host>/settings/tokens\n Sign in, create a token (repo:read / repo:write), copy once, then:\n export ATLAS_GIT_PAT=<token>\n\n Sequence staff: `seqapi login` then `auth pat create` (same Auth0 session\n as the Atlas UI). The CLI cannot mint a PAT without that login.\n\n On create the raw token is printed ONCE \u2014 store it; Atlas cannot re-show it.\n Git Basic auth: any username (e.g. git), PAT as the password.\n\n Flags: -e/--env <local|staging|production|banksouth>\n";
5
+ export declare function parsePatScopes(raw: string | undefined): PatScope[];
6
+ /**
7
+ * Map UI-style duration choices to an absolute ISO-8601 expiresAt, or undefined
8
+ * for never. Matches Settings Tokens dialog choices.
9
+ */
10
+ export declare function resolveExpiresAt(choice: string | undefined): string | undefined;
11
+ export declare function authPatCreateCommand(args: ParsedArgs): Promise<number>;
12
+ export declare function authPatListCommand(args: ParsedArgs): Promise<number>;
13
+ export declare function authPatRevokeCommand(args: ParsedArgs): Promise<number>;
14
+ export declare function runAuthCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;
15
+ export {};
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `seq-studio auth <sub>` — PAT management for the platform git service.
3
+ *
4
+ * Issues / lists / revokes Personal Access Tokens via Auth0 bearer auth
5
+ * (`seqapi login` — Sequence staff). Third-party / OpCo developers without
6
+ * seqapi should mint tokens in the Atlas UI at `/settings/tokens` instead;
7
+ * help text and error hints lead with that path.
8
+ *
9
+ * The raw token is printed exactly once on create; use it as the Basic-auth
10
+ * password for `git clone` / `git push` (username is ignored).
11
+ */
12
+ import { deleteNoContent, getJson, postJson } from '../atlas-client.js';
13
+ import { printCliError } from '../cli-errors.js';
14
+ import { buildContext, clientOptions, flagBool, LOG, } from '../functions/commands.js';
15
+ import { confirmYes } from '../prompt.js';
16
+ import { storeGitCredentials } from '../repos/git-clone.js';
17
+ import { formatPatSetupHint, settingsTokensUrl } from '../pat-hints.js';
18
+ const PAT_SCOPES = ['repo:read', 'repo:write', 'repo:admin'];
19
+ function stringFlag(flags, key) {
20
+ const value = flags[key];
21
+ if (value === undefined)
22
+ return undefined;
23
+ if (typeof value !== 'string')
24
+ throw new Error(`--${key} requires a value.`);
25
+ return value;
26
+ }
27
+ export const AUTH_USAGE = `usage:
28
+ seq-studio auth pat create --name <n> [--scopes repo:read,repo:write] [-e env]
29
+ [--expires 7d|30d|90d|1y|never] [--store-credentials]
30
+ seq-studio auth pat list [-e env]
31
+ seq-studio auth pat revoke <id> [-e env] [--yes]
32
+
33
+ Issue a personal access token for git clone / git push against the platform
34
+ git service.
35
+
36
+ Most users (no seqapi): open Atlas → Settings → Tokens, or:
37
+ https://<atlas-host>/settings/tokens
38
+ Sign in, create a token (repo:read / repo:write), copy once, then:
39
+ export ATLAS_GIT_PAT=<token>
40
+
41
+ Sequence staff: \`seqapi login\` then \`auth pat create\` (same Auth0 session
42
+ as the Atlas UI). The CLI cannot mint a PAT without that login.
43
+
44
+ On create the raw token is printed ONCE — store it; Atlas cannot re-show it.
45
+ Git Basic auth: any username (e.g. git), PAT as the password.
46
+
47
+ Flags: -e/--env <local|staging|production|banksouth>
48
+ `;
49
+ async function authContext(args) {
50
+ if (args.flags.env === true || args.flags.e === true) {
51
+ throw new Error('-e/--env requires a value (local|staging|production|banksouth|preview:<slug>).');
52
+ }
53
+ try {
54
+ return await buildContext(args);
55
+ }
56
+ catch (err) {
57
+ const message = err instanceof Error ? err.message : String(err);
58
+ // Prefer the Atlas UI path — third-party / OpCo developers do not have seqapi.
59
+ // buildContext failed before we know the env URL; point at the common hosts.
60
+ throw new Error(`${message}\n` +
61
+ ` No seqapi? Mint a PAT in the Atlas UI (Settings → Tokens), then:\n` +
62
+ ` export ATLAS_GIT_PAT=<token>\n` +
63
+ ` Staging: https://staging.atlas.seqholdings.com/settings/tokens\n` +
64
+ ` Production: https://atlas.seqholdings.com/settings/tokens\n` +
65
+ ` BankSouth: https://banksouth.seqholdings.com/settings/tokens\n` +
66
+ ` Sequence staff: run \`seqapi login\`, then retry \`auth pat create\`.`);
67
+ }
68
+ }
69
+ export function parsePatScopes(raw) {
70
+ if (raw === undefined || raw.trim() === '')
71
+ return ['repo:read'];
72
+ const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
73
+ if (parts.length === 0) {
74
+ throw new Error('--scopes requires at least one of: repo:read, repo:write, repo:admin');
75
+ }
76
+ const out = [];
77
+ for (const part of parts) {
78
+ if (!PAT_SCOPES.includes(part)) {
79
+ throw new Error(`unknown scope "${part}" — expected repo:read, repo:write, or repo:admin`);
80
+ }
81
+ // Deduplicate while preserving order.
82
+ if (!out.includes(part))
83
+ out.push(part);
84
+ }
85
+ return out;
86
+ }
87
+ /**
88
+ * Map UI-style duration choices to an absolute ISO-8601 expiresAt, or undefined
89
+ * for never. Matches Settings Tokens dialog choices.
90
+ */
91
+ export function resolveExpiresAt(choice) {
92
+ if (choice === undefined || choice === 'never')
93
+ return undefined;
94
+ const now = Date.now();
95
+ const day = 24 * 60 * 60 * 1000;
96
+ const map = {
97
+ '7d': 7 * day,
98
+ '30d': 30 * day,
99
+ '90d': 90 * day,
100
+ '1y': 365 * day,
101
+ };
102
+ const delta = map[choice];
103
+ if (delta === undefined) {
104
+ throw new Error(`--expires must be one of: 7d, 30d, 90d, 1y, never (got "${choice}")`);
105
+ }
106
+ return new Date(now + delta).toISOString();
107
+ }
108
+ export async function authPatCreateCommand(args) {
109
+ const name = stringFlag(args.flags, 'name');
110
+ if (!name) {
111
+ console.error('usage: seq-studio auth pat create --name <n> [--scopes ...] [-e env]');
112
+ return 1;
113
+ }
114
+ if (name.length < 3) {
115
+ throw new Error('--name must be at least 3 characters (e.g. laptop-cli)');
116
+ }
117
+ const scopes = parsePatScopes(stringFlag(args.flags, 'scopes'));
118
+ const expiresAt = resolveExpiresAt(stringFlag(args.flags, 'expires') ?? '30d');
119
+ const ctx = await authContext(args);
120
+ const issued = await postJson({
121
+ ...clientOptions(ctx),
122
+ path: '/api/git-service/pats',
123
+ body: {
124
+ name,
125
+ scopes,
126
+ ...(expiresAt ? { expiresAt } : {}),
127
+ },
128
+ });
129
+ console.log(`${LOG} issued PAT on ${ctx.env.name}:`);
130
+ console.log(` id: ${issued.id}`);
131
+ console.log(` name: ${issued.name}`);
132
+ console.log(` prefix: ${issued.tokenPrefix}`);
133
+ console.log(` scopes: ${issued.scopes.join(', ')}`);
134
+ console.log(` expires:${issued.expiresAt ? ` ${issued.expiresAt}` : ' never'}`);
135
+ console.log('');
136
+ console.log(' ── copy this token now; it will not be shown again ──');
137
+ console.log(` ${issued.token}`);
138
+ console.log(' ────────────────────────────────────────────────────');
139
+ console.log('');
140
+ console.log(' Git Basic auth: any username (e.g. git), this token as the password.');
141
+ console.log(` UI: ${settingsTokensUrl(ctx.env.url)}`);
142
+ console.log(` Clone with: ATLAS_GIT_PAT=<token> seq-studio repos clone <ns>/<name> -e ${ctx.env.name}`);
143
+ if (flagBool(args.flags, 'store-credentials')) {
144
+ // Store against the env origin so any repo under /api/git-service/repos/*
145
+ // can reuse the helper entry. Path is the git-service API prefix.
146
+ const credentialUrl = `${ctx.env.url.replace(/\/$/, '')}/api/git-service/repos/`;
147
+ try {
148
+ await storeGitCredentials({ cloneUrl: credentialUrl, pat: issued.token });
149
+ console.log(`${LOG} stored credentials via git credential approve for ${ctx.env.url}`);
150
+ }
151
+ catch (err) {
152
+ console.error(`${LOG} --store-credentials failed: ${err instanceof Error ? err.message : String(err)}`);
153
+ console.error(`${LOG} token was still issued — copy it from above and store manually.`);
154
+ // Exit 0: the PAT exists; a non-zero exit would encourage retries that mint duplicates.
155
+ return 0;
156
+ }
157
+ }
158
+ return 0;
159
+ }
160
+ export async function authPatListCommand(args) {
161
+ const ctx = await authContext(args);
162
+ const data = await getJson({
163
+ ...clientOptions(ctx),
164
+ path: '/api/git-service/pats',
165
+ });
166
+ if (data.items.length === 0) {
167
+ console.log(`${LOG} no PATs on ${ctx.env.name}.`);
168
+ for (const line of formatPatSetupHint({ envUrl: ctx.env.url, envName: ctx.env.name })) {
169
+ console.log(line);
170
+ }
171
+ return 0;
172
+ }
173
+ console.log(`${LOG} ${data.items.length} PAT${data.items.length === 1 ? '' : 's'} on ${ctx.env.name}:`);
174
+ const width = Math.max(...data.items.map((p) => p.name.length));
175
+ for (const pat of data.items) {
176
+ const expires = pat.expiresAt ? pat.expiresAt.slice(0, 10) : 'never';
177
+ const lastUsed = pat.lastUsedAt ? pat.lastUsedAt.slice(0, 10) : 'never';
178
+ console.log(` ${pat.name.padEnd(width)} ${pat.tokenPrefix}… ${pat.scopes.join(',')} expires=${expires} lastUsed=${lastUsed} id=${pat.id}`);
179
+ }
180
+ return 0;
181
+ }
182
+ export async function authPatRevokeCommand(args) {
183
+ const id = args.positional[0];
184
+ if (!id) {
185
+ console.error('usage: seq-studio auth pat revoke <id> [-e env] [--yes]');
186
+ return 1;
187
+ }
188
+ const ctx = await authContext(args);
189
+ const ok = await confirmYes({
190
+ preview: [
191
+ `${LOG} about to REVOKE PAT ${id} on ${ctx.env.name}.`,
192
+ `${LOG} any git clone/push using it will start failing immediately.`,
193
+ ],
194
+ confirmed: flagBool(args.flags, 'yes'),
195
+ });
196
+ if (!ok)
197
+ return 1;
198
+ await deleteNoContent({
199
+ ...clientOptions(ctx),
200
+ path: `/api/git-service/pats/${encodeURIComponent(id)}`,
201
+ });
202
+ console.log(`${LOG} revoked PAT ${id} on ${ctx.env.name}`);
203
+ return 0;
204
+ }
205
+ export async function runAuthCommand(sub, args) {
206
+ try {
207
+ switch (sub) {
208
+ case 'pat': {
209
+ const action = args.positional[0];
210
+ const nested = {
211
+ positional: args.positional.slice(1),
212
+ flags: args.flags,
213
+ };
214
+ switch (action) {
215
+ case 'create':
216
+ return await authPatCreateCommand(nested);
217
+ case 'list':
218
+ return await authPatListCommand(nested);
219
+ case 'revoke':
220
+ return await authPatRevokeCommand(nested);
221
+ case 'help':
222
+ case '--help':
223
+ case '-h':
224
+ case undefined:
225
+ console.log(AUTH_USAGE);
226
+ return action ? 0 : 1;
227
+ default:
228
+ console.error(`unknown auth pat command: ${action}`);
229
+ console.error(AUTH_USAGE);
230
+ return 1;
231
+ }
232
+ }
233
+ case 'help':
234
+ case '--help':
235
+ case '-h':
236
+ case undefined:
237
+ console.log(AUTH_USAGE);
238
+ return sub ? 0 : 1;
239
+ default:
240
+ console.error(`unknown auth command: ${sub}`);
241
+ console.error(AUTH_USAGE);
242
+ return 1;
243
+ }
244
+ }
245
+ catch (error) {
246
+ printCliError(error);
247
+ return 1;
248
+ }
249
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export declare function seqapiTokenDir(): string;
2
+ export declare function seqapiTokenPath(): string;
3
+ export declare class NotLoggedInError extends Error {
4
+ readonly name = "NotLoggedInError";
5
+ constructor();
6
+ }
7
+ /**
8
+ * Return a valid access token. Tries the M2M service account first when
9
+ * `AUTH0_M2M_CLIENT_SECRET` is set, then falls back to the cached user
10
+ * token (refreshed via the Auth0 refresh-token grant when within 60s of
11
+ * expiry). Same precedence as seqapi's `get_access_token`, so both CLIs
12
+ * resolve the same identity for the same environment.
13
+ */
14
+ export declare function getAccessToken(): Promise<string>;
15
+ /**
16
+ * Try to load a token without throwing. Returns `null` for any error
17
+ * condition (missing file, parse error, no token fields). Used by
18
+ * `doctor` for non-fatal "are you logged in?" checks.
19
+ */
20
+ export declare function tryGetAccessToken(options?: {
21
+ /**
22
+ * When the M2M secret is configured, failing to mint an M2M token should fail
23
+ * closed (avoid silently falling back to some other cached identity).
24
+ */
25
+ failClosedForM2m?: boolean;
26
+ }): Promise<string | null>;
package/dist/auth.js ADDED
@@ -0,0 +1,171 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ /**
6
+ * seq-studio is a read-only consumer of the seqapi token file. Tokens
7
+ * are written by `seqapi login` (Python, in `shared/seqapi/seqapi/auth.py`)
8
+ * and shared between every Sequence CLI: same Auth0 tenant, same client
9
+ * id, same audience — so the same access token validates against every
10
+ * Atlas /api/* route.
11
+ *
12
+ * Two token sources, in the SAME precedence order as seqapi's
13
+ * `get_access_token` (`shared/seqapi/seqapi/auth.py`):
14
+ * 1. M2M service account — Auth0 client-credentials grant, used when
15
+ * `AUTH0_M2M_CLIENT_SECRET` is set. This is the headless path: CI /
16
+ * cloud agents with no interactive `seqapi login` can still push.
17
+ * (M2M carries app scopes but NO user identity / workspace membership
18
+ * — see the `atlas-test-access` rule.)
19
+ * 2. Cached user token — read from the seqapi token file and refreshed
20
+ * via the Auth0 refresh-token grant when near expiry.
21
+ *
22
+ * Refresh is the only write path: when the cached access token is
23
+ * expired and we have a refresh token, we hit Auth0 and update the file
24
+ * with the rotated tokens. This mirrors `seqapi._save_tokens` exactly:
25
+ * same fields, same shape, same 0o600 permissions, atomic write via
26
+ * tmpfile + rename. The M2M token is in-memory only (never persisted).
27
+ */
28
+ // Match `shared/seqapi/seqapi/config.py`. Hard-coded because the seqapi
29
+ // CLI also hard-codes them — there's a single Sequence Auth0 tenant for
30
+ // all Sequence CLIs.
31
+ const AUTH0_DOMAIN = 'dev-n1t8ts403fp8oyxp.us.auth0.com';
32
+ const AUTH0_CLIENT_ID = 'GD9riCDWocfc66odpWBjwBiX43qqAX8r';
33
+ const AUTH0_AUDIENCE = 'https://api.studio.com';
34
+ const AUTH0_M2M_CLIENT_ID = '5TLffZqvLq4ztLDjZhwKVJCpB5VNrWj0';
35
+ // In-memory cache for the M2M token (seconds-based, mirrors seqapi's
36
+ // `_m2m_cache`). Reused while > 60s from expiry to avoid re-minting on every
37
+ // call within a single process (e.g. a long `artifact dev` watch).
38
+ let m2mCache = null;
39
+ /**
40
+ * Mint an M2M access token via the Auth0 client-credentials grant when
41
+ * `AUTH0_M2M_CLIENT_SECRET` is set. Returns null when the secret is unset
42
+ * (so the caller falls back to the user token). Throws on a configured-but-
43
+ * rejected secret, mirroring seqapi's `_get_m2m_token` (`raise_for_status`).
44
+ */
45
+ async function getM2mToken() {
46
+ const clientSecret = process.env.AUTH0_M2M_CLIENT_SECRET;
47
+ if (!clientSecret)
48
+ return null;
49
+ const now = Date.now() / 1000;
50
+ if (m2mCache && m2mCache.expiresAt > now + 60)
51
+ return m2mCache.accessToken;
52
+ const response = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({
56
+ grant_type: 'client_credentials',
57
+ client_id: AUTH0_M2M_CLIENT_ID,
58
+ client_secret: clientSecret,
59
+ audience: AUTH0_AUDIENCE,
60
+ }),
61
+ });
62
+ if (!response.ok) {
63
+ throw new Error(`Auth0 M2M token request failed (${response.status}): ${await response.text()}`);
64
+ }
65
+ const data = (await response.json());
66
+ if (!data.access_token) {
67
+ throw new Error('Auth0 M2M response missing access_token');
68
+ }
69
+ m2mCache = {
70
+ accessToken: data.access_token,
71
+ expiresAt: Date.now() / 1000 + (data.expires_in ?? 7200),
72
+ };
73
+ return m2mCache.accessToken;
74
+ }
75
+ export function seqapiTokenDir() {
76
+ return join(homedir(), '.config', 'sequence-api');
77
+ }
78
+ export function seqapiTokenPath() {
79
+ return join(seqapiTokenDir(), 'tokens.json');
80
+ }
81
+ export class NotLoggedInError extends Error {
82
+ name = 'NotLoggedInError';
83
+ constructor() {
84
+ super('Not logged in. Run: seqapi login\n' +
85
+ 'seq-studio does not have its own login flow — it shares tokens with seqapi.\n' +
86
+ 'For headless contexts (CI / cloud agents), set AUTH0_M2M_CLIENT_SECRET for ' +
87
+ 'service-account (M2M) access instead.');
88
+ }
89
+ }
90
+ /**
91
+ * Return a valid access token. Tries the M2M service account first when
92
+ * `AUTH0_M2M_CLIENT_SECRET` is set, then falls back to the cached user
93
+ * token (refreshed via the Auth0 refresh-token grant when within 60s of
94
+ * expiry). Same precedence as seqapi's `get_access_token`, so both CLIs
95
+ * resolve the same identity for the same environment.
96
+ */
97
+ export async function getAccessToken() {
98
+ const m2m = await getM2mToken();
99
+ if (m2m)
100
+ return m2m;
101
+ const tokens = await loadTokens();
102
+ if (!tokens)
103
+ throw new NotLoggedInError();
104
+ const now = Date.now() / 1000;
105
+ if (tokens.access_token && (tokens.expires_at ?? 0) > now + 60) {
106
+ return tokens.access_token;
107
+ }
108
+ if (!tokens.refresh_token)
109
+ throw new NotLoggedInError();
110
+ const response = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({
114
+ grant_type: 'refresh_token',
115
+ client_id: AUTH0_CLIENT_ID,
116
+ refresh_token: tokens.refresh_token,
117
+ }),
118
+ });
119
+ if (response.status === 401 || response.status === 403) {
120
+ throw new Error('Refresh token expired or revoked. Re-authenticate with: seqapi login');
121
+ }
122
+ if (!response.ok) {
123
+ throw new Error(`Auth0 token refresh failed (${response.status}): ${await response.text()}`);
124
+ }
125
+ const refreshed = (await response.json());
126
+ if (!refreshed.access_token) {
127
+ throw new Error('Auth0 refresh response missing access_token');
128
+ }
129
+ const updated = {
130
+ access_token: refreshed.access_token,
131
+ refresh_token: refreshed.refresh_token ?? tokens.refresh_token,
132
+ expires_at: Date.now() / 1000 + (refreshed.expires_in ?? 86400),
133
+ };
134
+ await saveTokens(updated);
135
+ return updated.access_token;
136
+ }
137
+ /**
138
+ * Try to load a token without throwing. Returns `null` for any error
139
+ * condition (missing file, parse error, no token fields). Used by
140
+ * `doctor` for non-fatal "are you logged in?" checks.
141
+ */
142
+ export async function tryGetAccessToken(options) {
143
+ try {
144
+ return await getAccessToken();
145
+ }
146
+ catch (err) {
147
+ if (options?.failClosedForM2m && process.env.AUTH0_M2M_CLIENT_SECRET?.trim()) {
148
+ throw err;
149
+ }
150
+ return null;
151
+ }
152
+ }
153
+ async function loadTokens() {
154
+ const path = seqapiTokenPath();
155
+ if (!existsSync(path))
156
+ return null;
157
+ try {
158
+ return JSON.parse(await readFile(path, 'utf8'));
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }
164
+ async function saveTokens(tokens) {
165
+ const path = seqapiTokenPath();
166
+ await mkdir(dirname(path), { recursive: true });
167
+ // Mirror seqapi's atomic-write pattern: write to tmpfile then rename.
168
+ const tmp = path + '.tmp';
169
+ await writeFile(tmp, JSON.stringify(tokens, null, 2), { encoding: 'utf8', mode: 0o600 });
170
+ await import('node:fs/promises').then((fs) => fs.rename(tmp, path));
171
+ }
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './main.js';
3
+ run()
4
+ .then((code) => process.exit(code))
5
+ .catch((error) => {
6
+ console.error(error instanceof Error ? error.message : error);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,5 @@
1
+ export declare const LOG = "[seq-studio]";
2
+ /** Map common apply failure reasons to clearer copy (original reason is preserved). */
3
+ export declare function clarifyApplyFailureReason(reason: string): string;
4
+ export declare function formatCliError(error: unknown): string[];
5
+ export declare function printCliError(error: unknown): void;