@sequenceholdings/studio-cli 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.js CHANGED
@@ -1,41 +1,46 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
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.
6
+ * seq-studio and seqapi share one token file and one Auth0 application:
7
+ * same tenant, client id, audience, and token shape. Either CLI can log in,
8
+ * and the resulting access token validates against every Atlas /api/* route.
11
9
  *
12
10
  * Two token sources, in the SAME precedence order as seqapi's
13
11
  * `get_access_token` (`shared/seqapi/seqapi/auth.py`):
14
12
  * 1. M2M service account — Auth0 client-credentials grant, used when
15
13
  * `AUTH0_M2M_CLIENT_SECRET` is set. This is the headless path: CI /
16
- * cloud agents with no interactive `seqapi login` can still push.
14
+ * cloud agents with no interactive login can still push.
17
15
  * (M2M carries app scopes but NO user identity / workspace membership
18
16
  * — see the `atlas-test-access` rule.)
19
17
  * 2. Cached user token — read from the seqapi token file and refreshed
20
18
  * via the Auth0 refresh-token grant when near expiry.
21
19
  *
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:
20
+ * Login and refresh both write the shared file. This mirrors
21
+ * `seqapi._save_tokens` exactly:
25
22
  * same fields, same shape, same 0o600 permissions, atomic write via
26
23
  * tmpfile + rename. The M2M token is in-memory only (never persisted).
27
24
  */
28
25
  // Match `shared/seqapi/seqapi/config.py`. Hard-coded because the seqapi
29
26
  // CLI also hard-codes them — there's a single Sequence Auth0 tenant for
30
27
  // 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';
28
+ export const AUTH0_DOMAIN = 'dev-n1t8ts403fp8oyxp.us.auth0.com';
29
+ export const AUTH0_CLIENT_ID = 'GD9riCDWocfc66odpWBjwBiX43qqAX8r';
30
+ export const AUTH0_AUDIENCE = 'https://api.studio.com';
34
31
  const AUTH0_M2M_CLIENT_ID = '5TLffZqvLq4ztLDjZhwKVJCpB5VNrWj0';
35
32
  // In-memory cache for the M2M token (seconds-based, mirrors seqapi's
36
33
  // `_m2m_cache`). Reused while > 60s from expiry to avoid re-minting on every
37
34
  // call within a single process (e.g. a long `artifact dev` watch).
38
35
  let m2mCache = null;
36
+ /**
37
+ * A configured M2M credential failed to mint a token. Typed so callers that
38
+ * normally swallow discovery errors (lazy catalog refresh) can still surface
39
+ * broken CI credentials instead of a misleading "unknown env" message.
40
+ */
41
+ export class M2mTokenError extends Error {
42
+ name = 'M2mTokenError';
43
+ }
39
44
  /**
40
45
  * Mint an M2M access token via the Auth0 client-credentials grant when
41
46
  * `AUTH0_M2M_CLIENT_SECRET` is set. Returns null when the secret is unset
@@ -60,11 +65,11 @@ async function getM2mToken() {
60
65
  }),
61
66
  });
62
67
  if (!response.ok) {
63
- throw new Error(`Auth0 M2M token request failed (${response.status}): ${await response.text()}`);
68
+ throw new M2mTokenError(`Auth0 M2M token request failed (${response.status}): ${await response.text()}`);
64
69
  }
65
70
  const data = (await response.json());
66
71
  if (!data.access_token) {
67
- throw new Error('Auth0 M2M response missing access_token');
72
+ throw new M2mTokenError('Auth0 M2M response missing access_token');
68
73
  }
69
74
  m2mCache = {
70
75
  accessToken: data.access_token,
@@ -81,8 +86,7 @@ export function seqapiTokenPath() {
81
86
  export class NotLoggedInError extends Error {
82
87
  name = 'NotLoggedInError';
83
88
  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' +
89
+ super('Not logged in. Run: seq-studio login\n' +
86
90
  'For headless contexts (CI / cloud agents), set AUTH0_M2M_CLIENT_SECRET for ' +
87
91
  'service-account (M2M) access instead.');
88
92
  }
@@ -117,7 +121,7 @@ export async function getAccessToken() {
117
121
  }),
118
122
  });
119
123
  if (response.status === 401 || response.status === 403) {
120
- throw new Error('Refresh token expired or revoked. Re-authenticate with: seqapi login');
124
+ throw new Error('Refresh token expired or revoked. Re-authenticate with: seq-studio login');
121
125
  }
122
126
  if (!response.ok) {
123
127
  throw new Error(`Auth0 token refresh failed (${response.status}): ${await response.text()}`);
@@ -150,6 +154,38 @@ export async function tryGetAccessToken(options) {
150
154
  return null;
151
155
  }
152
156
  }
157
+ /**
158
+ * Decode the `sub` claim from a JWT without verification. Verification is the
159
+ * server's job — this is only used to compare identities locally (e.g. to
160
+ * bind the cached environment catalog to the identity that fetched it).
161
+ */
162
+ export function decodeJwtSub(token) {
163
+ const payload = token.split('.')[1];
164
+ if (!payload)
165
+ return null;
166
+ try {
167
+ const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
168
+ const sub = typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'sub') : null;
169
+ return typeof sub === 'string' && sub ? sub : null;
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ /**
176
+ * The Auth0 subject the CLI would authenticate as right now, without any
177
+ * network call: the fixed M2M client subject when the secret is configured,
178
+ * else the `sub` of the cached user token, else null (anonymous).
179
+ */
180
+ export async function currentIdentitySubject() {
181
+ if (process.env.AUTH0_M2M_CLIENT_SECRET?.trim()) {
182
+ return `${AUTH0_M2M_CLIENT_ID}@clients`;
183
+ }
184
+ const tokens = await loadTokens();
185
+ if (!tokens?.access_token)
186
+ return null;
187
+ return decodeJwtSub(tokens.access_token);
188
+ }
153
189
  async function loadTokens() {
154
190
  const path = seqapiTokenPath();
155
191
  if (!existsSync(path))
@@ -161,11 +197,14 @@ async function loadTokens() {
161
197
  return null;
162
198
  }
163
199
  }
164
- async function saveTokens(tokens) {
200
+ export async function saveTokens(tokens) {
165
201
  const path = seqapiTokenPath();
166
202
  await mkdir(dirname(path), { recursive: true });
167
203
  // Mirror seqapi's atomic-write pattern: write to tmpfile then rename.
168
204
  const tmp = path + '.tmp';
169
205
  await writeFile(tmp, JSON.stringify(tokens, null, 2), { encoding: 'utf8', mode: 0o600 });
170
- await import('node:fs/promises').then((fs) => fs.rename(tmp, path));
206
+ // writeFile's mode only applies when creating a file. Reset it explicitly in
207
+ // case a prior interrupted login left a permissive tmp file behind.
208
+ await chmod(tmp, 0o600);
209
+ await rename(tmp, path);
171
210
  }
@@ -37,7 +37,7 @@ export function clarifyApplyFailureReason(reason) {
37
37
  function nextStepForAtlasError(error) {
38
38
  const msg = error.message.toLowerCase();
39
39
  if (error.status === 401) {
40
- return 'Next step: run `seqapi login` and retry.';
40
+ return 'Next step: run `seq-studio login` and retry.';
41
41
  }
42
42
  if (error.status === 403) {
43
43
  return 'Next step: confirm `-e` targets the right environment and you have the required access grant.';
package/dist/config.d.ts CHANGED
@@ -1,8 +1,11 @@
1
+ import { type EnvTier } from './env-catalog.js';
1
2
  /**
2
3
  * `--env preview:<branch-or-slug>` targets a per-PR Vercel preview deployment
3
4
  * without a config.toml entry. The slug after the prefix is run through the
4
5
  * canonical branch→slug transform (see `preview.ts`). PR-number resolution and
5
6
  * the Cloudflare WAF bypass header live in `artifact/delegate.ts`.
7
+ * Preview targeting is a Sequence-staff surface — resolveEnv gates it on the
8
+ * discovered tier.
6
9
  */
7
10
  export declare const PREVIEW_ENV_PREFIX = "preview:";
8
11
  export declare const ENV_NAMES: string[];
@@ -11,19 +14,22 @@ export interface LatticeConfig {
11
14
  url: string;
12
15
  }>;
13
16
  defaultEnv: string;
17
+ /** Discovered identity tier; `anonymous` when no catalog is cached. */
18
+ tier: EnvTier;
14
19
  }
15
20
  export declare function globalConfigDir(): string;
16
21
  export declare function configPath(): string;
17
22
  export declare function defaultConfig(): LatticeConfig;
18
23
  /**
19
- * Read the config from disk. If the file does not exist, return defaults
20
- * — no auto-write, since seq-studio should work out of the box without
21
- * any state on disk. The user can run `seq-studio config init` (future)
22
- * if they want a customizable file.
24
+ * Read the effective config. Merge precedence (later wins):
23
25
  *
24
- * User-supplied envs are merged on top of the built-ins, so an override
25
- * for `local` (say, pointing at a different port) takes effect, and net
26
- * new envs (e.g. `staging-2`) are picked up.
26
+ * 1. built-in `local`
27
+ * 2. the cached discovered catalog (`~/.config/lattice/environments.json`)
28
+ * 3. user entries in config.toml (an override for `local`, or net-new envs)
29
+ *
30
+ * If the config.toml file does not exist, defaults are returned — no
31
+ * auto-write, since seq-studio should work out of the box without any
32
+ * state on disk.
27
33
  */
28
34
  export declare function readConfig(): Promise<LatticeConfig>;
29
35
  /** Write the config to disk, creating the dir if missing. */
@@ -33,12 +39,22 @@ export interface ResolvedEnv {
33
39
  url: string;
34
40
  }
35
41
  /**
36
- * Resolve `--env <name>` against the user's config. Falls back to the
37
- * config's `defaultEnv` when no name is passed. Throws a clear error
38
- * when the name does not match any known env so users see a list of
39
- * what's available rather than a confusing 404 later.
42
+ * Resolve `--env <name>` against the effective config. Falls back to the
43
+ * config's `defaultEnv` when no name is passed. Throws a clear error when
44
+ * the name does not resolve at the caller's tier, listing only the envs
45
+ * that are actually visible.
40
46
  */
41
47
  export declare function resolveEnv({ config, requested, }: {
42
48
  config: LatticeConfig;
43
49
  requested?: string;
44
50
  }): ResolvedEnv;
51
+ /**
52
+ * Async resolveEnv with lazy discovery: when the requested name doesn't
53
+ * resolve from the current config (built-ins + cached catalog + toml),
54
+ * refresh the catalog once — a logged-in identity that never ran
55
+ * `seq-studio envs refresh` (or fresh CI with AUTH0_M2M_CLIENT_SECRET)
56
+ * gets its environments on first use — then retry.
57
+ */
58
+ export declare function resolveEnvWithDiscovery({ requested, }: {
59
+ requested?: string;
60
+ }): Promise<ResolvedEnv>;
package/dist/config.js CHANGED
@@ -3,25 +3,27 @@ import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
+ import { M2mTokenError } from './auth.js';
7
+ import { fetchCatalog, readCachedCatalog, } from './env-catalog.js';
6
8
  import { resolvePreviewFromSlug } from './preview.js';
7
9
  /**
8
10
  * `--env preview:<branch-or-slug>` targets a per-PR Vercel preview deployment
9
11
  * without a config.toml entry. The slug after the prefix is run through the
10
12
  * canonical branch→slug transform (see `preview.ts`). PR-number resolution and
11
13
  * the Cloudflare WAF bypass header live in `artifact/delegate.ts`.
14
+ * Preview targeting is a Sequence-staff surface — resolveEnv gates it on the
15
+ * discovered tier.
12
16
  */
13
17
  export const PREVIEW_ENV_PREFIX = 'preview:';
14
18
  /**
15
- * Environment URLs match the existing seqapi + artifact-studio mapping so
16
- * an engineer's mental model is the same across all three tools. `local`
17
- * is the dev / `make dev-all` URL kept named `local` (not `dev`) so it
18
- * lines up with `seqapi -e local` and `artifact-studio --env local`.
19
+ * Only `local` ships in the published package. Additional environments are
20
+ * discovered per-identity from the authenticated catalog endpoint (see
21
+ * `env-catalog.ts`) or added by the user in config.toml. `local` is the
22
+ * dev / `make dev-all` URL — kept named `local` (not `dev`) so it lines up
23
+ * with `seqapi -e local` and `artifact-studio --env local`.
19
24
  */
20
25
  const BUILT_IN_ENV_URLS = {
21
26
  local: 'http://localhost:5001',
22
- staging: 'https://staging.atlas.seqholdings.com',
23
- production: 'https://atlas.seqholdings.com',
24
- banksouth: 'https://banksouth.seqholdings.com',
25
27
  };
26
28
  export const ENV_NAMES = Object.keys(BUILT_IN_ENV_URLS);
27
29
  export function globalConfigDir() {
@@ -35,24 +37,32 @@ export function defaultConfig() {
35
37
  for (const [name, url] of Object.entries(BUILT_IN_ENV_URLS)) {
36
38
  envs[name] = { url };
37
39
  }
38
- return { envs, defaultEnv: 'local' };
40
+ return { envs, defaultEnv: 'local', tier: 'anonymous' };
39
41
  }
40
42
  /**
41
- * Read the config from disk. If the file does not exist, return defaults
42
- * — no auto-write, since seq-studio should work out of the box without
43
- * any state on disk. The user can run `seq-studio config init` (future)
44
- * if they want a customizable file.
43
+ * Read the effective config. Merge precedence (later wins):
45
44
  *
46
- * User-supplied envs are merged on top of the built-ins, so an override
47
- * for `local` (say, pointing at a different port) takes effect, and net
48
- * new envs (e.g. `staging-2`) are picked up.
45
+ * 1. built-in `local`
46
+ * 2. the cached discovered catalog (`~/.config/lattice/environments.json`)
47
+ * 3. user entries in config.toml (an override for `local`, or net-new envs)
48
+ *
49
+ * If the config.toml file does not exist, defaults are returned — no
50
+ * auto-write, since seq-studio should work out of the box without any
51
+ * state on disk.
49
52
  */
50
53
  export async function readConfig() {
54
+ const merged = defaultConfig();
55
+ const catalog = await readCachedCatalog();
56
+ if (catalog) {
57
+ merged.tier = catalog.tier;
58
+ for (const env of catalog.environments) {
59
+ merged.envs[env.name] = { url: env.url };
60
+ }
61
+ }
51
62
  const path = configPath();
52
63
  if (!existsSync(path))
53
- return defaultConfig();
64
+ return merged;
54
65
  const raw = parseToml(await readFile(path, 'utf8'));
55
- const merged = defaultConfig();
56
66
  if (raw.env) {
57
67
  for (const [name, value] of Object.entries(raw.env)) {
58
68
  if (typeof value?.url === 'string') {
@@ -81,15 +91,22 @@ export async function writeConfig(config) {
81
91
  lines.push(`default_env = "${config.defaultEnv}"`);
82
92
  await writeFile(path, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o600 });
83
93
  }
94
+ /** Shared hint appended to visibility errors at the anonymous/partner tiers. */
95
+ const DISCOVERY_HINT = 'If you expect more environments, authenticate (seq-studio login, or set ' +
96
+ 'AUTH0_M2M_CLIENT_SECRET) and run: seq-studio envs refresh';
84
97
  /**
85
- * Resolve `--env <name>` against the user's config. Falls back to the
86
- * config's `defaultEnv` when no name is passed. Throws a clear error
87
- * when the name does not match any known env so users see a list of
88
- * what's available rather than a confusing 404 later.
98
+ * Resolve `--env <name>` against the effective config. Falls back to the
99
+ * config's `defaultEnv` when no name is passed. Throws a clear error when
100
+ * the name does not resolve at the caller's tier, listing only the envs
101
+ * that are actually visible.
89
102
  */
90
103
  export function resolveEnv({ config, requested, }) {
91
104
  const name = requested ?? config.defaultEnv;
92
105
  if (name.startsWith(PREVIEW_ENV_PREFIX)) {
106
+ if (config.tier !== 'sequence') {
107
+ throw new Error(`Preview environments (${PREVIEW_ENV_PREFIX}<branch>) are a Sequence-staff ` +
108
+ `surface. ${DISCOVERY_HINT}`);
109
+ }
93
110
  const { slug, url } = resolvePreviewFromSlug(name.slice(PREVIEW_ENV_PREFIX.length));
94
111
  return { name: `${PREVIEW_ENV_PREFIX}${slug}`, url };
95
112
  }
@@ -97,7 +114,40 @@ export function resolveEnv({ config, requested, }) {
97
114
  if (!env) {
98
115
  const known = Object.keys(config.envs).join(', ');
99
116
  throw new Error(`Unknown env "${name}". Known envs: ${known}. ` +
100
- `Pass --env <name> or set default_env in ${configPath()}.`);
117
+ `Pass --env <name> or set default_env in ${configPath()}. ${DISCOVERY_HINT}`);
101
118
  }
102
119
  return { name, url: env.url };
103
120
  }
121
+ /**
122
+ * Async resolveEnv with lazy discovery: when the requested name doesn't
123
+ * resolve from the current config (built-ins + cached catalog + toml),
124
+ * refresh the catalog once — a logged-in identity that never ran
125
+ * `seq-studio envs refresh` (or fresh CI with AUTH0_M2M_CLIENT_SECRET)
126
+ * gets its environments on first use — then retry.
127
+ */
128
+ export async function resolveEnvWithDiscovery({ requested, }) {
129
+ const config = await readConfig();
130
+ try {
131
+ return resolveEnv({ config, requested });
132
+ }
133
+ catch (err) {
134
+ let catalog;
135
+ try {
136
+ catalog = await fetchCatalog();
137
+ }
138
+ catch (fetchErr) {
139
+ // A configured-but-broken M2M credential is the real failure — CI must
140
+ // see the Auth0 error, not a misleading "unknown env" message.
141
+ if (fetchErr instanceof M2mTokenError)
142
+ throw fetchErr;
143
+ // Network/server errors: fall back to whatever is cached, else
144
+ // surface the original resolve error.
145
+ catalog = await readCachedCatalog();
146
+ }
147
+ if (!catalog)
148
+ throw err;
149
+ // Retry against the refreshed catalog; if the name still doesn't
150
+ // resolve, the retry throws an error rendered from the fresh state.
151
+ return resolveEnv({ config: await readConfig(), requested });
152
+ }
153
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tiered environment discovery.
3
+ *
4
+ * The published package ships with only the `local` environment baked in.
5
+ * Every other deployment URL is *discovered* from the authenticated
6
+ * `GET /api/platform/environments` endpoint and cached on disk:
7
+ *
8
+ * - `anonymous` — no usable token (or the endpoint rejected it):
9
+ * `local` only, no catalog cached.
10
+ * - `sequence` — Sequence staff / trusted M2M: full catalog
11
+ * (local, staging, production, banksouth) + preview targeting.
12
+ * - `partner` — any other authenticated identity: local + their
13
+ * deployment(s).
14
+ *
15
+ * This is a visibility/UX layer, not access control — every deployment
16
+ * still enforces Auth0 + FGA per request. A user who knows a URL can
17
+ * always add it to config.toml; the server rejecting their token is the
18
+ * real gate.
19
+ *
20
+ * One bootstrap hostname is baked into the package for the discovery
21
+ * call itself. All deployments share the same Auth0 audience, so any
22
+ * identity's token validates there; the endpoint 401s anonymously.
23
+ */
24
+ export type EnvTier = 'anonymous' | 'sequence' | 'partner';
25
+ export interface CatalogEnvironment {
26
+ name: string;
27
+ url: string;
28
+ }
29
+ export interface EnvCatalog {
30
+ tier: Exclude<EnvTier, 'anonymous'>;
31
+ environments: CatalogEnvironment[];
32
+ /** Unix ms timestamp of the successful fetch. */
33
+ fetchedAt: number;
34
+ /**
35
+ * Auth0 `sub` of the identity that fetched this catalog. Reads ignore the
36
+ * cache when the current identity differs (logout, seqapi re-login as
37
+ * someone else), so one user's tier never carries over to another.
38
+ */
39
+ subject: string;
40
+ }
41
+ /** The host the discovery call goes to. Override for tests / self-hosting. */
42
+ export declare function bootstrapUrl(): string;
43
+ export declare function catalogPath(): string;
44
+ /**
45
+ * Read the cached catalog from disk. Returns null when missing, malformed,
46
+ * or fetched by a *different* identity than the one the CLI would
47
+ * authenticate as right now — callers treat all of those as tier
48
+ * `anonymous`. The subject check means a logout (token file removed) or a
49
+ * re-login as someone else invalidates the cache immediately, without
50
+ * waiting for the next discovery call.
51
+ */
52
+ export declare function readCachedCatalog(): Promise<EnvCatalog | null>;
53
+ export declare function clearCatalog(): Promise<void>;
54
+ /**
55
+ * Fetch the environment catalog from the discovery endpoint and cache it.
56
+ *
57
+ * Returns the fresh catalog, or null when the caller is effectively
58
+ * anonymous: no token available, or the endpoint rejected the token
59
+ * (401/403 — the stale cache is cleared so visibility downgrades too).
60
+ * Network/server errors leave any existing cache in place and rethrow so
61
+ * callers can distinguish "offline" from "not authorized".
62
+ */
63
+ export declare function fetchCatalog(): Promise<EnvCatalog | null>;
@@ -0,0 +1,111 @@
1
+ import { mkdir, readFile, rm, 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
+ import { currentIdentitySubject, decodeJwtSub, tryGetAccessToken } from './auth.js';
6
+ const DEFAULT_BOOTSTRAP_URL = 'https://atlas.seqholdings.com';
7
+ /** The host the discovery call goes to. Override for tests / self-hosting. */
8
+ export function bootstrapUrl() {
9
+ return (process.env['SEQ_STUDIO_DISCOVERY_URL']?.trim().replace(/\/+$/, '') ||
10
+ DEFAULT_BOOTSTRAP_URL);
11
+ }
12
+ export function catalogPath() {
13
+ return join(homedir(), '.config', 'lattice', 'environments.json');
14
+ }
15
+ function isValidCatalog(value) {
16
+ if (typeof value !== 'object' || value === null)
17
+ return false;
18
+ const candidate = value;
19
+ return ((candidate.tier === 'sequence' || candidate.tier === 'partner') &&
20
+ Array.isArray(candidate.environments) &&
21
+ candidate.environments.every((env) => typeof env === 'object' &&
22
+ env !== null &&
23
+ typeof env.name === 'string' &&
24
+ typeof env.url === 'string') &&
25
+ typeof candidate.fetchedAt === 'number' &&
26
+ typeof candidate.subject === 'string' &&
27
+ candidate.subject.length > 0);
28
+ }
29
+ /**
30
+ * Read the cached catalog from disk. Returns null when missing, malformed,
31
+ * or fetched by a *different* identity than the one the CLI would
32
+ * authenticate as right now — callers treat all of those as tier
33
+ * `anonymous`. The subject check means a logout (token file removed) or a
34
+ * re-login as someone else invalidates the cache immediately, without
35
+ * waiting for the next discovery call.
36
+ */
37
+ export async function readCachedCatalog() {
38
+ const path = catalogPath();
39
+ if (!existsSync(path))
40
+ return null;
41
+ let catalog;
42
+ try {
43
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
44
+ catalog = isValidCatalog(parsed) ? parsed : null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ if (!catalog)
50
+ return null;
51
+ const subject = await currentIdentitySubject();
52
+ if (subject === null || subject !== catalog.subject)
53
+ return null;
54
+ return catalog;
55
+ }
56
+ async function saveCatalog(catalog) {
57
+ const path = catalogPath();
58
+ await mkdir(dirname(path), { recursive: true });
59
+ await writeFile(path, JSON.stringify(catalog, null, 2) + '\n', {
60
+ encoding: 'utf8',
61
+ mode: 0o600,
62
+ });
63
+ }
64
+ export async function clearCatalog() {
65
+ await rm(catalogPath(), { force: true });
66
+ }
67
+ /**
68
+ * Fetch the environment catalog from the discovery endpoint and cache it.
69
+ *
70
+ * Returns the fresh catalog, or null when the caller is effectively
71
+ * anonymous: no token available, or the endpoint rejected the token
72
+ * (401/403 — the stale cache is cleared so visibility downgrades too).
73
+ * Network/server errors leave any existing cache in place and rethrow so
74
+ * callers can distinguish "offline" from "not authorized".
75
+ */
76
+ export async function fetchCatalog() {
77
+ // Fail closed when an M2M secret is configured but minting fails, so
78
+ // headless callers (CI) see the real Auth0 error instead of a silent
79
+ // downgrade to the anonymous tier.
80
+ const token = await tryGetAccessToken({ failClosedForM2m: true });
81
+ if (!token) {
82
+ await clearCatalog();
83
+ return null;
84
+ }
85
+ const response = await fetch(`${bootstrapUrl()}/api/platform/environments`, {
86
+ headers: { Authorization: `Bearer ${token}` },
87
+ });
88
+ if (response.status === 401 || response.status === 403) {
89
+ await clearCatalog();
90
+ return null;
91
+ }
92
+ if (!response.ok) {
93
+ throw new Error(`Environment discovery failed (${response.status}) against ${bootstrapUrl()}.`);
94
+ }
95
+ const body = (await response.json());
96
+ if ((body.tier !== 'sequence' && body.tier !== 'partner') ||
97
+ !Array.isArray(body.environments)) {
98
+ throw new Error('Environment discovery returned an unexpected response shape.');
99
+ }
100
+ const catalog = {
101
+ tier: body.tier,
102
+ environments: body.environments,
103
+ fetchedAt: Date.now(),
104
+ // Bind the cache to the identity that fetched it. `unknown` never
105
+ // matches a real subject, so an undecodable token yields a catalog
106
+ // that works for this invocation but is not trusted from disk later.
107
+ subject: decodeJwtSub(token) ?? 'unknown',
108
+ };
109
+ await saveCatalog(catalog);
110
+ return catalog;
111
+ }
@@ -0,0 +1 @@
1
+ export declare function runEnvsCommand(sub: string | undefined, _rest: string[]): Promise<number>;
@@ -0,0 +1,74 @@
1
+ import { readConfig, configPath } from '../config.js';
2
+ import { bootstrapUrl, fetchCatalog, readCachedCatalog, } from '../env-catalog.js';
3
+ const ENVS_USAGE = `usage:
4
+ seq-studio envs list show the environments visible to your identity
5
+ seq-studio envs refresh re-fetch the environment catalog for your identity
6
+
7
+ The published CLI ships with only the "local" environment. After you
8
+ authenticate (seq-studio login, or AUTH0_M2M_CLIENT_SECRET for headless use),
9
+ "envs refresh" discovers the environments your identity may target; the
10
+ catalog is cached at ~/.config/lattice/environments.json and also refreshes
11
+ lazily the first time you pass an -e <env> that isn't cached yet.
12
+
13
+ Custom entries in ${configPath()} are always honored on top.
14
+ `;
15
+ export async function runEnvsCommand(sub, _rest) {
16
+ switch (sub) {
17
+ case 'list':
18
+ return listCommand();
19
+ case 'refresh':
20
+ return refreshCommand();
21
+ case undefined:
22
+ case 'help':
23
+ case '--help':
24
+ case '-h':
25
+ console.log(ENVS_USAGE);
26
+ return sub ? 0 : 1;
27
+ default:
28
+ console.error(`unknown envs command: ${sub}`);
29
+ console.error(ENVS_USAGE);
30
+ return 1;
31
+ }
32
+ }
33
+ async function listCommand() {
34
+ const [config, catalog] = await Promise.all([readConfig(), readCachedCatalog()]);
35
+ const discovered = new Set(catalog?.environments.map((env) => env.name) ?? []);
36
+ console.log(`tier: ${config.tier}`);
37
+ if (catalog) {
38
+ console.log(`catalog fetched: ${new Date(catalog.fetchedAt).toISOString()}`);
39
+ }
40
+ console.log('');
41
+ const width = Math.max(...Object.keys(config.envs).map((name) => name.length));
42
+ for (const [name, { url }] of Object.entries(config.envs)) {
43
+ const source = name === 'local' && !discovered.has(name)
44
+ ? 'built-in'
45
+ : discovered.has(name)
46
+ ? 'discovered'
47
+ : 'config.toml';
48
+ console.log(` ${name.padEnd(width)} ${url} (${source})`);
49
+ }
50
+ if (config.tier === 'anonymous') {
51
+ console.log('');
52
+ console.log('Not authenticated — only "local" is visible. Authenticate with ' +
53
+ '`seq-studio login` (or set AUTH0_M2M_CLIENT_SECRET), then run: seq-studio envs refresh');
54
+ }
55
+ return 0;
56
+ }
57
+ async function refreshCommand() {
58
+ let catalog;
59
+ try {
60
+ catalog = await fetchCatalog();
61
+ }
62
+ catch (err) {
63
+ console.error(`envs refresh: FAIL — ${err instanceof Error ? err.message : String(err)}`);
64
+ return 1;
65
+ }
66
+ if (!catalog) {
67
+ console.log(`Not authenticated against ${bootstrapUrl()} — catalog cleared; only ` +
68
+ '"local" (plus any config.toml entries) is visible.\n' +
69
+ 'Authenticate with `seq-studio login` (or set AUTH0_M2M_CLIENT_SECRET) and retry.');
70
+ return 0;
71
+ }
72
+ console.log(`tier: ${catalog.tier} — ${catalog.environments.length} environment(s) cached.`);
73
+ return listCommand();
74
+ }
@@ -82,5 +82,5 @@ export declare function functionsRollbackCommand(args: ParsedArgs): Promise<numb
82
82
  /** Minimal dotenv parser — KEY=VALUE lines, quotes stripped, comments skipped. */
83
83
  export declare function parseDotenv(content: string): Record<string, string>;
84
84
  export declare function functionsDeleteCommand(args: ParsedArgs): Promise<number>;
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 [--yes] archive function + tear down GCP resources\n (version history is retained)\n\n Flags: -e/--env <local|staging|production|banksouth|preview:<slug>> \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 any 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 --repo clones over smart-HTTP and REQUIRES a git PAT in ATLAS_GIT_PAT\n (repo:read scope \u2014 `seq-studio auth pat create --scopes repo:read`, or\n Atlas \u2192 Settings \u2192 Tokens). It's the same token you clone the repo with;\n --env + seqapi login are still needed 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 [--yes] archive function + tear down GCP resources\n (version history is retained)\n\n Flags: -e/--env <env|preview:<slug>> (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 any 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 --repo clones over smart-HTTP and REQUIRES a git PAT in ATLAS_GIT_PAT\n (repo:read scope \u2014 `seq-studio auth pat create --scopes repo:read`, or\n Atlas \u2192 Settings \u2192 Tokens). It's the same token you clone the repo with;\n --env + seq-studio login are still needed to resolve the repo and deploy.\n";
86
86
  export declare function runFunctionsCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;