@sequenceholdings/studio-cli 0.1.13 → 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.
Files changed (63) hide show
  1. package/README.md +258 -38
  2. package/dist/agents/apply-chunks.d.ts +13 -0
  3. package/dist/agents/apply-chunks.js +43 -0
  4. package/dist/agents/commands.d.ts +10 -0
  5. package/dist/agents/commands.js +218 -0
  6. package/dist/agents/scaffold.d.ts +2 -0
  7. package/dist/agents/scaffold.js +77 -0
  8. package/dist/agents/source.d.ts +18 -0
  9. package/dist/agents/source.js +121 -0
  10. package/dist/artifact/delegate.d.ts +2 -2
  11. package/dist/artifact/delegate.js +31 -73
  12. package/dist/atlas-client.js +52 -37
  13. package/dist/auth-cmds/commands.d.ts +1 -1
  14. package/dist/auth-cmds/commands.js +12 -7
  15. package/dist/auth.d.ts +104 -24
  16. package/dist/auth.js +456 -94
  17. package/dist/config.d.ts +3 -3
  18. package/dist/config.js +18 -13
  19. package/dist/env-catalog.js +13 -3
  20. package/dist/env-flags.d.ts +2 -0
  21. package/dist/env-flags.js +2 -0
  22. package/dist/env-registry.d.ts +27 -0
  23. package/dist/env-registry.js +204 -0
  24. package/dist/envs/commands.d.ts +1 -1
  25. package/dist/envs/commands.js +41 -3
  26. package/dist/file-lock.d.ts +5 -0
  27. package/dist/file-lock.js +187 -0
  28. package/dist/functions/commands.d.ts +10 -10
  29. package/dist/functions/commands.js +87 -53
  30. package/dist/functions/manifest.d.ts +1 -0
  31. package/dist/functions/manifest.js +36 -0
  32. package/dist/functions/source-selection.d.ts +24 -0
  33. package/dist/functions/source-selection.js +67 -0
  34. package/dist/login.d.ts +8 -3
  35. package/dist/login.js +46 -34
  36. package/dist/main.d.ts +3 -1
  37. package/dist/main.js +41 -12
  38. package/dist/orm/delegate.js +25 -7
  39. package/dist/pat-hints.js +2 -2
  40. package/dist/pipeline/commands.d.ts +58 -0
  41. package/dist/pipeline/commands.js +330 -0
  42. package/dist/pipeline/lifecycle.d.ts +58 -0
  43. package/dist/pipeline/lifecycle.js +348 -0
  44. package/dist/pipeline/pinning.d.ts +5 -0
  45. package/dist/pipeline/pinning.js +9 -0
  46. package/dist/pipeline/templates.d.ts +11 -0
  47. package/dist/pipeline/templates.js +166 -0
  48. package/dist/process/build.d.ts +4 -0
  49. package/dist/process/build.js +33 -2
  50. package/dist/process/codegen.js +19 -1
  51. package/dist/process/commands.js +97 -47
  52. package/dist/process/compiler-subprocess.d.ts +29 -0
  53. package/dist/process/compiler-subprocess.js +99 -0
  54. package/dist/process/compiler-worker.d.ts +1 -0
  55. package/dist/process/compiler-worker.js +38 -0
  56. package/dist/process/lint.d.ts +8 -0
  57. package/dist/process/lint.js +84 -29
  58. package/dist/process/repo-install.js +18 -2
  59. package/dist/repos/commands.d.ts +1 -1
  60. package/dist/repos/commands.js +17 -12
  61. package/dist/secrets/commands.d.ts +1 -1
  62. package/dist/secrets/commands.js +18 -18
  63. package/package.json +12 -5
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
  }
@@ -111,7 +116,7 @@ export function manualEnvConfigHint() {
111
116
  return (`Or add an environment manually in ${configPath()} (append-safe):\n` +
112
117
  `\n` +
113
118
  ` [env.my-env]\n` +
114
- ` url = "https://your-atlas-host.example.com"\n` +
119
+ ` url = "https://my-atlas.seqholdings.com"\n` +
115
120
  `\n` +
116
121
  `To make it the default, put \`default_env = "my-env"\` at the TOP of ` +
117
122
  `that file — before any [env.*] table.`);
@@ -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,9 +1,12 @@
1
1
  import { tryGetAccessToken } from '../auth.js';
2
2
  import { readConfig, configPath, manualEnvConfigHint } from '../config.js';
3
+ import { readRegisteredEnvironmentUrls, registerEnvironment, } from '../env-registry.js';
3
4
  import { bootstrapUrl, fetchCatalog, readCachedCatalog, } from '../env-catalog.js';
4
5
  const ENVS_USAGE = `usage:
5
6
  seq-studio envs list show the environments visible to your identity
6
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
7
10
 
8
11
  The published CLI ships with only the "local" environment. After you
9
12
  authenticate (seq-studio login, or AUTH0_M2M_CLIENT_SECRET for headless use),
@@ -13,8 +16,10 @@ const ENVS_USAGE = `usage:
13
16
 
14
17
  Custom entries in ${configPath()} are always honored on top.
15
18
  `;
16
- export async function runEnvsCommand(sub, _rest) {
19
+ export async function runEnvsCommand(sub, rest) {
17
20
  switch (sub) {
21
+ case 'add':
22
+ return addCommand(rest);
18
23
  case 'list':
19
24
  return listCommand();
20
25
  case 'refresh':
@@ -31,6 +36,32 @@ export async function runEnvsCommand(sub, _rest) {
31
36
  return 1;
32
37
  }
33
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
+ }
34
65
  /**
35
66
  * Tier `anonymous` means "no catalog cached", not necessarily "no token".
36
67
  * Shared by list + refresh so they don't contradict each other.
@@ -50,8 +81,13 @@ async function printNoCatalogGuidance(log = console.log) {
50
81
  log(manualEnvConfigHint());
51
82
  }
52
83
  async function listCommand() {
53
- const [config, catalog] = await Promise.all([readConfig(), readCachedCatalog()]);
84
+ const [config, catalog, registered] = await Promise.all([
85
+ readConfig(),
86
+ readCachedCatalog(),
87
+ readRegisteredEnvironmentUrls(),
88
+ ]);
54
89
  const discovered = new Set(catalog?.environments.map((env) => env.name) ?? []);
90
+ const registeredNames = new Set(Object.keys(registered));
55
91
  console.log(`tier: ${config.tier}`);
56
92
  if (catalog) {
57
93
  console.log(`catalog fetched: ${new Date(catalog.fetchedAt).toISOString()}`);
@@ -63,7 +99,9 @@ async function listCommand() {
63
99
  ? 'built-in'
64
100
  : discovered.has(name)
65
101
  ? 'discovered'
66
- : 'config.toml';
102
+ : registeredNames.has(name)
103
+ ? 'registered'
104
+ : 'config.toml';
67
105
  console.log(` ${name.padEnd(width)} ${url} (${source})`);
68
106
  }
69
107
  if (config.tier === 'anonymous') {
@@ -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>;
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { link, mkdir, open, readFile, rename, stat, unlink } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ const LOCK_TIMEOUT_MS = 10_000;
5
+ const LOCK_STALE_MS = 30_000;
6
+ function hasErrorCode(error, code) {
7
+ return error instanceof Error && Reflect.get(error, 'code') === code;
8
+ }
9
+ function ownerProcessIsAlive(owner) {
10
+ const separator = owner.indexOf('-');
11
+ const pid = Number(owner.slice(0, separator));
12
+ if (separator <= 0 || !Number.isSafeInteger(pid) || pid <= 0)
13
+ return false;
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ return !hasErrorCode(error, 'ESRCH');
20
+ }
21
+ }
22
+ async function createExclusiveFile(path, contents) {
23
+ const temporary = `${path}.publish.${process.pid}.${randomUUID()}`;
24
+ const handle = await open(temporary, 'wx', 0o600);
25
+ try {
26
+ await handle.writeFile(contents, { encoding: 'utf8' });
27
+ }
28
+ catch (error) {
29
+ await handle.close().catch(() => undefined);
30
+ await unlink(temporary).catch(() => undefined);
31
+ throw error;
32
+ }
33
+ try {
34
+ await handle.close();
35
+ }
36
+ catch (error) {
37
+ await unlink(temporary).catch(() => undefined);
38
+ throw error;
39
+ }
40
+ try {
41
+ await link(temporary, path);
42
+ }
43
+ finally {
44
+ await unlink(temporary).catch(() => undefined);
45
+ }
46
+ }
47
+ async function restoreClaimedLock(reapPath, lockPath) {
48
+ try {
49
+ await link(reapPath, lockPath);
50
+ }
51
+ catch (error) {
52
+ if (!hasErrorCode(error, 'EEXIST'))
53
+ throw error;
54
+ }
55
+ }
56
+ async function claimObservedOwnerFile({ claimLabel, observedOwner, path, }) {
57
+ const claimedPath = `${path}.${claimLabel}.${process.pid}.${randomUUID()}`;
58
+ try {
59
+ await rename(path, claimedPath);
60
+ }
61
+ catch (error) {
62
+ if (hasErrorCode(error, 'ENOENT'))
63
+ return undefined;
64
+ throw error;
65
+ }
66
+ try {
67
+ if ((await readFile(claimedPath, 'utf8')) !== observedOwner) {
68
+ await restoreClaimedLock(claimedPath, path);
69
+ await unlink(claimedPath);
70
+ return undefined;
71
+ }
72
+ return claimedPath;
73
+ }
74
+ catch {
75
+ // The process-unique claim may have been concurrently cleaned up.
76
+ return undefined;
77
+ }
78
+ }
79
+ async function tryReapOrphanedGuard(guardPath) {
80
+ let observedOwner;
81
+ try {
82
+ observedOwner = await readFile(guardPath, 'utf8');
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (ownerProcessIsAlive(observedOwner))
88
+ return;
89
+ const orphanPath = await claimObservedOwnerFile({
90
+ claimLabel: 'orphan',
91
+ observedOwner,
92
+ path: guardPath,
93
+ });
94
+ if (orphanPath) {
95
+ await unlink(orphanPath).catch(() => undefined);
96
+ }
97
+ }
98
+ export async function tryReapStaleLock(lockPath, observedOwner) {
99
+ const guardPath = `${lockPath}.reaper`;
100
+ try {
101
+ await createExclusiveFile(guardPath, `${process.pid}-${randomUUID()}`);
102
+ }
103
+ catch (error) {
104
+ if (hasErrorCode(error, 'EEXIST')) {
105
+ await tryReapOrphanedGuard(guardPath);
106
+ return;
107
+ }
108
+ throw error;
109
+ }
110
+ try {
111
+ let stillCurrent;
112
+ let stillStale;
113
+ try {
114
+ stillCurrent = (await readFile(lockPath, 'utf8')) === observedOwner;
115
+ stillStale = Date.now() - (await stat(lockPath)).mtimeMs > LOCK_STALE_MS;
116
+ }
117
+ catch {
118
+ return;
119
+ }
120
+ if (!stillCurrent || !stillStale || ownerProcessIsAlive(observedOwner))
121
+ return;
122
+ const reapPath = await claimObservedOwnerFile({
123
+ claimLabel: 'reap',
124
+ observedOwner,
125
+ path: lockPath,
126
+ });
127
+ if (reapPath) {
128
+ await unlink(reapPath).catch(() => undefined);
129
+ }
130
+ }
131
+ finally {
132
+ try {
133
+ await unlink(guardPath);
134
+ }
135
+ catch {
136
+ // The guard was already removed; never mask the protected operation.
137
+ }
138
+ }
139
+ }
140
+ export async function withCrossProcessFileLock({ operation, path, }) {
141
+ const lockPath = `${path}.lock`;
142
+ const owner = `${process.pid}-${randomUUID()}`;
143
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
144
+ await mkdir(dirname(path), { recursive: true });
145
+ while (true) {
146
+ try {
147
+ await createExclusiveFile(lockPath, owner);
148
+ break;
149
+ }
150
+ catch (error) {
151
+ if (!hasErrorCode(error, 'EEXIST'))
152
+ throw error;
153
+ let observed;
154
+ let isStale;
155
+ try {
156
+ observed = await readFile(lockPath, 'utf8');
157
+ isStale = Date.now() - (await stat(lockPath)).mtimeMs > LOCK_STALE_MS;
158
+ }
159
+ catch (checkError) {
160
+ if (hasErrorCode(checkError, 'ENOENT'))
161
+ continue;
162
+ throw checkError;
163
+ }
164
+ if (isStale && !ownerProcessIsAlive(observed)) {
165
+ await tryReapStaleLock(lockPath, observed);
166
+ continue;
167
+ }
168
+ if (Date.now() >= deadline) {
169
+ throw new Error(`Timed out waiting for another CLI to update ${path}.`);
170
+ }
171
+ await new Promise((resolve) => setTimeout(resolve, 50));
172
+ }
173
+ }
174
+ try {
175
+ return await operation();
176
+ }
177
+ finally {
178
+ try {
179
+ if ((await readFile(lockPath, 'utf8')) === owner) {
180
+ await unlink(lockPath);
181
+ }
182
+ }
183
+ catch {
184
+ // Never mask the operation result when the lock was already recovered.
185
+ }
186
+ }
187
+ }