@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 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
- export async function getJson({ baseUrl, token, path }) {
51
- const response = await fetchWith503Retry(`${baseUrl}${path}`, {
52
- headers: { ...extraHeaders(), Authorization: `Bearer ${token}` },
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 fetchWith503Retry(`${baseUrl}${path}`, {
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 fetchWith503Retry(`${baseUrl}${path}`, {
70
- method: 'POST',
71
- headers: {
72
- ...extraHeaders(),
73
- Authorization: `Bearer ${token}`,
74
- 'Content-Type': 'application/json',
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>;
@@ -1,9 +1,15 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdtemp, readFile, rm } from 'node:fs/promises';
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
- const DEFAULT_WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'build-worker.js');
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 runWorker({ workerPath, dir, outFile, signal });
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 child = spawn(process.execPath, [workerPath, dir, outFile], {
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,5 @@
1
+ export declare function createScrubbedChildEnv({ homeDir, source, additionalEnv, }: {
2
+ homeDir: string;
3
+ source?: NodeJS.ProcessEnv;
4
+ additionalEnv?: NodeJS.ProcessEnv;
5
+ }): NodeJS.ProcessEnv;
@@ -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 setTokenProvider(provider: (() => Promise<string | null>) | null): void;
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, writeTokenConfig, } from './config.js';
7
- import { buildArtifactStudioProject, writeBuildArtifact } from './build.js';
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', 'login', 'logout', 'whoami', 'link', 'env', 'status', 'list', 'show',
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 any git URL (--git-url <url>). --ref picks a branch/tag/commit (default: the
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 clones over smart-HTTP and REQUIRES a git PAT in ATLAS_GIT_PAT
46
- (repo:read scope) — create one with \`seq-studio auth pat create --scopes
47
- repo:read\` or in Atlas → Settings → Tokens. It's the same token you clone the
48
- repo with. (--env + seq-studio login are still needed to resolve the repo and
49
- upload the deployment.)
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 buildArtifactStudioProject(source.dir);
335
+ return buildArtifactStudioProjectIsolated(source.dir);
349
336
  }
350
337
  async function buildCommand(args) {
351
338
  const spec = parseSourceSpec(args);
352
- // build is offline for a local/git-url source; only a git-service source
353
- // needs an env + token to fetch the tree.
354
- const remote = spec.kind === 'git-service'
355
- ? { baseUrl: (await envFromArgs(args)).url, token: await getRequiredToken(args) }
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 token = await getOptionalToken(args);
373
+ const auth = await getOptionalAuth(args);
374
+ const token = auth?.token ?? null;
379
375
  const spec = parseSourceSpec(args);
380
- const source = await resolveArtifactSource(spec, { baseUrl: env.url, token });
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 token = await getRequiredToken(args);
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, { baseUrl: env.url, token });
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
- const project = await ensureRemoteProject({ env: env.url, token, result, linkConfigDir: dir });
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
- async function ensureRemoteProject({ env, token, result, linkConfigDir, allowCreate = true, explicitProjectId = null }) {
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 envFromArgs(args) {
801
+ export async function getConfiguredDefaultEnv() {
775
802
  const config = await readLocalConfig().catch(() => ({ defaultEnv: undefined }));
776
- return resolveEnvironment(typeof args.flags.env === 'string' ? args.flags.env : undefined, config.defaultEnv);
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(args) {
790
- const token = await getOptionalToken(args);
791
- if (!token)
792
- throw new Error('Missing token. Run seq-studio login or pass --token.');
793
- return token;
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 getOptionalToken(args) {
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
- if (process.env.ARTIFACT_STUDIO_TOKEN)
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 + Bearer token.
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
- ...process.env,
124
- GIT_ASKPASS: scriptPath,
125
- GIT_TERMINAL_PROMPT: '0',
126
- SEQ_STUDIO_GIT_ASKPASS_PASSWORD: pat,
127
- GIT_CONFIG_COUNT: '1',
128
- GIT_CONFIG_KEY_0: 'credential.helper',
129
- GIT_CONFIG_VALUE_0: '',
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 });
@@ -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.
@@ -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 args = ['install', '--frozen-lockfile', '--ignore-scripts', '--prod'];
86
- const userNpmrcPath = process.env.npm_config_userconfig ?? join(homedir(), '.npmrc');
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
- ...process.env,
106
- npm_config_userconfig: controlledNpmrc,
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 any userinfo (`user:token@`) from a git URL before it's logged or put
48
- * in an error an https git URL can carry a PAT in its authority, and leaking
49
- * it to a terminal or CI log is a credential disclosure.
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`; git-url needs neither (it shells
61
- * out to `git clone`); local needs nothing.
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>;
@@ -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 any userinfo (`user:token@`) from a git URL before it's logged or put
14
- * in an error an https git URL can carry a PAT in its authority, and leaking
15
- * it to a terminal or CI log is a credential disclosure.
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 || parsed.password) {
21
- parsed.username = '';
22
- parsed.password = '';
23
- return parsed.toString();
24
- }
25
- return url;
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
- // Non-standard form (e.g. scp-like git@host:path) — drop a scheme://user@ prefix if present.
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
- // Guard against argument injection: git treats a leading-dash URL as a flag.
65
- if (gitUrl.startsWith('-'))
66
- throw new Error(`--git-url must be a URL, not a flag (got "${gitUrl}").`);
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`; git-url needs neither (it shells
74
- * out to `git clone`); local needs nothing.
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
- const dest = await makeTempDir();
81
- const cleanup = () => removeDir(dest);
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`, `DatePicker`, `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.
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.11",
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`, `DatePicker`, `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.
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
@@ -1,2 +0,0 @@
1
- export declare function loginWithPkce(): Promise<void>;
2
- export declare function getAccessToken(): Promise<string | null>;
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
- }