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