@sequenceholdings/studio-cli 0.1.18 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -11
- package/dist/agents/apply-chunks.d.ts +13 -0
- package/dist/agents/apply-chunks.js +43 -0
- package/dist/agents/commands.d.ts +10 -0
- package/dist/agents/commands.js +218 -0
- package/dist/agents/scaffold.d.ts +2 -0
- package/dist/agents/scaffold.js +77 -0
- package/dist/agents/source.d.ts +18 -0
- package/dist/agents/source.js +121 -0
- package/dist/auth.d.ts +19 -17
- package/dist/auth.js +100 -33
- package/dist/functions/commands.d.ts +2 -10
- package/dist/functions/commands.js +17 -25
- package/dist/functions/source-selection.d.ts +24 -0
- package/dist/functions/source-selection.js +67 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +6 -0
- package/dist/orm/delegate.js +11 -6
- package/dist/process/build.js +2 -1
- package/dist/process/lint.js +8 -0
- package/package.json +8 -6
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { hashAgentBundle, } from '@sequenceholdings/agent-spec';
|
|
5
|
+
import { agentDeployManifestSchema, assertKnownTargetEnvironment, compileAgentDirectory, selectAgentEntries, selectionStats, } from '@sequenceholdings/agent-spec/compiler';
|
|
6
|
+
import { parseSourceSpec, resolveArtifactSource, } from '@sequenceholdings/artifact-studio/source-resolver';
|
|
7
|
+
import { buildContext, clientOptions, requestedEnvironment, } from '../functions/commands.js';
|
|
8
|
+
const MANIFEST = 'deploy-manifest.json';
|
|
9
|
+
function sourceSpec(args) {
|
|
10
|
+
const dir = typeof args.flags.dir === 'string' ? args.flags.dir : undefined;
|
|
11
|
+
if (dir &&
|
|
12
|
+
(args.flags.repo !== undefined || args.flags['git-url'] !== undefined)) {
|
|
13
|
+
throw new Error('--dir cannot be combined with --repo / --git-url.');
|
|
14
|
+
}
|
|
15
|
+
return parseSourceSpec({
|
|
16
|
+
positional: dir ? [dir] : [],
|
|
17
|
+
flags: args.flags,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function sourceOptions(context) {
|
|
21
|
+
return {
|
|
22
|
+
...(context.authMode ? { authMode: context.authMode } : {}),
|
|
23
|
+
...clientOptions(context),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export async function materializeAgentSource({ args, requireEnvironment, }) {
|
|
27
|
+
const spec = sourceSpec(args);
|
|
28
|
+
const needsAuth = spec.kind !== 'local';
|
|
29
|
+
if (requireEnvironment && !requestedEnvironment(args)) {
|
|
30
|
+
throw new Error('Network commands require an explicit -e/--env.');
|
|
31
|
+
}
|
|
32
|
+
const context = needsAuth || requireEnvironment ? await buildContext(args) : null;
|
|
33
|
+
const source = await resolveArtifactSource(spec, context ? sourceOptions(context) : {});
|
|
34
|
+
return { spec, source, context };
|
|
35
|
+
}
|
|
36
|
+
async function readManifest(directory) {
|
|
37
|
+
const path = join(directory, MANIFEST);
|
|
38
|
+
if (!existsSync(path))
|
|
39
|
+
return null;
|
|
40
|
+
return agentDeployManifestSchema.parse(JSON.parse(await readFile(path, 'utf8')));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Refuse to deploy when a compiled export's id does not match the id the
|
|
44
|
+
* manifest keyed the environment override on. Without that check a mis-labeled
|
|
45
|
+
* agent.ts would silently deploy under a different identity and defeat tenant
|
|
46
|
+
* overrides.
|
|
47
|
+
*
|
|
48
|
+
* Every selected path is parsed in one pass. Compiling them one file at a
|
|
49
|
+
* time previously re-parsed each agent separately and dominated validate
|
|
50
|
+
* latency on a large fleet — paid again on plan, and twice more on apply,
|
|
51
|
+
* since apply plans before it writes.
|
|
52
|
+
*/
|
|
53
|
+
async function compileManifestEntries({ directory, entries, allowDuplicateIds = false, }) {
|
|
54
|
+
if (entries.length === 0) {
|
|
55
|
+
return { definitions: [], files: [], hash: hashAgentBundle({ definitions: [] }), sources: [] };
|
|
56
|
+
}
|
|
57
|
+
const compiled = await compileAgentDirectory({
|
|
58
|
+
rootDir: directory,
|
|
59
|
+
filePaths: entries.map((entry) => entry.path),
|
|
60
|
+
allowDuplicateIds,
|
|
61
|
+
});
|
|
62
|
+
const byFile = new Map();
|
|
63
|
+
for (const source of compiled.sources) {
|
|
64
|
+
const list = byFile.get(source.file) ?? [];
|
|
65
|
+
list.push(source.definition);
|
|
66
|
+
byFile.set(source.file, list);
|
|
67
|
+
}
|
|
68
|
+
const definitions = [];
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const exported = byFile.get(entry.path) ?? [];
|
|
71
|
+
if (exported.length !== 1) {
|
|
72
|
+
throw new Error(`Expected exactly one agent export in ${entry.path}, found ${exported.length}`);
|
|
73
|
+
}
|
|
74
|
+
const definition = exported[0];
|
|
75
|
+
if (!definition) {
|
|
76
|
+
throw new Error(`Expected exactly one agent export in ${entry.path}, found 0`);
|
|
77
|
+
}
|
|
78
|
+
if (definition.id !== entry.id) {
|
|
79
|
+
throw new Error(`Manifest id "${entry.id}" for ${entry.path} does not match exported id "${definition.id}"`);
|
|
80
|
+
}
|
|
81
|
+
definitions.push(definition);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
definitions,
|
|
85
|
+
files: compiled.files,
|
|
86
|
+
hash: hashAgentBundle({ definitions }),
|
|
87
|
+
sources: compiled.sources,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export async function compileAgentSource({ directory, targetEnvironment, deployEnvironments = [], }) {
|
|
91
|
+
const manifest = await readManifest(directory);
|
|
92
|
+
if (!manifest)
|
|
93
|
+
return compileAgentDirectory({ rootDir: directory });
|
|
94
|
+
if (targetEnvironment !== undefined) {
|
|
95
|
+
assertKnownTargetEnvironment({
|
|
96
|
+
manifest,
|
|
97
|
+
environment: targetEnvironment,
|
|
98
|
+
deployEnvironments,
|
|
99
|
+
});
|
|
100
|
+
// Selection is silent, so a target that matches nothing looks identical to
|
|
101
|
+
// a full deploy. Name the drop before the operator confirms an apply.
|
|
102
|
+
const { selected, skipped, total } = selectionStats({
|
|
103
|
+
manifest,
|
|
104
|
+
environment: targetEnvironment,
|
|
105
|
+
});
|
|
106
|
+
if (skipped > 0) {
|
|
107
|
+
console.log(`[seq-studio] target ${targetEnvironment}: ${selected} of ${total} agents selected, ${skipped} scoped to other environments`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// With a target: last-wins by id (tenant overrides). Without: every distinct
|
|
111
|
+
// path, so validate still compiles override sources that lose a collapse.
|
|
112
|
+
const entries = selectAgentEntries({
|
|
113
|
+
manifest,
|
|
114
|
+
environment: targetEnvironment,
|
|
115
|
+
});
|
|
116
|
+
return compileManifestEntries({
|
|
117
|
+
directory,
|
|
118
|
+
entries,
|
|
119
|
+
allowDuplicateIds: targetEnvironment === undefined,
|
|
120
|
+
});
|
|
121
|
+
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -5,18 +5,18 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Two token sources, in the SAME precedence order as seqapi's
|
|
7
7
|
* `get_access_token` (`shared/seqapi/seqapi/auth.py`):
|
|
8
|
-
* 1.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* 1. Cached user access token — read from the seqapi token file. When it
|
|
9
|
+
* expires, an interactive session performs a bounded PKCE login again.
|
|
10
|
+
* 2. M2M service account — Auth0 client-credentials grant when the realm's
|
|
11
|
+
* `AUTH0_M2M_CLIENT_SECRET` (or suffixed OpCo variant) is set. Used when
|
|
12
|
+
* no valid user session exists, or when `SEQAPI_AUTH_MODE=m2m` forces it.
|
|
11
13
|
* (M2M carries app scopes but NO user identity / workspace membership
|
|
12
14
|
* — see the `atlas-test-access` rule.)
|
|
13
|
-
* 2. Cached user access token — read from the seqapi token file. When it
|
|
14
|
-
* expires, an interactive session performs a bounded PKCE login again.
|
|
15
15
|
*
|
|
16
|
-
* Login writes the shared file. This mirrors
|
|
17
|
-
* `seqapi._save_tokens` exactly:
|
|
16
|
+
* Login writes the shared file. This mirrors `seqapi._save_tokens` exactly:
|
|
18
17
|
* same fields, same shape, same 0o600 permissions, atomic write via
|
|
19
|
-
* tmpfile + rename.
|
|
18
|
+
* tmpfile + rename. M2M tokens are cached in-process and also under the
|
|
19
|
+
* token file's `m2m` key so short-lived CLI processes reuse a grant.
|
|
20
20
|
*/
|
|
21
21
|
export declare const AUTH0_DOMAIN = "dev-n1t8ts403fp8oyxp.us.auth0.com";
|
|
22
22
|
export declare const AUTH0_CLIENT_ID = "GD9riCDWocfc66odpWBjwBiX43qqAX8r";
|
|
@@ -80,13 +80,15 @@ export declare class NotLoggedInError extends Error {
|
|
|
80
80
|
constructor(realmName?: string, reason?: string);
|
|
81
81
|
}
|
|
82
82
|
/**
|
|
83
|
-
* Return a valid access token for an environment's auth realm.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
83
|
+
* Return a valid access token for an environment's auth realm.
|
|
84
|
+
*
|
|
85
|
+
* Precedence (default `SEQAPI_AUTH_MODE=auto`), matching seqapi:
|
|
86
|
+
* 1. Valid cached user access token
|
|
87
|
+
* 2. M2M client-credentials when the realm's secret env var is set
|
|
88
|
+
* 3. Bounded PKCE login when browser auto-login is enabled
|
|
89
|
+
*
|
|
90
|
+
* Set `SEQAPI_AUTH_MODE=m2m` to skip the user token. No `env` (or a built-in
|
|
91
|
+
* Sequence env) means the shared Sequence realm.
|
|
90
92
|
*/
|
|
91
93
|
export declare class UnsafeAuthTargetError extends Error {
|
|
92
94
|
}
|
|
@@ -142,8 +144,8 @@ export declare function verifyTokenMatchesRealm({ accessToken, realm, requireOrg
|
|
|
142
144
|
export declare function decodeJwtSub(token: string): string | null;
|
|
143
145
|
/**
|
|
144
146
|
* The Auth0 subject the CLI would authenticate as right now, without any
|
|
145
|
-
* network call
|
|
146
|
-
*
|
|
147
|
+
* network call. Matches token resolution precedence: a valid user session
|
|
148
|
+
* wins over an ambient M2M secret unless `SEQAPI_AUTH_MODE=m2m`.
|
|
147
149
|
*/
|
|
148
150
|
export declare function currentIdentitySubject(options?: {
|
|
149
151
|
env?: string;
|
package/dist/auth.js
CHANGED
|
@@ -15,18 +15,18 @@ function hasErrorCode(error, code) {
|
|
|
15
15
|
*
|
|
16
16
|
* Two token sources, in the SAME precedence order as seqapi's
|
|
17
17
|
* `get_access_token` (`shared/seqapi/seqapi/auth.py`):
|
|
18
|
-
* 1.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* 1. Cached user access token — read from the seqapi token file. When it
|
|
19
|
+
* expires, an interactive session performs a bounded PKCE login again.
|
|
20
|
+
* 2. M2M service account — Auth0 client-credentials grant when the realm's
|
|
21
|
+
* `AUTH0_M2M_CLIENT_SECRET` (or suffixed OpCo variant) is set. Used when
|
|
22
|
+
* no valid user session exists, or when `SEQAPI_AUTH_MODE=m2m` forces it.
|
|
21
23
|
* (M2M carries app scopes but NO user identity / workspace membership
|
|
22
24
|
* — see the `atlas-test-access` rule.)
|
|
23
|
-
* 2. Cached user access token — read from the seqapi token file. When it
|
|
24
|
-
* expires, an interactive session performs a bounded PKCE login again.
|
|
25
25
|
*
|
|
26
|
-
* Login writes the shared file. This mirrors
|
|
27
|
-
* `seqapi._save_tokens` exactly:
|
|
26
|
+
* Login writes the shared file. This mirrors `seqapi._save_tokens` exactly:
|
|
28
27
|
* same fields, same shape, same 0o600 permissions, atomic write via
|
|
29
|
-
* tmpfile + rename.
|
|
28
|
+
* tmpfile + rename. M2M tokens are cached in-process and also under the
|
|
29
|
+
* token file's `m2m` key so short-lived CLI processes reuse a grant.
|
|
30
30
|
*/
|
|
31
31
|
// Match `shared/seqapi/seqapi/config.py`. Hard-coded because the seqapi
|
|
32
32
|
// CLI also hard-codes them — there's a single Sequence Auth0 tenant for
|
|
@@ -131,7 +131,8 @@ export function m2mSecretEnvName(realm) {
|
|
|
131
131
|
}
|
|
132
132
|
// In-memory per-realm cache for M2M tokens (seconds-based, mirrors seqapi's
|
|
133
133
|
// `_m2m_cache`). Reused while > 60s from expiry to avoid re-minting on every
|
|
134
|
-
// call within a single process (e.g. a long `artifact dev` watch).
|
|
134
|
+
// call within a single process (e.g. a long `artifact dev` watch). Disk
|
|
135
|
+
// persistence under tokens.json `m2m` covers cross-process reuse.
|
|
135
136
|
const m2mCache = new Map();
|
|
136
137
|
/**
|
|
137
138
|
* A configured M2M credential failed to mint a token. Typed so callers that
|
|
@@ -141,11 +142,41 @@ const m2mCache = new Map();
|
|
|
141
142
|
export class M2mTokenError extends Error {
|
|
142
143
|
name = 'M2mTokenError';
|
|
143
144
|
}
|
|
145
|
+
function authModePrefersM2m() {
|
|
146
|
+
const value = process.env.SEQAPI_AUTH_MODE?.trim().toLowerCase() ?? 'auto';
|
|
147
|
+
return value === 'm2m';
|
|
148
|
+
}
|
|
149
|
+
function tokenStillValid(entry) {
|
|
150
|
+
const now = Date.now() / 1000;
|
|
151
|
+
return Boolean(entry?.accessToken && (entry.expiresAt ?? 0) > now + 60);
|
|
152
|
+
}
|
|
153
|
+
async function loadPersistedM2m(realmName) {
|
|
154
|
+
if (!existsSync(seqapiTokenPath()))
|
|
155
|
+
return null;
|
|
156
|
+
return withTokenFileLock(async () => {
|
|
157
|
+
const file = await readTokenFile();
|
|
158
|
+
const entry = file?.m2m?.[realmName];
|
|
159
|
+
if (!entry?.access_token)
|
|
160
|
+
return null;
|
|
161
|
+
return {
|
|
162
|
+
accessToken: entry.access_token,
|
|
163
|
+
expiresAt: entry.expires_at ?? 0,
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
async function persistM2m({ realmName, accessToken, expiresAt, }) {
|
|
168
|
+
await withTokenFileLock(async () => {
|
|
169
|
+
const existing = (await readTokenFile()) ?? {};
|
|
170
|
+
stripPersistedRefreshTokens(existing);
|
|
171
|
+
const m2m = { ...existing.m2m, [realmName]: { access_token: accessToken, expires_at: expiresAt } };
|
|
172
|
+
await writeTokenFile({ ...existing, m2m });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
144
175
|
/**
|
|
145
176
|
* Mint an M2M access token via the Auth0 client-credentials grant when the
|
|
146
|
-
* realm's secret env var is set. Returns null when the secret is unset
|
|
147
|
-
*
|
|
148
|
-
*
|
|
177
|
+
* realm's secret env var is set. Returns null when the secret is unset.
|
|
178
|
+
* Throws on a configured-but-rejected secret, mirroring seqapi's
|
|
179
|
+
* `_get_m2m_token`. Cache order: in-process → shared token file → Auth0.
|
|
149
180
|
* The realm's own secret var is required — a Sequence secret in the shell is
|
|
150
181
|
* never sent to a tenant's Auth0 client.
|
|
151
182
|
*/
|
|
@@ -155,9 +186,8 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
|
|
|
155
186
|
const clientSecret = process.env[m2mSecretEnvName(realm)];
|
|
156
187
|
if (!clientSecret)
|
|
157
188
|
return null;
|
|
158
|
-
const now = Date.now() / 1000;
|
|
159
189
|
const cached = m2mCache.get(realm.name);
|
|
160
|
-
if (cached && cached
|
|
190
|
+
if (cached && tokenStillValid(cached)) {
|
|
161
191
|
verifyTokenMatchesRealm({
|
|
162
192
|
accessToken: cached.accessToken,
|
|
163
193
|
realm,
|
|
@@ -165,6 +195,16 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
|
|
|
165
195
|
});
|
|
166
196
|
return cached.accessToken;
|
|
167
197
|
}
|
|
198
|
+
const persisted = await loadPersistedM2m(realm.name);
|
|
199
|
+
if (persisted && tokenStillValid(persisted)) {
|
|
200
|
+
verifyTokenMatchesRealm({
|
|
201
|
+
accessToken: persisted.accessToken,
|
|
202
|
+
realm,
|
|
203
|
+
requireOrganization: false,
|
|
204
|
+
});
|
|
205
|
+
m2mCache.set(realm.name, persisted);
|
|
206
|
+
return persisted.accessToken;
|
|
207
|
+
}
|
|
168
208
|
const response = await fetch(`https://${realm.domain}/oauth/token`, {
|
|
169
209
|
method: 'POST',
|
|
170
210
|
redirect: 'manual',
|
|
@@ -193,6 +233,11 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
|
|
|
193
233
|
expiresAt: Date.now() / 1000 + (data.expires_in ?? 7200),
|
|
194
234
|
};
|
|
195
235
|
m2mCache.set(realm.name, entry);
|
|
236
|
+
await persistM2m({
|
|
237
|
+
realmName: realm.name,
|
|
238
|
+
accessToken: entry.accessToken,
|
|
239
|
+
expiresAt: entry.expiresAt,
|
|
240
|
+
});
|
|
196
241
|
return entry.accessToken;
|
|
197
242
|
}
|
|
198
243
|
export function seqapiTokenDir() {
|
|
@@ -216,13 +261,15 @@ export class NotLoggedInError extends Error {
|
|
|
216
261
|
}
|
|
217
262
|
}
|
|
218
263
|
/**
|
|
219
|
-
* Return a valid access token for an environment's auth realm.
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
264
|
+
* Return a valid access token for an environment's auth realm.
|
|
265
|
+
*
|
|
266
|
+
* Precedence (default `SEQAPI_AUTH_MODE=auto`), matching seqapi:
|
|
267
|
+
* 1. Valid cached user access token
|
|
268
|
+
* 2. M2M client-credentials when the realm's secret env var is set
|
|
269
|
+
* 3. Bounded PKCE login when browser auto-login is enabled
|
|
270
|
+
*
|
|
271
|
+
* Set `SEQAPI_AUTH_MODE=m2m` to skip the user token. No `env` (or a built-in
|
|
272
|
+
* Sequence env) means the shared Sequence realm.
|
|
226
273
|
*/
|
|
227
274
|
export class UnsafeAuthTargetError extends Error {
|
|
228
275
|
}
|
|
@@ -266,9 +313,21 @@ export async function getAccessTokenWithMode(options) {
|
|
|
266
313
|
const realm = await realmForEnv(options?.env);
|
|
267
314
|
if (options?.targetUrl)
|
|
268
315
|
validateRealmTarget({ realm, targetUrl: options.targetUrl });
|
|
316
|
+
const forceM2m = authModePrefersM2m();
|
|
317
|
+
if (!forceM2m) {
|
|
318
|
+
const tokens = await loadCachedUserTokens(realm.name);
|
|
319
|
+
const now = Date.now() / 1000;
|
|
320
|
+
if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
|
|
321
|
+
verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
|
|
322
|
+
return { authMode: 'user', token: tokens.access_token };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
269
325
|
const m2m = await getM2mToken(realm);
|
|
270
326
|
if (m2m)
|
|
271
327
|
return { authMode: 'm2m', token: m2m };
|
|
328
|
+
if (forceM2m) {
|
|
329
|
+
throw new NotLoggedInError(realm.name, `SEQAPI_AUTH_MODE=m2m but no M2M credentials for [${realm.name}].`);
|
|
330
|
+
}
|
|
272
331
|
const tokens = await loadCachedUserTokens(realm.name);
|
|
273
332
|
if (!tokens) {
|
|
274
333
|
if (options?.allowInteractiveLogin === false) {
|
|
@@ -282,11 +341,6 @@ export async function getAccessTokenWithMode(options) {
|
|
|
282
341
|
}),
|
|
283
342
|
};
|
|
284
343
|
}
|
|
285
|
-
const now = Date.now() / 1000;
|
|
286
|
-
if (tokens.access_token && (tokens.expires_at ?? 0) > now + 60) {
|
|
287
|
-
verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
|
|
288
|
-
return { authMode: 'user', token: tokens.access_token };
|
|
289
|
-
}
|
|
290
344
|
if (options?.allowInteractiveLogin === false) {
|
|
291
345
|
throw new NotLoggedInError(realm.name, `Access token expired [${realm.name}].`);
|
|
292
346
|
}
|
|
@@ -382,19 +436,24 @@ export function decodeJwtSub(token) {
|
|
|
382
436
|
}
|
|
383
437
|
/**
|
|
384
438
|
* The Auth0 subject the CLI would authenticate as right now, without any
|
|
385
|
-
* network call
|
|
386
|
-
*
|
|
439
|
+
* network call. Matches token resolution precedence: a valid user session
|
|
440
|
+
* wins over an ambient M2M secret unless `SEQAPI_AUTH_MODE=m2m`.
|
|
387
441
|
*/
|
|
388
442
|
export async function currentIdentitySubject(options = {}) {
|
|
389
443
|
const realm = await realmForEnv(options.env);
|
|
444
|
+
const forceM2m = authModePrefersM2m();
|
|
445
|
+
if (!forceM2m) {
|
|
446
|
+
const tokens = await loadCachedUserTokens(realm.name);
|
|
447
|
+
const now = Date.now() / 1000;
|
|
448
|
+
if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
|
|
449
|
+
verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
|
|
450
|
+
return decodeJwtSub(tokens.access_token);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
390
453
|
if (realm.m2mClientId && process.env[m2mSecretEnvName(realm)]?.trim()) {
|
|
391
454
|
return `${realm.m2mClientId}@clients`;
|
|
392
455
|
}
|
|
393
|
-
|
|
394
|
-
if (!tokens?.access_token)
|
|
395
|
-
return null;
|
|
396
|
-
verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
|
|
397
|
-
return decodeJwtSub(tokens.access_token);
|
|
456
|
+
return null;
|
|
398
457
|
}
|
|
399
458
|
async function readTokenFile() {
|
|
400
459
|
const path = seqapiTokenPath();
|
|
@@ -500,6 +559,14 @@ export async function deleteRealmTokens(realmName) {
|
|
|
500
559
|
delete updated.realms;
|
|
501
560
|
}
|
|
502
561
|
}
|
|
562
|
+
const m2m = { ...existing.m2m };
|
|
563
|
+
delete m2m[realmName];
|
|
564
|
+
if (Object.keys(m2m).length > 0) {
|
|
565
|
+
updated.m2m = m2m;
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
delete updated.m2m;
|
|
569
|
+
}
|
|
503
570
|
await writeTokenFile(updated);
|
|
504
571
|
});
|
|
505
572
|
}
|
|
@@ -2,7 +2,7 @@ import { type AuthMode } from '../auth.js';
|
|
|
2
2
|
import { type ResolvedEnv } from '../config.js';
|
|
3
3
|
import type { ParsedArgs } from '../process/commands.js';
|
|
4
4
|
import { type ManagedFunctionManifest } from './manifest.js';
|
|
5
|
-
|
|
5
|
+
export { parseFunctionsSourceSelection, parseFunctionsSourceSpec, resolveFunctionSourceDir } from './source-selection.js';
|
|
6
6
|
export declare const LOG = "[seq-studio]";
|
|
7
7
|
export interface FunctionSummary {
|
|
8
8
|
id: string;
|
|
@@ -50,14 +50,6 @@ export declare function clientOptions(ctx: CommandContext): {
|
|
|
50
50
|
};
|
|
51
51
|
export declare function readManifestOptional(dir: string): Promise<ManagedFunctionManifest | null>;
|
|
52
52
|
export declare function workDir(args: ParsedArgs): string;
|
|
53
|
-
/**
|
|
54
|
-
* Source selection for build/deploy: --dir (local, default '.'), a platform
|
|
55
|
-
* git-service repo (--repo <ns>/<name>), or any git URL (--git-url <url>);
|
|
56
|
-
* --ref picks a branch/tag/commit. Reuses the artifact-studio resolver —
|
|
57
|
-
* functions name their local dir with --dir rather than a positional, so map
|
|
58
|
-
* it onto the spec parser's positional slot.
|
|
59
|
-
*/
|
|
60
|
-
export declare function parseFunctionsSourceSpec(args: ParsedArgs): SourceSpec;
|
|
61
53
|
export declare function resolveOrRegisterFunction({ ctx, slug, title, description, }: {
|
|
62
54
|
ctx: CommandContext;
|
|
63
55
|
slug: string;
|
|
@@ -90,5 +82,5 @@ export declare function functionsRollbackCommand(args: ParsedArgs): Promise<numb
|
|
|
90
82
|
/** Minimal dotenv parser — KEY=VALUE lines, quotes stripped, comments skipped. */
|
|
91
83
|
export declare function parseDotenv(content: string): Record<string, string>;
|
|
92
84
|
export declare function functionsDeleteCommand(args: ParsedArgs): Promise<number>;
|
|
93
|
-
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 -e <env> [--yes] archive function + tear down GCP resources\n (version history is retained)\n\n Flags: -e/--env <env|preview:<slug>> (required for network commands; 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 a public HTTPS git URL (--git-url <url>). --
|
|
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 -e <env> [--yes] archive function + tear down GCP resources\n (version history is retained)\n\n Flags: -e/--env <env|preview:<slug>> (required for network commands; see: seq-studio envs list) \u00B7 --fn <slug> \u00B7 --dir <path>\n --path <repo-subdirectory> (remote build/deploy only) select one function in a multi-function repo\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 a public HTTPS git URL (--git-url <url>). --path selects\n a function directory within a remote repo; omit it for the existing root-manifest\n layout. --ref selects a branch/tag/commit (default: the repo's default branch).\n Remote sources record the pinned commit as provenance (never dirty) and NEVER\n read a repo-committed .env for secret values \u2014 provision secrets server-side\n or pass a local --from-env-file (resolved against your cwd).\n\n Interactive --repo builds clone over smart-HTTP and require a repo:read git\n PAT in ATLAS_GIT_PAT (`seq-studio auth pat create --scopes repo:read`, or\n Atlas \u2192 Settings \u2192 Tokens). Headless M2M builds use JSON materialize and\n accept only platform-managed --repo sources. Interactive builds also need\n --env + seq-studio login to resolve the repo and deploy.\n";
|
|
94
86
|
export declare function runFunctionsCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;
|
|
@@ -15,8 +15,10 @@ import { confirmYes } from '../prompt.js';
|
|
|
15
15
|
import { collectBundleFiles, isDirectory, validateLocalBundle, } from './bundle.js';
|
|
16
16
|
import { managedFunctionManifestSchema, MF_MANIFEST_FILENAME, manifestEgressHosts, manifestEgressIpRanges, } from './manifest.js';
|
|
17
17
|
import { buildEgressPreviewLines, egressPropagationNote, formatEgressSummary, printFunctionEgressHosts, } from './egress-preview.js';
|
|
18
|
-
import {
|
|
18
|
+
import { resolveArtifactSource, } from '@sequenceholdings/artifact-studio/source-resolver';
|
|
19
19
|
import { buildSecretPreviewLines, classifySecrets, } from './secret-reconcile.js';
|
|
20
|
+
import { parseFunctionsSourceSelection, resolveFunctionSourceDir, } from './source-selection.js';
|
|
21
|
+
export { parseFunctionsSourceSelection, parseFunctionsSourceSpec, resolveFunctionSourceDir } from './source-selection.js';
|
|
20
22
|
const execFileAsync = promisify(execFile);
|
|
21
23
|
export const LOG = '[seq-studio]';
|
|
22
24
|
const POLL_INTERVAL_MS = 5_000;
|
|
@@ -85,20 +87,6 @@ export function workDir(args) {
|
|
|
85
87
|
const dir = typeof args.flags.dir === 'string' ? args.flags.dir : '.';
|
|
86
88
|
return resolve(dir);
|
|
87
89
|
}
|
|
88
|
-
/**
|
|
89
|
-
* Source selection for build/deploy: --dir (local, default '.'), a platform
|
|
90
|
-
* git-service repo (--repo <ns>/<name>), or any git URL (--git-url <url>);
|
|
91
|
-
* --ref picks a branch/tag/commit. Reuses the artifact-studio resolver —
|
|
92
|
-
* functions name their local dir with --dir rather than a positional, so map
|
|
93
|
-
* it onto the spec parser's positional slot.
|
|
94
|
-
*/
|
|
95
|
-
export function parseFunctionsSourceSpec(args) {
|
|
96
|
-
const dir = typeof args.flags.dir === 'string' ? args.flags.dir : undefined;
|
|
97
|
-
if (dir !== undefined && (args.flags.repo !== undefined || args.flags['git-url'] !== undefined)) {
|
|
98
|
-
throw new Error('--dir cannot be combined with --repo / --git-url.');
|
|
99
|
-
}
|
|
100
|
-
return parseSourceSpec({ positional: dir === undefined ? [] : [dir], flags: args.flags });
|
|
101
|
-
}
|
|
102
90
|
/** Manifest read with a source-aware error for the missing case. */
|
|
103
91
|
async function readSourceManifest(dir, spec) {
|
|
104
92
|
const manifest = await readManifestOptional(dir);
|
|
@@ -106,7 +94,7 @@ async function readSourceManifest(dir, spec) {
|
|
|
106
94
|
return manifest;
|
|
107
95
|
throw new Error(spec.kind === 'local'
|
|
108
96
|
? `No ${MF_MANIFEST_FILENAME} in ${dir}. Run \`seq-studio functions init\` to scaffold one, or pass --dir.`
|
|
109
|
-
: `No ${MF_MANIFEST_FILENAME}
|
|
97
|
+
: `No ${MF_MANIFEST_FILENAME} in the selected remote source directory — keep it at the repo root or select a function directory with --path.`);
|
|
110
98
|
}
|
|
111
99
|
/**
|
|
112
100
|
* Resolve the target function: --fn <slug> wins, else the manifest in the
|
|
@@ -349,14 +337,15 @@ export async function functionsInitCommand(args) {
|
|
|
349
337
|
// build (local pre-flight)
|
|
350
338
|
// ---------------------------------------------------------------------------
|
|
351
339
|
export async function functionsBuildCommand(args) {
|
|
352
|
-
const spec =
|
|
340
|
+
const { spec, path } = parseFunctionsSourceSelection(args);
|
|
353
341
|
// A public git URL needs no bearer token, but optional auth resolution still
|
|
354
342
|
// identifies a selected M2M principal so policy can reject arbitrary CI
|
|
355
343
|
// input before cloning. Local builds remain completely offline.
|
|
356
344
|
const remote = await buildSourceOptions({ args, spec });
|
|
357
345
|
const source = await resolveArtifactSource(spec, remote);
|
|
358
346
|
try {
|
|
359
|
-
|
|
347
|
+
const dir = await resolveFunctionSourceDir({ repoDir: source.dir, path });
|
|
348
|
+
return await buildFromResolvedSource({ spec, source: { ...source, dir } });
|
|
360
349
|
}
|
|
361
350
|
finally {
|
|
362
351
|
await source.cleanup();
|
|
@@ -417,7 +406,7 @@ export async function functionsDeployCommand(args) {
|
|
|
417
406
|
console.error(`${LOG} ${REQUIRE_EXPLICIT_ENV_MESSAGE}`);
|
|
418
407
|
return 1;
|
|
419
408
|
}
|
|
420
|
-
const spec =
|
|
409
|
+
const { spec, path } = parseFunctionsSourceSelection(args);
|
|
421
410
|
// Remote deploys resolve auth up front: --repo needs it to fetch the tree,
|
|
422
411
|
// while --git-url must reject a selected M2M principal before cloning.
|
|
423
412
|
// Local sources still defer auth until after validation so malformed local
|
|
@@ -425,7 +414,8 @@ export async function functionsDeployCommand(args) {
|
|
|
425
414
|
const ctx = spec.kind !== 'local' ? await buildContext(args) : null;
|
|
426
415
|
const source = await resolveArtifactSource(spec, ctx ? sourceClientOptions(ctx) : {});
|
|
427
416
|
try {
|
|
428
|
-
|
|
417
|
+
const dir = await resolveFunctionSourceDir({ repoDir: source.dir, path });
|
|
418
|
+
return await deployFromResolvedSource({ args, spec, ctx, source: { ...source, dir } });
|
|
429
419
|
}
|
|
430
420
|
finally {
|
|
431
421
|
await source.cleanup();
|
|
@@ -983,17 +973,19 @@ export const FUNCTIONS_USAGE = `usage:
|
|
|
983
973
|
(version history is retained)
|
|
984
974
|
|
|
985
975
|
Flags: -e/--env <env|preview:<slug>> (required for network commands; see: seq-studio envs list) · --fn <slug> · --dir <path>
|
|
976
|
+
--path <repo-subdirectory> (remote build/deploy only) select one function in a multi-function repo
|
|
986
977
|
--from-env-file <path> (default: .env) source file for secret values
|
|
987
978
|
--no-wait · --yes
|
|
988
979
|
--no-provision (deploy) update-only: error instead of registering a new
|
|
989
980
|
shell, writing secret values, or attaching secrets (CI sweep)
|
|
990
981
|
|
|
991
982
|
Source for build/deploy: a local --dir (default .), a platform git-service
|
|
992
|
-
repo (--repo <ns>/<name>), or a public HTTPS git URL (--git-url <url>). --
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
983
|
+
repo (--repo <ns>/<name>), or a public HTTPS git URL (--git-url <url>). --path selects
|
|
984
|
+
a function directory within a remote repo; omit it for the existing root-manifest
|
|
985
|
+
layout. --ref selects a branch/tag/commit (default: the repo's default branch).
|
|
986
|
+
Remote sources record the pinned commit as provenance (never dirty) and NEVER
|
|
987
|
+
read a repo-committed .env for secret values — provision secrets server-side
|
|
988
|
+
or pass a local --from-env-file (resolved against your cwd).
|
|
997
989
|
|
|
998
990
|
Interactive --repo builds clone over smart-HTTP and require a repo:read git
|
|
999
991
|
PAT in ATLAS_GIT_PAT (\`seq-studio auth pat create --scopes repo:read\`, or
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type SourceSpec } from '@sequenceholdings/artifact-studio/source-resolver';
|
|
2
|
+
import type { ParsedArgs } from '../process/commands.js';
|
|
3
|
+
export interface FunctionsSourceSelection {
|
|
4
|
+
spec: SourceSpec;
|
|
5
|
+
path?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Functions name their local source with --dir rather than a positional, so
|
|
9
|
+
* map it onto the shared source parser used by other seq-studio primitives.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseFunctionsSourceSpec(args: ParsedArgs): SourceSpec;
|
|
12
|
+
/**
|
|
13
|
+
* Select a managed function within a remote repository. Local callers already
|
|
14
|
+
* select the function root with --dir, so --path is remote-only.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseFunctionsSourceSelection(args: ParsedArgs): FunctionsSourceSelection;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the selected function root after the repository is materialized.
|
|
19
|
+
* realpath containment prevents a committed symlink from escaping the repo.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveFunctionSourceDir({ repoDir, path, }: {
|
|
22
|
+
repoDir: string;
|
|
23
|
+
path?: string;
|
|
24
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { parseSourceSpec, } from '@sequenceholdings/artifact-studio/source-resolver';
|
|
4
|
+
import { isDirectory } from './bundle.js';
|
|
5
|
+
/**
|
|
6
|
+
* Functions name their local source with --dir rather than a positional, so
|
|
7
|
+
* map it onto the shared source parser used by other seq-studio primitives.
|
|
8
|
+
*/
|
|
9
|
+
export function parseFunctionsSourceSpec(args) {
|
|
10
|
+
const dir = typeof args.flags.dir === 'string' ? args.flags.dir : undefined;
|
|
11
|
+
if (dir !== undefined && (args.flags.repo !== undefined || args.flags['git-url'] !== undefined)) {
|
|
12
|
+
throw new Error('--dir cannot be combined with --repo / --git-url.');
|
|
13
|
+
}
|
|
14
|
+
return parseSourceSpec({ positional: dir === undefined ? [] : [dir], flags: args.flags });
|
|
15
|
+
}
|
|
16
|
+
function validateFunctionRepoPath(path) {
|
|
17
|
+
const segments = path.split('/');
|
|
18
|
+
if (path.length === 0 ||
|
|
19
|
+
path.trim() !== path ||
|
|
20
|
+
isAbsolute(path) ||
|
|
21
|
+
path.includes('\\') ||
|
|
22
|
+
[...path].some((character) => character.charCodeAt(0) < 0x20) ||
|
|
23
|
+
segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) {
|
|
24
|
+
throw new Error(`--path must be a canonical relative directory within the repository (got ${JSON.stringify(path)}).`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Select a managed function within a remote repository. Local callers already
|
|
29
|
+
* select the function root with --dir, so --path is remote-only.
|
|
30
|
+
*/
|
|
31
|
+
export function parseFunctionsSourceSelection(args) {
|
|
32
|
+
const spec = parseFunctionsSourceSpec(args);
|
|
33
|
+
const pathFlag = args.flags.path;
|
|
34
|
+
if (pathFlag === true)
|
|
35
|
+
throw new Error('--path requires a value.');
|
|
36
|
+
if (pathFlag === undefined)
|
|
37
|
+
return { spec };
|
|
38
|
+
if (spec.kind === 'local') {
|
|
39
|
+
throw new Error('--path only applies together with --repo or --git-url; use --dir for local source.');
|
|
40
|
+
}
|
|
41
|
+
validateFunctionRepoPath(pathFlag);
|
|
42
|
+
return { spec, path: pathFlag };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the selected function root after the repository is materialized.
|
|
46
|
+
* realpath containment prevents a committed symlink from escaping the repo.
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveFunctionSourceDir({ repoDir, path, }) {
|
|
49
|
+
if (path === undefined)
|
|
50
|
+
return repoDir;
|
|
51
|
+
validateFunctionRepoPath(path);
|
|
52
|
+
const candidate = resolve(repoDir, path);
|
|
53
|
+
if (!(await isDirectory(candidate))) {
|
|
54
|
+
throw new Error(`--path does not name a directory in the repository: ${path}`);
|
|
55
|
+
}
|
|
56
|
+
const [canonicalRepoDir, canonicalCandidate] = await Promise.all([
|
|
57
|
+
realpath(repoDir),
|
|
58
|
+
realpath(candidate),
|
|
59
|
+
]);
|
|
60
|
+
const relativePath = relative(canonicalRepoDir, canonicalCandidate);
|
|
61
|
+
if (relativePath === '..' ||
|
|
62
|
+
relativePath.startsWith(`..${sep}`) ||
|
|
63
|
+
isAbsolute(relativePath)) {
|
|
64
|
+
throw new Error(`--path must stay within the repository: ${path}`);
|
|
65
|
+
}
|
|
66
|
+
return canonicalCandidate;
|
|
67
|
+
}
|
package/dist/main.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* seq-studio process <sub> manage Lattice processes
|
|
6
6
|
* seq-studio artifact <sub> manage Artifact Studio apps
|
|
7
7
|
* seq-studio functions <sub> manage Managed Functions
|
|
8
|
+
* seq-studio agents <sub> manage typed agent definitions
|
|
8
9
|
* seq-studio secrets <sub> manage org-owned Managed Secrets
|
|
9
10
|
* seq-studio repos <sub> manage platform git-service repos
|
|
10
11
|
* seq-studio pipeline <sub> author + validate Data Pipelines stage specs
|