@sequenceholdings/artifact-studio 0.1.11 → 0.1.15
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/dist/api.js +23 -13
- package/dist/build-subprocess.d.ts +3 -1
- package/dist/build-subprocess.js +28 -6
- package/dist/child-environment.d.ts +5 -0
- package/dist/child-environment.js +83 -0
- package/dist/cli.d.ts +16 -1
- package/dist/cli.js +88 -63
- package/dist/config.d.ts +0 -9
- package/dist/config.js +0 -18
- package/dist/deployment-validation.d.ts +9 -0
- package/dist/deployment-validation.js +70 -0
- package/dist/git-clone.js +16 -10
- package/dist/prepare-build.d.ts +2 -0
- package/dist/prepare-build.js +27 -7
- package/dist/sdk.d.ts +4 -0
- package/dist/source-resolver.d.ts +8 -5
- package/dist/source-resolver.js +55 -30
- package/dist/templates/react-vite/CLAUDE.md +1 -1
- package/dist/templates/react-vite/src/styles.css +8 -0
- package/package.json +26 -2
- package/templates/react-vite/CLAUDE.md +1 -1
- package/templates/react-vite/src/styles.css +8 -0
- package/dist/auth.d.ts +0 -2
- package/dist/auth.js +0 -129
package/dist/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { authenticatedRequestUrl } from './deployment-validation.js';
|
|
1
2
|
const MAX_503_RETRIES = 5;
|
|
2
3
|
const DEFAULT_RETRY_AFTER_SECONDS = 2;
|
|
3
4
|
/**
|
|
@@ -47,18 +48,26 @@ async function fetchWith503Retry(input, init) {
|
|
|
47
48
|
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
51
|
+
async function authenticatedFetch({ baseUrl, init = {}, path, token, }) {
|
|
52
|
+
const url = authenticatedRequestUrl({ baseUrl, path });
|
|
53
|
+
return fetchWith503Retry(url, {
|
|
54
|
+
...init,
|
|
55
|
+
redirect: 'manual',
|
|
56
|
+
headers: {
|
|
57
|
+
...extraHeaders(),
|
|
58
|
+
...init.headers,
|
|
59
|
+
Authorization: `Bearer ${token}`,
|
|
60
|
+
},
|
|
53
61
|
});
|
|
62
|
+
}
|
|
63
|
+
export async function getJson({ baseUrl, token, path }) {
|
|
64
|
+
const response = await authenticatedFetch({ baseUrl, token, path });
|
|
54
65
|
if (!response.ok)
|
|
55
66
|
throw new Error(await responseError('GET', path, response));
|
|
56
67
|
return response.json();
|
|
57
68
|
}
|
|
58
69
|
export async function getJsonOr404({ baseUrl, token, path }) {
|
|
59
|
-
const response = await
|
|
60
|
-
headers: { ...extraHeaders(), Authorization: `Bearer ${token}` },
|
|
61
|
-
});
|
|
70
|
+
const response = await authenticatedFetch({ baseUrl, token, path });
|
|
62
71
|
if (response.status === 404)
|
|
63
72
|
return null;
|
|
64
73
|
if (!response.ok)
|
|
@@ -66,14 +75,15 @@ export async function getJsonOr404({ baseUrl, token, path }) {
|
|
|
66
75
|
return response.json();
|
|
67
76
|
}
|
|
68
77
|
export async function postJson({ baseUrl, token, path, body, }) {
|
|
69
|
-
const response = await
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
const response = await authenticatedFetch({
|
|
79
|
+
baseUrl,
|
|
80
|
+
token,
|
|
81
|
+
path,
|
|
82
|
+
init: {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
75
86
|
},
|
|
76
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
77
87
|
});
|
|
78
88
|
if (!response.ok)
|
|
79
89
|
throw new Error(await responseError('POST', path, response));
|
|
@@ -4,6 +4,8 @@ export interface IsolatedBuildOptions {
|
|
|
4
4
|
workerPath?: string;
|
|
5
5
|
/** Abort to kill the in-flight build child (e.g. on watcher shutdown). */
|
|
6
6
|
signal?: AbortSignal;
|
|
7
|
+
/** Parent environment override for deterministic security tests. */
|
|
8
|
+
sourceEnv?: NodeJS.ProcessEnv;
|
|
7
9
|
}
|
|
8
10
|
/**
|
|
9
11
|
* Run a single artifact build in a short-lived child process and return its
|
|
@@ -12,4 +14,4 @@ export interface IsolatedBuildOptions {
|
|
|
12
14
|
* long-running watch loops; one-shot commands can call
|
|
13
15
|
* `buildArtifactStudioProject` directly.
|
|
14
16
|
*/
|
|
15
|
-
export declare function buildArtifactStudioProjectIsolated(dir: string, { workerPath, signal }?: IsolatedBuildOptions): Promise<ArtifactStudioBuildResult>;
|
|
17
|
+
export declare function buildArtifactStudioProjectIsolated(dir: string, { workerPath, signal, sourceEnv }?: IsolatedBuildOptions): Promise<ArtifactStudioBuildResult>;
|
package/dist/build-subprocess.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
|
|
7
|
+
import { createScrubbedChildEnv } from './child-environment.js';
|
|
8
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const BUILT_WORKER_PATH = join(MODULE_DIR, 'build-worker.js');
|
|
10
|
+
const DEFAULT_WORKER_PATH = existsSync(BUILT_WORKER_PATH)
|
|
11
|
+
? BUILT_WORKER_PATH
|
|
12
|
+
: join(MODULE_DIR, 'build-worker.ts');
|
|
7
13
|
/**
|
|
8
14
|
* Run a single artifact build in a short-lived child process and return its
|
|
9
15
|
* result. The child exits as soon as the build finishes, so the OS reclaims
|
|
@@ -11,25 +17,41 @@ const DEFAULT_WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'build
|
|
|
11
17
|
* long-running watch loops; one-shot commands can call
|
|
12
18
|
* `buildArtifactStudioProject` directly.
|
|
13
19
|
*/
|
|
14
|
-
export async function buildArtifactStudioProjectIsolated(dir, { workerPath = DEFAULT_WORKER_PATH, signal } = {}) {
|
|
20
|
+
export async function buildArtifactStudioProjectIsolated(dir, { workerPath = DEFAULT_WORKER_PATH, signal, sourceEnv } = {}) {
|
|
15
21
|
const scratch = await mkdtemp(join(tmpdir(), 'artifact-build-'));
|
|
16
22
|
const outFile = join(scratch, 'result.json');
|
|
23
|
+
const homeDir = join(scratch, 'home');
|
|
17
24
|
try {
|
|
18
|
-
await
|
|
25
|
+
await mkdir(homeDir, { recursive: true });
|
|
26
|
+
await runWorker({
|
|
27
|
+
workerPath,
|
|
28
|
+
dir,
|
|
29
|
+
outFile,
|
|
30
|
+
homeDir,
|
|
31
|
+
signal,
|
|
32
|
+
...(sourceEnv ? { sourceEnv } : {}),
|
|
33
|
+
});
|
|
19
34
|
return JSON.parse(await readFile(outFile, 'utf8'));
|
|
20
35
|
}
|
|
21
36
|
finally {
|
|
22
37
|
await rm(scratch, { recursive: true, force: true });
|
|
23
38
|
}
|
|
24
39
|
}
|
|
25
|
-
function runWorker({ workerPath, dir, outFile, signal, }) {
|
|
40
|
+
function runWorker({ workerPath, dir, outFile, homeDir, signal, sourceEnv, }) {
|
|
26
41
|
return new Promise((resolve, reject) => {
|
|
27
42
|
if (signal?.aborted) {
|
|
28
43
|
reject(new Error('build aborted'));
|
|
29
44
|
return;
|
|
30
45
|
}
|
|
31
|
-
const
|
|
46
|
+
const workerArgs = workerPath.endsWith('.ts')
|
|
47
|
+
? ['--import', 'tsx', workerPath, dir, outFile]
|
|
48
|
+
: [workerPath, dir, outFile];
|
|
49
|
+
const child = spawn(process.execPath, workerArgs, {
|
|
32
50
|
stdio: ['ignore', 'inherit', 'pipe'],
|
|
51
|
+
env: createScrubbedChildEnv({
|
|
52
|
+
homeDir,
|
|
53
|
+
...(sourceEnv ? { source: sourceEnv } : {}),
|
|
54
|
+
}),
|
|
33
55
|
});
|
|
34
56
|
const onAbort = () => {
|
|
35
57
|
child.kill('SIGTERM');
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
// This prevents ambient credential inheritance and conventional HOME/config
|
|
3
|
+
// discovery. It is not an OS sandbox: the child still has the caller's uid and
|
|
4
|
+
// can read explicitly named filesystem paths and, where the OS permits it,
|
|
5
|
+
// inspect other same-uid processes (for example through Linux /proc).
|
|
6
|
+
const SAFE_CHILD_ENV_NAMES = new Set([
|
|
7
|
+
'PATH',
|
|
8
|
+
'PATHEXT',
|
|
9
|
+
'SYSTEMROOT',
|
|
10
|
+
'WINDIR',
|
|
11
|
+
'COMSPEC',
|
|
12
|
+
'TMPDIR',
|
|
13
|
+
'TMP',
|
|
14
|
+
'TEMP',
|
|
15
|
+
'LANG',
|
|
16
|
+
'LANGUAGE',
|
|
17
|
+
'LC_ALL',
|
|
18
|
+
'TERM',
|
|
19
|
+
'COLORTERM',
|
|
20
|
+
'NO_COLOR',
|
|
21
|
+
'FORCE_COLOR',
|
|
22
|
+
'CI',
|
|
23
|
+
'TZ',
|
|
24
|
+
'SOURCE_DATE_EPOCH',
|
|
25
|
+
'SSL_CERT_FILE',
|
|
26
|
+
'SSL_CERT_DIR',
|
|
27
|
+
'NODE_EXTRA_CA_CERTS',
|
|
28
|
+
]);
|
|
29
|
+
const PROXY_ENV_NAMES = new Set(['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY']);
|
|
30
|
+
export function createScrubbedChildEnv({ homeDir, source = process.env, additionalEnv = {}, }) {
|
|
31
|
+
const env = {};
|
|
32
|
+
for (const [name, value] of Object.entries(source)) {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
continue;
|
|
35
|
+
const normalized = name.toUpperCase();
|
|
36
|
+
if (PROXY_ENV_NAMES.has(normalized)) {
|
|
37
|
+
env[name] = value;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (SAFE_CHILD_ENV_NAMES.has(normalized) || normalized.startsWith('LC_')) {
|
|
41
|
+
env[name] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
Object.assign(env, additionalEnv);
|
|
45
|
+
for (const [name, value] of Object.entries(env)) {
|
|
46
|
+
if (value !== undefined && PROXY_ENV_NAMES.has(name.toUpperCase())) {
|
|
47
|
+
assertProxySettingIsSafe({ name, value });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
...env,
|
|
52
|
+
HOME: homeDir,
|
|
53
|
+
USERPROFILE: homeDir,
|
|
54
|
+
XDG_CONFIG_HOME: join(homeDir, '.config'),
|
|
55
|
+
XDG_CACHE_HOME: join(homeDir, '.cache'),
|
|
56
|
+
XDG_DATA_HOME: join(homeDir, '.local', 'share'),
|
|
57
|
+
APPDATA: join(homeDir, 'AppData', 'Roaming'),
|
|
58
|
+
LOCALAPPDATA: join(homeDir, 'AppData', 'Local'),
|
|
59
|
+
USER: 'sequence-build',
|
|
60
|
+
USERNAME: 'sequence-build',
|
|
61
|
+
LOGNAME: 'sequence-build',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function assertProxySettingIsSafe({ name, value, }) {
|
|
65
|
+
if (name.toUpperCase() === 'NO_PROXY')
|
|
66
|
+
return;
|
|
67
|
+
let proxyUrl;
|
|
68
|
+
try {
|
|
69
|
+
// git/npm accept the legacy credential-free `host:port` form. Prefix a
|
|
70
|
+
// scheme for validation only; preserve the caller's exact value below.
|
|
71
|
+
proxyUrl = new URL(value.includes('://') ? value : `http://${value}`);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new Error(`${name} must be a valid proxy endpoint before it can be forwarded to a build worker`);
|
|
75
|
+
}
|
|
76
|
+
if (proxyUrl.username ||
|
|
77
|
+
proxyUrl.password ||
|
|
78
|
+
proxyUrl.search ||
|
|
79
|
+
proxyUrl.hash ||
|
|
80
|
+
(proxyUrl.pathname !== '' && proxyUrl.pathname !== '/')) {
|
|
81
|
+
throw new Error(`${name} contains credential-bearing URL components and cannot be forwarded to source-controlled build tooling`);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,17 @@
|
|
|
1
|
+
import { type SourceAuthMode } from './source-resolver.js';
|
|
1
2
|
export declare function runCli(argv?: string[]): Promise<number>;
|
|
2
|
-
export declare function
|
|
3
|
+
export declare function getConfiguredDefaultEnv(): Promise<string | undefined>;
|
|
4
|
+
/**
|
|
5
|
+
* Optional refreshing token provider, injected by an embedder (e.g. seq-studio)
|
|
6
|
+
* that owns the shared seqapi session and can resolve an access token on
|
|
7
|
+
* demand. Authentication is owned by the embedding `seq-studio` CLI; this
|
|
8
|
+
* package deliberately has no bearer-token argv or environment escape hatch.
|
|
9
|
+
*/
|
|
10
|
+
export interface TokenProviderOptions {
|
|
11
|
+
allowInteractiveLogin: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ProvidedToken {
|
|
14
|
+
authMode: SourceAuthMode;
|
|
15
|
+
token: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function setTokenProvider(provider: ((options: TokenProviderOptions) => Promise<ProvidedToken | null>) | null): void;
|
package/dist/cli.js
CHANGED
|
@@ -3,21 +3,20 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { getJson, getJsonOr404, postJson } from './api.js';
|
|
6
|
-
import { ENV_URLS, readLocalConfig, resolveEnvironment, writeLocalConfig,
|
|
7
|
-
import {
|
|
6
|
+
import { ENV_URLS, readLocalConfig, resolveEnvironment, writeLocalConfig, } from './config.js';
|
|
7
|
+
import { writeBuildArtifact } from './build.js';
|
|
8
8
|
import { buildArtifactStudioProjectIsolated } from './build-subprocess.js';
|
|
9
9
|
import { prepareArtifactBuildRoot } from './prepare-build.js';
|
|
10
10
|
import { parseArtifactStudioManifest } from './manifest.js';
|
|
11
11
|
import { hasArtifactStudioManifest, readArtifactStudioSource } from './project.js';
|
|
12
12
|
import { localGitMetadata, parseSourceSpec, resolveArtifactSource, } from './source-resolver.js';
|
|
13
|
-
import { getAccessToken, loginWithPkce } from './auth.js';
|
|
14
13
|
import { acquireDevLock } from './dev-lock.js';
|
|
15
14
|
import { runWatchLoop } from './watch-loop.js';
|
|
16
15
|
import { hashLinkedSources, parseLinks, rebuildLinkedDep, resolveLinkedDeps, syncLinkedDist, } from './dev-link.js';
|
|
17
16
|
import { lintArtifactSandbox, reportSandboxWarnings } from './sandbox-lint.js';
|
|
18
17
|
import yaml from 'js-yaml';
|
|
19
18
|
const COMMANDS = [
|
|
20
|
-
'init', '
|
|
19
|
+
'init', 'whoami', 'link', 'env', 'status', 'list', 'show',
|
|
21
20
|
'validate', 'build', 'plan', 'deploy', 'dev', 'promote', 'pull', 'rollback',
|
|
22
21
|
];
|
|
23
22
|
const USAGE = `usage:
|
|
@@ -38,18 +37,24 @@ const USAGE = `usage:
|
|
|
38
37
|
seq-studio artifact rollback <deployment-id> --env <env>
|
|
39
38
|
|
|
40
39
|
Source: a local [dir] (default), a platform git-service repo (--repo <ns>/<name>),
|
|
41
|
-
or
|
|
40
|
+
or a public HTTPS git URL (--git-url <url>). --ref picks a branch/tag/commit (default: the
|
|
42
41
|
repo's default branch). The target project comes from the source's
|
|
43
42
|
artifact.bundle.yml project_id.
|
|
44
43
|
|
|
45
|
-
--repo
|
|
46
|
-
|
|
47
|
-
repo:read\` or in Atlas → Settings → Tokens.
|
|
48
|
-
repo
|
|
49
|
-
upload the
|
|
44
|
+
Interactive --repo builds clone over smart-HTTP and require a repo:read git
|
|
45
|
+
PAT in ATLAS_GIT_PAT — create one with \`seq-studio auth pat create --scopes
|
|
46
|
+
repo:read\` or in Atlas → Settings → Tokens. Headless M2M builds use JSON
|
|
47
|
+
materialize and accept only platform-managed --repo sources. (--env +
|
|
48
|
+
seq-studio login are still needed to resolve the repo and upload the
|
|
49
|
+
deployment.)
|
|
50
50
|
|
|
51
51
|
Authenticate with: seq-studio login`;
|
|
52
52
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
53
|
+
if (argv.some((arg) => arg === '--token' || arg.startsWith('--token='))) {
|
|
54
|
+
console.error('Artifact Studio does not accept --token bearer injection. ' +
|
|
55
|
+
'Authenticate with seq-studio login.');
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
53
58
|
const parsed = parseArgs(argv);
|
|
54
59
|
if ('error' in parsed) {
|
|
55
60
|
console.error(parsed.error);
|
|
@@ -57,8 +62,6 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
57
62
|
}
|
|
58
63
|
switch (parsed.command) {
|
|
59
64
|
case 'init': return initCommand(parsed);
|
|
60
|
-
case 'login': return loginCommand(parsed);
|
|
61
|
-
case 'logout': return logoutCommand();
|
|
62
65
|
case 'whoami': return whoamiCommand(parsed);
|
|
63
66
|
case 'link': return linkCommand(parsed);
|
|
64
67
|
case 'env': return envCommand(parsed);
|
|
@@ -119,22 +122,6 @@ async function initCommand(args) {
|
|
|
119
122
|
console.log('Next: seq-studio login && seq-studio artifact dev --env <env> (see: seq-studio envs list)');
|
|
120
123
|
return 0;
|
|
121
124
|
}
|
|
122
|
-
async function loginCommand(args) {
|
|
123
|
-
const token = typeof args.flags.token === 'string' ? args.flags.token : undefined;
|
|
124
|
-
if (token) {
|
|
125
|
-
await writeTokenConfig({ accessToken: token });
|
|
126
|
-
console.log('[seq-studio] saved access token');
|
|
127
|
-
return 0;
|
|
128
|
-
}
|
|
129
|
-
await loginWithPkce();
|
|
130
|
-
console.log('[seq-studio] logged in');
|
|
131
|
-
return 0;
|
|
132
|
-
}
|
|
133
|
-
async function logoutCommand() {
|
|
134
|
-
await writeTokenConfig({});
|
|
135
|
-
console.log('[seq-studio] logged out');
|
|
136
|
-
return 0;
|
|
137
|
-
}
|
|
138
125
|
async function whoamiCommand(args) {
|
|
139
126
|
const env = await envFromArgs(args);
|
|
140
127
|
const token = await getRequiredToken(args);
|
|
@@ -345,15 +332,23 @@ async function validateCommand(args) {
|
|
|
345
332
|
}
|
|
346
333
|
async function buildPreparedArtifactSource(source) {
|
|
347
334
|
await prepareArtifactBuildRoot(source.dir, { remote: source.remote });
|
|
348
|
-
return
|
|
335
|
+
return buildArtifactStudioProjectIsolated(source.dir);
|
|
349
336
|
}
|
|
350
337
|
async function buildCommand(args) {
|
|
351
338
|
const spec = parseSourceSpec(args);
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
339
|
+
// Local builds stay offline. Public git URLs need no bearer token, but an
|
|
340
|
+
// optional auth lookup still identifies a selected M2M principal so the
|
|
341
|
+
// source policy can reject arbitrary CI input before cloning.
|
|
342
|
+
const auth = spec.kind === 'git-service'
|
|
343
|
+
? await getRequiredAuth(args)
|
|
344
|
+
: spec.kind === 'git-url'
|
|
345
|
+
? await getOptionalAuth(args)
|
|
346
|
+
: null;
|
|
347
|
+
const remote = spec.kind === 'git-service' && auth
|
|
348
|
+
? { authMode: auth.authMode, baseUrl: (await envFromArgs(args)).url, token: auth.token }
|
|
349
|
+
: auth
|
|
350
|
+
? { authMode: auth.authMode }
|
|
351
|
+
: {};
|
|
357
352
|
const source = await resolveArtifactSource(spec, remote);
|
|
358
353
|
try {
|
|
359
354
|
const result = await buildPreparedArtifactSource(source);
|
|
@@ -375,9 +370,14 @@ async function buildCommand(args) {
|
|
|
375
370
|
}
|
|
376
371
|
async function planCommand(args) {
|
|
377
372
|
const env = await envFromArgs(args);
|
|
378
|
-
const
|
|
373
|
+
const auth = await getOptionalAuth(args);
|
|
374
|
+
const token = auth?.token ?? null;
|
|
379
375
|
const spec = parseSourceSpec(args);
|
|
380
|
-
const source = await resolveArtifactSource(spec, {
|
|
376
|
+
const source = await resolveArtifactSource(spec, {
|
|
377
|
+
...(auth ? { authMode: auth.authMode } : {}),
|
|
378
|
+
baseUrl: env.url,
|
|
379
|
+
token,
|
|
380
|
+
});
|
|
381
381
|
try {
|
|
382
382
|
const result = await buildPreparedArtifactSource(source);
|
|
383
383
|
console.log(`[seq-studio] plan ${result.manifest.artifact.project_id} -> ${env.name}`);
|
|
@@ -402,10 +402,15 @@ async function planCommand(args) {
|
|
|
402
402
|
}
|
|
403
403
|
async function deployCommand(args) {
|
|
404
404
|
const env = await envFromArgs(args);
|
|
405
|
-
const
|
|
405
|
+
const auth = await getRequiredAuth(args);
|
|
406
|
+
const { token } = auth;
|
|
406
407
|
const spec = parseSourceSpec(args);
|
|
407
408
|
const explicitProjectId = typeof args.flags.project === 'string' ? args.flags.project : null;
|
|
408
|
-
const source = await resolveArtifactSource(spec, {
|
|
409
|
+
const source = await resolveArtifactSource(spec, {
|
|
410
|
+
authMode: auth.authMode,
|
|
411
|
+
baseUrl: env.url,
|
|
412
|
+
token,
|
|
413
|
+
});
|
|
409
414
|
try {
|
|
410
415
|
// --skip-unchanged: hash the raw source (no build needed) and no-op when
|
|
411
416
|
// the remote active deployment already matches. Lets CI redeploy every
|
|
@@ -436,6 +441,7 @@ async function deployCommand(args) {
|
|
|
436
441
|
linkConfigDir: spec.kind === 'local' ? source.dir : null,
|
|
437
442
|
allowCreate: args.flags['no-create'] !== true,
|
|
438
443
|
explicitProjectId,
|
|
444
|
+
envName: env.name,
|
|
439
445
|
});
|
|
440
446
|
const deployment = await uploadDeployment({ env: env.url, token, projectId: project.id, result, channel: 'active', provenance: source.provenance });
|
|
441
447
|
console.log(`[seq-studio] deployed ${project.title} ${deployment.version}`);
|
|
@@ -520,7 +526,9 @@ async function devCommand(args) {
|
|
|
520
526
|
// Resolve per push so the token refreshes across a long watch session.
|
|
521
527
|
const token = await getRequiredToken(args);
|
|
522
528
|
if (!projectId) {
|
|
523
|
-
|
|
529
|
+
// envName so a first-time `artifact dev` creation honors the manifest
|
|
530
|
+
// visibility too (and notes drift, once, when the project pre-exists).
|
|
531
|
+
const project = await ensureRemoteProject({ env: env.url, token, result, linkConfigDir: dir, envName: env.name });
|
|
524
532
|
projectId = project.id;
|
|
525
533
|
}
|
|
526
534
|
const deployment = await uploadDeployment({
|
|
@@ -684,7 +692,21 @@ async function rollbackCommand(args) {
|
|
|
684
692
|
console.log(`[seq-studio] rolled back to ${deploymentId}`);
|
|
685
693
|
return 0;
|
|
686
694
|
}
|
|
687
|
-
|
|
695
|
+
/**
|
|
696
|
+
* Deploys never change a live project's visibility — the Studio sharing UI /
|
|
697
|
+
* PATCH own that. The manifest's targets.<env>.visibility is honored at
|
|
698
|
+
* project CREATION only; on drift, say so instead of silently ignoring the
|
|
699
|
+
* field (which is how it read as decorative for a year).
|
|
700
|
+
*/
|
|
701
|
+
function warnOnVisibilityDrift({ project, declared, envName }) {
|
|
702
|
+
if (!declared || !envName || !project.visibility || declared === project.visibility)
|
|
703
|
+
return;
|
|
704
|
+
console.warn(`[seq-studio] note: artifact.bundle.yml targets.${envName}.visibility is "${declared}" but the project is ` +
|
|
705
|
+
`"${project.visibility}" on this environment — deploys never change visibility. Update it in Studio sharing ` +
|
|
706
|
+
`(or PATCH /api/artifact-studio/projects/${project.id}) if the manifest is what you intend.`);
|
|
707
|
+
}
|
|
708
|
+
async function ensureRemoteProject({ env, token, result, linkConfigDir, allowCreate = true, explicitProjectId = null, envName = null }) {
|
|
709
|
+
const declaredVisibility = envName ? result.manifest.targets?.[envName]?.visibility : undefined;
|
|
688
710
|
if (explicitProjectId) {
|
|
689
711
|
const data = await getJsonOr404({ baseUrl: env, token, path: `/api/artifact-studio/projects/${explicitProjectId}` });
|
|
690
712
|
if (!data)
|
|
@@ -693,19 +715,23 @@ async function ensureRemoteProject({ env, token, result, linkConfigDir, allowCre
|
|
|
693
715
|
throw new Error(`--project ${explicitProjectId} resolves to slug "${data.project.slug}" but this source's ` +
|
|
694
716
|
`artifact.bundle.yml declares project_id "${result.manifest.artifact.project_id}" — refusing to deploy over a different project`);
|
|
695
717
|
}
|
|
718
|
+
warnOnVisibilityDrift({ project: data.project, declared: declaredVisibility, envName });
|
|
696
719
|
return data.project;
|
|
697
720
|
}
|
|
698
721
|
const config = linkConfigDir ? await readLocalConfig(linkConfigDir) : {};
|
|
699
722
|
if (config.projectId) {
|
|
700
723
|
const data = await getJsonOr404({ baseUrl: env, token, path: `/api/artifact-studio/projects/${config.projectId}` });
|
|
701
|
-
if (data)
|
|
724
|
+
if (data) {
|
|
725
|
+
warnOnVisibilityDrift({ project: data.project, declared: declaredVisibility, envName });
|
|
702
726
|
return data.project;
|
|
727
|
+
}
|
|
703
728
|
console.warn(`[seq-studio] cached projectId ${config.projectId} not found on ${env} — relinking by slug`);
|
|
704
729
|
}
|
|
705
730
|
const existing = await fetchRemoteProject({ env, token, slug: result.manifest.artifact.project_id });
|
|
706
731
|
if (existing) {
|
|
707
732
|
if (linkConfigDir)
|
|
708
733
|
await writeLocalConfig({ ...config, projectId: existing.id, projectSlug: existing.slug }, linkConfigDir);
|
|
734
|
+
warnOnVisibilityDrift({ project: existing, declared: declaredVisibility, envName });
|
|
709
735
|
return existing;
|
|
710
736
|
}
|
|
711
737
|
if (!allowCreate) {
|
|
@@ -721,6 +747,7 @@ async function ensureRemoteProject({ env, token, result, linkConfigDir, allowCre
|
|
|
721
747
|
title: result.manifest.artifact.title,
|
|
722
748
|
slug: result.manifest.artifact.project_id,
|
|
723
749
|
description: result.manifest.artifact.description ?? null,
|
|
750
|
+
...(declaredVisibility ? { visibility: declaredVisibility } : {}),
|
|
724
751
|
},
|
|
725
752
|
});
|
|
726
753
|
if (linkConfigDir)
|
|
@@ -771,38 +798,36 @@ async function fetchRemoteProject({ env, token, slug, disambiguator }) {
|
|
|
771
798
|
}
|
|
772
799
|
return matches[0] ?? null;
|
|
773
800
|
}
|
|
774
|
-
async function
|
|
801
|
+
export async function getConfiguredDefaultEnv() {
|
|
775
802
|
const config = await readLocalConfig().catch(() => ({ defaultEnv: undefined }));
|
|
776
|
-
return
|
|
803
|
+
return config.defaultEnv;
|
|
804
|
+
}
|
|
805
|
+
async function envFromArgs(args) {
|
|
806
|
+
return resolveEnvironment(typeof args.flags.env === 'string' ? args.flags.env : undefined, await getConfiguredDefaultEnv());
|
|
777
807
|
}
|
|
778
|
-
/**
|
|
779
|
-
* Optional refreshing token provider, injected by an embedder (e.g. seq-studio)
|
|
780
|
-
* that owns the shared seqapi token file and can mint a fresh access token on
|
|
781
|
-
* demand. Consulted before the static ARTIFACT_STUDIO_TOKEN env var so a
|
|
782
|
-
* long-running `dev` watcher keeps deploying past the initial token's TTL —
|
|
783
|
-
* the env var is captured once at process start and never refreshes.
|
|
784
|
-
*/
|
|
785
808
|
let tokenProvider = null;
|
|
786
809
|
export function setTokenProvider(provider) {
|
|
787
810
|
tokenProvider = provider;
|
|
788
811
|
}
|
|
789
|
-
async function getRequiredToken(
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
812
|
+
async function getRequiredToken(_args) {
|
|
813
|
+
return (await getRequiredAuth(_args)).token;
|
|
814
|
+
}
|
|
815
|
+
async function getRequiredAuth(_args) {
|
|
816
|
+
const auth = await resolveAuth({ allowInteractiveLogin: true });
|
|
817
|
+
if (!auth)
|
|
818
|
+
throw new Error('Missing token. Authenticate with seq-studio login.');
|
|
819
|
+
return auth;
|
|
820
|
+
}
|
|
821
|
+
async function getOptionalAuth(_args) {
|
|
822
|
+
return resolveAuth({ allowInteractiveLogin: false });
|
|
794
823
|
}
|
|
795
|
-
async function
|
|
796
|
-
if (typeof args.flags.token === 'string')
|
|
797
|
-
return args.flags.token;
|
|
824
|
+
async function resolveAuth(options) {
|
|
798
825
|
if (tokenProvider) {
|
|
799
|
-
const provided = await tokenProvider();
|
|
826
|
+
const provided = await tokenProvider(options);
|
|
800
827
|
if (provided)
|
|
801
828
|
return provided;
|
|
802
829
|
}
|
|
803
|
-
|
|
804
|
-
return process.env.ARTIFACT_STUDIO_TOKEN;
|
|
805
|
-
return getAccessToken();
|
|
830
|
+
return null;
|
|
806
831
|
}
|
|
807
832
|
async function replaceInFile(path, pattern, value) {
|
|
808
833
|
const content = await import('node:fs/promises').then((fs) => fs.readFile(path, 'utf8'));
|
|
@@ -831,4 +856,4 @@ function warnIfInlinedManifestDriftsFromBundle(result) {
|
|
|
831
856
|
// This module is a pure library export. `seq-studio artifact` imports
|
|
832
857
|
// `runCli` from `@sequenceholdings/artifact-studio/cli` (see
|
|
833
858
|
// shared/services/studio-cli/README.md) and invokes it after injecting a
|
|
834
|
-
// shared base URL +
|
|
859
|
+
// shared base URL + internal token provider.
|
package/dist/config.d.ts
CHANGED
|
@@ -11,19 +11,10 @@ export interface LocalProjectConfig {
|
|
|
11
11
|
defaultEnv?: string;
|
|
12
12
|
previewKey?: string;
|
|
13
13
|
}
|
|
14
|
-
export interface TokenConfig {
|
|
15
|
-
accessToken?: string;
|
|
16
|
-
refreshToken?: string;
|
|
17
|
-
expiresAt?: number;
|
|
18
|
-
}
|
|
19
|
-
export declare function globalConfigDir(): string;
|
|
20
|
-
export declare function tokenConfigPath(): string;
|
|
21
14
|
export declare function localConfigPath(cwd?: string): string;
|
|
22
15
|
export declare function devLockPath(cwd?: string): string;
|
|
23
16
|
export declare function readLocalConfig(cwd?: string): Promise<LocalProjectConfig>;
|
|
24
17
|
export declare function writeLocalConfig(config: LocalProjectConfig, cwd?: string): Promise<void>;
|
|
25
|
-
export declare function readTokenConfig(): Promise<TokenConfig>;
|
|
26
|
-
export declare function writeTokenConfig(config: TokenConfig): Promise<void>;
|
|
27
18
|
export declare function knownEnvUrls(): Record<string, string>;
|
|
28
19
|
export declare function resolveEnvironment(name: string | undefined, fallback?: string): {
|
|
29
20
|
name: string;
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
4
|
/**
|
|
6
5
|
* Only `local` ships in the published package. Deployed-environment URLs
|
|
7
6
|
* are resolved by the embedding CLI (seq-studio), which discovers the
|
|
@@ -11,12 +10,6 @@ import { homedir } from 'node:os';
|
|
|
11
10
|
export const ENV_URLS = {
|
|
12
11
|
local: 'http://localhost:5001',
|
|
13
12
|
};
|
|
14
|
-
export function globalConfigDir() {
|
|
15
|
-
return join(homedir(), '.config', 'sequence-artifact-studio');
|
|
16
|
-
}
|
|
17
|
-
export function tokenConfigPath() {
|
|
18
|
-
return join(globalConfigDir(), 'tokens.json');
|
|
19
|
-
}
|
|
20
13
|
export function localConfigPath(cwd = process.cwd()) {
|
|
21
14
|
return join(resolve(cwd), '.artifact-studio', 'config.json');
|
|
22
15
|
}
|
|
@@ -34,17 +27,6 @@ export async function writeLocalConfig(config, cwd = process.cwd()) {
|
|
|
34
27
|
await mkdir(dirname(path), { recursive: true });
|
|
35
28
|
await writeFile(path, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
36
29
|
}
|
|
37
|
-
export async function readTokenConfig() {
|
|
38
|
-
const path = tokenConfigPath();
|
|
39
|
-
if (!existsSync(path))
|
|
40
|
-
return {};
|
|
41
|
-
return JSON.parse(await readFile(path, 'utf8'));
|
|
42
|
-
}
|
|
43
|
-
export async function writeTokenConfig(config) {
|
|
44
|
-
const path = tokenConfigPath();
|
|
45
|
-
await mkdir(dirname(path), { recursive: true });
|
|
46
|
-
await writeFile(path, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
47
|
-
}
|
|
48
30
|
/**
|
|
49
31
|
* Environment map injected by the embedding CLI (seq-studio sets
|
|
50
32
|
* ARTIFACT_STUDIO_ENV_URLS to the JSON name→url map of environments visible
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function validateDeploymentBaseUrl(input: string): string;
|
|
2
|
+
export declare function validateDeploymentAudience({ audience, baseUrl, }: {
|
|
3
|
+
audience: string;
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
}): string;
|
|
6
|
+
export declare function authenticatedRequestUrl({ baseUrl, path, }: {
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
path: string;
|
|
9
|
+
}): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const TRUSTED_DEPLOYMENT_DOMAIN = 'seqholdings.com';
|
|
2
|
+
export function validateDeploymentBaseUrl(input) {
|
|
3
|
+
if (input !== input.trim()) {
|
|
4
|
+
throw new Error(`Invalid deployment URL '${input}'.`);
|
|
5
|
+
}
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = new URL(input);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error(`Invalid deployment URL '${input}'.`);
|
|
12
|
+
}
|
|
13
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
14
|
+
const originOnly = !parsed.username &&
|
|
15
|
+
!parsed.password &&
|
|
16
|
+
(parsed.pathname === '' || parsed.pathname === '/') &&
|
|
17
|
+
!parsed.search &&
|
|
18
|
+
!parsed.hash;
|
|
19
|
+
const trustedHttps = parsed.protocol === 'https:' &&
|
|
20
|
+
(hostname === TRUSTED_DEPLOYMENT_DOMAIN ||
|
|
21
|
+
hostname.endsWith(`.${TRUSTED_DEPLOYMENT_DOMAIN}`)) &&
|
|
22
|
+
parsed.port === '';
|
|
23
|
+
const localHttp = parsed.protocol === 'http:' &&
|
|
24
|
+
['localhost', '127.0.0.1', '[::1]'].includes(hostname);
|
|
25
|
+
if (!originOnly || (!trustedHttps && !localHttp)) {
|
|
26
|
+
throw new Error(`Untrusted deployment URL '${input}': expected an HTTPS origin under ` +
|
|
27
|
+
`*.${TRUSTED_DEPLOYMENT_DOMAIN}, or an HTTP localhost/loopback origin.`);
|
|
28
|
+
}
|
|
29
|
+
return trustedHttps ? `https://${hostname}` : parsed.origin.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
export function validateDeploymentAudience({ audience, baseUrl, }) {
|
|
32
|
+
const expected = `${baseUrl}/api`;
|
|
33
|
+
if (audience !== expected) {
|
|
34
|
+
throw new Error(`Untrusted Auth0 audience '${audience}': deployment ${baseUrl} must use '${expected}'.`);
|
|
35
|
+
}
|
|
36
|
+
return audience;
|
|
37
|
+
}
|
|
38
|
+
export function authenticatedRequestUrl({ baseUrl, path, }) {
|
|
39
|
+
const trustedOrigin = validateDeploymentBaseUrl(baseUrl);
|
|
40
|
+
if (!path.startsWith('/')) {
|
|
41
|
+
throw new Error(`Invalid authenticated request path '${path}': expected an absolute path.`);
|
|
42
|
+
}
|
|
43
|
+
if (path.startsWith('//')) {
|
|
44
|
+
throw new Error(`Invalid authenticated request path '${path}': scheme-relative paths are not allowed.`);
|
|
45
|
+
}
|
|
46
|
+
if ([...path].some((character) => character.charCodeAt(0) < 0x20)) {
|
|
47
|
+
throw new Error(`Invalid authenticated request path '${path}': control character.`);
|
|
48
|
+
}
|
|
49
|
+
const separatorIndex = path.search(/[?#]/);
|
|
50
|
+
const rawPathname = separatorIndex === -1 ? path : path.slice(0, separatorIndex);
|
|
51
|
+
const traversalCandidate = rawPathname
|
|
52
|
+
.replace(/%2e/gi, '.')
|
|
53
|
+
.replace(/%2f/gi, '/')
|
|
54
|
+
.replace(/%5c/gi, '\\');
|
|
55
|
+
if (traversalCandidate.includes('\\') ||
|
|
56
|
+
traversalCandidate.split('/').some((segment) => segment === '.' || segment === '..')) {
|
|
57
|
+
throw new Error(`Invalid authenticated request path '${path}': traversal is not allowed.`);
|
|
58
|
+
}
|
|
59
|
+
let request;
|
|
60
|
+
try {
|
|
61
|
+
request = new URL(path, `${trustedOrigin}/`);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error(`Invalid authenticated request path '${path}'.`);
|
|
65
|
+
}
|
|
66
|
+
if (request.origin.toLowerCase() !== trustedOrigin) {
|
|
67
|
+
throw new Error(`Untrusted authenticated request origin '${request.origin}': expected '${trustedOrigin}'.`);
|
|
68
|
+
}
|
|
69
|
+
return request.href;
|
|
70
|
+
}
|
package/dist/git-clone.js
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* HTTPS clone URL plus the PAT separately.
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
+
import { createScrubbedChildEnv } from './child-environment.js';
|
|
16
17
|
/** Read the git PAT from the environment (`ATLAS_GIT_PAT`), trimmed. */
|
|
17
18
|
export function resolveGitPatFromEnv() {
|
|
18
19
|
const value = process.env.ATLAS_GIT_PAT?.trim();
|
|
@@ -108,6 +109,7 @@ async function spawnGit({ args, env, cwd, timeoutMs = GIT_CLONE_TIMEOUT_MS, }) {
|
|
|
108
109
|
*/
|
|
109
110
|
export async function withGitAskpass({ pat, fn, }) {
|
|
110
111
|
const dir = await mkdtemp(join(tmpdir(), 'artifact-askpass-'));
|
|
112
|
+
const homeDir = join(dir, 'home');
|
|
111
113
|
const scriptPath = join(dir, 'askpass.sh');
|
|
112
114
|
const script = [
|
|
113
115
|
'#!/bin/sh',
|
|
@@ -117,17 +119,21 @@ export async function withGitAskpass({ pat, fn, }) {
|
|
|
117
119
|
'esac',
|
|
118
120
|
'',
|
|
119
121
|
].join('\n');
|
|
122
|
+
await mkdir(homeDir, { recursive: true });
|
|
120
123
|
await writeFile(scriptPath, script, { mode: 0o700 });
|
|
121
124
|
try {
|
|
122
|
-
return await fn({
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
return await fn(createScrubbedChildEnv({
|
|
126
|
+
homeDir,
|
|
127
|
+
additionalEnv: {
|
|
128
|
+
GIT_ASKPASS: scriptPath,
|
|
129
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
130
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
131
|
+
SEQ_STUDIO_GIT_ASKPASS_PASSWORD: pat,
|
|
132
|
+
GIT_CONFIG_COUNT: '1',
|
|
133
|
+
GIT_CONFIG_KEY_0: 'credential.helper',
|
|
134
|
+
GIT_CONFIG_VALUE_0: '',
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
131
137
|
}
|
|
132
138
|
finally {
|
|
133
139
|
await rm(dir, { recursive: true, force: true });
|
package/dist/prepare-build.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export declare function buildControlledArtifactNpmrc(userNpmrcPath?: string): st
|
|
|
16
16
|
export interface PrepareBuildOptions {
|
|
17
17
|
/** Remote sources (--repo / --git-url) install when package.json exists. */
|
|
18
18
|
remote?: boolean;
|
|
19
|
+
/** Parent environment override for deterministic security tests. */
|
|
20
|
+
sourceEnv?: NodeJS.ProcessEnv;
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
23
|
* Whether the build should run `pnpm install` before Vite bundles the tree.
|
package/dist/prepare-build.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { homedir, tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { createScrubbedChildEnv } from './child-environment.js';
|
|
6
7
|
import { stripInstallControlFiles } from './sanitize-remote-tree.js';
|
|
7
8
|
import { assertTrustedArtifactInstallTree } from './trusted-install.js';
|
|
8
9
|
/**
|
|
@@ -82,10 +83,25 @@ export async function prepareArtifactBuildRoot(dir, opts = {}) {
|
|
|
82
83
|
await rm(join(dir, 'node_modules'), { recursive: true, force: true });
|
|
83
84
|
const configDir = await mkdtemp(join(tmpdir(), 'artifact-pnpm-'));
|
|
84
85
|
const controlledNpmrc = join(configDir, '.npmrc');
|
|
85
|
-
const
|
|
86
|
-
|
|
86
|
+
const homeDir = join(configDir, 'home');
|
|
87
|
+
// Caches are deliberately scoped to one install. The untrusted package
|
|
88
|
+
// manager subprocess runs as the caller's uid, so a persistent cache would
|
|
89
|
+
// let one source poison dependencies consumed by a later build.
|
|
90
|
+
const npmCache = join(configDir, 'npm-cache');
|
|
91
|
+
const pnpmStore = join(configDir, 'pnpm-store');
|
|
92
|
+
const args = [
|
|
93
|
+
'install',
|
|
94
|
+
'--frozen-lockfile',
|
|
95
|
+
'--ignore-scripts',
|
|
96
|
+
'--prod',
|
|
97
|
+
'--store-dir',
|
|
98
|
+
pnpmStore,
|
|
99
|
+
];
|
|
100
|
+
const sourceEnv = opts.sourceEnv ?? process.env;
|
|
101
|
+
const userNpmrcPath = sourceEnv.npm_config_userconfig ?? join(homedir(), '.npmrc');
|
|
87
102
|
console.log(`[seq-studio] installing dependencies (frozen lockfile, prod only) via ${ARTIFACT_BUILD_PNPM}…`);
|
|
88
103
|
try {
|
|
104
|
+
await mkdir(homeDir, { recursive: true });
|
|
89
105
|
await writeFile(controlledNpmrc, buildControlledArtifactNpmrc(userNpmrcPath), { mode: 0o600 });
|
|
90
106
|
// Run a PINNED pnpm via `npx`, not the operator's ambient `pnpm`. The
|
|
91
107
|
// install must be reproducible across laptops and CI: in a bare temp clone
|
|
@@ -101,10 +117,14 @@ export async function prepareArtifactBuildRoot(dir, opts = {}) {
|
|
|
101
117
|
execFileSync('npx', ['--yes', ARTIFACT_BUILD_PNPM, '--dir', dir, ...args], {
|
|
102
118
|
cwd: configDir,
|
|
103
119
|
stdio: 'inherit',
|
|
104
|
-
env: {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
env: createScrubbedChildEnv({
|
|
121
|
+
homeDir,
|
|
122
|
+
source: sourceEnv,
|
|
123
|
+
additionalEnv: {
|
|
124
|
+
npm_config_userconfig: controlledNpmrc,
|
|
125
|
+
npm_config_cache: npmCache,
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
108
128
|
});
|
|
109
129
|
}
|
|
110
130
|
catch (error) {
|
package/dist/sdk.d.ts
CHANGED
|
@@ -8,8 +8,12 @@ export interface SequenceApiFetchResponse<TBody = unknown> {
|
|
|
8
8
|
status: number;
|
|
9
9
|
body: TBody;
|
|
10
10
|
}
|
|
11
|
+
export interface SequenceApiUploadOptions {
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
11
14
|
export interface SequenceApi {
|
|
12
15
|
fetch<TBody = unknown>(path: string, options?: SequenceApiFetchOptions): Promise<SequenceApiFetchResponse<TBody>>;
|
|
16
|
+
upload<TBody = unknown>(path: string, file: File, fields?: Record<string, string>, options?: SequenceApiUploadOptions): Promise<SequenceApiFetchResponse<TBody>>;
|
|
13
17
|
stream(path: string, options?: SequenceApiFetchOptions): Promise<ReadableStream<Uint8Array>>;
|
|
14
18
|
get<TBody = unknown>(path: string): Promise<TBody>;
|
|
15
19
|
post<TBody = unknown>(path: string, body?: unknown): Promise<TBody>;
|
|
@@ -37,6 +37,7 @@ export interface ResolvedSource {
|
|
|
37
37
|
/** Remove any temp materialization. No-op for a local source. Never throws. */
|
|
38
38
|
cleanup: () => Promise<void>;
|
|
39
39
|
}
|
|
40
|
+
export type SourceAuthMode = 'm2m' | 'user';
|
|
40
41
|
export interface ParsedFlags {
|
|
41
42
|
positional: string[];
|
|
42
43
|
flags: Record<string, string | true>;
|
|
@@ -44,9 +45,9 @@ export interface ParsedFlags {
|
|
|
44
45
|
/** True for sources that must be fetched from a remote (git-service / git-url). */
|
|
45
46
|
export declare function isRemoteSpec(spec: SourceSpec): boolean;
|
|
46
47
|
/**
|
|
47
|
-
* Strip
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* Strip every URL component that can carry credentials before a git URL is
|
|
49
|
+
* logged or put in an error. Unparseable input is fully withheld because its
|
|
50
|
+
* structure cannot be inspected safely.
|
|
50
51
|
*/
|
|
51
52
|
export declare function redactGitUrl(url: string): string;
|
|
52
53
|
/**
|
|
@@ -57,10 +58,12 @@ export declare function redactGitUrl(url: string): string;
|
|
|
57
58
|
export declare function parseSourceSpec(input: ParsedFlags): SourceSpec;
|
|
58
59
|
/**
|
|
59
60
|
* Resolve a spec to an on-disk source directory + provenance. For git-service
|
|
60
|
-
* the caller must supply `baseUrl` + `token
|
|
61
|
-
*
|
|
61
|
+
* the caller must supply `baseUrl` + `token` and the resolved auth mode;
|
|
62
|
+
* git-url needs no token but rejects a selected M2M principal (it shells out
|
|
63
|
+
* to `git clone`); local needs nothing.
|
|
62
64
|
*/
|
|
63
65
|
export declare function resolveArtifactSource(spec: SourceSpec, opts?: {
|
|
66
|
+
authMode?: SourceAuthMode;
|
|
64
67
|
baseUrl?: string;
|
|
65
68
|
token?: string | null;
|
|
66
69
|
}): Promise<ResolvedSource>;
|
package/dist/source-resolver.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdirSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
|
+
import { createScrubbedChildEnv } from './child-environment.js';
|
|
6
7
|
import { materializeRepo, resolveCommitSha, resolveRepo } from './git-service-client.js';
|
|
7
8
|
import { gitServiceCloneUrl, resolveGitPatFromEnv, runGitClone } from './git-clone.js';
|
|
8
9
|
/** True for sources that must be fetched from a remote (git-service / git-url). */
|
|
@@ -10,23 +11,23 @@ export function isRemoteSpec(spec) {
|
|
|
10
11
|
return spec.kind !== 'local';
|
|
11
12
|
}
|
|
12
13
|
/**
|
|
13
|
-
* Strip
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Strip every URL component that can carry credentials before a git URL is
|
|
15
|
+
* logged or put in an error. Unparseable input is fully withheld because its
|
|
16
|
+
* structure cannot be inspected safely.
|
|
16
17
|
*/
|
|
17
18
|
export function redactGitUrl(url) {
|
|
18
19
|
try {
|
|
19
20
|
const parsed = new URL(url);
|
|
20
|
-
if (parsed.username
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
if (!parsed.username && !parsed.password && !parsed.search && !parsed.hash)
|
|
22
|
+
return url;
|
|
23
|
+
parsed.username = '';
|
|
24
|
+
parsed.password = '';
|
|
25
|
+
parsed.search = '';
|
|
26
|
+
parsed.hash = '';
|
|
27
|
+
return parsed.toString();
|
|
26
28
|
}
|
|
27
29
|
catch {
|
|
28
|
-
|
|
29
|
-
return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, '$1');
|
|
30
|
+
return '<redacted-invalid-git-url>';
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
function flagString(flags, key) {
|
|
@@ -61,27 +62,43 @@ export function parseSourceSpec(input) {
|
|
|
61
62
|
return { kind: 'git-service', namespace: parts[0], name: parts[1], ref };
|
|
62
63
|
}
|
|
63
64
|
if (gitUrl) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = new URL(gitUrl);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error(`--git-url must be a credential-free HTTPS URL (got "${redactGitUrl(gitUrl)}").`);
|
|
71
|
+
}
|
|
72
|
+
if (parsed.protocol !== 'https:' ||
|
|
73
|
+
parsed.username ||
|
|
74
|
+
parsed.password ||
|
|
75
|
+
parsed.search ||
|
|
76
|
+
parsed.hash) {
|
|
77
|
+
throw new Error(`--git-url must be a credential-free HTTPS URL (got "${redactGitUrl(gitUrl)}").`);
|
|
78
|
+
}
|
|
67
79
|
return { kind: 'git-url', url: gitUrl, ref };
|
|
68
80
|
}
|
|
69
81
|
return { kind: 'local', dir: resolve(dir ?? '.') };
|
|
70
82
|
}
|
|
71
83
|
/**
|
|
72
84
|
* Resolve a spec to an on-disk source directory + provenance. For git-service
|
|
73
|
-
* the caller must supply `baseUrl` + `token
|
|
74
|
-
*
|
|
85
|
+
* the caller must supply `baseUrl` + `token` and the resolved auth mode;
|
|
86
|
+
* git-url needs no token but rejects a selected M2M principal (it shells out
|
|
87
|
+
* to `git clone`); local needs nothing.
|
|
75
88
|
*/
|
|
76
89
|
export async function resolveArtifactSource(spec, opts = {}) {
|
|
77
90
|
if (spec.kind === 'local') {
|
|
78
91
|
return { dir: spec.dir, remote: false, provenance: localGitMetadata(spec.dir), cleanup: async () => { } };
|
|
79
92
|
}
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
if (spec.kind === 'git-url' && opts.authMode === 'm2m') {
|
|
94
|
+
throw new Error('M2M/CI builds only accept platform-managed --repo sources; arbitrary --git-url sources are not trusted in CI.');
|
|
95
|
+
}
|
|
96
|
+
const workspace = await makeTempDir();
|
|
97
|
+
const dest = join(workspace, 'source');
|
|
98
|
+
const cleanup = () => removeDir(workspace);
|
|
82
99
|
try {
|
|
83
100
|
if (spec.kind === 'git-service') {
|
|
84
|
-
if (!opts.baseUrl || !opts.token) {
|
|
101
|
+
if (!opts.authMode || !opts.baseUrl || !opts.token) {
|
|
85
102
|
throw new Error('Deploying from --repo needs a target environment and auth. ' +
|
|
86
103
|
'Pass --env and run `seq-studio login`.');
|
|
87
104
|
}
|
|
@@ -92,6 +109,16 @@ export async function resolveArtifactSource(spec, opts = {}) {
|
|
|
92
109
|
const repo = await resolveRepo({ baseUrl: opts.baseUrl, token: opts.token, namespace: spec.namespace, name: spec.name });
|
|
93
110
|
const ref = spec.ref ?? repo.defaultBranch;
|
|
94
111
|
const sha = await resolveCommitSha({ baseUrl: opts.baseUrl, token: opts.token, repoId: repo.id, ref });
|
|
112
|
+
// CI/system path: the trusted M2M service account can't own a PAT (PAT
|
|
113
|
+
// issuance is gated on a human Auth0 user row), so it keeps the JSON
|
|
114
|
+
// materialize path it has always used, authenticated by its M2M token.
|
|
115
|
+
// Ignore any unrelated human PAT inherited by the parent shell: the
|
|
116
|
+
// selected principal determines both transport and audit identity.
|
|
117
|
+
if (opts.authMode === 'm2m') {
|
|
118
|
+
const count = await materializeRepo({ baseUrl: opts.baseUrl, token: opts.token, repoId: repo.id, ref: sha, destDir: dest });
|
|
119
|
+
console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, ${count} file(s), M2M)`);
|
|
120
|
+
return { dir: dest, remote: true, provenance: { gitCommit: sha, gitBranch: ref, gitDirty: false }, cleanup };
|
|
121
|
+
}
|
|
95
122
|
// Human path: materialize via a smart-HTTP `git clone` (one pack
|
|
96
123
|
// transfer). The earlier per-blob JSON walk was O(files) serial HTTP
|
|
97
124
|
// round-trips and stalled/failed on large artifacts. Smart-HTTP
|
|
@@ -104,17 +131,6 @@ export async function resolveArtifactSource(spec, opts = {}) {
|
|
|
104
131
|
console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, git clone)`);
|
|
105
132
|
return { dir: dest, remote: true, provenance: { gitCommit: sha, gitBranch: ref, gitDirty: false }, cleanup };
|
|
106
133
|
}
|
|
107
|
-
// CI/system path: the trusted M2M service account can't own a PAT (PAT
|
|
108
|
-
// issuance is gated on a human Auth0 user row), so it keeps the JSON
|
|
109
|
-
// materialize path it has always used, authenticated by its M2M token.
|
|
110
|
-
// Interactive callers (no PAT, no M2M) fall through to the hard error.
|
|
111
|
-
// TODO: teach the git-service smart-HTTP endpoint to accept the trusted
|
|
112
|
-
// M2M identity so CI can use the fast clone path too.
|
|
113
|
-
if (process.env.AUTH0_M2M_CLIENT_SECRET?.trim()) {
|
|
114
|
-
const count = await materializeRepo({ baseUrl: opts.baseUrl, token: opts.token, repoId: repo.id, ref: sha, destDir: dest });
|
|
115
|
-
console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, ${count} file(s), M2M)`);
|
|
116
|
-
return { dir: dest, remote: true, provenance: { gitCommit: sha, gitBranch: ref, gitDirty: false }, cleanup };
|
|
117
|
-
}
|
|
118
134
|
throw new Error('Deploying from --repo requires a git PAT. Set ATLAS_GIT_PAT to a token with the ' +
|
|
119
135
|
'repo:read scope (create one in Atlas → Settings → Tokens, or `seq-studio auth pat create`). ' +
|
|
120
136
|
'The git-service clone uses smart-HTTP, which authenticates by PAT — the same token you clone the repo with.');
|
|
@@ -163,9 +179,18 @@ async function removeDir(dir) {
|
|
|
163
179
|
}
|
|
164
180
|
}
|
|
165
181
|
function cloneGitUrl(url, ref, dest) {
|
|
182
|
+
const homeDir = join(resolve(dest, '..'), 'home');
|
|
183
|
+
mkdirSync(homeDir, { recursive: true });
|
|
166
184
|
const opts = {
|
|
167
185
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
168
186
|
encoding: 'utf8',
|
|
187
|
+
env: createScrubbedChildEnv({
|
|
188
|
+
homeDir,
|
|
189
|
+
additionalEnv: {
|
|
190
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
191
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
169
194
|
};
|
|
170
195
|
const git = (args) => String(execFileSync('git', args, opts)).trim();
|
|
171
196
|
try {
|
|
@@ -41,7 +41,7 @@ Use **pnpm** for artifacts you will deploy from git — `seq-studio artifact bui
|
|
|
41
41
|
|
|
42
42
|
## Defaults
|
|
43
43
|
|
|
44
|
-
- UI: prefer `@sequenceholdings/atlas-ui` primitives (`Button`, `Card`, `Input`, `Dialog`, `Select`, `Checkbox`, `DropdownMenu`, `KpiTile`, `Switch`, …) over raw Tailwind. The design-system cutover made `Select`, `Checkbox`, `DropdownMenu`, `Command`, `Calendar`, `
|
|
44
|
+
- UI: prefer `@sequenceholdings/atlas-ui` primitives (`Button`, `Card`, `Input`, `Dialog`, `Select`, `Checkbox`, `DropdownMenu`, `KpiTile`, `Switch`, …) over raw Tailwind. The design-system cutover made `Select`, `Checkbox`, `DropdownMenu`, `Command`, `Calendar`, `ScrollArea`, `Table`, and the Task family first-class exports — use them directly; fall back to `Popover` compositions or native controls only when a component is genuinely missing.
|
|
45
45
|
- Routing: `HashRouter`. Mount routes at `/`, not at the Atlas app prefix.
|
|
46
46
|
- Data: React Query is wired in `src/main.tsx`. Use `useQuery` / `useMutation` against `unwrap(seq.api.*)`.
|
|
47
47
|
- Icons: do **not** add third-party deps like `@tabler/icons-react`. Deploys from git (`--repo` / `--git-url`) build a bare tree — `seq-studio` runs `pnpm install --prod` from your lockfile, then aliases platform peers (`react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, `@sequenceholdings/atlas-ui`). List only app-specific imports (e.g. `recharts`) in `dependencies`; do not add file/workspace `@sequenceholdings/*` specs in git-service repos, because remote installs only accept registry semver specs. Inline SVG components in `src/icons.tsx`.
|
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@import "tw-animate-css";
|
|
3
3
|
@import "@sequenceholdings/atlas-ui/tokens.css";
|
|
4
|
+
|
|
5
|
+
/* Tailwind v4 skips node_modules during automatic source detection, so the
|
|
6
|
+
utility classes compiled into atlas-ui's components are never generated under
|
|
7
|
+
raw `pnpm dev`. The deploy build auto-injects this @source (build.ts's
|
|
8
|
+
atlasUiCssResolverPlugin), but plain Vite dev does not — declare it here so
|
|
9
|
+
scaffolded artifacts render atlas-ui correctly in local dev too. Idempotent
|
|
10
|
+
with the build-time injection. */
|
|
11
|
+
@source "../node_modules/@sequenceholdings/atlas-ui/dist";
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
|
-
"version": "0.1.
|
|
12
|
+
"version": "0.1.15",
|
|
13
13
|
"description": "SDK types and CLI library for building Artifact Studio apps. Driven via `seq-studio artifact <sub>` (@sequenceholdings/studio-cli), which runs the `./cli` runCli export.",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
@@ -32,6 +32,30 @@
|
|
|
32
32
|
"./git-clone": {
|
|
33
33
|
"types": "./dist/git-clone.d.ts",
|
|
34
34
|
"default": "./dist/git-clone.js"
|
|
35
|
+
},
|
|
36
|
+
"./prepare-build": {
|
|
37
|
+
"types": "./dist/prepare-build.d.ts",
|
|
38
|
+
"default": "./dist/prepare-build.js"
|
|
39
|
+
},
|
|
40
|
+
"./sanitize-remote-tree": {
|
|
41
|
+
"types": "./dist/sanitize-remote-tree.d.ts",
|
|
42
|
+
"default": "./dist/sanitize-remote-tree.js"
|
|
43
|
+
},
|
|
44
|
+
"./lockfile-origin": {
|
|
45
|
+
"types": "./dist/lockfile-origin.d.ts",
|
|
46
|
+
"default": "./dist/lockfile-origin.js"
|
|
47
|
+
},
|
|
48
|
+
"./trusted-install": {
|
|
49
|
+
"types": "./dist/trusted-install.d.ts",
|
|
50
|
+
"default": "./dist/trusted-install.js"
|
|
51
|
+
},
|
|
52
|
+
"./deployment-validation": {
|
|
53
|
+
"types": "./dist/deployment-validation.d.ts",
|
|
54
|
+
"default": "./dist/deployment-validation.js"
|
|
55
|
+
},
|
|
56
|
+
"./child-environment": {
|
|
57
|
+
"types": "./dist/child-environment.d.ts",
|
|
58
|
+
"default": "./dist/child-environment.js"
|
|
35
59
|
}
|
|
36
60
|
},
|
|
37
61
|
"files": [
|
|
@@ -61,7 +85,7 @@
|
|
|
61
85
|
"vitest": "^4.1.2"
|
|
62
86
|
},
|
|
63
87
|
"scripts": {
|
|
64
|
-
"build": "tsc && node scripts/copy-templates.mjs",
|
|
88
|
+
"build": "rm -rf dist && tsc && node scripts/copy-templates.mjs",
|
|
65
89
|
"type-check": "tsc --noEmit",
|
|
66
90
|
"test": "vitest run",
|
|
67
91
|
"bench:build-isolation": "node --expose-gc scripts/bench-build-isolation.mjs"
|
|
@@ -41,7 +41,7 @@ Use **pnpm** for artifacts you will deploy from git — `seq-studio artifact bui
|
|
|
41
41
|
|
|
42
42
|
## Defaults
|
|
43
43
|
|
|
44
|
-
- UI: prefer `@sequenceholdings/atlas-ui` primitives (`Button`, `Card`, `Input`, `Dialog`, `Select`, `Checkbox`, `DropdownMenu`, `KpiTile`, `Switch`, …) over raw Tailwind. The design-system cutover made `Select`, `Checkbox`, `DropdownMenu`, `Command`, `Calendar`, `
|
|
44
|
+
- UI: prefer `@sequenceholdings/atlas-ui` primitives (`Button`, `Card`, `Input`, `Dialog`, `Select`, `Checkbox`, `DropdownMenu`, `KpiTile`, `Switch`, …) over raw Tailwind. The design-system cutover made `Select`, `Checkbox`, `DropdownMenu`, `Command`, `Calendar`, `ScrollArea`, `Table`, and the Task family first-class exports — use them directly; fall back to `Popover` compositions or native controls only when a component is genuinely missing.
|
|
45
45
|
- Routing: `HashRouter`. Mount routes at `/`, not at the Atlas app prefix.
|
|
46
46
|
- Data: React Query is wired in `src/main.tsx`. Use `useQuery` / `useMutation` against `unwrap(seq.api.*)`.
|
|
47
47
|
- Icons: do **not** add third-party deps like `@tabler/icons-react`. Deploys from git (`--repo` / `--git-url`) build a bare tree — `seq-studio` runs `pnpm install --prod` from your lockfile, then aliases platform peers (`react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, `@sequenceholdings/atlas-ui`). List only app-specific imports (e.g. `recharts`) in `dependencies`; do not add file/workspace `@sequenceholdings/*` specs in git-service repos, because remote installs only accept registry semver specs. Inline SVG components in `src/icons.tsx`.
|
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@import "tw-animate-css";
|
|
3
3
|
@import "@sequenceholdings/atlas-ui/tokens.css";
|
|
4
|
+
|
|
5
|
+
/* Tailwind v4 skips node_modules during automatic source detection, so the
|
|
6
|
+
utility classes compiled into atlas-ui's components are never generated under
|
|
7
|
+
raw `pnpm dev`. The deploy build auto-injects this @source (build.ts's
|
|
8
|
+
atlasUiCssResolverPlugin), but plain Vite dev does not — declare it here so
|
|
9
|
+
scaffolded artifacts render atlas-ui correctly in local dev too. Idempotent
|
|
10
|
+
with the build-time injection. */
|
|
11
|
+
@source "../node_modules/@sequenceholdings/atlas-ui/dist";
|
package/dist/auth.d.ts
DELETED
package/dist/auth.js
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { createServer } from 'node:http';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { writeTokenConfig, readTokenConfig } from './config.js';
|
|
5
|
-
const REDIRECT_PORT = 5099;
|
|
6
|
-
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`;
|
|
7
|
-
const DEFAULT_AUTH0_DOMAIN = 'dev-n1t8ts403fp8oyxp.us.auth0.com';
|
|
8
|
-
const DEFAULT_AUTH0_CLIENT_ID = 'GD9riCDWocfc66odpWBjwBiX43qqAX8r';
|
|
9
|
-
const DEFAULT_AUTH0_AUDIENCE = 'https://api.studio.com';
|
|
10
|
-
function envOr(name, fallback) {
|
|
11
|
-
return process.env[name]?.trim() || fallback;
|
|
12
|
-
}
|
|
13
|
-
function readAuth0Config() {
|
|
14
|
-
return {
|
|
15
|
-
domain: envOr('ARTIFACT_STUDIO_AUTH0_DOMAIN', DEFAULT_AUTH0_DOMAIN),
|
|
16
|
-
clientId: envOr('ARTIFACT_STUDIO_AUTH0_CLIENT_ID', DEFAULT_AUTH0_CLIENT_ID),
|
|
17
|
-
audience: envOr('ARTIFACT_STUDIO_AUTH0_AUDIENCE', DEFAULT_AUTH0_AUDIENCE),
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
function base64Url(input) {
|
|
21
|
-
return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
22
|
-
}
|
|
23
|
-
function openBrowser(url) {
|
|
24
|
-
const command = process.platform === 'darwin'
|
|
25
|
-
? 'open'
|
|
26
|
-
: process.platform === 'win32'
|
|
27
|
-
? 'cmd'
|
|
28
|
-
: 'xdg-open';
|
|
29
|
-
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
30
|
-
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
31
|
-
child.unref();
|
|
32
|
-
}
|
|
33
|
-
export async function loginWithPkce() {
|
|
34
|
-
const config = readAuth0Config();
|
|
35
|
-
const verifier = base64Url(randomBytes(32));
|
|
36
|
-
const challenge = base64Url(createHash('sha256').update(verifier).digest());
|
|
37
|
-
const authUrl = new URL(`https://${config.domain}/authorize`);
|
|
38
|
-
authUrl.searchParams.set('response_type', 'code');
|
|
39
|
-
authUrl.searchParams.set('client_id', config.clientId);
|
|
40
|
-
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
41
|
-
authUrl.searchParams.set('scope', 'openid profile email offline_access');
|
|
42
|
-
authUrl.searchParams.set('audience', config.audience);
|
|
43
|
-
authUrl.searchParams.set('code_challenge', challenge);
|
|
44
|
-
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
45
|
-
const code = await waitForCode(authUrl.toString());
|
|
46
|
-
const tokenResponse = await fetch(`https://${config.domain}/oauth/token`, {
|
|
47
|
-
method: 'POST',
|
|
48
|
-
headers: { 'Content-Type': 'application/json' },
|
|
49
|
-
body: JSON.stringify({
|
|
50
|
-
grant_type: 'authorization_code',
|
|
51
|
-
client_id: config.clientId,
|
|
52
|
-
code,
|
|
53
|
-
redirect_uri: REDIRECT_URI,
|
|
54
|
-
code_verifier: verifier,
|
|
55
|
-
}),
|
|
56
|
-
});
|
|
57
|
-
if (!tokenResponse.ok) {
|
|
58
|
-
throw new Error(`Auth0 token exchange failed: ${await tokenResponse.text()}`);
|
|
59
|
-
}
|
|
60
|
-
const tokens = await tokenResponse.json();
|
|
61
|
-
if (!tokens.refresh_token) {
|
|
62
|
-
throw new Error('No refresh token returned. Ensure Auth0 offline access is enabled.');
|
|
63
|
-
}
|
|
64
|
-
await writeTokenConfig({
|
|
65
|
-
accessToken: tokens.access_token,
|
|
66
|
-
refreshToken: tokens.refresh_token,
|
|
67
|
-
expiresAt: Date.now() + (tokens.expires_in ?? 86400) * 1000,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
export async function getAccessToken() {
|
|
71
|
-
const tokens = await readTokenConfig();
|
|
72
|
-
if (!tokens.accessToken && !tokens.refreshToken)
|
|
73
|
-
return null;
|
|
74
|
-
if (tokens.accessToken && tokens.expiresAt && Date.now() < tokens.expiresAt - 60_000) {
|
|
75
|
-
return tokens.accessToken;
|
|
76
|
-
}
|
|
77
|
-
if (!tokens.refreshToken)
|
|
78
|
-
return tokens.accessToken ?? null;
|
|
79
|
-
const config = readAuth0Config();
|
|
80
|
-
const response = await fetch(`https://${config.domain}/oauth/token`, {
|
|
81
|
-
method: 'POST',
|
|
82
|
-
headers: { 'Content-Type': 'application/json' },
|
|
83
|
-
body: JSON.stringify({
|
|
84
|
-
grant_type: 'refresh_token',
|
|
85
|
-
client_id: config.clientId,
|
|
86
|
-
refresh_token: tokens.refreshToken,
|
|
87
|
-
}),
|
|
88
|
-
});
|
|
89
|
-
if (!response.ok) {
|
|
90
|
-
throw new Error('Stored refresh token is no longer valid. Run seq-studio login again.');
|
|
91
|
-
}
|
|
92
|
-
const refreshed = await response.json();
|
|
93
|
-
await writeTokenConfig({
|
|
94
|
-
accessToken: refreshed.access_token,
|
|
95
|
-
refreshToken: refreshed.refresh_token ?? tokens.refreshToken,
|
|
96
|
-
expiresAt: Date.now() + (refreshed.expires_in ?? 86400) * 1000,
|
|
97
|
-
});
|
|
98
|
-
return refreshed.access_token;
|
|
99
|
-
}
|
|
100
|
-
async function waitForCode(authUrl) {
|
|
101
|
-
return new Promise((resolve, reject) => {
|
|
102
|
-
const server = createServer((req, res) => {
|
|
103
|
-
const url = new URL(req.url ?? '/', REDIRECT_URI);
|
|
104
|
-
const code = url.searchParams.get('code');
|
|
105
|
-
const error = url.searchParams.get('error');
|
|
106
|
-
if (error) {
|
|
107
|
-
res.writeHead(400, { 'content-type': 'text/plain' });
|
|
108
|
-
res.end(`Login failed: ${error}`);
|
|
109
|
-
server.close();
|
|
110
|
-
reject(new Error(`Auth0 login failed: ${error}`));
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
if (!code) {
|
|
114
|
-
res.writeHead(400, { 'content-type': 'text/plain' });
|
|
115
|
-
res.end('Missing authorization code');
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
res.writeHead(200, { 'content-type': 'text/html' });
|
|
119
|
-
res.end('<h1>Artifact Studio login complete</h1><p>You may close this tab.</p>');
|
|
120
|
-
server.close();
|
|
121
|
-
resolve(code);
|
|
122
|
-
});
|
|
123
|
-
server.once('error', reject);
|
|
124
|
-
server.listen(REDIRECT_PORT, () => {
|
|
125
|
-
console.log(`Opening browser for Auth0 login. If it does not open, visit:\n${authUrl}`);
|
|
126
|
-
openBrowser(authUrl);
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
}
|