@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.
- package/README.md +116 -30
- package/dist/artifact/delegate.d.ts +2 -2
- package/dist/artifact/delegate.js +31 -73
- package/dist/atlas-client.js +52 -37
- package/dist/auth-cmds/commands.d.ts +1 -1
- package/dist/auth-cmds/commands.js +12 -7
- package/dist/auth.d.ts +97 -19
- package/dist/auth.js +376 -81
- package/dist/config.d.ts +13 -3
- package/dist/config.js +41 -14
- package/dist/env-catalog.js +13 -3
- package/dist/env-flags.d.ts +2 -0
- package/dist/env-flags.js +2 -0
- package/dist/env-registry.d.ts +27 -0
- package/dist/env-registry.js +204 -0
- package/dist/envs/commands.d.ts +1 -1
- package/dist/envs/commands.js +65 -10
- package/dist/file-lock.d.ts +5 -0
- package/dist/file-lock.js +187 -0
- package/dist/functions/commands.d.ts +9 -1
- package/dist/functions/commands.js +71 -29
- package/dist/functions/manifest.d.ts +1 -0
- package/dist/functions/manifest.js +36 -0
- package/dist/login.d.ts +14 -3
- package/dist/login.js +60 -40
- package/dist/main.d.ts +2 -1
- package/dist/main.js +36 -12
- package/dist/orm/delegate.d.ts +9 -0
- package/dist/orm/delegate.js +36 -4
- package/dist/pat-hints.js +2 -2
- package/dist/pipeline/commands.d.ts +58 -0
- package/dist/pipeline/commands.js +330 -0
- package/dist/pipeline/lifecycle.d.ts +58 -0
- package/dist/pipeline/lifecycle.js +348 -0
- package/dist/pipeline/pinning.d.ts +5 -0
- package/dist/pipeline/pinning.js +9 -0
- package/dist/pipeline/templates.d.ts +11 -0
- package/dist/pipeline/templates.js +166 -0
- package/dist/process/build.d.ts +12 -1
- package/dist/process/build.js +49 -2
- package/dist/process/codegen.js +21 -1
- package/dist/process/commands.d.ts +19 -0
- package/dist/process/commands.js +153 -40
- package/dist/process/compiler-subprocess.d.ts +29 -0
- package/dist/process/compiler-subprocess.js +99 -0
- package/dist/process/compiler-worker.d.ts +1 -0
- package/dist/process/compiler-worker.js +38 -0
- package/dist/process/discover.d.ts +4 -1
- package/dist/process/discover.js +5 -2
- package/dist/process/lint.d.ts +8 -0
- package/dist/process/lint.js +125 -29
- package/dist/process/repo-install.d.ts +21 -0
- package/dist/process/repo-install.js +99 -0
- package/dist/process/simulate.js +14 -1
- package/dist/repos/commands.d.ts +1 -1
- package/dist/repos/commands.js +17 -12
- package/dist/secrets/commands.d.ts +1 -1
- package/dist/secrets/commands.js +18 -18
- 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
|
-
*
|
|
50
|
-
*
|
|
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 (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
package/dist/env-catalog.js
CHANGED
|
@@ -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({
|
|
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(
|
|
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 ${
|
|
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') ||
|
package/dist/env-flags.d.ts
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 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
|
+
}
|
package/dist/envs/commands.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function runEnvsCommand(sub: string | undefined,
|
|
1
|
+
export declare function runEnvsCommand(sub: string | undefined, rest: string[]): Promise<number>;
|
package/dist/envs/commands.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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([
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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.`);
|