@sequenceholdings/studio-cli 0.1.10 → 0.1.12

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/README.md CHANGED
@@ -6,9 +6,10 @@ any repo against the platform over HTTP — no monorepo checkout required.
6
6
 
7
7
  ```
8
8
  seq-studio process lint
9
- seq-studio process plan -e staging
10
- seq-studio process apply -e staging
11
- seq-studio artifact deploy -e staging
9
+ seq-studio process plan -e <env>
10
+ seq-studio process apply -e <env>
11
+ seq-studio artifact deploy -e <env>
12
+ seq-studio envs list
12
13
  seq-studio doctor
13
14
  ```
14
15
 
@@ -62,7 +63,7 @@ secret and `seq-studio` mints a token via the Auth0 client-credentials grant
62
63
 
63
64
  ```bash
64
65
  export AUTH0_M2M_CLIENT_SECRET=... # provided by your platform administrator
65
- seq-studio artifact deploy -e staging
66
+ seq-studio artifact deploy -e <env>
66
67
  ```
67
68
 
68
69
  The secret is read at runtime — never commit it. M2M carries app scopes but
@@ -72,17 +73,35 @@ user-scoped/private resources.
72
73
  **Manual escape hatch:** any `artifact` command also accepts an explicit
73
74
  `--token <jwt>`, which wins over both the M2M and cached-user paths.
74
75
 
75
- ## Environments (`~/.config/lattice/config.toml`)
76
+ ## Environments
76
77
 
77
- Environment names map to platform URLs. Your deployment's URLs are configured
78
- in `~/.config/lattice/config.toml`:
78
+ The CLI ships with a single built-in environment, `local`
79
+ (`http://localhost:5001`). The other environments your identity may target
80
+ are **discovered** after you authenticate: the CLI calls the platform's
81
+ environment-discovery endpoint and caches the result at
82
+ `~/.config/lattice/environments.json`.
83
+
84
+ ```bash
85
+ seq-studio login # or: export AUTH0_M2M_CLIENT_SECRET=...
86
+ seq-studio envs refresh # fetch the environments visible to your identity
87
+ seq-studio envs list # show them (name, URL, source)
88
+ ```
89
+
90
+ Discovery also happens lazily: the first time you pass an `-e <env>` that
91
+ isn't cached yet, the CLI refreshes the catalog before failing. What you can
92
+ see depends on who you are — unauthenticated installs get `local` only, and
93
+ authenticated identities get the deployments they're entitled to. Visibility
94
+ is not access control: every request is still authorized server-side.
95
+
96
+ You can always add or override environments yourself in
97
+ `~/.config/lattice/config.toml` (user entries win over discovered ones):
79
98
 
80
99
  ```toml
81
100
  [env.local]
82
101
  url = "http://localhost:5001"
83
102
 
84
- [env.staging]
85
- url = "https://staging.example.com"
103
+ [env.my-atlas]
104
+ url = "https://atlas.example.com"
86
105
 
87
106
  default_env = "local"
88
107
  ```
@@ -191,17 +210,16 @@ any username, PAT as password). **You do not need `seqapi`.**
191
210
 
192
211
  ### Everyone (recommended) — Atlas UI
193
212
 
194
- 1. Open **Settings → Tokens** in Atlas for your environment, e.g.
195
- - Staging: https://staging.atlas.seqholdings.com/settings/tokens
196
- - Production: https://atlas.seqholdings.com/settings/tokens
197
- - BankSouth: https://banksouth.seqholdings.com/settings/tokens
213
+ 1. Open **Settings → Tokens** in Atlas for your environment
214
+ (`<your-atlas-url>/settings/tokens` — run `seq-studio envs list` for the
215
+ URLs visible to your identity)
198
216
  2. **New token** → scopes `repo:read` (add `repo:write` for push) → copy once
199
217
  3. Export and clone:
200
218
 
201
219
  ```bash
202
220
  export ATLAS_GIT_PAT=atlas_git_…
203
- seq-studio repos clone artifacts/ai-fluency -e staging
204
- # or: git clone https://git:$ATLAS_GIT_PAT@staging.atlas.seqholdings.com/api/git-service/repos/<id>/git
221
+ seq-studio repos clone <namespace>/<repo> -e <env>
222
+ # or: git clone https://git:$ATLAS_GIT_PAT@<your-atlas-host>/api/git-service/repos/<id>/git
205
223
  ```
206
224
 
207
225
  You can also open **Repositories → Access tokens** / the clone popover’s
@@ -213,12 +231,12 @@ Requires Auth0 login. Same identity as the UI:
213
231
 
214
232
  ```bash
215
233
  seq-studio login
216
- seq-studio auth pat create --name laptop --scopes repo:read,repo:write -e staging
234
+ seq-studio auth pat create --name laptop --scopes repo:read,repo:write -e <env>
217
235
  # optional: --expires 7d|30d|90d|1y|never (default 30d)
218
236
  # optional: --store-credentials # git credential approve for the env host
219
237
 
220
- seq-studio auth pat list -e staging
221
- seq-studio auth pat revoke <id> -e staging --yes
238
+ seq-studio auth pat list -e <env>
239
+ seq-studio auth pat revoke <id> -e <env> --yes
222
240
  ```
223
241
 
224
242
  The raw token is printed **once** on create.
@@ -11,8 +11,9 @@
11
11
  * is shared with the rest of `seq-studio`.
12
12
  * 4. Forward all remaining argv to `runCli`.
13
13
  */
14
- import { getAccessToken, tryGetAccessToken } from '../auth.js';
15
- import { readConfig, resolveEnv } from '../config.js';
14
+ import { getAccessToken, M2mTokenError, tryGetAccessToken } from '../auth.js';
15
+ import { readConfig, resolveEnvWithDiscovery } from '../config.js';
16
+ import { fetchCatalog, readCachedCatalog } from '../env-catalog.js';
16
17
  import { normalizeShortEnvFlag, readEnvFromArgv } from '../env-flags.js';
17
18
  import { PREVIEW_DOMAIN, PREVIEW_PROJECT, resolvePreviewByPr } from '../preview.js';
18
19
  const ARTIFACT_USAGE = `usage:
@@ -31,7 +32,8 @@ const ARTIFACT_USAGE = `usage:
31
32
  seq-studio artifact whoami -e <env>
32
33
  seq-studio artifact env list
33
34
 
34
- Built-in envs: local, staging, production, banksouth.
35
+ Environments: see \`seq-studio envs list\` (built-in: local; more are
36
+ discovered after you authenticate).
35
37
 
36
38
  Source for build/plan/deploy: a local [dir] (default), a platform git-service
37
39
  repo (--repo <ns>/<name>), or any git URL (--git-url <url>). --ref selects a
@@ -78,6 +80,12 @@ export async function runArtifactCommand(sub, rest) {
78
80
  console.log(ARTIFACT_USAGE);
79
81
  return sub ? 0 : 1;
80
82
  }
83
+ // `artifact env list` renders the tier-aware discovered catalog instead of
84
+ // artifact-studio's built-in (local-only) list.
85
+ if (sub === 'env' && rest[0] === 'list') {
86
+ const { runEnvsCommand } = await import('../envs/commands.js');
87
+ return runEnvsCommand('list', []);
88
+ }
81
89
  // Normalize `-e <env>` / `-e=<env>` to `--env <env>` because
82
90
  // artifact-studio's argv parser (`shared/services/artifact-studio/src/cli.ts`)
83
91
  // only recognizes long flags. Without this rewrite `seq-studio
@@ -93,6 +101,7 @@ export async function runArtifactCommand(sub, rest) {
93
101
  let resolved;
94
102
  let argvForCli = forwardRest;
95
103
  if (envUrl !== undefined) {
104
+ await requireSequenceTier('--env-url');
96
105
  const validated = validatePreviewEnvUrl(envUrl);
97
106
  // Explicit override wins over everything. Keep the user's --env name if
98
107
  // they gave one, else label it `preview`.
@@ -100,13 +109,13 @@ export async function runArtifactCommand(sub, rest) {
100
109
  argvForCli = ensureEnvFlag(forwardRest, resolved.name);
101
110
  }
102
111
  else if (prNumber !== undefined) {
112
+ await requireSequenceTier('--pr');
103
113
  const preview = await resolvePreviewByPr({ pr: prNumber });
104
114
  resolved = { name: `preview:${preview.slug}`, url: preview.url };
105
115
  argvForCli = ensureEnvFlag(forwardRest, resolved.name);
106
116
  }
107
117
  else if (requested) {
108
- const config = await readConfig();
109
- resolved = resolveEnv({ config, requested });
118
+ resolved = await resolveEnvWithDiscovery({ requested });
110
119
  // For `preview:<slug>` normalize the forwarded --env to the canonical name.
111
120
  if (resolved.name !== requested)
112
121
  argvForCli = ensureEnvFlag(forwardRest, resolved.name);
@@ -119,6 +128,13 @@ export async function runArtifactCommand(sub, rest) {
119
128
  if (isPreviewUrl(resolved.url))
120
129
  applyPreviewAccessHeader();
121
130
  }
131
+ // Always hand artifact-studio the env map visible to this identity
132
+ // (discovered catalog + config.toml). Commands that resolve an env *name*
133
+ // without an explicit --env flag — the stored `.artifact-studio/config.json`
134
+ // defaultEnv, `artifact env set <name>` — would otherwise fall back to
135
+ // artifact-studio's built-in local-only map and fail on deployed envs.
136
+ const { envs } = await readConfig();
137
+ process.env['ARTIFACT_STUDIO_ENV_URLS'] = JSON.stringify(Object.fromEntries(Object.entries(envs).map(([name, { url }]) => [name, url])));
122
138
  // An explicit `--token <jwt>` is the manual escape hatch and must win over
123
139
  // everything, including a configured-but-failing M2M credential (which
124
140
  // `tryGetAccessToken({ failClosedForM2m: true })` would otherwise turn into
@@ -164,6 +180,31 @@ export async function runArtifactCommand(sub, rest) {
164
180
  }
165
181
  return runArtifactStudio([sub, ...argvForCli]);
166
182
  }
183
+ /**
184
+ * Preview targeting (`preview:<slug>`, `--pr`, `--env-url`) is a
185
+ * Sequence-staff surface. The tier comes from the cached discovery catalog;
186
+ * refresh it lazily so a just-logged-in engineer isn't blocked on a stale
187
+ * anonymous cache.
188
+ */
189
+ async function requireSequenceTier(flag) {
190
+ let { tier } = await readConfig();
191
+ if (tier !== 'sequence') {
192
+ try {
193
+ tier = (await fetchCatalog())?.tier ?? 'anonymous';
194
+ }
195
+ catch (err) {
196
+ // Broken CI credentials must surface as the Auth0 error, not as a
197
+ // misleading "Sequence-staff surface" rejection.
198
+ if (err instanceof M2mTokenError)
199
+ throw err;
200
+ tier = (await readCachedCatalog())?.tier ?? 'anonymous';
201
+ }
202
+ }
203
+ if (tier !== 'sequence') {
204
+ throw new Error(`${flag} targets per-PR preview environments, a Sequence-staff surface. ` +
205
+ 'Authenticate with `seq-studio login` and run `seq-studio envs refresh`.');
206
+ }
207
+ }
167
208
  /**
168
209
  * Strip the seq-studio-only `--pr <number>` and `--env-url <url>` flags from
169
210
  * argv and return the rest (forwarded to artifact-studio) plus the parsed
@@ -1,7 +1,7 @@
1
1
  import type { ParsedArgs } from '../process/commands.js';
2
2
  declare const PAT_SCOPES: readonly ["repo:read", "repo:write", "repo:admin"];
3
3
  type PatScope = (typeof PAT_SCOPES)[number];
4
- export declare const AUTH_USAGE = "usage:\n seq-studio auth pat create --name <n> [--scopes repo:read,repo:write] [-e env]\n [--expires 7d|30d|90d|1y|never] [--store-credentials]\n seq-studio auth pat list [-e env]\n seq-studio auth pat revoke <id> [-e env] [--yes]\n\n Issue a personal access token for git clone / git push against the platform\n git service.\n\n Authenticate with `seq-studio login`, then run `auth pat create`.\n Alternatively, open Atlas \u2192 Settings \u2192 Tokens:\n https://<atlas-host>/settings/tokens\n Sign in, create a token (repo:read / repo:write), copy once, then:\n export ATLAS_GIT_PAT=<token>\n\n On create the raw token is printed ONCE \u2014 store it; Atlas cannot re-show it.\n Git Basic auth: any username (e.g. git), PAT as the password.\n\n Flags: -e/--env <local|staging|production|banksouth>\n";
4
+ export declare const AUTH_USAGE = "usage:\n seq-studio auth pat create --name <n> [--scopes repo:read,repo:write] [-e env]\n [--expires 7d|30d|90d|1y|never] [--store-credentials]\n seq-studio auth pat list [-e env]\n seq-studio auth pat revoke <id> [-e env] [--yes]\n\n Issue a personal access token for git clone / git push against the platform\n git service.\n\n Authenticate with `seq-studio login`, then run `auth pat create`.\n Alternatively, open Atlas \u2192 Settings \u2192 Tokens:\n https://<atlas-host>/settings/tokens\n Sign in, create a token (repo:read / repo:write), copy once, then:\n export ATLAS_GIT_PAT=<token>\n\n On create the raw token is printed ONCE \u2014 store it; Atlas cannot re-show it.\n Git Basic auth: any username (e.g. git), PAT as the password.\n\n Flags: -e/--env <env> (see: seq-studio envs list)\n";
5
5
  export declare function parsePatScopes(raw: string | undefined): PatScope[];
6
6
  /**
7
7
  * Map UI-style duration choices to an absolute ISO-8601 expiresAt, or undefined
@@ -11,9 +11,23 @@
11
11
  import { deleteNoContent, getJson, postJson } from '../atlas-client.js';
12
12
  import { printCliError } from '../cli-errors.js';
13
13
  import { buildContext, clientOptions, flagBool, LOG, } from '../functions/commands.js';
14
+ import { readConfig } from '../config.js';
14
15
  import { confirmYes } from '../prompt.js';
15
16
  import { storeGitCredentials } from '../repos/git-clone.js';
16
17
  import { formatPatSetupHint, settingsTokensUrl } from '../pat-hints.js';
18
+ /**
19
+ * Tokens-page URLs rendered from the environments visible to this identity
20
+ * (discovered catalog + config.toml). Anonymous users get a generic pointer
21
+ * instead of a hardcoded list of internal hosts.
22
+ */
23
+ async function settingsTokensLines() {
24
+ const config = await readConfig().catch(() => null);
25
+ const remote = Object.entries(config?.envs ?? {}).filter(([name]) => name !== 'local');
26
+ if (remote.length === 0) {
27
+ return ['Tokens page: <your Atlas deployment URL>/settings/tokens'];
28
+ }
29
+ return remote.map(([name, { url }]) => `${name}: ${settingsTokensUrl(url)}`);
30
+ }
17
31
  const PAT_SCOPES = ['repo:read', 'repo:write', 'repo:admin'];
18
32
  function stringFlag(flags, key) {
19
33
  const value = flags[key];
@@ -41,26 +55,26 @@ export const AUTH_USAGE = `usage:
41
55
  On create the raw token is printed ONCE — store it; Atlas cannot re-show it.
42
56
  Git Basic auth: any username (e.g. git), PAT as the password.
43
57
 
44
- Flags: -e/--env <local|staging|production|banksouth>
58
+ Flags: -e/--env <env> (see: seq-studio envs list)
45
59
  `;
46
60
  async function authContext(args) {
47
61
  if (args.flags.env === true || args.flags.e === true) {
48
- throw new Error('-e/--env requires a value (local|staging|production|banksouth|preview:<slug>).');
62
+ throw new Error('-e/--env requires a value — see: seq-studio envs list.');
49
63
  }
50
64
  try {
51
65
  return await buildContext(args);
52
66
  }
53
67
  catch (err) {
54
68
  const message = err instanceof Error ? err.message : String(err);
55
- // buildContext failed before we know the env URL; offer CLI login and point
56
- // at the common Atlas hosts as an alternative.
69
+ // buildContext failed before we know the env URL; offer CLI login and
70
+ // render the tokens-page URLs from the environments visible to this
71
+ // identity (third-party / OpCo developers only see their own hosts).
72
+ const tokenUrls = await settingsTokensLines();
57
73
  throw new Error(`${message}\n` +
58
74
  ` Run \`seq-studio login\`, then retry \`auth pat create\`.\n` +
59
75
  ` Or mint a PAT in the Atlas UI (Settings → Tokens), then:\n` +
60
76
  ` export ATLAS_GIT_PAT=<token>\n` +
61
- ` Staging: https://staging.atlas.seqholdings.com/settings/tokens\n` +
62
- ` Production: https://atlas.seqholdings.com/settings/tokens\n` +
63
- ` BankSouth: https://banksouth.seqholdings.com/settings/tokens`);
77
+ tokenUrls.map((line) => ` ${line}`).join('\n'));
64
78
  }
65
79
  }
66
80
  export function parsePatScopes(raw) {
package/dist/auth.d.ts CHANGED
@@ -26,6 +26,14 @@ export interface SeqapiTokens {
26
26
  access_token?: string;
27
27
  expires_at?: number;
28
28
  }
29
+ /**
30
+ * A configured M2M credential failed to mint a token. Typed so callers that
31
+ * normally swallow discovery errors (lazy catalog refresh) can still surface
32
+ * broken CI credentials instead of a misleading "unknown env" message.
33
+ */
34
+ export declare class M2mTokenError extends Error {
35
+ readonly name = "M2mTokenError";
36
+ }
29
37
  export declare function seqapiTokenDir(): string;
30
38
  export declare function seqapiTokenPath(): string;
31
39
  export declare class NotLoggedInError extends Error {
@@ -52,4 +60,16 @@ export declare function tryGetAccessToken(options?: {
52
60
  */
53
61
  failClosedForM2m?: boolean;
54
62
  }): Promise<string | null>;
63
+ /**
64
+ * Decode the `sub` claim from a JWT without verification. Verification is the
65
+ * server's job — this is only used to compare identities locally (e.g. to
66
+ * bind the cached environment catalog to the identity that fetched it).
67
+ */
68
+ export declare function decodeJwtSub(token: string): string | null;
69
+ /**
70
+ * The Auth0 subject the CLI would authenticate as right now, without any
71
+ * network call: the fixed M2M client subject when the secret is configured,
72
+ * else the `sub` of the cached user token, else null (anonymous).
73
+ */
74
+ export declare function currentIdentitySubject(): Promise<string | null>;
55
75
  export declare function saveTokens(tokens: SeqapiTokens): Promise<void>;
package/dist/auth.js CHANGED
@@ -33,6 +33,14 @@ const AUTH0_M2M_CLIENT_ID = '5TLffZqvLq4ztLDjZhwKVJCpB5VNrWj0';
33
33
  // `_m2m_cache`). Reused while > 60s from expiry to avoid re-minting on every
34
34
  // call within a single process (e.g. a long `artifact dev` watch).
35
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
+ }
36
44
  /**
37
45
  * Mint an M2M access token via the Auth0 client-credentials grant when
38
46
  * `AUTH0_M2M_CLIENT_SECRET` is set. Returns null when the secret is unset
@@ -57,11 +65,11 @@ async function getM2mToken() {
57
65
  }),
58
66
  });
59
67
  if (!response.ok) {
60
- 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()}`);
61
69
  }
62
70
  const data = (await response.json());
63
71
  if (!data.access_token) {
64
- throw new Error('Auth0 M2M response missing access_token');
72
+ throw new M2mTokenError('Auth0 M2M response missing access_token');
65
73
  }
66
74
  m2mCache = {
67
75
  accessToken: data.access_token,
@@ -146,6 +154,38 @@ export async function tryGetAccessToken(options) {
146
154
  return null;
147
155
  }
148
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
+ }
149
189
  async function loadTokens() {
150
190
  const path = seqapiTokenPath();
151
191
  if (!existsSync(path))
package/dist/bin.js CHANGED
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from './main.js';
3
+ import { maybeNotifyUpdate } from './update-check.js';
3
4
  run()
4
- .then((code) => process.exit(code))
5
5
  .catch((error) => {
6
6
  console.error(error instanceof Error ? error.message : error);
7
- process.exit(1);
7
+ return 1;
8
+ })
9
+ // The update notice runs after the command so it never delays real output,
10
+ // and it is best-effort (never throws, never changes the exit code).
11
+ .then(async (code) => {
12
+ await maybeNotifyUpdate();
13
+ process.exit(code);
8
14
  });
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
+ }