@sequenceholdings/studio-cli 0.1.22 → 0.1.25
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 +136 -12
- package/dist/app/commands.d.ts +16 -0
- package/dist/app/commands.js +227 -0
- package/dist/app/deploy.d.ts +49 -0
- package/dist/app/deploy.js +197 -0
- package/dist/app/kinds.d.ts +10 -0
- package/dist/app/kinds.js +36 -0
- package/dist/app/manifest.d.ts +94 -0
- package/dist/app/manifest.js +273 -0
- package/dist/app/scaffold.d.ts +28 -0
- package/dist/app/scaffold.js +263 -0
- package/dist/auth.d.ts +9 -5
- package/dist/auth.js +121 -8
- package/dist/config.d.ts +7 -3
- package/dist/config.js +77 -5
- package/dist/envs/commands.js +9 -6
- package/dist/functions/bundle.js +3 -3
- package/dist/functions/commands.js +18 -5
- package/dist/functions/manifest.d.ts +25 -0
- package/dist/functions/manifest.js +93 -0
- package/dist/login.js +3 -3
- package/dist/main.d.ts +3 -0
- package/dist/main.js +21 -0
- package/dist/pipeline/codegen.d.ts +2 -0
- package/dist/pipeline/codegen.js +118 -0
- package/dist/pipeline/commands.d.ts +7 -0
- package/dist/pipeline/commands.js +90 -12
- package/dist/pipeline/lifecycle.d.ts +1 -12
- package/dist/pipeline/lifecycle.js +228 -46
- package/dist/pipeline/templates.js +3 -1
- package/dist/preview.d.ts +4 -3
- package/dist/preview.js +5 -4
- package/dist/secrets/commands.d.ts +3 -1
- package/dist/secrets/commands.js +87 -26
- package/package.json +7 -7
- package/dist/pipeline/pinning.d.ts +0 -5
- package/dist/pipeline/pinning.js +0 -9
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffold ORM / function / artifact into an app monorepo and keep
|
|
3
|
+
* sequence.app.yml in sync. Delegates to each primitive's real `init`.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
8
|
+
import { functionsInitCommand } from '../functions/commands.js';
|
|
9
|
+
import { parseArgs } from '../process/commands.js';
|
|
10
|
+
import { currentVersion } from '../update-check.js';
|
|
11
|
+
import { defaultDeployOrder, extendDeployOrder, loadAppManifest, ormNamespaceFromAppId, titleizeSlug, writeAppManifest, } from './manifest.js';
|
|
12
|
+
const LOG = '[seq-studio]';
|
|
13
|
+
const APP_MANIFEST_LABEL = 'sequence.app.yml';
|
|
14
|
+
export function defaultScaffoldNames(_appId) {
|
|
15
|
+
return { functionName: 'hello' };
|
|
16
|
+
}
|
|
17
|
+
function primitiveIdFor({ kind, name }) {
|
|
18
|
+
switch (kind) {
|
|
19
|
+
case 'orm':
|
|
20
|
+
return `${name}-orm`;
|
|
21
|
+
case 'artifact':
|
|
22
|
+
return `${name}-ui`;
|
|
23
|
+
case 'function':
|
|
24
|
+
return name;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function relativePathFor({ kind, appId, name, }) {
|
|
28
|
+
switch (kind) {
|
|
29
|
+
case 'orm':
|
|
30
|
+
return join('orm', ormNamespaceFromAppId(appId));
|
|
31
|
+
case 'function':
|
|
32
|
+
return join('functions', name);
|
|
33
|
+
case 'artifact':
|
|
34
|
+
return 'artifact';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function buildPrimitiveEntry({ kind, appId, name, dependsOn, }) {
|
|
38
|
+
const path = relativePathFor({ kind, appId, name }).replace(/\\/g, '/');
|
|
39
|
+
const id = primitiveIdFor({ kind, name: kind === 'orm' || kind === 'artifact' ? appId : name });
|
|
40
|
+
const base = { id, kind, path, depends_on: dependsOn };
|
|
41
|
+
switch (kind) {
|
|
42
|
+
case 'orm':
|
|
43
|
+
return { ...base, namespace: ormNamespaceFromAppId(appId) };
|
|
44
|
+
case 'function':
|
|
45
|
+
return { ...base, slug: name };
|
|
46
|
+
case 'artifact':
|
|
47
|
+
return { ...base, project_id: appId };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function rewriteArtifactIdentity({ artifactDir, appId, }) {
|
|
51
|
+
const title = titleizeSlug(appId);
|
|
52
|
+
const manifestPath = join(artifactDir, 'artifact.bundle.yml');
|
|
53
|
+
if (existsSync(manifestPath)) {
|
|
54
|
+
const body = await readFile(manifestPath, 'utf8');
|
|
55
|
+
const next = body
|
|
56
|
+
.replace(/(project_id:\s*)\S+/g, `$1${appId}`)
|
|
57
|
+
.replace(/^(\s*)title:\s*.*$/m, `$1title: "${title}"`);
|
|
58
|
+
if (next !== body)
|
|
59
|
+
await writeFile(manifestPath, next, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
const pkgPath = join(artifactDir, 'package.json');
|
|
62
|
+
if (existsSync(pkgPath)) {
|
|
63
|
+
try {
|
|
64
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
65
|
+
pkg.name = appId;
|
|
66
|
+
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// leave non-JSON package.json alone
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function scaffoldOrm({ absPath }) {
|
|
74
|
+
const { runOrmCommand } = await import('../orm/delegate.js');
|
|
75
|
+
const code = await runOrmCommand('init', [absPath]);
|
|
76
|
+
if (code !== 0) {
|
|
77
|
+
throw new Error(`orm init failed for ${absPath} (exit ${code}). ` +
|
|
78
|
+
'`seq-studio init --with orm` requires `@sequenceholdings/orm` ' +
|
|
79
|
+
'(workspace / internal installs today; not yet available via public npm).');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function scaffoldArtifact({ absPath, appId, }) {
|
|
83
|
+
const { runCli } = await import('@sequenceholdings/artifact-studio/cli');
|
|
84
|
+
const code = await runCli(['init', absPath]);
|
|
85
|
+
if (code !== 0) {
|
|
86
|
+
throw new Error(`artifact init failed for ${absPath} (exit ${code})`);
|
|
87
|
+
}
|
|
88
|
+
await rewriteArtifactIdentity({ artifactDir: absPath, appId });
|
|
89
|
+
}
|
|
90
|
+
async function scaffoldFunction({ absPath }) {
|
|
91
|
+
const code = await functionsInitCommand(parseArgs([absPath]));
|
|
92
|
+
if (code !== 0) {
|
|
93
|
+
throw new Error(`functions init failed for ${absPath} (exit ${code})`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function scaffoldKind({ kind, appRoot, appId, name, }) {
|
|
97
|
+
const relPath = relativePathFor({ kind, appId, name }).replace(/\\/g, '/');
|
|
98
|
+
const absPath = join(appRoot, relPath);
|
|
99
|
+
const entry = buildPrimitiveEntry({ kind, appId, name, dependsOn: [] });
|
|
100
|
+
switch (kind) {
|
|
101
|
+
case 'orm':
|
|
102
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
103
|
+
await scaffoldOrm({ absPath });
|
|
104
|
+
break;
|
|
105
|
+
case 'function':
|
|
106
|
+
await scaffoldFunction({ absPath });
|
|
107
|
+
break;
|
|
108
|
+
case 'artifact':
|
|
109
|
+
await mkdir(absPath, { recursive: true });
|
|
110
|
+
await scaffoldArtifact({ absPath, appId });
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
return entry;
|
|
114
|
+
}
|
|
115
|
+
function applyDefaultDependsOn(primitives) {
|
|
116
|
+
const orm = primitives.find((entry) => entry.kind === 'orm');
|
|
117
|
+
if (!orm)
|
|
118
|
+
return primitives;
|
|
119
|
+
return primitives.map((entry) => {
|
|
120
|
+
if (entry.kind === 'orm' || entry.depends_on.length > 0)
|
|
121
|
+
return entry;
|
|
122
|
+
return { ...entry, depends_on: [orm.id] };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export async function initAppMonorepo({ rootDir, appId, kinds, names = defaultScaffoldNames(appId), description, }) {
|
|
126
|
+
if (kinds.length === 0) {
|
|
127
|
+
throw new Error('select at least one primitive via --with orm,function,artifact (or --orm --function …)');
|
|
128
|
+
}
|
|
129
|
+
const appRoot = resolve(rootDir);
|
|
130
|
+
if (existsSync(join(appRoot, 'sequence.app.yml'))) {
|
|
131
|
+
throw new Error(`${join(appRoot, 'sequence.app.yml')} already exists — refusing to re-init`);
|
|
132
|
+
}
|
|
133
|
+
if (existsSync(appRoot)) {
|
|
134
|
+
const { readdir } = await import('node:fs/promises');
|
|
135
|
+
const entries = await readdir(appRoot).catch((error) => {
|
|
136
|
+
if (error.code === 'ENOENT')
|
|
137
|
+
return [];
|
|
138
|
+
throw error;
|
|
139
|
+
});
|
|
140
|
+
if (entries.length > 0) {
|
|
141
|
+
throw new Error(`${appRoot} is not empty — init into a new directory, or remove existing files first`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await mkdir(appRoot, { recursive: true });
|
|
145
|
+
try {
|
|
146
|
+
const version = currentVersion();
|
|
147
|
+
const primitives = [];
|
|
148
|
+
for (const kind of kinds) {
|
|
149
|
+
const name = kind === 'function' ? names.functionName : appId;
|
|
150
|
+
primitives.push(await scaffoldKind({ kind, appRoot, appId, name }));
|
|
151
|
+
}
|
|
152
|
+
const withDeps = applyDefaultDependsOn(primitives);
|
|
153
|
+
const manifest = {
|
|
154
|
+
schema_version: 1,
|
|
155
|
+
app: {
|
|
156
|
+
id: appId,
|
|
157
|
+
title: titleizeSlug(appId),
|
|
158
|
+
...(description !== undefined ? { description } : {}),
|
|
159
|
+
},
|
|
160
|
+
studio: {
|
|
161
|
+
created_with: version,
|
|
162
|
+
min_cli: version,
|
|
163
|
+
},
|
|
164
|
+
primitives: withDeps,
|
|
165
|
+
deploy: {
|
|
166
|
+
order: defaultDeployOrder(withDeps),
|
|
167
|
+
on_error: 'stop',
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
await writeAppManifest({ rootDir: appRoot, manifest });
|
|
171
|
+
await writeFile(join(appRoot, '.gitignore'), ['node_modules/', 'dist/', '.env', '.env.*', '!.env.example', '.DS_Store', '*.log'].join('\n') + '\n', 'utf8');
|
|
172
|
+
return manifest;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
// Directory was empty (or newly created) — remove partial scaffolds so a
|
|
176
|
+
// retry can `init` into the same path without a manual cleanup.
|
|
177
|
+
await rm(appRoot, { recursive: true, force: true }).catch(() => {
|
|
178
|
+
/* best-effort */
|
|
179
|
+
});
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export async function addPrimitiveToApp({ rootDir, kind, name, }) {
|
|
184
|
+
const appRoot = resolve(rootDir);
|
|
185
|
+
const manifest = await loadAppManifest(appRoot);
|
|
186
|
+
const appId = manifest.app.id;
|
|
187
|
+
if (kind === 'orm') {
|
|
188
|
+
throw new Error('adding a second orm namespace is not supported via `seq-studio add` yet — edit sequence.app.yml and scaffold with `seq-studio orm init` manually');
|
|
189
|
+
}
|
|
190
|
+
if (kind === 'artifact' && manifest.primitives.some((entry) => entry.kind === 'artifact')) {
|
|
191
|
+
throw new Error('this app already has an artifact primitive — remove it from sequence.app.yml before re-adding');
|
|
192
|
+
}
|
|
193
|
+
const entryId = primitiveIdFor({ kind, name: kind === 'artifact' ? appId : name });
|
|
194
|
+
if (manifest.primitives.some((entry) => entry.id === entryId)) {
|
|
195
|
+
throw new Error(`primitive id "${entryId}" already exists in sequence.app.yml`);
|
|
196
|
+
}
|
|
197
|
+
const relPath = relativePathFor({ kind, appId, name }).replace(/\\/g, '/');
|
|
198
|
+
if (manifest.primitives.some((entry) => entry.path === relPath)) {
|
|
199
|
+
throw new Error(`path "${relPath}" is already declared in sequence.app.yml`);
|
|
200
|
+
}
|
|
201
|
+
const absPath = join(appRoot, relPath);
|
|
202
|
+
// Refuse before scaffolding so a failed init cannot roll back pre-existing files.
|
|
203
|
+
if (existsSync(absPath)) {
|
|
204
|
+
throw new Error(`${relPath} already exists on disk — remove it or choose a different name before \`seq-studio add\``);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
let entry = await scaffoldKind({ kind, appRoot, appId, name });
|
|
208
|
+
const orm = manifest.primitives.find((item) => item.kind === 'orm');
|
|
209
|
+
if (orm && entry.depends_on.length === 0) {
|
|
210
|
+
entry = { ...entry, depends_on: [orm.id] };
|
|
211
|
+
}
|
|
212
|
+
const primitives = [...manifest.primitives, entry];
|
|
213
|
+
const next = {
|
|
214
|
+
...manifest,
|
|
215
|
+
primitives,
|
|
216
|
+
deploy: {
|
|
217
|
+
...manifest.deploy,
|
|
218
|
+
order: extendDeployOrder({
|
|
219
|
+
existingOrder: manifest.deploy.order,
|
|
220
|
+
primitives,
|
|
221
|
+
newId: entry.id,
|
|
222
|
+
}),
|
|
223
|
+
on_error: manifest.deploy.on_error,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
await writeAppManifest({ rootDir: appRoot, manifest: next });
|
|
227
|
+
return { manifest: next, entry };
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
// Safe: absPath did not exist before this invocation.
|
|
231
|
+
await rm(absPath, { recursive: true, force: true }).catch(() => {
|
|
232
|
+
/* best-effort */
|
|
233
|
+
});
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
export function printInitNextSteps({ appRoot, manifest, }) {
|
|
238
|
+
const rel = relative(process.cwd(), appRoot);
|
|
239
|
+
const display = !rel || rel.startsWith('..') ? appRoot : rel;
|
|
240
|
+
console.log(`${LOG} scaffolded app "${manifest.app.id}" in ${display}`);
|
|
241
|
+
console.log(`${LOG} wrote ${APP_MANIFEST_LABEL} with ${manifest.primitives.length} primitive(s):`);
|
|
242
|
+
for (const entry of manifest.primitives) {
|
|
243
|
+
console.log(`${LOG} - ${entry.kind.padEnd(9)} ${entry.id} (${entry.path})`);
|
|
244
|
+
}
|
|
245
|
+
console.log('');
|
|
246
|
+
console.log('Next:');
|
|
247
|
+
console.log(` cd ${display}`);
|
|
248
|
+
for (const entry of manifest.primitives) {
|
|
249
|
+
console.log(` (cd ${entry.path} && pnpm install)`);
|
|
250
|
+
}
|
|
251
|
+
console.log(' seq-studio login');
|
|
252
|
+
console.log(' seq-studio deploy -e local --yes');
|
|
253
|
+
console.log(' # or targeted:');
|
|
254
|
+
const fn = manifest.primitives.find((entry) => entry.kind === 'function');
|
|
255
|
+
if (fn) {
|
|
256
|
+
console.log(` seq-studio functions deploy --dir ${fn.path} -e local --yes`);
|
|
257
|
+
}
|
|
258
|
+
if (manifest.primitives.some((entry) => entry.kind === 'artifact')) {
|
|
259
|
+
console.log(' seq-studio artifact deploy artifact -e local');
|
|
260
|
+
}
|
|
261
|
+
console.log(' # add another function later:');
|
|
262
|
+
console.log(' seq-studio add function list-types');
|
|
263
|
+
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -53,14 +53,17 @@ export interface SeqapiTokens {
|
|
|
53
53
|
access_token?: string;
|
|
54
54
|
expires_at?: number;
|
|
55
55
|
}
|
|
56
|
+
interface RealmResolutionOptions {
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
targetUrl?: string;
|
|
59
|
+
}
|
|
56
60
|
/**
|
|
57
61
|
* Resolve the auth realm for an environment name. Undefined, built-ins, and
|
|
58
|
-
* per-PR preview targets map to the shared Sequence realm
|
|
59
|
-
*
|
|
60
|
-
* other explicit name must have a valid seqapi registry entry
|
|
61
|
-
* closed so a shared Sequence bearer token can never be sent to a tenant URL.
|
|
62
|
+
* per-PR preview targets map to the shared Sequence realm, except loopback
|
|
63
|
+
* targets can advertise the isolated Sequence-staging realm used by contractor
|
|
64
|
+
* local dev. Every other explicit name must have a valid seqapi registry entry.
|
|
62
65
|
*/
|
|
63
|
-
export declare function realmForEnv(envName?: string): Promise<AuthRealm>;
|
|
66
|
+
export declare function realmForEnv(envName?: string, options?: RealmResolutionOptions): Promise<AuthRealm>;
|
|
64
67
|
/** Env var(s) carrying a realm's M2M client secret — the Sequence realm keeps
|
|
65
68
|
* the legacy bare name; OpCo realms use a suffixed name so one shell can hold
|
|
66
69
|
* several credentials unambiguously. Mirrors seqapi's `_m2m_secret_env_names`. */
|
|
@@ -153,3 +156,4 @@ export declare function currentIdentitySubject(options?: {
|
|
|
153
156
|
export declare function loadCachedUserTokens(realmName?: string): Promise<SeqapiTokens | null>;
|
|
154
157
|
export declare function saveTokens(tokens: SeqapiTokens, realmName?: string): Promise<void>;
|
|
155
158
|
export declare function deleteRealmTokens(realmName: string): Promise<void>;
|
|
159
|
+
export {};
|
package/dist/auth.js
CHANGED
|
@@ -40,6 +40,12 @@ const AUTH0_M2M_CLIENT_ID = '5TLffZqvLq4ztLDjZhwKVJCpB5VNrWj0';
|
|
|
40
40
|
* also the implicit realm of the legacy flat fields in tokens.json. */
|
|
41
41
|
export const SEQUENCE_REALM = 'sequence';
|
|
42
42
|
const SEQUENCE_BUILTIN_ENVS = new Set(['local', 'staging', 'production', 'banksouth']);
|
|
43
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
|
|
44
|
+
const SEQUENCE_STAGING_AUTH0_AUDIENCE = 'https://staging.sequence.seqholdings.com/api';
|
|
45
|
+
const TRUSTED_LOOPBACK_AUTH0_AUDIENCES = new Set([
|
|
46
|
+
AUTH0_AUDIENCE,
|
|
47
|
+
SEQUENCE_STAGING_AUTH0_AUDIENCE,
|
|
48
|
+
]);
|
|
43
49
|
export function isSequenceAuthEnvName(envName) {
|
|
44
50
|
return (envName === SEQUENCE_REALM ||
|
|
45
51
|
SEQUENCE_BUILTIN_ENVS.has(envName) ||
|
|
@@ -76,14 +82,116 @@ export const SEQUENCE_AUTH_REALM = {
|
|
|
76
82
|
function seqapiConfigPath() {
|
|
77
83
|
return join(seqapiTokenDir(), 'config.json');
|
|
78
84
|
}
|
|
85
|
+
function isRecord(value) {
|
|
86
|
+
return typeof value === 'object' && value !== null;
|
|
87
|
+
}
|
|
88
|
+
function isLoopbackEnvName(envName) {
|
|
89
|
+
return envName === 'local' || envName.startsWith('local:');
|
|
90
|
+
}
|
|
91
|
+
function loopbackTargetUrl({ configuredTargetUrl, envName, }) {
|
|
92
|
+
if (configuredTargetUrl)
|
|
93
|
+
return configuredTargetUrl;
|
|
94
|
+
return envName === 'local' ? 'http://localhost:5001' : undefined;
|
|
95
|
+
}
|
|
96
|
+
function optionalString({ field, value, }) {
|
|
97
|
+
if (value === undefined || value === null)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (typeof value === 'string')
|
|
100
|
+
return value;
|
|
101
|
+
throw new Error(`Local Auth0 discovery returned invalid '${field}'.`);
|
|
102
|
+
}
|
|
103
|
+
function parseDiscoveredAuth0(payload) {
|
|
104
|
+
const auth0 = isRecord(payload) ? Reflect.get(payload, 'auth0') : undefined;
|
|
105
|
+
const auth0Record = isRecord(auth0) ? auth0 : {};
|
|
106
|
+
const domain = Reflect.get(auth0Record, 'domain');
|
|
107
|
+
const clientId = Reflect.get(auth0Record, 'clientId');
|
|
108
|
+
const audience = Reflect.get(auth0Record, 'audience');
|
|
109
|
+
if (typeof domain !== 'string' ||
|
|
110
|
+
!domain ||
|
|
111
|
+
typeof clientId !== 'string' ||
|
|
112
|
+
!clientId ||
|
|
113
|
+
typeof audience !== 'string' ||
|
|
114
|
+
!audience) {
|
|
115
|
+
throw new Error('Local Auth0 discovery returned incomplete required config.');
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
domain,
|
|
119
|
+
clientId,
|
|
120
|
+
audience,
|
|
121
|
+
organization: optionalString({
|
|
122
|
+
field: 'organization',
|
|
123
|
+
value: Reflect.get(auth0Record, 'organization'),
|
|
124
|
+
}),
|
|
125
|
+
m2mClientId: optionalString({
|
|
126
|
+
field: 'm2mClientId',
|
|
127
|
+
value: Reflect.get(auth0Record, 'm2mClientId'),
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function discoverLoopbackRealm({ envName, fetchImpl = fetch, targetUrl, }) {
|
|
132
|
+
const baseUrl = validateDeploymentBaseUrl(targetUrl);
|
|
133
|
+
const hostname = new URL(baseUrl).hostname.toLowerCase();
|
|
134
|
+
if (!LOOPBACK_HOSTNAMES.has(hostname))
|
|
135
|
+
return SEQUENCE_AUTH_REALM;
|
|
136
|
+
let response;
|
|
137
|
+
try {
|
|
138
|
+
response = await fetchImpl(`${baseUrl}/api/auth/cli-config`, {
|
|
139
|
+
redirect: 'error',
|
|
140
|
+
signal: AbortSignal.timeout(2_000),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Preserve the historical realm choice while local Atlas is stopped. The
|
|
145
|
+
// subsequent API request reports that the local server is unavailable.
|
|
146
|
+
return SEQUENCE_AUTH_REALM;
|
|
147
|
+
}
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
throw new Error(`Could not discover Auth0 config for local environment '${envName}': ` +
|
|
150
|
+
`${baseUrl}/api/auth/cli-config returned HTTP ${response.status}.`);
|
|
151
|
+
}
|
|
152
|
+
const payload = await response.json();
|
|
153
|
+
const { audience, clientId, domain, m2mClientId, organization } = parseDiscoveredAuth0(payload);
|
|
154
|
+
if (!TRUSTED_LOOPBACK_AUTH0_AUDIENCES.has(audience)) {
|
|
155
|
+
throw new Error(`Untrusted Auth0 audience '${audience}' for local environment '${envName}'.`);
|
|
156
|
+
}
|
|
157
|
+
const trustedDomain = validateAuth0Domain(domain);
|
|
158
|
+
if (clientId === AUTH0_CLIENT_ID && audience === AUTH0_AUDIENCE) {
|
|
159
|
+
return SEQUENCE_AUTH_REALM;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
name: 'local',
|
|
163
|
+
domain: trustedDomain,
|
|
164
|
+
clientId,
|
|
165
|
+
audience,
|
|
166
|
+
organization,
|
|
167
|
+
m2mClientId,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
async function maybeDiscoverLoopbackRealm({ envName, options, }) {
|
|
171
|
+
if (!envName || !isLoopbackEnvName(envName))
|
|
172
|
+
return undefined;
|
|
173
|
+
const targetUrl = loopbackTargetUrl({
|
|
174
|
+
configuredTargetUrl: options.targetUrl,
|
|
175
|
+
envName,
|
|
176
|
+
});
|
|
177
|
+
if (!targetUrl)
|
|
178
|
+
return undefined;
|
|
179
|
+
return discoverLoopbackRealm({
|
|
180
|
+
envName,
|
|
181
|
+
fetchImpl: options.fetchImpl,
|
|
182
|
+
targetUrl,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
79
185
|
/**
|
|
80
186
|
* Resolve the auth realm for an environment name. Undefined, built-ins, and
|
|
81
|
-
* per-PR preview targets map to the shared Sequence realm
|
|
82
|
-
*
|
|
83
|
-
* other explicit name must have a valid seqapi registry entry
|
|
84
|
-
* closed so a shared Sequence bearer token can never be sent to a tenant URL.
|
|
187
|
+
* per-PR preview targets map to the shared Sequence realm, except loopback
|
|
188
|
+
* targets can advertise the isolated Sequence-staging realm used by contractor
|
|
189
|
+
* local dev. Every other explicit name must have a valid seqapi registry entry.
|
|
85
190
|
*/
|
|
86
|
-
export async function realmForEnv(envName) {
|
|
191
|
+
export async function realmForEnv(envName, options = {}) {
|
|
192
|
+
const discoveredRealm = await maybeDiscoverLoopbackRealm({ envName, options });
|
|
193
|
+
if (discoveredRealm)
|
|
194
|
+
return discoveredRealm;
|
|
87
195
|
if (!envName || isSequenceAuthEnvName(envName)) {
|
|
88
196
|
return SEQUENCE_AUTH_REALM;
|
|
89
197
|
}
|
|
@@ -303,7 +411,12 @@ function validateRealmTarget({ realm, targetUrl, }) {
|
|
|
303
411
|
try {
|
|
304
412
|
const baseUrl = validateDeploymentBaseUrl(targetUrl);
|
|
305
413
|
if (realm.name !== SEQUENCE_REALM) {
|
|
306
|
-
|
|
414
|
+
const hostname = new URL(baseUrl).hostname.toLowerCase();
|
|
415
|
+
const isReviewedLoopbackRealm = LOOPBACK_HOSTNAMES.has(hostname) &&
|
|
416
|
+
TRUSTED_LOOPBACK_AUTH0_AUDIENCES.has(realm.audience);
|
|
417
|
+
if (!isReviewedLoopbackRealm) {
|
|
418
|
+
validateDeploymentAudience({ audience: realm.audience, baseUrl });
|
|
419
|
+
}
|
|
307
420
|
}
|
|
308
421
|
}
|
|
309
422
|
catch (error) {
|
|
@@ -312,7 +425,7 @@ function validateRealmTarget({ realm, targetUrl, }) {
|
|
|
312
425
|
}
|
|
313
426
|
}
|
|
314
427
|
export async function getAccessTokenWithMode(options) {
|
|
315
|
-
const realm = await realmForEnv(options?.env);
|
|
428
|
+
const realm = await realmForEnv(options?.env, { targetUrl: options?.targetUrl });
|
|
316
429
|
if (options?.targetUrl)
|
|
317
430
|
validateRealmTarget({ realm, targetUrl: options.targetUrl });
|
|
318
431
|
const forceM2m = authModePrefersM2m();
|
|
@@ -377,7 +490,7 @@ export async function tryGetAccessTokenWithMode(options) {
|
|
|
377
490
|
// Do not replace an invalid tenant registration with the Sequence realm:
|
|
378
491
|
// that could hide a rejected discovery token host or inspect the wrong
|
|
379
492
|
// M2M secret. Config-resolution errors must surface unchanged.
|
|
380
|
-
const realm = await realmForEnv(options.env);
|
|
493
|
+
const realm = await realmForEnv(options.env, { targetUrl: options.targetUrl });
|
|
381
494
|
if (process.env[m2mSecretEnvName(realm)]?.trim())
|
|
382
495
|
throw err;
|
|
383
496
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -24,14 +24,18 @@ export declare function defaultConfig(): LatticeConfig;
|
|
|
24
24
|
* Read the effective config. Merge precedence (later wins):
|
|
25
25
|
*
|
|
26
26
|
* 1. built-in `local`
|
|
27
|
-
* 2.
|
|
28
|
-
* 3.
|
|
29
|
-
* 4.
|
|
27
|
+
* 2. local worktrees discovered from `.wt.env`
|
|
28
|
+
* 3. the cached discovered catalog (`~/.config/lattice/environments.json`)
|
|
29
|
+
* 4. user entries in config.toml (an override for `local`, or net-new envs)
|
|
30
|
+
* 5. OpCo registrations shared with seqapi (`~/.config/sequence-api/config.json`)
|
|
30
31
|
*
|
|
31
32
|
* Registered OpCo routes are authoritative for their names so a lower-trust
|
|
32
33
|
* config.toml override cannot send a tenant token to another origin.
|
|
33
34
|
*/
|
|
34
35
|
export declare function readConfig(): Promise<LatticeConfig>;
|
|
36
|
+
export declare function localAuthRealmOptions(envName?: string): Promise<{
|
|
37
|
+
targetUrl?: string;
|
|
38
|
+
}>;
|
|
35
39
|
/** Write the config to disk, creating the dir if missing. */
|
|
36
40
|
export declare function writeConfig(config: LatticeConfig): Promise<void>;
|
|
37
41
|
export interface ResolvedEnv {
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
5
7
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
6
8
|
import { M2mTokenError } from './auth.js';
|
|
7
9
|
import { fetchCatalog, readCachedCatalog, } from './env-catalog.js';
|
|
@@ -26,7 +28,69 @@ export const PREVIEW_ENV_PREFIX = 'preview:';
|
|
|
26
28
|
const BUILT_IN_ENV_URLS = {
|
|
27
29
|
local: 'http://localhost:5001',
|
|
28
30
|
};
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
const WORKTREE_ENV_PREFIX = 'local:';
|
|
33
|
+
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
29
34
|
export const ENV_NAMES = Object.keys(BUILT_IN_ENV_URLS);
|
|
35
|
+
async function atlasPortFromWorktreeEnv(directory) {
|
|
36
|
+
let text;
|
|
37
|
+
try {
|
|
38
|
+
text = await readFile(join(directory, '.wt.env'), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const value = text
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map((line) => line.split('=', 2))
|
|
46
|
+
.find(([key]) => key?.trim() === 'ATLAS_PORT')?.[1]
|
|
47
|
+
?.trim();
|
|
48
|
+
if (!value || !/^\d+$/.test(value))
|
|
49
|
+
return undefined;
|
|
50
|
+
const port = Number(value);
|
|
51
|
+
return port >= 1 && port <= 65_535 ? port : undefined;
|
|
52
|
+
}
|
|
53
|
+
async function worktreeDirectories() {
|
|
54
|
+
const root = process.env.WT_ROOT ?? join(homedir(), 'studio-worktrees');
|
|
55
|
+
const directories = [];
|
|
56
|
+
try {
|
|
57
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
58
|
+
directories.push(...entries
|
|
59
|
+
.filter((entry) => entry.isDirectory())
|
|
60
|
+
.map((entry) => join(root, entry.name)));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// The default worktree root is optional.
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain']);
|
|
67
|
+
const gitWorktrees = stdout
|
|
68
|
+
.split('\n')
|
|
69
|
+
.filter((line) => line.startsWith('worktree '))
|
|
70
|
+
.map((line) => line.slice('worktree '.length))
|
|
71
|
+
.slice(1);
|
|
72
|
+
directories.push(...gitWorktrees);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Global CLI use outside a git checkout still supports WT_ROOT discovery.
|
|
76
|
+
}
|
|
77
|
+
return [...new Set(directories)];
|
|
78
|
+
}
|
|
79
|
+
async function worktreeEnvironmentUrls() {
|
|
80
|
+
const environments = {};
|
|
81
|
+
for (const directory of await worktreeDirectories()) {
|
|
82
|
+
const name = basename(directory);
|
|
83
|
+
if (!WORKTREE_NAME_PATTERN.test(name))
|
|
84
|
+
continue;
|
|
85
|
+
const port = await atlasPortFromWorktreeEnv(directory);
|
|
86
|
+
if (port === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
environments[`${WORKTREE_ENV_PREFIX}${name}`] ??= {
|
|
89
|
+
url: `http://localhost:${port}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return environments;
|
|
93
|
+
}
|
|
30
94
|
export function globalConfigDir() {
|
|
31
95
|
return join(homedir(), '.config', 'lattice');
|
|
32
96
|
}
|
|
@@ -44,15 +108,17 @@ export function defaultConfig() {
|
|
|
44
108
|
* Read the effective config. Merge precedence (later wins):
|
|
45
109
|
*
|
|
46
110
|
* 1. built-in `local`
|
|
47
|
-
* 2.
|
|
48
|
-
* 3.
|
|
49
|
-
* 4.
|
|
111
|
+
* 2. local worktrees discovered from `.wt.env`
|
|
112
|
+
* 3. the cached discovered catalog (`~/.config/lattice/environments.json`)
|
|
113
|
+
* 4. user entries in config.toml (an override for `local`, or net-new envs)
|
|
114
|
+
* 5. OpCo registrations shared with seqapi (`~/.config/sequence-api/config.json`)
|
|
50
115
|
*
|
|
51
116
|
* Registered OpCo routes are authoritative for their names so a lower-trust
|
|
52
117
|
* config.toml override cannot send a tenant token to another origin.
|
|
53
118
|
*/
|
|
54
119
|
export async function readConfig() {
|
|
55
120
|
const merged = defaultConfig();
|
|
121
|
+
Object.assign(merged.envs, await worktreeEnvironmentUrls());
|
|
56
122
|
const catalog = await readCachedCatalog();
|
|
57
123
|
if (catalog) {
|
|
58
124
|
merged.tier = catalog.tier;
|
|
@@ -80,6 +146,12 @@ export async function readConfig() {
|
|
|
80
146
|
}
|
|
81
147
|
return merged;
|
|
82
148
|
}
|
|
149
|
+
export async function localAuthRealmOptions(envName) {
|
|
150
|
+
if (!envName || (envName !== 'local' && !envName.startsWith('local:'))) {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
return { targetUrl: (await readConfig()).envs[envName]?.url };
|
|
154
|
+
}
|
|
83
155
|
/** Write the config to disk, creating the dir if missing. */
|
|
84
156
|
export async function writeConfig(config) {
|
|
85
157
|
const path = configPath();
|
package/dist/envs/commands.js
CHANGED
|
@@ -14,7 +14,8 @@ const ENVS_USAGE = `usage:
|
|
|
14
14
|
catalog is cached at ~/.config/lattice/environments.json and also refreshes
|
|
15
15
|
lazily the first time you pass an -e <env> that isn't cached yet.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Local worktrees are discovered from their .wt.env files. Custom entries in
|
|
18
|
+
${configPath()} are always honored on top.
|
|
18
19
|
`;
|
|
19
20
|
export async function runEnvsCommand(sub, rest) {
|
|
20
21
|
switch (sub) {
|
|
@@ -97,11 +98,13 @@ async function listCommand() {
|
|
|
97
98
|
for (const [name, { url }] of Object.entries(config.envs)) {
|
|
98
99
|
const source = name === 'local' && !discovered.has(name)
|
|
99
100
|
? 'built-in'
|
|
100
|
-
:
|
|
101
|
-
? '
|
|
102
|
-
:
|
|
103
|
-
? '
|
|
104
|
-
:
|
|
101
|
+
: name.startsWith('local:')
|
|
102
|
+
? 'worktree'
|
|
103
|
+
: discovered.has(name)
|
|
104
|
+
? 'discovered'
|
|
105
|
+
: registeredNames.has(name)
|
|
106
|
+
? 'registered'
|
|
107
|
+
: 'config.toml';
|
|
105
108
|
console.log(` ${name.padEnd(width)} ${url} (${source})`);
|
|
106
109
|
}
|
|
107
110
|
if (config.tier === 'anonymous') {
|
package/dist/functions/bundle.js
CHANGED
|
@@ -59,13 +59,13 @@ export async function validateLocalBundle({ rootDir, files, }) {
|
|
|
59
59
|
if (existsSync(join(rootDir, 'package-lock.json')) || existsSync(join(rootDir, 'yarn.lock'))) {
|
|
60
60
|
issues.push({
|
|
61
61
|
level: 'warning',
|
|
62
|
-
message: 'Found a non-pnpm lockfile (package-lock.json / yarn.lock). It is ignored
|
|
62
|
+
message: 'Found a non-pnpm lockfile (package-lock.json / yarn.lock). It is ignored; dependencies are resolved server-side at deploy time.',
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
else {
|
|
66
66
|
issues.push({
|
|
67
67
|
level: 'info',
|
|
68
|
-
message: 'No pnpm-lock.yaml —
|
|
68
|
+
message: 'No pnpm-lock.yaml — dependencies are resolved server-side at deploy time.',
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -76,7 +76,7 @@ export async function validateLocalBundle({ rootDir, files, }) {
|
|
|
76
76
|
if (classifyLockfileOrigin(lockfileText) !== 'chainguard') {
|
|
77
77
|
issues.push({
|
|
78
78
|
level: 'warning',
|
|
79
|
-
message: 'pnpm-lock.yaml
|
|
79
|
+
message: 'pnpm-lock.yaml cannot be used for this deployment, so dependencies are resolved server-side at deploy time. Resolved versions may differ.',
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
}
|