@sequenceholdings/studio-cli 0.1.18 → 0.1.21

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,121 @@
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 = [], }) {
91
+ const manifest = await readManifest(directory);
92
+ if (!manifest)
93
+ return compileAgentDirectory({ rootDir: directory });
94
+ if (targetEnvironment !== undefined) {
95
+ assertKnownTargetEnvironment({
96
+ manifest,
97
+ environment: targetEnvironment,
98
+ deployEnvironments,
99
+ });
100
+ // Selection is silent, so a target that matches nothing looks identical to
101
+ // a full deploy. Name the drop before the operator confirms an apply.
102
+ const { selected, skipped, total } = selectionStats({
103
+ manifest,
104
+ environment: targetEnvironment,
105
+ });
106
+ if (skipped > 0) {
107
+ console.log(`[seq-studio] target ${targetEnvironment}: ${selected} of ${total} agents selected, ${skipped} scoped to other environments`);
108
+ }
109
+ }
110
+ // With a target: last-wins by id (tenant overrides). Without: every distinct
111
+ // path, so validate still compiles override sources that lose a collapse.
112
+ const entries = selectAgentEntries({
113
+ manifest,
114
+ environment: targetEnvironment,
115
+ });
116
+ return compileManifestEntries({
117
+ directory,
118
+ entries,
119
+ allowDuplicateIds: targetEnvironment === undefined,
120
+ });
121
+ }
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
@@ -131,7 +131,8 @@ export function m2mSecretEnvName(realm) {
131
131
  }
132
132
  // In-memory per-realm cache for M2M tokens (seconds-based, mirrors seqapi's
133
133
  // `_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).
134
+ // call within a single process (e.g. a long `artifact dev` watch). Disk
135
+ // persistence under tokens.json `m2m` covers cross-process reuse.
135
136
  const m2mCache = new Map();
136
137
  /**
137
138
  * A configured M2M credential failed to mint a token. Typed so callers that
@@ -141,11 +142,41 @@ const m2mCache = new Map();
141
142
  export class M2mTokenError extends Error {
142
143
  name = 'M2mTokenError';
143
144
  }
145
+ function authModePrefersM2m() {
146
+ const value = process.env.SEQAPI_AUTH_MODE?.trim().toLowerCase() ?? 'auto';
147
+ return value === 'm2m';
148
+ }
149
+ function tokenStillValid(entry) {
150
+ const now = Date.now() / 1000;
151
+ return Boolean(entry?.accessToken && (entry.expiresAt ?? 0) > now + 60);
152
+ }
153
+ async function loadPersistedM2m(realmName) {
154
+ if (!existsSync(seqapiTokenPath()))
155
+ return null;
156
+ return withTokenFileLock(async () => {
157
+ const file = await readTokenFile();
158
+ const entry = file?.m2m?.[realmName];
159
+ if (!entry?.access_token)
160
+ return null;
161
+ return {
162
+ accessToken: entry.access_token,
163
+ expiresAt: entry.expires_at ?? 0,
164
+ };
165
+ });
166
+ }
167
+ async function persistM2m({ realmName, accessToken, expiresAt, }) {
168
+ await withTokenFileLock(async () => {
169
+ const existing = (await readTokenFile()) ?? {};
170
+ stripPersistedRefreshTokens(existing);
171
+ const m2m = { ...existing.m2m, [realmName]: { access_token: accessToken, expires_at: expiresAt } };
172
+ await writeTokenFile({ ...existing, m2m });
173
+ });
174
+ }
144
175
  /**
145
176
  * 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`).
177
+ * realm's secret env var is set. Returns null when the secret is unset.
178
+ * Throws on a configured-but-rejected secret, mirroring seqapi's
179
+ * `_get_m2m_token`. Cache order: in-process shared token file → Auth0.
149
180
  * The realm's own secret var is required — a Sequence secret in the shell is
150
181
  * never sent to a tenant's Auth0 client.
151
182
  */
@@ -155,9 +186,8 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
155
186
  const clientSecret = process.env[m2mSecretEnvName(realm)];
156
187
  if (!clientSecret)
157
188
  return null;
158
- const now = Date.now() / 1000;
159
189
  const cached = m2mCache.get(realm.name);
160
- if (cached && cached.expiresAt > now + 60) {
190
+ if (cached && tokenStillValid(cached)) {
161
191
  verifyTokenMatchesRealm({
162
192
  accessToken: cached.accessToken,
163
193
  realm,
@@ -165,6 +195,16 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
165
195
  });
166
196
  return cached.accessToken;
167
197
  }
198
+ const persisted = await loadPersistedM2m(realm.name);
199
+ if (persisted && tokenStillValid(persisted)) {
200
+ verifyTokenMatchesRealm({
201
+ accessToken: persisted.accessToken,
202
+ realm,
203
+ requireOrganization: false,
204
+ });
205
+ m2mCache.set(realm.name, persisted);
206
+ return persisted.accessToken;
207
+ }
168
208
  const response = await fetch(`https://${realm.domain}/oauth/token`, {
169
209
  method: 'POST',
170
210
  redirect: 'manual',
@@ -193,6 +233,11 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
193
233
  expiresAt: Date.now() / 1000 + (data.expires_in ?? 7200),
194
234
  };
195
235
  m2mCache.set(realm.name, entry);
236
+ await persistM2m({
237
+ realmName: realm.name,
238
+ accessToken: entry.accessToken,
239
+ expiresAt: entry.expiresAt,
240
+ });
196
241
  return entry.accessToken;
197
242
  }
198
243
  export function seqapiTokenDir() {
@@ -216,13 +261,15 @@ export class NotLoggedInError extends Error {
216
261
  }
217
262
  }
218
263
  /**
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.
264
+ * Return a valid access token for an environment's auth realm.
265
+ *
266
+ * Precedence (default `SEQAPI_AUTH_MODE=auto`), matching seqapi:
267
+ * 1. Valid cached user access token
268
+ * 2. M2M client-credentials when the realm's secret env var is set
269
+ * 3. Bounded PKCE login when browser auto-login is enabled
270
+ *
271
+ * Set `SEQAPI_AUTH_MODE=m2m` to skip the user token. No `env` (or a built-in
272
+ * Sequence env) means the shared Sequence realm.
226
273
  */
227
274
  export class UnsafeAuthTargetError extends Error {
228
275
  }
@@ -266,9 +313,21 @@ export async function getAccessTokenWithMode(options) {
266
313
  const realm = await realmForEnv(options?.env);
267
314
  if (options?.targetUrl)
268
315
  validateRealmTarget({ realm, targetUrl: options.targetUrl });
316
+ const forceM2m = authModePrefersM2m();
317
+ if (!forceM2m) {
318
+ const tokens = await loadCachedUserTokens(realm.name);
319
+ const now = Date.now() / 1000;
320
+ if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
321
+ verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
322
+ return { authMode: 'user', token: tokens.access_token };
323
+ }
324
+ }
269
325
  const m2m = await getM2mToken(realm);
270
326
  if (m2m)
271
327
  return { authMode: 'm2m', token: m2m };
328
+ if (forceM2m) {
329
+ throw new NotLoggedInError(realm.name, `SEQAPI_AUTH_MODE=m2m but no M2M credentials for [${realm.name}].`);
330
+ }
272
331
  const tokens = await loadCachedUserTokens(realm.name);
273
332
  if (!tokens) {
274
333
  if (options?.allowInteractiveLogin === false) {
@@ -282,11 +341,6 @@ export async function getAccessTokenWithMode(options) {
282
341
  }),
283
342
  };
284
343
  }
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
344
  if (options?.allowInteractiveLogin === false) {
291
345
  throw new NotLoggedInError(realm.name, `Access token expired [${realm.name}].`);
292
346
  }
@@ -382,19 +436,24 @@ export function decodeJwtSub(token) {
382
436
  }
383
437
  /**
384
438
  * 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.
439
+ * network call. Matches token resolution precedence: a valid user session
440
+ * wins over an ambient M2M secret unless `SEQAPI_AUTH_MODE=m2m`.
387
441
  */
388
442
  export async function currentIdentitySubject(options = {}) {
389
443
  const realm = await realmForEnv(options.env);
444
+ const forceM2m = authModePrefersM2m();
445
+ if (!forceM2m) {
446
+ const tokens = await loadCachedUserTokens(realm.name);
447
+ const now = Date.now() / 1000;
448
+ if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
449
+ verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
450
+ return decodeJwtSub(tokens.access_token);
451
+ }
452
+ }
390
453
  if (realm.m2mClientId && process.env[m2mSecretEnvName(realm)]?.trim()) {
391
454
  return `${realm.m2mClientId}@clients`;
392
455
  }
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);
456
+ return null;
398
457
  }
399
458
  async function readTokenFile() {
400
459
  const path = seqapiTokenPath();
@@ -500,6 +559,14 @@ export async function deleteRealmTokens(realmName) {
500
559
  delete updated.realms;
501
560
  }
502
561
  }
562
+ const m2m = { ...existing.m2m };
563
+ delete m2m[realmName];
564
+ if (Object.keys(m2m).length > 0) {
565
+ updated.m2m = m2m;
566
+ }
567
+ else {
568
+ delete updated.m2m;
569
+ }
503
570
  await writeTokenFile(updated);
504
571
  });
505
572
  }
@@ -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>;
@@ -15,8 +15,10 @@ import { confirmYes } from '../prompt.js';
15
15
  import { collectBundleFiles, isDirectory, validateLocalBundle, } from './bundle.js';
16
16
  import { managedFunctionManifestSchema, MF_MANIFEST_FILENAME, manifestEgressHosts, manifestEgressIpRanges, } from './manifest.js';
17
17
  import { buildEgressPreviewLines, egressPropagationNote, formatEgressSummary, printFunctionEgressHosts, } from './egress-preview.js';
18
- import { parseSourceSpec, resolveArtifactSource, } from '@sequenceholdings/artifact-studio/source-resolver';
18
+ import { resolveArtifactSource, } from '@sequenceholdings/artifact-studio/source-resolver';
19
19
  import { buildSecretPreviewLines, classifySecrets, } from './secret-reconcile.js';
20
+ import { parseFunctionsSourceSelection, resolveFunctionSourceDir, } from './source-selection.js';
21
+ export { parseFunctionsSourceSelection, parseFunctionsSourceSpec, resolveFunctionSourceDir } from './source-selection.js';
20
22
  const execFileAsync = promisify(execFile);
21
23
  export const LOG = '[seq-studio]';
22
24
  const POLL_INTERVAL_MS = 5_000;
@@ -85,20 +87,6 @@ export function workDir(args) {
85
87
  const dir = typeof args.flags.dir === 'string' ? args.flags.dir : '.';
86
88
  return resolve(dir);
87
89
  }
88
- /**
89
- * Source selection for build/deploy: --dir (local, default '.'), a platform
90
- * git-service repo (--repo <ns>/<name>), or any git URL (--git-url <url>);
91
- * --ref picks a branch/tag/commit. Reuses the artifact-studio resolver —
92
- * functions name their local dir with --dir rather than a positional, so map
93
- * it onto the spec parser's positional slot.
94
- */
95
- export function parseFunctionsSourceSpec(args) {
96
- const dir = typeof args.flags.dir === 'string' ? args.flags.dir : undefined;
97
- if (dir !== undefined && (args.flags.repo !== undefined || args.flags['git-url'] !== undefined)) {
98
- throw new Error('--dir cannot be combined with --repo / --git-url.');
99
- }
100
- return parseSourceSpec({ positional: dir === undefined ? [] : [dir], flags: args.flags });
101
- }
102
90
  /** Manifest read with a source-aware error for the missing case. */
103
91
  async function readSourceManifest(dir, spec) {
104
92
  const manifest = await readManifestOptional(dir);
@@ -106,7 +94,7 @@ async function readSourceManifest(dir, spec) {
106
94
  return manifest;
107
95
  throw new Error(spec.kind === 'local'
108
96
  ? `No ${MF_MANIFEST_FILENAME} in ${dir}. Run \`seq-studio functions init\` to scaffold one, or pass --dir.`
109
- : `No ${MF_MANIFEST_FILENAME} at the root of the source repoa managed-function repo keeps its manifest at the top level.`);
97
+ : `No ${MF_MANIFEST_FILENAME} in the selected remote source directorykeep it at the repo root or select a function directory with --path.`);
110
98
  }
111
99
  /**
112
100
  * Resolve the target function: --fn <slug> wins, else the manifest in the
@@ -349,14 +337,15 @@ export async function functionsInitCommand(args) {
349
337
  // build (local pre-flight)
350
338
  // ---------------------------------------------------------------------------
351
339
  export async function functionsBuildCommand(args) {
352
- const spec = parseFunctionsSourceSpec(args);
340
+ const { spec, path } = parseFunctionsSourceSelection(args);
353
341
  // A public git URL needs no bearer token, but optional auth resolution still
354
342
  // identifies a selected M2M principal so policy can reject arbitrary CI
355
343
  // input before cloning. Local builds remain completely offline.
356
344
  const remote = await buildSourceOptions({ args, spec });
357
345
  const source = await resolveArtifactSource(spec, remote);
358
346
  try {
359
- return await buildFromResolvedSource({ spec, source });
347
+ const dir = await resolveFunctionSourceDir({ repoDir: source.dir, path });
348
+ return await buildFromResolvedSource({ spec, source: { ...source, dir } });
360
349
  }
361
350
  finally {
362
351
  await source.cleanup();
@@ -417,7 +406,7 @@ export async function functionsDeployCommand(args) {
417
406
  console.error(`${LOG} ${REQUIRE_EXPLICIT_ENV_MESSAGE}`);
418
407
  return 1;
419
408
  }
420
- const spec = parseFunctionsSourceSpec(args);
409
+ const { spec, path } = parseFunctionsSourceSelection(args);
421
410
  // Remote deploys resolve auth up front: --repo needs it to fetch the tree,
422
411
  // while --git-url must reject a selected M2M principal before cloning.
423
412
  // Local sources still defer auth until after validation so malformed local
@@ -425,7 +414,8 @@ export async function functionsDeployCommand(args) {
425
414
  const ctx = spec.kind !== 'local' ? await buildContext(args) : null;
426
415
  const source = await resolveArtifactSource(spec, ctx ? sourceClientOptions(ctx) : {});
427
416
  try {
428
- return await deployFromResolvedSource({ args, spec, ctx, source });
417
+ const dir = await resolveFunctionSourceDir({ repoDir: source.dir, path });
418
+ return await deployFromResolvedSource({ args, spec, ctx, source: { ...source, dir } });
429
419
  }
430
420
  finally {
431
421
  await source.cleanup();
@@ -983,17 +973,19 @@ export const FUNCTIONS_USAGE = `usage:
983
973
  (version history is retained)
984
974
 
985
975
  Flags: -e/--env <env|preview:<slug>> (required for network commands; see: seq-studio envs list) · --fn <slug> · --dir <path>
976
+ --path <repo-subdirectory> (remote build/deploy only) select one function in a multi-function repo
986
977
  --from-env-file <path> (default: .env) source file for secret values
987
978
  --no-wait · --yes
988
979
  --no-provision (deploy) update-only: error instead of registering a new
989
980
  shell, writing secret values, or attaching secrets (CI sweep)
990
981
 
991
982
  Source for build/deploy: a local --dir (default .), a platform git-service
992
- repo (--repo <ns>/<name>), or a public HTTPS git URL (--git-url <url>). --ref selects a
993
- branch/tag/commit (default: the repo's default branch). Remote sources record
994
- the pinned commit as provenance (never dirty) and NEVER read a repo-committed
995
- .env for secret values provision secrets server-side or pass a local
996
- --from-env-file (resolved against your cwd).
983
+ repo (--repo <ns>/<name>), or a public HTTPS git URL (--git-url <url>). --path selects
984
+ a function directory within a remote repo; omit it for the existing root-manifest
985
+ layout. --ref selects a branch/tag/commit (default: the repo's default branch).
986
+ Remote sources record the pinned commit as provenance (never dirty) and NEVER
987
+ read a repo-committed .env for secret values — provision secrets server-side
988
+ or pass a local --from-env-file (resolved against your cwd).
997
989
 
998
990
  Interactive --repo builds clone over smart-HTTP and require a repo:read git
999
991
  PAT in ATLAS_GIT_PAT (\`seq-studio auth pat create --scopes repo:read\`, or
@@ -0,0 +1,24 @@
1
+ import { type SourceSpec } from '@sequenceholdings/artifact-studio/source-resolver';
2
+ import type { ParsedArgs } from '../process/commands.js';
3
+ export interface FunctionsSourceSelection {
4
+ spec: SourceSpec;
5
+ path?: string;
6
+ }
7
+ /**
8
+ * Functions name their local source with --dir rather than a positional, so
9
+ * map it onto the shared source parser used by other seq-studio primitives.
10
+ */
11
+ export declare function parseFunctionsSourceSpec(args: ParsedArgs): SourceSpec;
12
+ /**
13
+ * Select a managed function within a remote repository. Local callers already
14
+ * select the function root with --dir, so --path is remote-only.
15
+ */
16
+ export declare function parseFunctionsSourceSelection(args: ParsedArgs): FunctionsSourceSelection;
17
+ /**
18
+ * Resolve the selected function root after the repository is materialized.
19
+ * realpath containment prevents a committed symlink from escaping the repo.
20
+ */
21
+ export declare function resolveFunctionSourceDir({ repoDir, path, }: {
22
+ repoDir: string;
23
+ path?: string;
24
+ }): Promise<string>;
@@ -0,0 +1,67 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { parseSourceSpec, } from '@sequenceholdings/artifact-studio/source-resolver';
4
+ import { isDirectory } from './bundle.js';
5
+ /**
6
+ * Functions name their local source with --dir rather than a positional, so
7
+ * map it onto the shared source parser used by other seq-studio primitives.
8
+ */
9
+ export function parseFunctionsSourceSpec(args) {
10
+ const dir = typeof args.flags.dir === 'string' ? args.flags.dir : undefined;
11
+ if (dir !== undefined && (args.flags.repo !== undefined || args.flags['git-url'] !== undefined)) {
12
+ throw new Error('--dir cannot be combined with --repo / --git-url.');
13
+ }
14
+ return parseSourceSpec({ positional: dir === undefined ? [] : [dir], flags: args.flags });
15
+ }
16
+ function validateFunctionRepoPath(path) {
17
+ const segments = path.split('/');
18
+ if (path.length === 0 ||
19
+ path.trim() !== path ||
20
+ isAbsolute(path) ||
21
+ path.includes('\\') ||
22
+ [...path].some((character) => character.charCodeAt(0) < 0x20) ||
23
+ segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) {
24
+ throw new Error(`--path must be a canonical relative directory within the repository (got ${JSON.stringify(path)}).`);
25
+ }
26
+ }
27
+ /**
28
+ * Select a managed function within a remote repository. Local callers already
29
+ * select the function root with --dir, so --path is remote-only.
30
+ */
31
+ export function parseFunctionsSourceSelection(args) {
32
+ const spec = parseFunctionsSourceSpec(args);
33
+ const pathFlag = args.flags.path;
34
+ if (pathFlag === true)
35
+ throw new Error('--path requires a value.');
36
+ if (pathFlag === undefined)
37
+ return { spec };
38
+ if (spec.kind === 'local') {
39
+ throw new Error('--path only applies together with --repo or --git-url; use --dir for local source.');
40
+ }
41
+ validateFunctionRepoPath(pathFlag);
42
+ return { spec, path: pathFlag };
43
+ }
44
+ /**
45
+ * Resolve the selected function root after the repository is materialized.
46
+ * realpath containment prevents a committed symlink from escaping the repo.
47
+ */
48
+ export async function resolveFunctionSourceDir({ repoDir, path, }) {
49
+ if (path === undefined)
50
+ return repoDir;
51
+ validateFunctionRepoPath(path);
52
+ const candidate = resolve(repoDir, path);
53
+ if (!(await isDirectory(candidate))) {
54
+ throw new Error(`--path does not name a directory in the repository: ${path}`);
55
+ }
56
+ const [canonicalRepoDir, canonicalCandidate] = await Promise.all([
57
+ realpath(repoDir),
58
+ realpath(candidate),
59
+ ]);
60
+ const relativePath = relative(canonicalRepoDir, canonicalCandidate);
61
+ if (relativePath === '..' ||
62
+ relativePath.startsWith(`..${sep}`) ||
63
+ isAbsolute(relativePath)) {
64
+ throw new Error(`--path must stay within the repository: ${path}`);
65
+ }
66
+ return canonicalCandidate;
67
+ }
package/dist/main.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * seq-studio process <sub> manage Lattice processes
6
6
  * seq-studio artifact <sub> manage Artifact Studio apps
7
7
  * seq-studio functions <sub> manage Managed Functions
8
+ * seq-studio agents <sub> manage typed agent definitions
8
9
  * seq-studio secrets <sub> manage org-owned Managed Secrets
9
10
  * seq-studio repos <sub> manage platform git-service repos
10
11
  * seq-studio pipeline <sub> author + validate Data Pipelines stage specs