@sequenceholdings/studio-cli 0.1.18 → 0.1.22

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.
@@ -0,0 +1,77 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { basename, join, resolve } from 'node:path';
5
+ import { currentVersion } from '../update-check.js';
6
+ const LOG = '[seq-studio]';
7
+ const scaffoldSource = ({ id, name, }) => `import { defineAgent } from '@sequenceholdings/agent-spec'
8
+
9
+ export const agent = defineAgent({
10
+ id: '${id}',
11
+ name: '${name}',
12
+ model: {
13
+ provider: 'anthropic',
14
+ name: 'claude-sonnet-4-6',
15
+ },
16
+ systemPrompt: 'Describe the agent role and operating instructions.',
17
+ })
18
+ `;
19
+ export async function agentsInitCommand(args) {
20
+ const target = args.positional[0];
21
+ if (!target) {
22
+ console.error('usage: seq-studio agents init <dir>');
23
+ return 1;
24
+ }
25
+ const directory = resolve(target);
26
+ const file = join(directory, 'agent.ts');
27
+ if (existsSync(file)) {
28
+ console.error(`${LOG} ${file} already exists`);
29
+ return 1;
30
+ }
31
+ const name = basename(directory)
32
+ .split(/[-_]+/)
33
+ .filter(Boolean)
34
+ .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
35
+ .join(' ');
36
+ await mkdir(directory, { recursive: true });
37
+ await writeFile(file, scaffoldSource({ id: randomBytes(12).toString('hex'), name }));
38
+ await writeFile(join(directory, 'package.json'), `${JSON.stringify({
39
+ name: basename(directory),
40
+ version: '0.0.1',
41
+ private: true,
42
+ type: 'module',
43
+ scripts: { validate: 'seq-studio agents validate' },
44
+ dependencies: { '@sequenceholdings/agent-spec': '^0.1.0' },
45
+ devDependencies: {
46
+ '@sequenceholdings/studio-cli': `^${currentVersion()}`,
47
+ },
48
+ }, null, 2)}\n`);
49
+ await writeFile(join(directory, 'tsconfig.json'), `${JSON.stringify({
50
+ compilerOptions: {
51
+ target: 'ES2022',
52
+ module: 'NodeNext',
53
+ moduleResolution: 'NodeNext',
54
+ strict: true,
55
+ noEmit: true,
56
+ },
57
+ include: ['agent.ts'],
58
+ }, null, 2)}\n`);
59
+ await writeFile(join(directory, 'pnpm-workspace.yaml'), `packages:
60
+ - '.'
61
+
62
+ minimumReleaseAge: 10080
63
+ minimumReleaseAgeExclude:
64
+ - '@sequenceholdings/agent-spec'
65
+ - '@sequenceholdings/atlas-ui'
66
+ - '@sequenceholdings/lattice-form-renderer'
67
+ - '@sequenceholdings/artifact-studio'
68
+ - '@sequenceholdings/lattice'
69
+ - '@sequenceholdings/studio-cli'
70
+ strictDepBuilds: true
71
+ allowBuilds:
72
+ esbuild: true
73
+ `);
74
+ console.log(`${LOG} scaffolded typed agent in ${directory}`);
75
+ console.log('Next: pnpm install && seq-studio agents validate');
76
+ return 0;
77
+ }
@@ -0,0 +1,20 @@
1
+ import { type CompiledAgentBundle } from '@sequenceholdings/agent-spec/compiler';
2
+ import { type ResolvedSource, type SourceSpec } from '@sequenceholdings/artifact-studio/source-resolver';
3
+ import type { ParsedArgs } from '../process/commands.js';
4
+ import { type CommandContext } from '../functions/commands.js';
5
+ export declare function materializeAgentSource({ args, requireEnvironment, }: {
6
+ args: ParsedArgs;
7
+ requireEnvironment: boolean;
8
+ }): Promise<{
9
+ spec: SourceSpec;
10
+ source: ResolvedSource;
11
+ context: CommandContext | null;
12
+ }>;
13
+ export declare function compileAgentSource({ directory, targetEnvironment, deployEnvironments, onlyIds, }: {
14
+ directory: string;
15
+ targetEnvironment?: string;
16
+ /** Registered deployment environments, so a real env absent from the manifest is not read as a typo. */
17
+ deployEnvironments?: readonly string[];
18
+ /** Restrict a plan or apply to explicit agent IDs after environment selection. */
19
+ onlyIds?: readonly string[];
20
+ }): Promise<CompiledAgentBundle>;
@@ -0,0 +1,144 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { hashAgentBundle, } from '@sequenceholdings/agent-spec';
5
+ import { agentDeployManifestSchema, assertKnownTargetEnvironment, compileAgentDirectory, selectAgentEntries, selectionStats, } from '@sequenceholdings/agent-spec/compiler';
6
+ import { parseSourceSpec, resolveArtifactSource, } from '@sequenceholdings/artifact-studio/source-resolver';
7
+ import { buildContext, clientOptions, requestedEnvironment, } from '../functions/commands.js';
8
+ const MANIFEST = 'deploy-manifest.json';
9
+ function sourceSpec(args) {
10
+ const dir = typeof args.flags.dir === 'string' ? args.flags.dir : undefined;
11
+ if (dir &&
12
+ (args.flags.repo !== undefined || args.flags['git-url'] !== undefined)) {
13
+ throw new Error('--dir cannot be combined with --repo / --git-url.');
14
+ }
15
+ return parseSourceSpec({
16
+ positional: dir ? [dir] : [],
17
+ flags: args.flags,
18
+ });
19
+ }
20
+ function sourceOptions(context) {
21
+ return {
22
+ ...(context.authMode ? { authMode: context.authMode } : {}),
23
+ ...clientOptions(context),
24
+ };
25
+ }
26
+ export async function materializeAgentSource({ args, requireEnvironment, }) {
27
+ const spec = sourceSpec(args);
28
+ const needsAuth = spec.kind !== 'local';
29
+ if (requireEnvironment && !requestedEnvironment(args)) {
30
+ throw new Error('Network commands require an explicit -e/--env.');
31
+ }
32
+ const context = needsAuth || requireEnvironment ? await buildContext(args) : null;
33
+ const source = await resolveArtifactSource(spec, context ? sourceOptions(context) : {});
34
+ return { spec, source, context };
35
+ }
36
+ async function readManifest(directory) {
37
+ const path = join(directory, MANIFEST);
38
+ if (!existsSync(path))
39
+ return null;
40
+ return agentDeployManifestSchema.parse(JSON.parse(await readFile(path, 'utf8')));
41
+ }
42
+ /**
43
+ * Refuse to deploy when a compiled export's id does not match the id the
44
+ * manifest keyed the environment override on. Without that check a mis-labeled
45
+ * agent.ts would silently deploy under a different identity and defeat tenant
46
+ * overrides.
47
+ *
48
+ * Every selected path is parsed in one pass. Compiling them one file at a
49
+ * time previously re-parsed each agent separately and dominated validate
50
+ * latency on a large fleet — paid again on plan, and twice more on apply,
51
+ * since apply plans before it writes.
52
+ */
53
+ async function compileManifestEntries({ directory, entries, allowDuplicateIds = false, }) {
54
+ if (entries.length === 0) {
55
+ return { definitions: [], files: [], hash: hashAgentBundle({ definitions: [] }), sources: [] };
56
+ }
57
+ const compiled = await compileAgentDirectory({
58
+ rootDir: directory,
59
+ filePaths: entries.map((entry) => entry.path),
60
+ allowDuplicateIds,
61
+ });
62
+ const byFile = new Map();
63
+ for (const source of compiled.sources) {
64
+ const list = byFile.get(source.file) ?? [];
65
+ list.push(source.definition);
66
+ byFile.set(source.file, list);
67
+ }
68
+ const definitions = [];
69
+ for (const entry of entries) {
70
+ const exported = byFile.get(entry.path) ?? [];
71
+ if (exported.length !== 1) {
72
+ throw new Error(`Expected exactly one agent export in ${entry.path}, found ${exported.length}`);
73
+ }
74
+ const definition = exported[0];
75
+ if (!definition) {
76
+ throw new Error(`Expected exactly one agent export in ${entry.path}, found 0`);
77
+ }
78
+ if (definition.id !== entry.id) {
79
+ throw new Error(`Manifest id "${entry.id}" for ${entry.path} does not match exported id "${definition.id}"`);
80
+ }
81
+ definitions.push(definition);
82
+ }
83
+ return {
84
+ definitions,
85
+ files: compiled.files,
86
+ hash: hashAgentBundle({ definitions }),
87
+ sources: compiled.sources,
88
+ };
89
+ }
90
+ export async function compileAgentSource({ directory, targetEnvironment, deployEnvironments = [], onlyIds, }) {
91
+ const manifest = await readManifest(directory);
92
+ if (!manifest) {
93
+ const compiled = await compileAgentDirectory({ rootDir: directory });
94
+ if (!onlyIds || onlyIds.length === 0)
95
+ return compiled;
96
+ const requested = new Set(onlyIds);
97
+ const definitions = compiled.definitions.filter((definition) => requested.has(definition.id));
98
+ const missing = [...requested].filter((id) => !definitions.some((definition) => definition.id === id));
99
+ if (missing.length > 0) {
100
+ throw new Error(`Requested agent IDs were not selected: ${missing.join(', ')}`);
101
+ }
102
+ return {
103
+ ...compiled,
104
+ definitions,
105
+ hash: hashAgentBundle({ definitions }),
106
+ sources: compiled.sources.filter((source) => requested.has(source.definition.id)),
107
+ };
108
+ }
109
+ if (targetEnvironment !== undefined) {
110
+ assertKnownTargetEnvironment({
111
+ manifest,
112
+ environment: targetEnvironment,
113
+ deployEnvironments,
114
+ });
115
+ // Selection is silent, so a target that matches nothing looks identical to
116
+ // a full deploy. Name the drop before the operator confirms an apply.
117
+ const { selected, skipped, total } = selectionStats({
118
+ manifest,
119
+ environment: targetEnvironment,
120
+ });
121
+ if (skipped > 0) {
122
+ console.log(`[seq-studio] target ${targetEnvironment}: ${selected} of ${total} agents selected, ${skipped} scoped to other environments`);
123
+ }
124
+ }
125
+ // With a target: last-wins by id (tenant overrides). Without: every distinct
126
+ // path, so validate still compiles override sources that lose a collapse.
127
+ const selectedEntries = selectAgentEntries({
128
+ manifest,
129
+ environment: targetEnvironment,
130
+ });
131
+ const requested = new Set(onlyIds);
132
+ const entries = requested.size === 0
133
+ ? selectedEntries
134
+ : selectedEntries.filter((entry) => requested.has(entry.id));
135
+ const missing = [...requested].filter((id) => !entries.some((entry) => entry.id === id));
136
+ if (missing.length > 0) {
137
+ throw new Error(`Requested agent IDs were not selected: ${missing.join(', ')}`);
138
+ }
139
+ return compileManifestEntries({
140
+ directory,
141
+ entries,
142
+ allowDuplicateIds: targetEnvironment === undefined,
143
+ });
144
+ }
@@ -9,6 +9,7 @@ import { PREVIEW_DOMAIN } from './preview.js';
9
9
  const MAX_503_RETRIES = 5;
10
10
  const DEFAULT_RETRY_AFTER_SECONDS = 2;
11
11
  const LOG_PREFIX = '[seq-studio]';
12
+ const CF_ACCESS_DOMAIN = 'seqholdings.com';
12
13
  export class AtlasApiError extends Error {
13
14
  status;
14
15
  path;
@@ -42,6 +43,7 @@ async function authenticatedFetch({ baseUrl, init = {}, path, token, }) {
42
43
  redirect: 'manual',
43
44
  headers: {
44
45
  ...previewAccessHeaders(baseUrl),
46
+ ...cfAccessHeaders(baseUrl),
45
47
  ...init.headers,
46
48
  Authorization: `Bearer ${token}`,
47
49
  },
@@ -63,6 +65,33 @@ function previewAccessHeaders(baseUrl) {
63
65
  return {};
64
66
  }
65
67
  }
68
+ /**
69
+ * Cloudflare Access fronts *.seqholdings.com and only bypasses the company
70
+ * network, so a CI runner is turned away at the edge with an HTML "Access
71
+ * Restricted" page before Atlas ever sees the bearer token. A service token
72
+ * gets through the edge; the bearer still authenticates at the app.
73
+ *
74
+ * Scoped to seqholdings.com so the token is never sent to localhost, a tenant
75
+ * domain, or any other host the CLI can be pointed at.
76
+ */
77
+ function cfAccessHeaders(baseUrl) {
78
+ const clientId = process.env.CF_ACCESS_CLIENT_ID?.trim();
79
+ const clientSecret = process.env.CF_ACCESS_CLIENT_SECRET?.trim();
80
+ if (!clientId || !clientSecret)
81
+ return {};
82
+ try {
83
+ const parsed = new URL(baseUrl);
84
+ if (parsed.protocol !== 'https:')
85
+ return {};
86
+ const hostname = parsed.hostname.toLowerCase();
87
+ if (hostname !== CF_ACCESS_DOMAIN && !hostname.endsWith(`.${CF_ACCESS_DOMAIN}`))
88
+ return {};
89
+ return { 'CF-Access-Client-Id': clientId, 'CF-Access-Client-Secret': clientSecret };
90
+ }
91
+ catch {
92
+ return {};
93
+ }
94
+ }
66
95
  function messageFromBody(body, status, statusText) {
67
96
  if (body && typeof body === 'object') {
68
97
  const record = body;
package/dist/auth.d.ts CHANGED
@@ -5,18 +5,18 @@
5
5
  *
6
6
  * Two token sources, in the SAME precedence order as seqapi's
7
7
  * `get_access_token` (`shared/seqapi/seqapi/auth.py`):
8
- * 1. M2M service accountAuth0 client-credentials grant, used when
9
- * `AUTH0_M2M_CLIENT_SECRET` is set. This is the headless path: CI /
10
- * cloud agents with no interactive login can still push.
8
+ * 1. Cached user access token read from the seqapi token file. When it
9
+ * expires, an interactive session performs a bounded PKCE login again.
10
+ * 2. M2M service account Auth0 client-credentials grant when the realm's
11
+ * `AUTH0_M2M_CLIENT_SECRET` (or suffixed OpCo variant) is set. Used when
12
+ * no valid user session exists, or when `SEQAPI_AUTH_MODE=m2m` forces it.
11
13
  * (M2M carries app scopes but NO user identity / workspace membership
12
14
  * — see the `atlas-test-access` rule.)
13
- * 2. Cached user access token — read from the seqapi token file. When it
14
- * expires, an interactive session performs a bounded PKCE login again.
15
15
  *
16
- * Login writes the shared file. This mirrors
17
- * `seqapi._save_tokens` exactly:
16
+ * Login writes the shared file. This mirrors `seqapi._save_tokens` exactly:
18
17
  * same fields, same shape, same 0o600 permissions, atomic write via
19
- * tmpfile + rename. The M2M token is in-memory only (never persisted).
18
+ * tmpfile + rename. M2M tokens are cached in-process and also under the
19
+ * token file's `m2m` key so short-lived CLI processes reuse a grant.
20
20
  */
21
21
  export declare const AUTH0_DOMAIN = "dev-n1t8ts403fp8oyxp.us.auth0.com";
22
22
  export declare const AUTH0_CLIENT_ID = "GD9riCDWocfc66odpWBjwBiX43qqAX8r";
@@ -80,13 +80,15 @@ export declare class NotLoggedInError extends Error {
80
80
  constructor(realmName?: string, reason?: string);
81
81
  }
82
82
  /**
83
- * Return a valid access token for an environment's auth realm. Tries the
84
- * realm's M2M service account first (secret env var), then falls back to the
85
- * cached user access token. Expired user sessions perform bounded PKCE login
86
- * when browser auto-login is enabled. Same precedence and token file as seqapi's
87
- * `get_access_token`, so both CLIs resolve the same identity for the same
88
- * environment. No `env` (or a built-in Sequence env) means the shared
89
- * Sequence realm — the legacy behavior every existing caller gets unchanged.
83
+ * Return a valid access token for an environment's auth realm.
84
+ *
85
+ * Precedence (default `SEQAPI_AUTH_MODE=auto`), matching seqapi:
86
+ * 1. Valid cached user access token
87
+ * 2. M2M client-credentials when the realm's secret env var is set
88
+ * 3. Bounded PKCE login when browser auto-login is enabled
89
+ *
90
+ * Set `SEQAPI_AUTH_MODE=m2m` to skip the user token. No `env` (or a built-in
91
+ * Sequence env) means the shared Sequence realm.
90
92
  */
91
93
  export declare class UnsafeAuthTargetError extends Error {
92
94
  }
@@ -142,8 +144,8 @@ export declare function verifyTokenMatchesRealm({ accessToken, realm, requireOrg
142
144
  export declare function decodeJwtSub(token: string): string | null;
143
145
  /**
144
146
  * The Auth0 subject the CLI would authenticate as right now, without any
145
- * network call: the requested realm's M2M client subject when its secret is
146
- * configured, else the `sub` of that realm's cached user token, else null.
147
+ * network call. Matches token resolution precedence: a valid user session
148
+ * wins over an ambient M2M secret unless `SEQAPI_AUTH_MODE=m2m`.
147
149
  */
148
150
  export declare function currentIdentitySubject(options?: {
149
151
  env?: string;
package/dist/auth.js CHANGED
@@ -15,18 +15,18 @@ function hasErrorCode(error, code) {
15
15
  *
16
16
  * Two token sources, in the SAME precedence order as seqapi's
17
17
  * `get_access_token` (`shared/seqapi/seqapi/auth.py`):
18
- * 1. M2M service accountAuth0 client-credentials grant, used when
19
- * `AUTH0_M2M_CLIENT_SECRET` is set. This is the headless path: CI /
20
- * cloud agents with no interactive login can still push.
18
+ * 1. Cached user access token read from the seqapi token file. When it
19
+ * expires, an interactive session performs a bounded PKCE login again.
20
+ * 2. M2M service account Auth0 client-credentials grant when the realm's
21
+ * `AUTH0_M2M_CLIENT_SECRET` (or suffixed OpCo variant) is set. Used when
22
+ * no valid user session exists, or when `SEQAPI_AUTH_MODE=m2m` forces it.
21
23
  * (M2M carries app scopes but NO user identity / workspace membership
22
24
  * — see the `atlas-test-access` rule.)
23
- * 2. Cached user access token — read from the seqapi token file. When it
24
- * expires, an interactive session performs a bounded PKCE login again.
25
25
  *
26
- * Login writes the shared file. This mirrors
27
- * `seqapi._save_tokens` exactly:
26
+ * Login writes the shared file. This mirrors `seqapi._save_tokens` exactly:
28
27
  * same fields, same shape, same 0o600 permissions, atomic write via
29
- * tmpfile + rename. The M2M token is in-memory only (never persisted).
28
+ * tmpfile + rename. M2M tokens are cached in-process and also under the
29
+ * token file's `m2m` key so short-lived CLI processes reuse a grant.
30
30
  */
31
31
  // Match `shared/seqapi/seqapi/config.py`. Hard-coded because the seqapi
32
32
  // CLI also hard-codes them — there's a single Sequence Auth0 tenant for
@@ -43,6 +43,8 @@ const SEQUENCE_BUILTIN_ENVS = new Set(['local', 'staging', 'production', 'bankso
43
43
  export function isSequenceAuthEnvName(envName) {
44
44
  return (envName === SEQUENCE_REALM ||
45
45
  SEQUENCE_BUILTIN_ENVS.has(envName) ||
46
+ envName === 'worktree' ||
47
+ envName.startsWith('local:') ||
46
48
  envName === 'preview' ||
47
49
  envName.startsWith('preview:'));
48
50
  }
@@ -131,7 +133,8 @@ export function m2mSecretEnvName(realm) {
131
133
  }
132
134
  // In-memory per-realm cache for M2M tokens (seconds-based, mirrors seqapi's
133
135
  // `_m2m_cache`). Reused while > 60s from expiry to avoid re-minting on every
134
- // call within a single process (e.g. a long `artifact dev` watch).
136
+ // call within a single process (e.g. a long `artifact dev` watch). Disk
137
+ // persistence under tokens.json `m2m` covers cross-process reuse.
135
138
  const m2mCache = new Map();
136
139
  /**
137
140
  * A configured M2M credential failed to mint a token. Typed so callers that
@@ -141,11 +144,41 @@ const m2mCache = new Map();
141
144
  export class M2mTokenError extends Error {
142
145
  name = 'M2mTokenError';
143
146
  }
147
+ function authModePrefersM2m() {
148
+ const value = process.env.SEQAPI_AUTH_MODE?.trim().toLowerCase() ?? 'auto';
149
+ return value === 'm2m';
150
+ }
151
+ function tokenStillValid(entry) {
152
+ const now = Date.now() / 1000;
153
+ return Boolean(entry?.accessToken && (entry.expiresAt ?? 0) > now + 60);
154
+ }
155
+ async function loadPersistedM2m(realmName) {
156
+ if (!existsSync(seqapiTokenPath()))
157
+ return null;
158
+ return withTokenFileLock(async () => {
159
+ const file = await readTokenFile();
160
+ const entry = file?.m2m?.[realmName];
161
+ if (!entry?.access_token)
162
+ return null;
163
+ return {
164
+ accessToken: entry.access_token,
165
+ expiresAt: entry.expires_at ?? 0,
166
+ };
167
+ });
168
+ }
169
+ async function persistM2m({ realmName, accessToken, expiresAt, }) {
170
+ await withTokenFileLock(async () => {
171
+ const existing = (await readTokenFile()) ?? {};
172
+ stripPersistedRefreshTokens(existing);
173
+ const m2m = { ...existing.m2m, [realmName]: { access_token: accessToken, expires_at: expiresAt } };
174
+ await writeTokenFile({ ...existing, m2m });
175
+ });
176
+ }
144
177
  /**
145
178
  * Mint an M2M access token via the Auth0 client-credentials grant when the
146
- * realm's secret env var is set. Returns null when the secret is unset
147
- * (so the caller falls back to the user token). Throws on a configured-but-
148
- * rejected secret, mirroring seqapi's `_get_m2m_token` (`raise_for_status`).
179
+ * realm's secret env var is set. Returns null when the secret is unset.
180
+ * Throws on a configured-but-rejected secret, mirroring seqapi's
181
+ * `_get_m2m_token`. Cache order: in-process shared token file → Auth0.
149
182
  * The realm's own secret var is required — a Sequence secret in the shell is
150
183
  * never sent to a tenant's Auth0 client.
151
184
  */
@@ -155,9 +188,8 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
155
188
  const clientSecret = process.env[m2mSecretEnvName(realm)];
156
189
  if (!clientSecret)
157
190
  return null;
158
- const now = Date.now() / 1000;
159
191
  const cached = m2mCache.get(realm.name);
160
- if (cached && cached.expiresAt > now + 60) {
192
+ if (cached && tokenStillValid(cached)) {
161
193
  verifyTokenMatchesRealm({
162
194
  accessToken: cached.accessToken,
163
195
  realm,
@@ -165,6 +197,16 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
165
197
  });
166
198
  return cached.accessToken;
167
199
  }
200
+ const persisted = await loadPersistedM2m(realm.name);
201
+ if (persisted && tokenStillValid(persisted)) {
202
+ verifyTokenMatchesRealm({
203
+ accessToken: persisted.accessToken,
204
+ realm,
205
+ requireOrganization: false,
206
+ });
207
+ m2mCache.set(realm.name, persisted);
208
+ return persisted.accessToken;
209
+ }
168
210
  const response = await fetch(`https://${realm.domain}/oauth/token`, {
169
211
  method: 'POST',
170
212
  redirect: 'manual',
@@ -193,6 +235,11 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
193
235
  expiresAt: Date.now() / 1000 + (data.expires_in ?? 7200),
194
236
  };
195
237
  m2mCache.set(realm.name, entry);
238
+ await persistM2m({
239
+ realmName: realm.name,
240
+ accessToken: entry.accessToken,
241
+ expiresAt: entry.expiresAt,
242
+ });
196
243
  return entry.accessToken;
197
244
  }
198
245
  export function seqapiTokenDir() {
@@ -216,13 +263,15 @@ export class NotLoggedInError extends Error {
216
263
  }
217
264
  }
218
265
  /**
219
- * Return a valid access token for an environment's auth realm. Tries the
220
- * realm's M2M service account first (secret env var), then falls back to the
221
- * cached user access token. Expired user sessions perform bounded PKCE login
222
- * when browser auto-login is enabled. Same precedence and token file as seqapi's
223
- * `get_access_token`, so both CLIs resolve the same identity for the same
224
- * environment. No `env` (or a built-in Sequence env) means the shared
225
- * Sequence realm — the legacy behavior every existing caller gets unchanged.
266
+ * Return a valid access token for an environment's auth realm.
267
+ *
268
+ * Precedence (default `SEQAPI_AUTH_MODE=auto`), matching seqapi:
269
+ * 1. Valid cached user access token
270
+ * 2. M2M client-credentials when the realm's secret env var is set
271
+ * 3. Bounded PKCE login when browser auto-login is enabled
272
+ *
273
+ * Set `SEQAPI_AUTH_MODE=m2m` to skip the user token. No `env` (or a built-in
274
+ * Sequence env) means the shared Sequence realm.
226
275
  */
227
276
  export class UnsafeAuthTargetError extends Error {
228
277
  }
@@ -266,9 +315,21 @@ export async function getAccessTokenWithMode(options) {
266
315
  const realm = await realmForEnv(options?.env);
267
316
  if (options?.targetUrl)
268
317
  validateRealmTarget({ realm, targetUrl: options.targetUrl });
318
+ const forceM2m = authModePrefersM2m();
319
+ if (!forceM2m) {
320
+ const tokens = await loadCachedUserTokens(realm.name);
321
+ const now = Date.now() / 1000;
322
+ if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
323
+ verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
324
+ return { authMode: 'user', token: tokens.access_token };
325
+ }
326
+ }
269
327
  const m2m = await getM2mToken(realm);
270
328
  if (m2m)
271
329
  return { authMode: 'm2m', token: m2m };
330
+ if (forceM2m) {
331
+ throw new NotLoggedInError(realm.name, `SEQAPI_AUTH_MODE=m2m but no M2M credentials for [${realm.name}].`);
332
+ }
272
333
  const tokens = await loadCachedUserTokens(realm.name);
273
334
  if (!tokens) {
274
335
  if (options?.allowInteractiveLogin === false) {
@@ -282,11 +343,6 @@ export async function getAccessTokenWithMode(options) {
282
343
  }),
283
344
  };
284
345
  }
285
- const now = Date.now() / 1000;
286
- if (tokens.access_token && (tokens.expires_at ?? 0) > now + 60) {
287
- verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
288
- return { authMode: 'user', token: tokens.access_token };
289
- }
290
346
  if (options?.allowInteractiveLogin === false) {
291
347
  throw new NotLoggedInError(realm.name, `Access token expired [${realm.name}].`);
292
348
  }
@@ -382,19 +438,24 @@ export function decodeJwtSub(token) {
382
438
  }
383
439
  /**
384
440
  * The Auth0 subject the CLI would authenticate as right now, without any
385
- * network call: the requested realm's M2M client subject when its secret is
386
- * configured, else the `sub` of that realm's cached user token, else null.
441
+ * network call. Matches token resolution precedence: a valid user session
442
+ * wins over an ambient M2M secret unless `SEQAPI_AUTH_MODE=m2m`.
387
443
  */
388
444
  export async function currentIdentitySubject(options = {}) {
389
445
  const realm = await realmForEnv(options.env);
446
+ const forceM2m = authModePrefersM2m();
447
+ if (!forceM2m) {
448
+ const tokens = await loadCachedUserTokens(realm.name);
449
+ const now = Date.now() / 1000;
450
+ if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
451
+ verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
452
+ return decodeJwtSub(tokens.access_token);
453
+ }
454
+ }
390
455
  if (realm.m2mClientId && process.env[m2mSecretEnvName(realm)]?.trim()) {
391
456
  return `${realm.m2mClientId}@clients`;
392
457
  }
393
- const tokens = await loadCachedUserTokens(realm.name);
394
- if (!tokens?.access_token)
395
- return null;
396
- verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
397
- return decodeJwtSub(tokens.access_token);
458
+ return null;
398
459
  }
399
460
  async function readTokenFile() {
400
461
  const path = seqapiTokenPath();
@@ -500,6 +561,14 @@ export async function deleteRealmTokens(realmName) {
500
561
  delete updated.realms;
501
562
  }
502
563
  }
564
+ const m2m = { ...existing.m2m };
565
+ delete m2m[realmName];
566
+ if (Object.keys(m2m).length > 0) {
567
+ updated.m2m = m2m;
568
+ }
569
+ else {
570
+ delete updated.m2m;
571
+ }
503
572
  await writeTokenFile(updated);
504
573
  });
505
574
  }
@@ -2,7 +2,7 @@ import { type AuthMode } from '../auth.js';
2
2
  import { type ResolvedEnv } from '../config.js';
3
3
  import type { ParsedArgs } from '../process/commands.js';
4
4
  import { type ManagedFunctionManifest } from './manifest.js';
5
- import { type SourceSpec } from '@sequenceholdings/artifact-studio/source-resolver';
5
+ export { parseFunctionsSourceSelection, parseFunctionsSourceSpec, resolveFunctionSourceDir } from './source-selection.js';
6
6
  export declare const LOG = "[seq-studio]";
7
7
  export interface FunctionSummary {
8
8
  id: string;
@@ -50,14 +50,6 @@ export declare function clientOptions(ctx: CommandContext): {
50
50
  };
51
51
  export declare function readManifestOptional(dir: string): Promise<ManagedFunctionManifest | null>;
52
52
  export declare function workDir(args: ParsedArgs): string;
53
- /**
54
- * Source selection for build/deploy: --dir (local, default '.'), a platform
55
- * git-service repo (--repo <ns>/<name>), or any git URL (--git-url <url>);
56
- * --ref picks a branch/tag/commit. Reuses the artifact-studio resolver —
57
- * functions name their local dir with --dir rather than a positional, so map
58
- * it onto the spec parser's positional slot.
59
- */
60
- export declare function parseFunctionsSourceSpec(args: ParsedArgs): SourceSpec;
61
53
  export declare function resolveOrRegisterFunction({ ctx, slug, title, description, }: {
62
54
  ctx: CommandContext;
63
55
  slug: string;
@@ -90,5 +82,5 @@ export declare function functionsRollbackCommand(args: ParsedArgs): Promise<numb
90
82
  /** Minimal dotenv parser — KEY=VALUE lines, quotes stripped, comments skipped. */
91
83
  export declare function parseDotenv(content: string): Record<string, string>;
92
84
  export declare function functionsDeleteCommand(args: ParsedArgs): Promise<number>;
93
- export declare const FUNCTIONS_USAGE = "usage:\n seq-studio functions init <dir> scaffold manifest + TypeScript hello-world handler\n seq-studio functions build [--dir d] local pre-flight (manifest, lockfile, size)\n seq-studio functions deploy -e <env> [-m msg] preview + confirm secrets + upload and deploy\n seq-studio functions list -e <env> [--match-local] functions visible on the environment\n seq-studio functions show -e <env> [--fn slug] detail for one function (versions, secrets)\n seq-studio functions logs -e <env> [--limit N] [--since t] Cloud Logging snapshot (reader-gated)\n seq-studio functions promote <version> -e <env> make a version live\n seq-studio functions rollback [<version>] -e <env> redeploy a prior version\n seq-studio functions delete -e <env> [--yes] archive function + tear down GCP resources\n (version history is retained)\n\n Flags: -e/--env <env|preview:<slug>> (required for network commands; see: seq-studio envs list) \u00B7 --fn <slug> \u00B7 --dir <path>\n --from-env-file <path> (default: .env) source file for secret values\n --no-wait \u00B7 --yes\n --no-provision (deploy) update-only: error instead of registering a new\n shell, writing secret values, or attaching secrets (CI sweep)\n\n Source for build/deploy: a local --dir (default .), a platform git-service\n repo (--repo <ns>/<name>), or a public HTTPS git URL (--git-url <url>). --ref selects a\n branch/tag/commit (default: the repo's default branch). Remote sources record\n the pinned commit as provenance (never dirty) and NEVER read a repo-committed\n .env for secret values \u2014 provision secrets server-side or pass a local\n --from-env-file (resolved against your cwd).\n\n Interactive --repo builds clone over smart-HTTP and require a repo:read git\n PAT in ATLAS_GIT_PAT (`seq-studio auth pat create --scopes repo:read`, or\n Atlas \u2192 Settings \u2192 Tokens). Headless M2M builds use JSON materialize and\n accept only platform-managed --repo sources. Interactive builds also need\n --env + seq-studio login to resolve the repo and deploy.\n";
85
+ export declare const FUNCTIONS_USAGE = "usage:\n seq-studio functions init <dir> scaffold manifest + TypeScript hello-world handler\n seq-studio functions build [--dir d] local pre-flight (manifest, lockfile, size)\n seq-studio functions deploy -e <env> [-m msg] preview + confirm secrets + upload and deploy\n seq-studio functions list -e <env> [--match-local] functions visible on the environment\n seq-studio functions show -e <env> [--fn slug] detail for one function (versions, secrets)\n seq-studio functions logs -e <env> [--limit N] [--since t] Cloud Logging snapshot (reader-gated)\n seq-studio functions promote <version> -e <env> make a version live\n seq-studio functions rollback [<version>] -e <env> redeploy a prior version\n seq-studio functions delete -e <env> [--yes] archive function + tear down GCP resources\n (version history is retained)\n\n Flags: -e/--env <env|preview:<slug>> (required for network commands; see: seq-studio envs list) \u00B7 --fn <slug> \u00B7 --dir <path>\n --path <repo-subdirectory> (remote build/deploy only) select one function in a multi-function repo\n --from-env-file <path> (default: .env) source file for secret values\n --no-wait \u00B7 --yes\n --no-provision (deploy) update-only: error instead of registering a new\n shell, writing secret values, or attaching secrets (CI sweep)\n\n Source for build/deploy: a local --dir (default .), a platform git-service\n repo (--repo <ns>/<name>), or a public HTTPS git URL (--git-url <url>). --path selects\n a function directory within a remote repo; omit it for the existing root-manifest\n layout. --ref selects a branch/tag/commit (default: the repo's default branch).\n Remote sources record the pinned commit as provenance (never dirty) and NEVER\n read a repo-committed .env for secret values \u2014 provision secrets server-side\n or pass a local --from-env-file (resolved against your cwd).\n\n Interactive --repo builds clone over smart-HTTP and require a repo:read git\n PAT in ATLAS_GIT_PAT (`seq-studio auth pat create --scopes repo:read`, or\n Atlas \u2192 Settings \u2192 Tokens). Headless M2M builds use JSON materialize and\n accept only platform-managed --repo sources. Interactive builds also need\n --env + seq-studio login to resolve the repo and deploy.\n";
94
86
  export declare function runFunctionsCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;