@sequenceholdings/studio-cli 0.1.12 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +116 -30
  2. package/dist/artifact/delegate.d.ts +2 -2
  3. package/dist/artifact/delegate.js +31 -73
  4. package/dist/atlas-client.js +52 -37
  5. package/dist/auth-cmds/commands.d.ts +1 -1
  6. package/dist/auth-cmds/commands.js +12 -7
  7. package/dist/auth.d.ts +97 -19
  8. package/dist/auth.js +376 -81
  9. package/dist/config.d.ts +13 -3
  10. package/dist/config.js +41 -14
  11. package/dist/env-catalog.js +13 -3
  12. package/dist/env-flags.d.ts +2 -0
  13. package/dist/env-flags.js +2 -0
  14. package/dist/env-registry.d.ts +27 -0
  15. package/dist/env-registry.js +204 -0
  16. package/dist/envs/commands.d.ts +1 -1
  17. package/dist/envs/commands.js +65 -10
  18. package/dist/file-lock.d.ts +5 -0
  19. package/dist/file-lock.js +187 -0
  20. package/dist/functions/commands.d.ts +9 -1
  21. package/dist/functions/commands.js +71 -29
  22. package/dist/functions/manifest.d.ts +1 -0
  23. package/dist/functions/manifest.js +36 -0
  24. package/dist/login.d.ts +14 -3
  25. package/dist/login.js +60 -40
  26. package/dist/main.d.ts +2 -1
  27. package/dist/main.js +36 -12
  28. package/dist/orm/delegate.d.ts +9 -0
  29. package/dist/orm/delegate.js +36 -4
  30. package/dist/pat-hints.js +2 -2
  31. package/dist/pipeline/commands.d.ts +58 -0
  32. package/dist/pipeline/commands.js +330 -0
  33. package/dist/pipeline/lifecycle.d.ts +58 -0
  34. package/dist/pipeline/lifecycle.js +348 -0
  35. package/dist/pipeline/pinning.d.ts +5 -0
  36. package/dist/pipeline/pinning.js +9 -0
  37. package/dist/pipeline/templates.d.ts +11 -0
  38. package/dist/pipeline/templates.js +166 -0
  39. package/dist/process/build.d.ts +12 -1
  40. package/dist/process/build.js +49 -2
  41. package/dist/process/codegen.js +21 -1
  42. package/dist/process/commands.d.ts +19 -0
  43. package/dist/process/commands.js +153 -40
  44. package/dist/process/compiler-subprocess.d.ts +29 -0
  45. package/dist/process/compiler-subprocess.js +99 -0
  46. package/dist/process/compiler-worker.d.ts +1 -0
  47. package/dist/process/compiler-worker.js +38 -0
  48. package/dist/process/discover.d.ts +4 -1
  49. package/dist/process/discover.js +5 -2
  50. package/dist/process/lint.d.ts +8 -0
  51. package/dist/process/lint.js +125 -29
  52. package/dist/process/repo-install.d.ts +21 -0
  53. package/dist/process/repo-install.js +99 -0
  54. package/dist/process/simulate.js +14 -1
  55. package/dist/repos/commands.d.ts +1 -1
  56. package/dist/repos/commands.js +17 -12
  57. package/dist/secrets/commands.d.ts +1 -1
  58. package/dist/secrets/commands.js +18 -18
  59. package/package.json +9 -4
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
6
  import { M2mTokenError } from './auth.js';
7
7
  import { fetchCatalog, readCachedCatalog, } from './env-catalog.js';
8
+ import { readRegisteredEnvironmentUrls } from './env-registry.js';
8
9
  import { resolvePreviewFromSlug } from './preview.js';
9
10
  /**
10
11
  * `--env preview:<branch-or-slug>` targets a per-PR Vercel preview deployment
@@ -45,10 +46,10 @@ export function defaultConfig() {
45
46
  * 1. built-in `local`
46
47
  * 2. the cached discovered catalog (`~/.config/lattice/environments.json`)
47
48
  * 3. user entries in config.toml (an override for `local`, or net-new envs)
49
+ * 4. OpCo registrations shared with seqapi (`~/.config/sequence-api/config.json`)
48
50
  *
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.
51
+ * Registered OpCo routes are authoritative for their names so a lower-trust
52
+ * config.toml override cannot send a tenant token to another origin.
52
53
  */
53
54
  export async function readConfig() {
54
55
  const merged = defaultConfig();
@@ -60,18 +61,22 @@ export async function readConfig() {
60
61
  }
61
62
  }
62
63
  const path = configPath();
63
- if (!existsSync(path))
64
- return merged;
65
- const raw = parseToml(await readFile(path, 'utf8'));
66
- if (raw.env) {
67
- for (const [name, value] of Object.entries(raw.env)) {
68
- if (typeof value?.url === 'string') {
69
- merged.envs[name] = { url: value.url };
64
+ if (existsSync(path)) {
65
+ const raw = parseToml(await readFile(path, 'utf8'));
66
+ if (raw.env) {
67
+ for (const [name, value] of Object.entries(raw.env)) {
68
+ if (typeof value?.url === 'string') {
69
+ merged.envs[name] = { url: value.url };
70
+ }
70
71
  }
71
72
  }
73
+ if (typeof raw.default_env === 'string') {
74
+ merged.defaultEnv = raw.default_env;
75
+ }
72
76
  }
73
- if (typeof raw.default_env === 'string') {
74
- merged.defaultEnv = raw.default_env;
77
+ const registered = await readRegisteredEnvironmentUrls();
78
+ for (const [name, value] of Object.entries(registered)) {
79
+ merged.envs[name] = value;
75
80
  }
76
81
  return merged;
77
82
  }
@@ -82,18 +87,40 @@ export async function writeConfig(config) {
82
87
  // smol-toml's stringify can't accept arbitrary nested keys via the
83
88
  // `env.<name>` syntax directly; we build it manually for stable
84
89
  // ordering and clean output.
85
- const lines = [];
90
+ //
91
+ // `default_env` MUST come before any `[env.*]` table. In TOML, a bare
92
+ // key after a table header belongs to that table — putting it last
93
+ // would silently attach it to the final `[env.*]` and `readConfig`
94
+ // (which only reads top-level `default_env`) would ignore it.
95
+ const lines = [`default_env = "${config.defaultEnv}"`, ''];
86
96
  for (const [name, value] of Object.entries(config.envs)) {
87
97
  lines.push(`[env.${name}]`);
88
98
  lines.push(`url = ${stringifyToml({ url: value.url }).trim().replace(/^url = /, '')}`);
89
99
  lines.push('');
90
100
  }
91
- lines.push(`default_env = "${config.defaultEnv}"`);
92
101
  await writeFile(path, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o600 });
93
102
  }
94
103
  /** Shared hint appended to visibility errors at the anonymous/partner tiers. */
95
104
  const DISCOVERY_HINT = 'If you expect more environments, authenticate (seq-studio login, or set ' +
96
105
  'AUTH0_M2M_CLIENT_SECRET) and run: seq-studio envs refresh';
106
+ /**
107
+ * Copy-pasteable fallback when the discovery catalog is unavailable.
108
+ *
109
+ * The `[env.*]` table is safe to append to an existing config.toml. We
110
+ * intentionally omit `default_env` from the snippet — appending it after
111
+ * an existing `[env.*]` table would scope it into that table (TOML bare-key
112
+ * rule) and silently no-op. Callers who want a default should put
113
+ * `default_env = "..."` at the top of the file, before any table.
114
+ */
115
+ export function manualEnvConfigHint() {
116
+ return (`Or add an environment manually in ${configPath()} (append-safe):\n` +
117
+ `\n` +
118
+ ` [env.my-env]\n` +
119
+ ` url = "https://my-atlas.seqholdings.com"\n` +
120
+ `\n` +
121
+ `To make it the default, put \`default_env = "my-env"\` at the TOP of ` +
122
+ `that file — before any [env.*] table.`);
123
+ }
97
124
  /**
98
125
  * Resolve `--env <name>` against the effective config. Falls back to the
99
126
  * config's `defaultEnv` when no name is passed. Throws a clear error when
@@ -2,6 +2,7 @@ import { mkdir, readFile, rm, 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
+ import { authenticatedRequestUrl } from '@sequenceholdings/artifact-studio/deployment-validation';
5
6
  import { currentIdentitySubject, decodeJwtSub, tryGetAccessToken } from './auth.js';
6
7
  const DEFAULT_BOOTSTRAP_URL = 'https://atlas.seqholdings.com';
7
8
  /** The host the discovery call goes to. Override for tests / self-hosting. */
@@ -74,23 +75,32 @@ export async function clearCatalog() {
74
75
  * callers can distinguish "offline" from "not authorized".
75
76
  */
76
77
  export async function fetchCatalog() {
78
+ const targetUrl = bootstrapUrl();
79
+ const requestUrl = authenticatedRequestUrl({
80
+ baseUrl: targetUrl,
81
+ path: '/api/platform/environments',
82
+ });
77
83
  // Fail closed when an M2M secret is configured but minting fails, so
78
84
  // headless callers (CI) see the real Auth0 error instead of a silent
79
85
  // downgrade to the anonymous tier.
80
- const token = await tryGetAccessToken({ failClosedForM2m: true });
86
+ const token = await tryGetAccessToken({
87
+ failClosedForM2m: true,
88
+ targetUrl,
89
+ });
81
90
  if (!token) {
82
91
  await clearCatalog();
83
92
  return null;
84
93
  }
85
- const response = await fetch(`${bootstrapUrl()}/api/platform/environments`, {
94
+ const response = await fetch(requestUrl, {
86
95
  headers: { Authorization: `Bearer ${token}` },
96
+ redirect: 'manual',
87
97
  });
88
98
  if (response.status === 401 || response.status === 403) {
89
99
  await clearCatalog();
90
100
  return null;
91
101
  }
92
102
  if (!response.ok) {
93
- throw new Error(`Environment discovery failed (${response.status}) against ${bootstrapUrl()}.`);
103
+ throw new Error(`Environment discovery failed (${response.status}) against ${targetUrl}.`);
94
104
  }
95
105
  const body = (await response.json());
96
106
  if ((body.tier !== 'sequence' && body.tier !== 'partner') ||
@@ -3,6 +3,8 @@
3
3
  * CLIs only recognize the two-token `--env <value>` form, so every `-e value`
4
4
  * / `-e=value` / `--env=value` spelling is rewritten before forwarding.
5
5
  */
6
+ /** Shared copy for network commands that must not silently fall back to `local`. */
7
+ export declare const REQUIRE_EXPLICIT_ENV_MESSAGE = "You must specify an environment you have access to with `-e <env>`. Run `seq-studio envs list` to see available environments.";
6
8
  export declare function normalizeShortEnvFlag(argv: readonly string[]): string[];
7
9
  /** Only valid AFTER normalizeShortEnvFlag — recognizes the two-token form. */
8
10
  export declare function readEnvFromArgv(rest: readonly string[]): string | undefined;
package/dist/env-flags.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * CLIs only recognize the two-token `--env <value>` form, so every `-e value`
4
4
  * / `-e=value` / `--env=value` spelling is rewritten before forwarding.
5
5
  */
6
+ /** Shared copy for network commands that must not silently fall back to `local`. */
7
+ export const REQUIRE_EXPLICIT_ENV_MESSAGE = 'You must specify an environment you have access to with `-e <env>`. Run `seq-studio envs list` to see available environments.';
6
8
  export function normalizeShortEnvFlag(argv) {
7
9
  const out = [];
8
10
  for (let i = 0; i < argv.length; i++) {
@@ -0,0 +1,27 @@
1
+ interface DiscoveredRealm {
2
+ domain: string;
3
+ clientId: string;
4
+ audience: string;
5
+ organization?: string;
6
+ m2mClientId?: string;
7
+ }
8
+ interface RegistrationResult {
9
+ name: string;
10
+ baseUrl: string;
11
+ replacing: boolean;
12
+ realm: DiscoveredRealm;
13
+ }
14
+ export declare function seqapiConfigPath(): string;
15
+ export declare function readRegisteredEnvironmentUrls({ configFile, }?: {
16
+ configFile?: string;
17
+ }): Promise<Record<string, {
18
+ url: string;
19
+ }>>;
20
+ export declare function registerEnvironment({ baseUrl: rawBaseUrl, clearCredentials, configFile, fetchImpl, name: rawName, }: {
21
+ baseUrl: string;
22
+ clearCredentials?: (name: string) => Promise<void>;
23
+ configFile?: string;
24
+ fetchImpl?: typeof fetch;
25
+ name: string;
26
+ }): Promise<RegistrationResult>;
27
+ export {};
@@ -0,0 +1,204 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { validateDeploymentAudience, validateDeploymentBaseUrl, } from '@sequenceholdings/artifact-studio/deployment-validation';
6
+ import { deleteRealmTokens, isSequenceAuthEnvName, validateAuth0Domain, } from './auth.js';
7
+ import { withCrossProcessFileLock } from './file-lock.js';
8
+ const DISCOVERY_PATH = '/api/auth/cli-config';
9
+ const ENV_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
10
+ function isRecord(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ function hasErrorCode(error, code) {
14
+ return error instanceof Error && Reflect.get(error, 'code') === code;
15
+ }
16
+ function optionalString(value) {
17
+ return typeof value === 'string' && value ? value : undefined;
18
+ }
19
+ export function seqapiConfigPath() {
20
+ return join(homedir(), '.config', 'sequence-api', 'config.json');
21
+ }
22
+ function validateEnvironmentName(input) {
23
+ const name = input.toLowerCase();
24
+ if (!ENV_NAME_PATTERN.test(name)) {
25
+ throw new Error(`Invalid environment name '${input}': use lowercase letters, digits, and hyphens.`);
26
+ }
27
+ if (isSequenceAuthEnvName(name)) {
28
+ throw new Error(`'${name}' is a reserved built-in environment name.`);
29
+ }
30
+ return name;
31
+ }
32
+ async function discoverRealm({ baseUrl, fetchImpl, }) {
33
+ const url = `${baseUrl}${DISCOVERY_PATH}`;
34
+ let response;
35
+ try {
36
+ response = await fetchImpl(url, {
37
+ redirect: 'manual',
38
+ signal: AbortSignal.timeout(15_000),
39
+ });
40
+ }
41
+ catch (error) {
42
+ throw new Error(`Could not reach ${url}: ${error instanceof Error ? error.message : error}`, {
43
+ cause: error,
44
+ });
45
+ }
46
+ if (response.status >= 300 && response.status < 400) {
47
+ throw new Error(`${url} redirected to ${JSON.stringify(response.headers.get('location'))}; ` +
48
+ 'CLI discovery redirects are not allowed.');
49
+ }
50
+ if (response.status === 404) {
51
+ throw new Error(`${url} returned 404 — this deployment does not expose the CLI discovery endpoint yet.`);
52
+ }
53
+ if (!response.ok) {
54
+ throw new Error(`${url} returned HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`);
55
+ }
56
+ let data;
57
+ try {
58
+ data = await response.json();
59
+ }
60
+ catch (error) {
61
+ throw new Error(`${url} did not return JSON.`, { cause: error });
62
+ }
63
+ const auth0 = isRecord(data) ? Reflect.get(data, 'auth0') : undefined;
64
+ if (!isRecord(auth0)) {
65
+ throw new Error(`${url} response is missing auth0 configuration.`);
66
+ }
67
+ const domain = optionalString(Reflect.get(auth0, 'domain'));
68
+ const clientId = optionalString(Reflect.get(auth0, 'clientId'));
69
+ const audience = optionalString(Reflect.get(auth0, 'audience'));
70
+ const missing = [
71
+ !domain ? 'domain' : null,
72
+ !clientId ? 'clientId' : null,
73
+ !audience ? 'audience' : null,
74
+ ].filter((key) => key !== null);
75
+ if (missing.length > 0 || !domain || !clientId || !audience) {
76
+ throw new Error(`${url} response is missing auth0.{${missing.join(', ')}}.`);
77
+ }
78
+ return {
79
+ domain: validateAuth0Domain(domain),
80
+ clientId,
81
+ audience: validateDeploymentAudience({ audience, baseUrl }),
82
+ organization: optionalString(Reflect.get(auth0, 'organization')),
83
+ m2mClientId: optionalString(Reflect.get(auth0, 'm2mClientId')),
84
+ };
85
+ }
86
+ async function loadConfig(configFile) {
87
+ try {
88
+ const parsed = JSON.parse(await readFile(configFile, 'utf8'));
89
+ if (!isRecord(parsed))
90
+ throw new Error('root value must be an object');
91
+ return parsed;
92
+ }
93
+ catch (error) {
94
+ if (hasErrorCode(error, 'ENOENT'))
95
+ return {};
96
+ throw new Error(`Could not read tenant auth registry ${configFile}.`, { cause: error });
97
+ }
98
+ }
99
+ export async function readRegisteredEnvironmentUrls({ configFile = seqapiConfigPath(), } = {}) {
100
+ let config;
101
+ try {
102
+ config = await loadConfig(configFile);
103
+ }
104
+ catch {
105
+ // Aggregate routing degrades to built-ins/catalog/config.toml when the
106
+ // registry is corrupt. Explicit auth use still fails loudly in realmForEnv.
107
+ return {};
108
+ }
109
+ const stored = Reflect.get(config, 'environments');
110
+ if (!isRecord(stored))
111
+ return {};
112
+ const valid = {};
113
+ for (const [name, value] of Object.entries(stored)) {
114
+ try {
115
+ if (validateEnvironmentName(name) !== name || !isRecord(value))
116
+ continue;
117
+ const auth0 = Reflect.get(value, 'auth0');
118
+ const rawUrl = Reflect.get(value, 'url');
119
+ if (!isRecord(auth0) || typeof rawUrl !== 'string')
120
+ continue;
121
+ const domain = optionalString(Reflect.get(auth0, 'domain'));
122
+ const clientId = optionalString(Reflect.get(auth0, 'client_id'));
123
+ const audience = optionalString(Reflect.get(auth0, 'audience'));
124
+ if (!domain || !clientId || !audience)
125
+ continue;
126
+ validateAuth0Domain(domain);
127
+ const url = validateDeploymentBaseUrl(rawUrl);
128
+ validateDeploymentAudience({ audience, baseUrl: url });
129
+ valid[name] = { url };
130
+ }
131
+ catch {
132
+ // Aggregate routing skips malformed entries; explicit auth use still
133
+ // fails closed with a detailed error from realmForEnv.
134
+ }
135
+ }
136
+ return valid;
137
+ }
138
+ async function writeConfig(configFile, config) {
139
+ await mkdir(dirname(configFile), { recursive: true });
140
+ const temporary = `${configFile}.${process.pid}.${randomUUID()}.tmp`;
141
+ try {
142
+ await writeFile(temporary, JSON.stringify(config, null, 2) + '\n', {
143
+ encoding: 'utf8',
144
+ mode: 0o600,
145
+ flag: 'wx',
146
+ });
147
+ await rename(temporary, configFile);
148
+ }
149
+ finally {
150
+ await rm(temporary, { force: true });
151
+ }
152
+ }
153
+ async function clearImpersonationForEnv({ configFile, name, }) {
154
+ const impersonationFile = join(dirname(configFile), 'impersonation.json');
155
+ try {
156
+ const parsed = JSON.parse(await readFile(impersonationFile, 'utf8'));
157
+ const env = isRecord(parsed) ? Reflect.get(parsed, 'env') : undefined;
158
+ if (env === undefined || env === null || env === name) {
159
+ await rm(impersonationFile, { force: true });
160
+ }
161
+ }
162
+ catch (error) {
163
+ if (!hasErrorCode(error, 'ENOENT'))
164
+ throw error;
165
+ }
166
+ }
167
+ async function clearEnvironmentCredentials({ configFile, name, }) {
168
+ await deleteRealmTokens(name);
169
+ await clearImpersonationForEnv({ configFile, name });
170
+ }
171
+ export async function registerEnvironment({ baseUrl: rawBaseUrl, clearCredentials, configFile = seqapiConfigPath(), fetchImpl = fetch, name: rawName, }) {
172
+ const name = validateEnvironmentName(rawName);
173
+ const baseUrl = validateDeploymentBaseUrl(rawBaseUrl);
174
+ const realm = await discoverRealm({ baseUrl, fetchImpl });
175
+ // Lock ordering contract: credential cleanup may acquire tokens.json.lock
176
+ // while this is held; token-file code must never acquire config.json.lock.
177
+ const replacing = await withCrossProcessFileLock({
178
+ path: configFile,
179
+ operation: async () => {
180
+ const config = await loadConfig(configFile);
181
+ const existingEnvironments = Reflect.get(config, 'environments');
182
+ const environments = isRecord(existingEnvironments) ? { ...existingEnvironments } : {};
183
+ const isReplacement = Reflect.has(environments, name);
184
+ // Clear unconditionally: a manually removed or partially written
185
+ // registration can leave orphaned credentials even when no entry exists.
186
+ await (clearCredentials
187
+ ? clearCredentials(name)
188
+ : clearEnvironmentCredentials({ configFile, name }));
189
+ environments[name] = {
190
+ url: baseUrl,
191
+ auth0: {
192
+ domain: realm.domain,
193
+ client_id: realm.clientId,
194
+ audience: realm.audience,
195
+ organization: realm.organization ?? null,
196
+ m2m_client_id: realm.m2mClientId ?? null,
197
+ },
198
+ };
199
+ await writeConfig(configFile, { ...config, environments });
200
+ return isReplacement;
201
+ },
202
+ });
203
+ return { name, baseUrl, replacing, realm };
204
+ }
@@ -1 +1 @@
1
- export declare function runEnvsCommand(sub: string | undefined, _rest: string[]): Promise<number>;
1
+ export declare function runEnvsCommand(sub: string | undefined, rest: string[]): Promise<number>;
@@ -1,8 +1,12 @@
1
- import { readConfig, configPath } from '../config.js';
1
+ import { tryGetAccessToken } from '../auth.js';
2
+ import { readConfig, configPath, manualEnvConfigHint } from '../config.js';
3
+ import { readRegisteredEnvironmentUrls, registerEnvironment, } from '../env-registry.js';
2
4
  import { bootstrapUrl, fetchCatalog, readCachedCatalog, } from '../env-catalog.js';
3
5
  const ENVS_USAGE = `usage:
4
6
  seq-studio envs list show the environments visible to your identity
5
7
  seq-studio envs refresh re-fetch the environment catalog for your identity
8
+ seq-studio envs add <name> <url>
9
+ register an OpCo deployment from public discovery
6
10
 
7
11
  The published CLI ships with only the "local" environment. After you
8
12
  authenticate (seq-studio login, or AUTH0_M2M_CLIENT_SECRET for headless use),
@@ -12,8 +16,10 @@ const ENVS_USAGE = `usage:
12
16
 
13
17
  Custom entries in ${configPath()} are always honored on top.
14
18
  `;
15
- export async function runEnvsCommand(sub, _rest) {
19
+ export async function runEnvsCommand(sub, rest) {
16
20
  switch (sub) {
21
+ case 'add':
22
+ return addCommand(rest);
17
23
  case 'list':
18
24
  return listCommand();
19
25
  case 'refresh':
@@ -30,9 +36,58 @@ export async function runEnvsCommand(sub, _rest) {
30
36
  return 1;
31
37
  }
32
38
  }
39
+ async function addCommand(rest) {
40
+ const [name, baseUrl, ...extra] = rest;
41
+ if (!name || !baseUrl || extra.length > 0) {
42
+ console.error('usage: seq-studio envs add <name> <url>');
43
+ return 1;
44
+ }
45
+ try {
46
+ const registration = await registerEnvironment({ name, baseUrl });
47
+ const verb = registration.replacing ? 'Updated' : 'Registered';
48
+ console.log(`${verb} environment '${registration.name}' -> ${registration.baseUrl}`);
49
+ console.log(` auth0 domain: ${registration.realm.domain}`);
50
+ console.log(` audience: ${registration.realm.audience}`);
51
+ console.log(` organization: ${registration.realm.organization ?? '(none)'}`);
52
+ if (registration.realm.m2mClientId) {
53
+ const suffix = registration.name.toUpperCase().replaceAll('-', '_');
54
+ console.log(` m2m client: ${registration.realm.m2mClientId} ` +
55
+ `(headless: export AUTH0_M2M_CLIENT_SECRET_${suffix}=...)`);
56
+ }
57
+ console.log(`Next: seq-studio login --env ${registration.name}`);
58
+ return 0;
59
+ }
60
+ catch (error) {
61
+ console.error(`envs add: FAIL — ${error instanceof Error ? error.message : String(error)}`);
62
+ return 1;
63
+ }
64
+ }
65
+ /**
66
+ * Tier `anonymous` means "no catalog cached", not necessarily "no token".
67
+ * Shared by list + refresh so they don't contradict each other.
68
+ */
69
+ async function printNoCatalogGuidance(log = console.log) {
70
+ const token = await tryGetAccessToken();
71
+ log('');
72
+ if (token) {
73
+ log(`Logged in, but no environment catalog is cached against ${bootstrapUrl()} — ` +
74
+ 'only "local" (plus any config.toml entries) is visible. Run: seq-studio envs refresh');
75
+ }
76
+ else {
77
+ log('Not authenticated — only "local" is visible. Authenticate with ' +
78
+ '`seq-studio login` (or set AUTH0_M2M_CLIENT_SECRET), then run: seq-studio envs refresh');
79
+ }
80
+ log('');
81
+ log(manualEnvConfigHint());
82
+ }
33
83
  async function listCommand() {
34
- const [config, catalog] = await Promise.all([readConfig(), readCachedCatalog()]);
84
+ const [config, catalog, registered] = await Promise.all([
85
+ readConfig(),
86
+ readCachedCatalog(),
87
+ readRegisteredEnvironmentUrls(),
88
+ ]);
35
89
  const discovered = new Set(catalog?.environments.map((env) => env.name) ?? []);
90
+ const registeredNames = new Set(Object.keys(registered));
36
91
  console.log(`tier: ${config.tier}`);
37
92
  if (catalog) {
38
93
  console.log(`catalog fetched: ${new Date(catalog.fetchedAt).toISOString()}`);
@@ -44,13 +99,13 @@ async function listCommand() {
44
99
  ? 'built-in'
45
100
  : discovered.has(name)
46
101
  ? 'discovered'
47
- : 'config.toml';
102
+ : registeredNames.has(name)
103
+ ? 'registered'
104
+ : 'config.toml';
48
105
  console.log(` ${name.padEnd(width)} ${url} (${source})`);
49
106
  }
50
107
  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');
108
+ await printNoCatalogGuidance();
54
109
  }
55
110
  return 0;
56
111
  }
@@ -61,12 +116,12 @@ async function refreshCommand() {
61
116
  }
62
117
  catch (err) {
63
118
  console.error(`envs refresh: FAIL — ${err instanceof Error ? err.message : String(err)}`);
119
+ console.error('');
120
+ console.error(manualEnvConfigHint());
64
121
  return 1;
65
122
  }
66
123
  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.');
124
+ await printNoCatalogGuidance();
70
125
  return 0;
71
126
  }
72
127
  console.log(`tier: ${catalog.tier} — ${catalog.environments.length} environment(s) cached.`);
@@ -0,0 +1,5 @@
1
+ export declare function tryReapStaleLock(lockPath: string, observedOwner: string): Promise<void>;
2
+ export declare function withCrossProcessFileLock<T>({ operation, path, }: {
3
+ operation: () => Promise<T>;
4
+ path: string;
5
+ }): Promise<T>;