@sequenceholdings/studio-cli 0.1.18 → 0.1.22
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 +232 -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 +238 -0
- package/dist/agents/scaffold.d.ts +2 -0
- package/dist/agents/scaffold.js +77 -0
- package/dist/agents/source.d.ts +20 -0
- package/dist/agents/source.js +144 -0
- package/dist/atlas-client.js +29 -0
- package/dist/auth.d.ts +19 -17
- package/dist/auth.js +102 -33
- package/dist/functions/commands.d.ts +2 -10
- package/dist/functions/commands.js +18 -25
- package/dist/functions/manifest.d.ts +2 -0
- package/dist/functions/manifest.js +39 -9
- 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/pipeline/commands.js +21 -0
- package/dist/pipeline/lifecycle.d.ts +2 -0
- package/dist/pipeline/lifecycle.js +107 -0
- package/dist/process/build.js +2 -1
- package/dist/process/lint.js +8 -0
- package/dist/repos/commands.d.ts +53 -1
- package/dist/repos/commands.js +258 -1
- package/package.json +8 -6
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
5
|
+
import { currentVersion } from '../update-check.js';
|
|
6
|
+
const LOG = '[seq-studio]';
|
|
7
|
+
const scaffoldSource = ({ id, name, }) => `import { defineAgent } from '@sequenceholdings/agent-spec'
|
|
8
|
+
|
|
9
|
+
export const agent = defineAgent({
|
|
10
|
+
id: '${id}',
|
|
11
|
+
name: '${name}',
|
|
12
|
+
model: {
|
|
13
|
+
provider: 'anthropic',
|
|
14
|
+
name: 'claude-sonnet-4-6',
|
|
15
|
+
},
|
|
16
|
+
systemPrompt: 'Describe the agent role and operating instructions.',
|
|
17
|
+
})
|
|
18
|
+
`;
|
|
19
|
+
export async function agentsInitCommand(args) {
|
|
20
|
+
const target = args.positional[0];
|
|
21
|
+
if (!target) {
|
|
22
|
+
console.error('usage: seq-studio agents init <dir>');
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
const directory = resolve(target);
|
|
26
|
+
const file = join(directory, 'agent.ts');
|
|
27
|
+
if (existsSync(file)) {
|
|
28
|
+
console.error(`${LOG} ${file} already exists`);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
const name = basename(directory)
|
|
32
|
+
.split(/[-_]+/)
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
|
35
|
+
.join(' ');
|
|
36
|
+
await mkdir(directory, { recursive: true });
|
|
37
|
+
await writeFile(file, scaffoldSource({ id: randomBytes(12).toString('hex'), name }));
|
|
38
|
+
await writeFile(join(directory, 'package.json'), `${JSON.stringify({
|
|
39
|
+
name: basename(directory),
|
|
40
|
+
version: '0.0.1',
|
|
41
|
+
private: true,
|
|
42
|
+
type: 'module',
|
|
43
|
+
scripts: { validate: 'seq-studio agents validate' },
|
|
44
|
+
dependencies: { '@sequenceholdings/agent-spec': '^0.1.0' },
|
|
45
|
+
devDependencies: {
|
|
46
|
+
'@sequenceholdings/studio-cli': `^${currentVersion()}`,
|
|
47
|
+
},
|
|
48
|
+
}, null, 2)}\n`);
|
|
49
|
+
await writeFile(join(directory, 'tsconfig.json'), `${JSON.stringify({
|
|
50
|
+
compilerOptions: {
|
|
51
|
+
target: 'ES2022',
|
|
52
|
+
module: 'NodeNext',
|
|
53
|
+
moduleResolution: 'NodeNext',
|
|
54
|
+
strict: true,
|
|
55
|
+
noEmit: true,
|
|
56
|
+
},
|
|
57
|
+
include: ['agent.ts'],
|
|
58
|
+
}, null, 2)}\n`);
|
|
59
|
+
await writeFile(join(directory, 'pnpm-workspace.yaml'), `packages:
|
|
60
|
+
- '.'
|
|
61
|
+
|
|
62
|
+
minimumReleaseAge: 10080
|
|
63
|
+
minimumReleaseAgeExclude:
|
|
64
|
+
- '@sequenceholdings/agent-spec'
|
|
65
|
+
- '@sequenceholdings/atlas-ui'
|
|
66
|
+
- '@sequenceholdings/lattice-form-renderer'
|
|
67
|
+
- '@sequenceholdings/artifact-studio'
|
|
68
|
+
- '@sequenceholdings/lattice'
|
|
69
|
+
- '@sequenceholdings/studio-cli'
|
|
70
|
+
strictDepBuilds: true
|
|
71
|
+
allowBuilds:
|
|
72
|
+
esbuild: true
|
|
73
|
+
`);
|
|
74
|
+
console.log(`${LOG} scaffolded typed agent in ${directory}`);
|
|
75
|
+
console.log('Next: pnpm install && seq-studio agents validate');
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type CompiledAgentBundle } from '@sequenceholdings/agent-spec/compiler';
|
|
2
|
+
import { type ResolvedSource, type SourceSpec } from '@sequenceholdings/artifact-studio/source-resolver';
|
|
3
|
+
import type { ParsedArgs } from '../process/commands.js';
|
|
4
|
+
import { type CommandContext } from '../functions/commands.js';
|
|
5
|
+
export declare function materializeAgentSource({ args, requireEnvironment, }: {
|
|
6
|
+
args: ParsedArgs;
|
|
7
|
+
requireEnvironment: boolean;
|
|
8
|
+
}): Promise<{
|
|
9
|
+
spec: SourceSpec;
|
|
10
|
+
source: ResolvedSource;
|
|
11
|
+
context: CommandContext | null;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function compileAgentSource({ directory, targetEnvironment, deployEnvironments, onlyIds, }: {
|
|
14
|
+
directory: string;
|
|
15
|
+
targetEnvironment?: string;
|
|
16
|
+
/** Registered deployment environments, so a real env absent from the manifest is not read as a typo. */
|
|
17
|
+
deployEnvironments?: readonly string[];
|
|
18
|
+
/** Restrict a plan or apply to explicit agent IDs after environment selection. */
|
|
19
|
+
onlyIds?: readonly string[];
|
|
20
|
+
}): Promise<CompiledAgentBundle>;
|
|
@@ -0,0 +1,144 @@
|
|
|
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 = [], onlyIds, }) {
|
|
91
|
+
const manifest = await readManifest(directory);
|
|
92
|
+
if (!manifest) {
|
|
93
|
+
const compiled = await compileAgentDirectory({ rootDir: directory });
|
|
94
|
+
if (!onlyIds || onlyIds.length === 0)
|
|
95
|
+
return compiled;
|
|
96
|
+
const requested = new Set(onlyIds);
|
|
97
|
+
const definitions = compiled.definitions.filter((definition) => requested.has(definition.id));
|
|
98
|
+
const missing = [...requested].filter((id) => !definitions.some((definition) => definition.id === id));
|
|
99
|
+
if (missing.length > 0) {
|
|
100
|
+
throw new Error(`Requested agent IDs were not selected: ${missing.join(', ')}`);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
...compiled,
|
|
104
|
+
definitions,
|
|
105
|
+
hash: hashAgentBundle({ definitions }),
|
|
106
|
+
sources: compiled.sources.filter((source) => requested.has(source.definition.id)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (targetEnvironment !== undefined) {
|
|
110
|
+
assertKnownTargetEnvironment({
|
|
111
|
+
manifest,
|
|
112
|
+
environment: targetEnvironment,
|
|
113
|
+
deployEnvironments,
|
|
114
|
+
});
|
|
115
|
+
// Selection is silent, so a target that matches nothing looks identical to
|
|
116
|
+
// a full deploy. Name the drop before the operator confirms an apply.
|
|
117
|
+
const { selected, skipped, total } = selectionStats({
|
|
118
|
+
manifest,
|
|
119
|
+
environment: targetEnvironment,
|
|
120
|
+
});
|
|
121
|
+
if (skipped > 0) {
|
|
122
|
+
console.log(`[seq-studio] target ${targetEnvironment}: ${selected} of ${total} agents selected, ${skipped} scoped to other environments`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// With a target: last-wins by id (tenant overrides). Without: every distinct
|
|
126
|
+
// path, so validate still compiles override sources that lose a collapse.
|
|
127
|
+
const selectedEntries = selectAgentEntries({
|
|
128
|
+
manifest,
|
|
129
|
+
environment: targetEnvironment,
|
|
130
|
+
});
|
|
131
|
+
const requested = new Set(onlyIds);
|
|
132
|
+
const entries = requested.size === 0
|
|
133
|
+
? selectedEntries
|
|
134
|
+
: selectedEntries.filter((entry) => requested.has(entry.id));
|
|
135
|
+
const missing = [...requested].filter((id) => !entries.some((entry) => entry.id === id));
|
|
136
|
+
if (missing.length > 0) {
|
|
137
|
+
throw new Error(`Requested agent IDs were not selected: ${missing.join(', ')}`);
|
|
138
|
+
}
|
|
139
|
+
return compileManifestEntries({
|
|
140
|
+
directory,
|
|
141
|
+
entries,
|
|
142
|
+
allowDuplicateIds: targetEnvironment === undefined,
|
|
143
|
+
});
|
|
144
|
+
}
|
package/dist/atlas-client.js
CHANGED
|
@@ -9,6 +9,7 @@ import { PREVIEW_DOMAIN } from './preview.js';
|
|
|
9
9
|
const MAX_503_RETRIES = 5;
|
|
10
10
|
const DEFAULT_RETRY_AFTER_SECONDS = 2;
|
|
11
11
|
const LOG_PREFIX = '[seq-studio]';
|
|
12
|
+
const CF_ACCESS_DOMAIN = 'seqholdings.com';
|
|
12
13
|
export class AtlasApiError extends Error {
|
|
13
14
|
status;
|
|
14
15
|
path;
|
|
@@ -42,6 +43,7 @@ async function authenticatedFetch({ baseUrl, init = {}, path, token, }) {
|
|
|
42
43
|
redirect: 'manual',
|
|
43
44
|
headers: {
|
|
44
45
|
...previewAccessHeaders(baseUrl),
|
|
46
|
+
...cfAccessHeaders(baseUrl),
|
|
45
47
|
...init.headers,
|
|
46
48
|
Authorization: `Bearer ${token}`,
|
|
47
49
|
},
|
|
@@ -63,6 +65,33 @@ function previewAccessHeaders(baseUrl) {
|
|
|
63
65
|
return {};
|
|
64
66
|
}
|
|
65
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Cloudflare Access fronts *.seqholdings.com and only bypasses the company
|
|
70
|
+
* network, so a CI runner is turned away at the edge with an HTML "Access
|
|
71
|
+
* Restricted" page before Atlas ever sees the bearer token. A service token
|
|
72
|
+
* gets through the edge; the bearer still authenticates at the app.
|
|
73
|
+
*
|
|
74
|
+
* Scoped to seqholdings.com so the token is never sent to localhost, a tenant
|
|
75
|
+
* domain, or any other host the CLI can be pointed at.
|
|
76
|
+
*/
|
|
77
|
+
function cfAccessHeaders(baseUrl) {
|
|
78
|
+
const clientId = process.env.CF_ACCESS_CLIENT_ID?.trim();
|
|
79
|
+
const clientSecret = process.env.CF_ACCESS_CLIENT_SECRET?.trim();
|
|
80
|
+
if (!clientId || !clientSecret)
|
|
81
|
+
return {};
|
|
82
|
+
try {
|
|
83
|
+
const parsed = new URL(baseUrl);
|
|
84
|
+
if (parsed.protocol !== 'https:')
|
|
85
|
+
return {};
|
|
86
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
87
|
+
if (hostname !== CF_ACCESS_DOMAIN && !hostname.endsWith(`.${CF_ACCESS_DOMAIN}`))
|
|
88
|
+
return {};
|
|
89
|
+
return { 'CF-Access-Client-Id': clientId, 'CF-Access-Client-Secret': clientSecret };
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
66
95
|
function messageFromBody(body, status, statusText) {
|
|
67
96
|
if (body && typeof body === 'object') {
|
|
68
97
|
const record = body;
|
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
|
|
@@ -43,6 +43,8 @@ const SEQUENCE_BUILTIN_ENVS = new Set(['local', 'staging', 'production', 'bankso
|
|
|
43
43
|
export function isSequenceAuthEnvName(envName) {
|
|
44
44
|
return (envName === SEQUENCE_REALM ||
|
|
45
45
|
SEQUENCE_BUILTIN_ENVS.has(envName) ||
|
|
46
|
+
envName === 'worktree' ||
|
|
47
|
+
envName.startsWith('local:') ||
|
|
46
48
|
envName === 'preview' ||
|
|
47
49
|
envName.startsWith('preview:'));
|
|
48
50
|
}
|
|
@@ -131,7 +133,8 @@ export function m2mSecretEnvName(realm) {
|
|
|
131
133
|
}
|
|
132
134
|
// In-memory per-realm cache for M2M tokens (seconds-based, mirrors seqapi's
|
|
133
135
|
// `_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).
|
|
136
|
+
// call within a single process (e.g. a long `artifact dev` watch). Disk
|
|
137
|
+
// persistence under tokens.json `m2m` covers cross-process reuse.
|
|
135
138
|
const m2mCache = new Map();
|
|
136
139
|
/**
|
|
137
140
|
* A configured M2M credential failed to mint a token. Typed so callers that
|
|
@@ -141,11 +144,41 @@ const m2mCache = new Map();
|
|
|
141
144
|
export class M2mTokenError extends Error {
|
|
142
145
|
name = 'M2mTokenError';
|
|
143
146
|
}
|
|
147
|
+
function authModePrefersM2m() {
|
|
148
|
+
const value = process.env.SEQAPI_AUTH_MODE?.trim().toLowerCase() ?? 'auto';
|
|
149
|
+
return value === 'm2m';
|
|
150
|
+
}
|
|
151
|
+
function tokenStillValid(entry) {
|
|
152
|
+
const now = Date.now() / 1000;
|
|
153
|
+
return Boolean(entry?.accessToken && (entry.expiresAt ?? 0) > now + 60);
|
|
154
|
+
}
|
|
155
|
+
async function loadPersistedM2m(realmName) {
|
|
156
|
+
if (!existsSync(seqapiTokenPath()))
|
|
157
|
+
return null;
|
|
158
|
+
return withTokenFileLock(async () => {
|
|
159
|
+
const file = await readTokenFile();
|
|
160
|
+
const entry = file?.m2m?.[realmName];
|
|
161
|
+
if (!entry?.access_token)
|
|
162
|
+
return null;
|
|
163
|
+
return {
|
|
164
|
+
accessToken: entry.access_token,
|
|
165
|
+
expiresAt: entry.expires_at ?? 0,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async function persistM2m({ realmName, accessToken, expiresAt, }) {
|
|
170
|
+
await withTokenFileLock(async () => {
|
|
171
|
+
const existing = (await readTokenFile()) ?? {};
|
|
172
|
+
stripPersistedRefreshTokens(existing);
|
|
173
|
+
const m2m = { ...existing.m2m, [realmName]: { access_token: accessToken, expires_at: expiresAt } };
|
|
174
|
+
await writeTokenFile({ ...existing, m2m });
|
|
175
|
+
});
|
|
176
|
+
}
|
|
144
177
|
/**
|
|
145
178
|
* 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
|
-
*
|
|
179
|
+
* realm's secret env var is set. Returns null when the secret is unset.
|
|
180
|
+
* Throws on a configured-but-rejected secret, mirroring seqapi's
|
|
181
|
+
* `_get_m2m_token`. Cache order: in-process → shared token file → Auth0.
|
|
149
182
|
* The realm's own secret var is required — a Sequence secret in the shell is
|
|
150
183
|
* never sent to a tenant's Auth0 client.
|
|
151
184
|
*/
|
|
@@ -155,9 +188,8 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
|
|
|
155
188
|
const clientSecret = process.env[m2mSecretEnvName(realm)];
|
|
156
189
|
if (!clientSecret)
|
|
157
190
|
return null;
|
|
158
|
-
const now = Date.now() / 1000;
|
|
159
191
|
const cached = m2mCache.get(realm.name);
|
|
160
|
-
if (cached && cached
|
|
192
|
+
if (cached && tokenStillValid(cached)) {
|
|
161
193
|
verifyTokenMatchesRealm({
|
|
162
194
|
accessToken: cached.accessToken,
|
|
163
195
|
realm,
|
|
@@ -165,6 +197,16 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
|
|
|
165
197
|
});
|
|
166
198
|
return cached.accessToken;
|
|
167
199
|
}
|
|
200
|
+
const persisted = await loadPersistedM2m(realm.name);
|
|
201
|
+
if (persisted && tokenStillValid(persisted)) {
|
|
202
|
+
verifyTokenMatchesRealm({
|
|
203
|
+
accessToken: persisted.accessToken,
|
|
204
|
+
realm,
|
|
205
|
+
requireOrganization: false,
|
|
206
|
+
});
|
|
207
|
+
m2mCache.set(realm.name, persisted);
|
|
208
|
+
return persisted.accessToken;
|
|
209
|
+
}
|
|
168
210
|
const response = await fetch(`https://${realm.domain}/oauth/token`, {
|
|
169
211
|
method: 'POST',
|
|
170
212
|
redirect: 'manual',
|
|
@@ -193,6 +235,11 @@ async function getM2mToken(realm = SEQUENCE_AUTH_REALM) {
|
|
|
193
235
|
expiresAt: Date.now() / 1000 + (data.expires_in ?? 7200),
|
|
194
236
|
};
|
|
195
237
|
m2mCache.set(realm.name, entry);
|
|
238
|
+
await persistM2m({
|
|
239
|
+
realmName: realm.name,
|
|
240
|
+
accessToken: entry.accessToken,
|
|
241
|
+
expiresAt: entry.expiresAt,
|
|
242
|
+
});
|
|
196
243
|
return entry.accessToken;
|
|
197
244
|
}
|
|
198
245
|
export function seqapiTokenDir() {
|
|
@@ -216,13 +263,15 @@ export class NotLoggedInError extends Error {
|
|
|
216
263
|
}
|
|
217
264
|
}
|
|
218
265
|
/**
|
|
219
|
-
* Return a valid access token for an environment's auth realm.
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
266
|
+
* Return a valid access token for an environment's auth realm.
|
|
267
|
+
*
|
|
268
|
+
* Precedence (default `SEQAPI_AUTH_MODE=auto`), matching seqapi:
|
|
269
|
+
* 1. Valid cached user access token
|
|
270
|
+
* 2. M2M client-credentials when the realm's secret env var is set
|
|
271
|
+
* 3. Bounded PKCE login when browser auto-login is enabled
|
|
272
|
+
*
|
|
273
|
+
* Set `SEQAPI_AUTH_MODE=m2m` to skip the user token. No `env` (or a built-in
|
|
274
|
+
* Sequence env) means the shared Sequence realm.
|
|
226
275
|
*/
|
|
227
276
|
export class UnsafeAuthTargetError extends Error {
|
|
228
277
|
}
|
|
@@ -266,9 +315,21 @@ export async function getAccessTokenWithMode(options) {
|
|
|
266
315
|
const realm = await realmForEnv(options?.env);
|
|
267
316
|
if (options?.targetUrl)
|
|
268
317
|
validateRealmTarget({ realm, targetUrl: options.targetUrl });
|
|
318
|
+
const forceM2m = authModePrefersM2m();
|
|
319
|
+
if (!forceM2m) {
|
|
320
|
+
const tokens = await loadCachedUserTokens(realm.name);
|
|
321
|
+
const now = Date.now() / 1000;
|
|
322
|
+
if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
|
|
323
|
+
verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
|
|
324
|
+
return { authMode: 'user', token: tokens.access_token };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
269
327
|
const m2m = await getM2mToken(realm);
|
|
270
328
|
if (m2m)
|
|
271
329
|
return { authMode: 'm2m', token: m2m };
|
|
330
|
+
if (forceM2m) {
|
|
331
|
+
throw new NotLoggedInError(realm.name, `SEQAPI_AUTH_MODE=m2m but no M2M credentials for [${realm.name}].`);
|
|
332
|
+
}
|
|
272
333
|
const tokens = await loadCachedUserTokens(realm.name);
|
|
273
334
|
if (!tokens) {
|
|
274
335
|
if (options?.allowInteractiveLogin === false) {
|
|
@@ -282,11 +343,6 @@ export async function getAccessTokenWithMode(options) {
|
|
|
282
343
|
}),
|
|
283
344
|
};
|
|
284
345
|
}
|
|
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
346
|
if (options?.allowInteractiveLogin === false) {
|
|
291
347
|
throw new NotLoggedInError(realm.name, `Access token expired [${realm.name}].`);
|
|
292
348
|
}
|
|
@@ -382,19 +438,24 @@ export function decodeJwtSub(token) {
|
|
|
382
438
|
}
|
|
383
439
|
/**
|
|
384
440
|
* The Auth0 subject the CLI would authenticate as right now, without any
|
|
385
|
-
* network call
|
|
386
|
-
*
|
|
441
|
+
* network call. Matches token resolution precedence: a valid user session
|
|
442
|
+
* wins over an ambient M2M secret unless `SEQAPI_AUTH_MODE=m2m`.
|
|
387
443
|
*/
|
|
388
444
|
export async function currentIdentitySubject(options = {}) {
|
|
389
445
|
const realm = await realmForEnv(options.env);
|
|
446
|
+
const forceM2m = authModePrefersM2m();
|
|
447
|
+
if (!forceM2m) {
|
|
448
|
+
const tokens = await loadCachedUserTokens(realm.name);
|
|
449
|
+
const now = Date.now() / 1000;
|
|
450
|
+
if (tokens?.access_token && (tokens.expires_at ?? 0) > now + 60) {
|
|
451
|
+
verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
|
|
452
|
+
return decodeJwtSub(tokens.access_token);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
390
455
|
if (realm.m2mClientId && process.env[m2mSecretEnvName(realm)]?.trim()) {
|
|
391
456
|
return `${realm.m2mClientId}@clients`;
|
|
392
457
|
}
|
|
393
|
-
|
|
394
|
-
if (!tokens?.access_token)
|
|
395
|
-
return null;
|
|
396
|
-
verifyTokenMatchesRealm({ accessToken: tokens.access_token, realm });
|
|
397
|
-
return decodeJwtSub(tokens.access_token);
|
|
458
|
+
return null;
|
|
398
459
|
}
|
|
399
460
|
async function readTokenFile() {
|
|
400
461
|
const path = seqapiTokenPath();
|
|
@@ -500,6 +561,14 @@ export async function deleteRealmTokens(realmName) {
|
|
|
500
561
|
delete updated.realms;
|
|
501
562
|
}
|
|
502
563
|
}
|
|
564
|
+
const m2m = { ...existing.m2m };
|
|
565
|
+
delete m2m[realmName];
|
|
566
|
+
if (Object.keys(m2m).length > 0) {
|
|
567
|
+
updated.m2m = m2m;
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
delete updated.m2m;
|
|
571
|
+
}
|
|
503
572
|
await writeTokenFile(updated);
|
|
504
573
|
});
|
|
505
574
|
}
|
|
@@ -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>;
|