@sequenceholdings/artifact-studio 0.1.12 → 0.2.0

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.
Files changed (43) hide show
  1. package/VERSION-PIN.md +54 -0
  2. package/dist/active-deploy-policy.d.ts +36 -0
  3. package/dist/active-deploy-policy.js +66 -0
  4. package/dist/api.js +59 -13
  5. package/dist/artifact-ignore.d.ts +16 -0
  6. package/dist/artifact-ignore.js +38 -0
  7. package/dist/atlas-ui-css-plugin.d.ts +15 -0
  8. package/dist/atlas-ui-css-plugin.js +69 -0
  9. package/dist/build-subprocess.d.ts +3 -1
  10. package/dist/build-subprocess.js +28 -6
  11. package/dist/build.d.ts +25 -0
  12. package/dist/build.js +97 -50
  13. package/dist/child-environment.d.ts +5 -0
  14. package/dist/child-environment.js +83 -0
  15. package/dist/cli.d.ts +40 -1
  16. package/dist/cli.js +173 -87
  17. package/dist/config.d.ts +0 -9
  18. package/dist/config.js +0 -18
  19. package/dist/deployment-validation.d.ts +9 -0
  20. package/dist/deployment-validation.js +70 -0
  21. package/dist/git-clone.js +16 -10
  22. package/dist/peer-versions.d.ts +19 -0
  23. package/dist/peer-versions.js +44 -0
  24. package/dist/prepare-build.d.ts +2 -0
  25. package/dist/prepare-build.js +27 -7
  26. package/dist/project.d.ts +3 -0
  27. package/dist/project.js +22 -6
  28. package/dist/runtime-chunks.d.ts +4 -0
  29. package/dist/runtime-chunks.js +15 -0
  30. package/dist/sdk.d.ts +10 -0
  31. package/dist/source-resolver.d.ts +8 -5
  32. package/dist/source-resolver.js +81 -32
  33. package/dist/templates/react-vite/AGENTS.md +90 -0
  34. package/dist/templates/react-vite/CLAUDE.md +4 -63
  35. package/dist/templates/react-vite/src/main.tsx +7 -1
  36. package/dist/templates/react-vite/src/styles.css +11 -0
  37. package/package.json +18 -5
  38. package/templates/react-vite/AGENTS.md +90 -0
  39. package/templates/react-vite/CLAUDE.md +4 -63
  40. package/templates/react-vite/src/main.tsx +7 -1
  41. package/templates/react-vite/src/styles.css +11 -0
  42. package/dist/auth.d.ts +0 -2
  43. package/dist/auth.js +0 -129
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const cliRequire = createRequire(import.meta.url);
6
+ const thisModuleDir = dirname(fileURLToPath(import.meta.url));
7
+ function readPackageVersion({ packageJsonPath, expectedName }) {
8
+ const raw = readFileSync(packageJsonPath, 'utf8');
9
+ let parsed;
10
+ try {
11
+ parsed = JSON.parse(raw);
12
+ }
13
+ catch (error) {
14
+ throw new Error(`Failed to parse ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
15
+ }
16
+ if (typeof parsed.name === 'string' && parsed.name !== expectedName) {
17
+ throw new Error(`Expected package name ${expectedName} at ${packageJsonPath}, got ${parsed.name}`);
18
+ }
19
+ if (typeof parsed.version !== 'string' || parsed.version.trim() === '') {
20
+ throw new Error(`Missing version in ${packageJsonPath}`);
21
+ }
22
+ return parsed.version.trim();
23
+ }
24
+ /**
25
+ * Resolve the building CLI + force-aliased atlas-ui versions from disk.
26
+ * Throws if either package.json is unreadable — a silent null here would
27
+ * undermine the observability DES-254 adds for the blast-radius scanner.
28
+ */
29
+ export function resolveArtifactStudioPeerVersions() {
30
+ // Built code lives in `dist/`; package.json sits one level up. The same
31
+ // relative path works when vitest loads from `src/`.
32
+ const cliPackageJson = resolve(thisModuleDir, '..', 'package.json');
33
+ const atlasUiPackageJson = cliRequire.resolve('@sequenceholdings/atlas-ui/package.json');
34
+ return {
35
+ cliVersion: readPackageVersion({
36
+ packageJsonPath: cliPackageJson,
37
+ expectedName: '@sequenceholdings/artifact-studio',
38
+ }),
39
+ atlasUiVersion: readPackageVersion({
40
+ packageJsonPath: atlasUiPackageJson,
41
+ expectedName: '@sequenceholdings/atlas-ui',
42
+ }),
43
+ };
44
+ }
@@ -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/project.d.ts CHANGED
@@ -9,6 +9,9 @@ export interface ArtifactStudioSource {
9
9
  manifest: ArtifactStudioManifest;
10
10
  files: ArtifactStudioSourceFile[];
11
11
  sourceHash: string;
12
+ /** Path prefixes from `.artifactignore` — also enforced as non-importable by Vite. */
13
+ ignorePatterns: string[];
12
14
  }
13
15
  export declare function hasArtifactStudioManifest(rootDir: string): boolean;
14
16
  export declare function readArtifactStudioSource(rootDir: string): Promise<ArtifactStudioSource>;
17
+ export declare function loadArtifactIgnorePatterns(root: string): Promise<string[]>;
package/dist/project.js CHANGED
@@ -2,6 +2,7 @@ import { readFile, readdir } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join, relative, resolve } from 'node:path';
4
4
  import yaml from 'js-yaml';
5
+ import { ARTIFACT_IGNORE_FILE, isArtifactIgnoredPath, parseArtifactIgnore, } from './artifact-ignore.js';
5
6
  import { computeSourceHash } from './hash.js';
6
7
  import { parseArtifactStudioManifest } from './manifest.js';
7
8
  import { normalizeArtifactPath } from './paths.js';
@@ -16,7 +17,8 @@ export async function readArtifactStudioSource(rootDir) {
16
17
  const root = resolve(rootDir);
17
18
  const rawManifest = yaml.load(await readFile(join(root, 'artifact.bundle.yml'), 'utf8'));
18
19
  const manifest = parseArtifactStudioManifest(rawManifest);
19
- const files = await collectSourceFiles(root);
20
+ const ignorePatterns = await loadArtifactIgnorePatterns(root);
21
+ const files = await collectSourceFiles(root, ignorePatterns);
20
22
  if (!files.some((file) => file.path === manifest.artifact.entrypoint)) {
21
23
  throw new Error(`Entrypoint file not found: ${manifest.artifact.entrypoint}`);
22
24
  }
@@ -26,22 +28,28 @@ export async function readArtifactStudioSource(rootDir) {
26
28
  files,
27
29
  // Hash the RAW manifest, not the parsed one — see computeSourceHash.
28
30
  sourceHash: computeSourceHash({ files, manifest: rawManifest }),
31
+ ignorePatterns,
29
32
  };
30
33
  }
31
- async function collectSourceFiles(root) {
34
+ async function collectSourceFiles(root, ignorePatterns) {
32
35
  const files = [];
33
36
  async function walk(dir) {
34
37
  const entries = await readdir(dir, { withFileTypes: true });
35
38
  for (const entry of entries) {
39
+ // Built-in ignores first — `normalizeArtifactPath` rejects `node_modules`
40
+ // (and similar) as source paths, so we must not normalize those names.
41
+ if (entry.isDirectory() && IGNORED_DIRS.has(entry.name))
42
+ continue;
36
43
  const absolute = join(dir, entry.name);
44
+ const path = normalizeArtifactPath(relative(root, absolute));
45
+ if (isArtifactIgnoredPath(path, ignorePatterns))
46
+ continue;
37
47
  if (entry.isDirectory()) {
38
- if (!IGNORED_DIRS.has(entry.name))
39
- await walk(absolute);
48
+ await walk(absolute);
40
49
  continue;
41
50
  }
42
51
  if (!entry.isFile())
43
52
  continue;
44
- const path = normalizeArtifactPath(relative(root, absolute));
45
53
  if (!isTextFile(path))
46
54
  continue;
47
55
  files.push({
@@ -54,9 +62,17 @@ async function collectSourceFiles(root) {
54
62
  await walk(root);
55
63
  return files.sort((a, b) => a.path.localeCompare(b.path));
56
64
  }
65
+ export async function loadArtifactIgnorePatterns(root) {
66
+ const ignorePath = join(root, ARTIFACT_IGNORE_FILE);
67
+ if (!existsSync(ignorePath))
68
+ return [];
69
+ return parseArtifactIgnore(await readFile(ignorePath, 'utf8'));
70
+ }
57
71
  function isTextFile(path) {
58
72
  const ext = path.includes('.') ? path.slice(path.lastIndexOf('.')) : '';
59
- return path === 'artifact.bundle.yml' || TEXT_EXTENSIONS.has(ext);
73
+ return (path === 'artifact.bundle.yml' ||
74
+ path === ARTIFACT_IGNORE_FILE ||
75
+ TEXT_EXTENSIONS.has(ext));
60
76
  }
61
77
  function inferContentType(path) {
62
78
  if (path.endsWith('.tsx') || path.endsWith('.ts'))
@@ -0,0 +1,4 @@
1
+ /** Vite async-chunk namespace. Keep in sync with atlas `limits.ts` (ARTIFACT_RUNTIME_CHUNK_DIR). */
2
+ export declare const ARTIFACT_RUNTIME_CHUNK_DIR = "chunks";
3
+ export declare function isArtifactRuntimeChunkPath(path: string): boolean;
4
+ export declare function assertSafeRuntimeChunkFileName(fileName: string): string;
@@ -0,0 +1,15 @@
1
+ /** Vite async-chunk namespace. Keep in sync with atlas `limits.ts` (ARTIFACT_RUNTIME_CHUNK_DIR). */
2
+ export const ARTIFACT_RUNTIME_CHUNK_DIR = 'chunks';
3
+ export function isArtifactRuntimeChunkPath(path) {
4
+ return path === ARTIFACT_RUNTIME_CHUNK_DIR || path.startsWith(`${ARTIFACT_RUNTIME_CHUNK_DIR}/`);
5
+ }
6
+ export function assertSafeRuntimeChunkFileName(fileName) {
7
+ const normalized = fileName.replace(/\\/g, '/').replace(/^\.\//, '');
8
+ if (!isArtifactRuntimeChunkPath(normalized)) {
9
+ throw new Error(`Runtime chunk path must be under ${ARTIFACT_RUNTIME_CHUNK_DIR}/: ${fileName}`);
10
+ }
11
+ if (normalized.includes('..') || normalized.startsWith('/')) {
12
+ throw new Error(`Invalid runtime chunk path: ${fileName}`);
13
+ }
14
+ return normalized;
15
+ }
package/dist/sdk.d.ts CHANGED
@@ -8,8 +8,18 @@ export interface SequenceApiFetchResponse<TBody = unknown> {
8
8
  status: number;
9
9
  body: TBody;
10
10
  }
11
+ export interface SequenceApiUploadOptions {
12
+ timeoutMs?: number;
13
+ /**
14
+ * HTTP method for the multipart request. Defaults to POST; PUT is allowed
15
+ * for update-with-file routes (e.g. replacing an existing disclosure's
16
+ * file). Both are gated by the same `capabilities.api.write` check.
17
+ */
18
+ method?: 'POST' | 'PUT';
19
+ }
11
20
  export interface SequenceApi {
12
21
  fetch<TBody = unknown>(path: string, options?: SequenceApiFetchOptions): Promise<SequenceApiFetchResponse<TBody>>;
22
+ upload<TBody = unknown>(path: string, file: File, fields?: Record<string, string>, options?: SequenceApiUploadOptions): Promise<SequenceApiFetchResponse<TBody>>;
13
23
  stream(path: string, options?: SequenceApiFetchOptions): Promise<ReadableStream<Uint8Array>>;
14
24
  get<TBody = unknown>(path: string): Promise<TBody>;
15
25
  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.');
@@ -137,14 +153,38 @@ export function localGitMetadata(dir) {
137
153
  };
138
154
  try {
139
155
  const gitCommit = String(execFileSync('git', ['rev-parse', 'HEAD'], gitOpts)).trim();
140
- const gitBranch = String(execFileSync('git', ['branch', '--show-current'], gitOpts)).trim();
156
+ let gitBranch = String(execFileSync('git', ['branch', '--show-current'], gitOpts)).trim();
157
+ // GitHub Actions (and similar) check out a SHA → detached HEAD even when
158
+ // deploying main. Map that back to `main` only when HEAD equals the tip of
159
+ // main / origin/main — do not trust GITHUB_REF alone (workflow branch can
160
+ // differ from the checked-out SHA).
161
+ if (!gitBranch) {
162
+ gitBranch = resolveDetachedMainBranch({ gitOpts, gitCommit }) ?? '';
163
+ }
141
164
  const gitDirty = String(execFileSync('git', ['status', '--porcelain'], gitOpts)).trim().length > 0;
142
- return { gitCommit, gitBranch, gitDirty };
165
+ return { gitCommit, gitBranch: gitBranch || null, gitDirty };
143
166
  }
144
167
  catch {
145
168
  return { gitCommit: null, gitBranch: null, gitDirty: null };
146
169
  }
147
170
  }
171
+ /**
172
+ * When `git branch --show-current` is empty (detached HEAD), return `main` if
173
+ * HEAD is exactly the tip of a local/remote main ref — otherwise null.
174
+ */
175
+ function resolveDetachedMainBranch({ gitOpts, gitCommit, }) {
176
+ for (const ref of ['refs/heads/main', 'main', 'refs/remotes/origin/main', 'origin/main']) {
177
+ try {
178
+ const tip = String(execFileSync('git', ['rev-parse', ref], gitOpts)).trim();
179
+ if (tip === gitCommit)
180
+ return 'main';
181
+ }
182
+ catch {
183
+ // ref missing in this clone (shallow / no remotes) — try the next one
184
+ }
185
+ }
186
+ return null;
187
+ }
148
188
  async function makeTempDir() {
149
189
  const dir = await mkdtemp(join(tmpdir(), 'artifact-src-'));
150
190
  // Canonicalize: the Vite html plugin emits the entry file name relative to
@@ -163,9 +203,18 @@ async function removeDir(dir) {
163
203
  }
164
204
  }
165
205
  function cloneGitUrl(url, ref, dest) {
206
+ const homeDir = join(resolve(dest, '..'), 'home');
207
+ mkdirSync(homeDir, { recursive: true });
166
208
  const opts = {
167
209
  stdio: ['ignore', 'pipe', 'pipe'],
168
210
  encoding: 'utf8',
211
+ env: createScrubbedChildEnv({
212
+ homeDir,
213
+ additionalEnv: {
214
+ GIT_CONFIG_NOSYSTEM: '1',
215
+ GIT_TERMINAL_PROMPT: '0',
216
+ },
217
+ }),
169
218
  };
170
219
  const git = (args) => String(execFileSync('git', args, opts)).trim();
171
220
  try {
@@ -0,0 +1,90 @@
1
+ # Sequence Artifact Studio app
2
+
3
+ This is a sandboxed iframe SPA that runs inside Atlas and talks to Atlas backend routes through a postMessage bridge exposed as `seq.api.*` from `@sequenceholdings/artifact-studio`. No direct network access, no API keys in the bundle — everything goes through the bridge, and every endpoint the artifact uses must be declared in `artifact.bundle.yml`.
4
+
5
+ ## Three rules that bite
6
+
7
+ 1. **Envelope unwrap.** Atlas wraps every CRM-style response in `{ success: true, data: T }`. The bridge passes that envelope through raw — it does **not** unwrap automatically. Route every read/write through `unwrap<T>()` in `src/lib/api.ts`:
8
+ ```ts
9
+ import { unwrap, seq } from './lib/api'
10
+ const customer = await unwrap<Customer>(seq.api.get(`/api/crm/customers/${id}`))
11
+ ```
12
+ For endpoints with extra envelope fields (pagination `meta`, warnings), drop to `seq.api.fetch` and read `res.body` directly.
13
+
14
+ 2. **FormData blocker.** `seq.api.fetch` `JSON.stringify`s any non-string `body` value, which silently turns `FormData` into `"{}"`. For multipart uploads, use `seq.api.upload(path, file, fields?, options?)`; the bridge constructs `FormData` in the trusted host and returns the usual `{ ok, status, body }` envelope.
15
+
16
+ 3. **Capability matcher: exact paths, `/*` prefix wildcards, and single-segment `*` patterns.** `{id}` template forms do **not** match. List both the bare path and the prefix wildcard for broad resources, or use a least-privilege segment pattern for dynamic routes:
17
+ ```yaml
18
+ - /api/crm/customers
19
+ - /api/crm/customers/*
20
+ - /api/lattice/runs/*/advance
21
+ ```
22
+
23
+ ## Prerequisite
24
+
25
+ None — `seq-studio artifact init <dir>` works from any directory; no monorepo checkout is required. The template lists `@sequenceholdings/artifact-studio` and `@sequenceholdings/atlas-ui` in **devDependencies** for local editor/type-check tooling only. `seq-studio artifact build|deploy` bundles against the CLI's own pinned copies of `react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, and `@sequenceholdings/atlas-ui`, and resolves `@sequenceholdings/artifact-studio` to a virtual `window.seq` module. Remote deploys (`--repo` / `--git-url`) run `pnpm install --prod` for **app-specific** `dependencies` only (e.g. `recharts`, `reactflow`) from the artifact lockfile.
26
+
27
+ ## Package manager
28
+
29
+ Use **pnpm** for artifacts you will deploy from git — `seq-studio artifact build|deploy --repo/--git-url` runs `pnpm install --frozen-lockfile --prod` when `package.json` is present, so commit `pnpm-lock.yaml`. Local editor-only installs can use another package manager, but pnpm keeps Sequence's `minimumReleaseAge` supply-chain quarantine in effect (see the studio-cli README). If you have a stale checkout from the old `file:`-link era, `rm -rf node_modules` and reinstall.
30
+
31
+ ## Key files
32
+
33
+ | File | Role |
34
+ |---|---|
35
+ | `artifact.bundle.yml` | Capability manifest — every API path the artifact calls: exact paths, `/*` prefix wildcards, or single-segment `*` patterns |
36
+ | `src/main.tsx` | Providers: `QueryClientProvider` → `PortalContainerProvider` → `App` |
37
+ | `src/App.tsx` | Entrypoint UI |
38
+ | `src/lib/api.ts` | `unwrap<T>` envelope helper + re-export of `seq` |
39
+ | `src/styles.css` | Tailwind v4 + `@sequenceholdings/atlas-ui/tokens.css` (+ v2 sheets; build injects them when missing) |
40
+ | `vite.config.ts` | Vite + React + Tailwind v4 plugin |
41
+
42
+ ## Defaults
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`, `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
+ - Routing: `HashRouter`. Mount routes at `/`, not at the Atlas app prefix.
46
+ - Data: React Query is wired in `src/main.tsx`. Use `useQuery` / `useMutation` against `unwrap(seq.api.*)`.
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`.
48
+
49
+ ## Tokens — colour comes from semantic roles, never a raw ramp
50
+
51
+ This app bundles `@sequenceholdings/atlas-ui/tokens.css` and re-themes per tenant for free. Reach for a **semantic role** and colour follows the host brand and light/dark automatically.
52
+
53
+ **Never** use the numbered `black-*` / `white-*` opacity scales (`text-black-40`, `bg-black-6`, `border-black-10`), raw Tailwind neutrals (`gray|slate|zinc|neutral|stone-N`), arbitrary hex in class strings (`bg-[#1f2937]`), or `var(--black-*)` / `var(--color-black-*)`. These are **retired** — post-cutover they resolve to nothing or to a fixed ink that won't flip in dark. Bare `text-white` / `bg-black` and opacity modifiers like `bg-foreground/10` stay legal; only the numbered steps and raw palettes are banned. There is no old→new lookup table on purpose — pick by *what the element is and what it sits on*.
54
+
55
+ | Situation | Role |
56
+ |---|---|
57
+ | The page surface | `bg-background` — the page only, never a panel |
58
+ | Bordered/rounded panel, card, list | `bg-card` — a border is not a surface; every bordered container needs an explicit surface |
59
+ | Inset well inside a card (code block, read-only value) | `bg-muted` |
60
+ | Form field (input, textarea, select trigger) | `bg-field` |
61
+ | Interactive / hover fill | `bg-accent` / `bg-surface-hover` |
62
+ | Selected / active / focus state fill | `bg-foreground/8`–`bg-foreground/15` overlay |
63
+ | Modal scrim behind a dialog | `bg-overlay` — never `bg-foreground/N` (washes lighter in dark) |
64
+ | Primary text | `text-foreground` |
65
+ | Resting nav items, meta rows | `text-foreground-secondary` |
66
+ | Section labels, captions | `text-muted-foreground` |
67
+ | Standard border | `border-border` (form controls: `border-input`) |
68
+
69
+ Surfaces **nest** — never repeat a rung (`bg-card` inside `bg-card` is invisible): `bg-background` (page) → `bg-card` (panel, + border + `shadow-xs` to lift) → `bg-muted` (inset well).
70
+
71
+ **Dual-runtime.** Inside Atlas the artifact runs in its own iframe and inherits nothing — the runtime posts the host's resolved brand in as inline custom properties (theme channel + `data-tokens`). Standalone `vite dev` has no host, so the artifact must still render on the base semantic roles. **Do not** bundle `tokens.v2.tenants.css`, hardcode an opco's brand, or add `[data-theme]` selectors — that pins the artifact to one tenant.
72
+
73
+ ## Bridge methods
74
+
75
+ Two primitives on `seq.api`:
76
+
77
+ - `seq.api.fetch(path, options?)` returns `{ ok, status, body }` — does not throw on non-2xx. Use when you need the status (e.g. handle 409) or the raw envelope with extra fields like `meta`.
78
+ - `seq.api.request(method, path, body?)` throws on non-2xx and returns parsed JSON. Sugar wrappers `seq.api.get / .post / .patch / .delete` are thin shells around `request`, so they also throw.
79
+
80
+ Wrap reads/writes that should auto-throw through `unwrap()` in `src/lib/api.ts`. Drop to `seq.api.fetch` when you need pagination `meta` or status inspection.
81
+
82
+ ## Lattice embed context
83
+
84
+ When the artifact is served from a Lattice human node (the **Tasks/Inbox** task surface, or the builder Artifacts tab), `seq.lattice.context()` tells you which run/node/step you're rendering for:
85
+
86
+ ```ts
87
+ const { runId, nodeId, stepId, processId } = seq.lattice.context()
88
+ ```
89
+
90
+ It's **synchronous** (the host injects this into the sandbox up front — no bridge round trip) and each field is `null` when unavailable. The Inbox provides all four; the builder Artifacts tab has no live `stepId`; a standalone open has no context at all. Use it instead of hand-parsing `window.location` — that's the sanctioned, stable accessor, and you don't need to declare anything in `artifact.bundle.yml` for it. To read step-scoped data, pass `runId`/`stepId` to the relevant Atlas endpoint (which still authorizes the caller server-side).
@@ -1,66 +1,7 @@
1
1
  # Sequence Artifact Studio app
2
2
 
3
- This is a sandboxed iframe SPA that runs inside Atlas and talks to Atlas backend routes through a postMessage bridge exposed as `seq.api.*` from `@sequenceholdings/artifact-studio`. No direct network access, no API keys in the bundle everything goes through the bridge, and every endpoint the artifact uses must be declared in `artifact.bundle.yml`.
3
+ Agent instructions for this artifact live in `AGENTS.md`one file every coding agent reads
4
+ (Cursor and Codex load `AGENTS.md` natively; the import below pulls it in for Claude Code).
5
+ Add or edit guidance in `AGENTS.md`, not here.
4
6
 
5
- ## Three rules that bite
6
-
7
- 1. **Envelope unwrap.** Atlas wraps every CRM-style response in `{ success: true, data: T }`. The bridge passes that envelope through raw — it does **not** unwrap automatically. Route every read/write through `unwrap<T>()` in `src/lib/api.ts`:
8
- ```ts
9
- import { unwrap, seq } from './lib/api'
10
- const customer = await unwrap<Customer>(seq.api.get(`/api/crm/customers/${id}`))
11
- ```
12
- For endpoints with extra envelope fields (pagination `meta`, warnings), drop to `seq.api.fetch` and read `res.body` directly.
13
-
14
- 2. **FormData blocker.** `seq.api.fetch` `JSON.stringify`s any non-string `body` value, which silently turns `FormData` into `"{}"`. For uploads, base64-encode the bytes and POST as JSON, or patch the Atlas bridge first.
15
-
16
- 3. **Capability matcher: exact paths, `/*` prefix wildcards, and single-segment `*` patterns.** `{id}` template forms do **not** match. List both the bare path and the prefix wildcard for broad resources, or use a least-privilege segment pattern for dynamic routes:
17
- ```yaml
18
- - /api/crm/customers
19
- - /api/crm/customers/*
20
- - /api/lattice/runs/*/advance
21
- ```
22
-
23
- ## Prerequisite
24
-
25
- None — `seq-studio artifact init <dir>` works from any directory; no monorepo checkout is required. The template lists `@sequenceholdings/artifact-studio` and `@sequenceholdings/atlas-ui` in **devDependencies** for local editor/type-check tooling only. `seq-studio artifact build|deploy` bundles against the CLI's own pinned copies of `react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, and `@sequenceholdings/atlas-ui`, and resolves `@sequenceholdings/artifact-studio` to a virtual `window.seq` module. Remote deploys (`--repo` / `--git-url`) run `pnpm install --prod` for **app-specific** `dependencies` only (e.g. `recharts`, `reactflow`) from the artifact lockfile.
26
-
27
- ## Package manager
28
-
29
- Use **pnpm** for artifacts you will deploy from git — `seq-studio artifact build|deploy --repo/--git-url` runs `pnpm install --frozen-lockfile --prod` when `package.json` is present, so commit `pnpm-lock.yaml`. Local editor-only installs can use another package manager, but pnpm keeps Sequence's `minimumReleaseAge` supply-chain quarantine in effect (see the studio-cli README). If you have a stale checkout from the old `file:`-link era, `rm -rf node_modules` and reinstall.
30
-
31
- ## Key files
32
-
33
- | File | Role |
34
- |---|---|
35
- | `artifact.bundle.yml` | Capability manifest — every API path the artifact calls: exact paths, `/*` prefix wildcards, or single-segment `*` patterns |
36
- | `src/main.tsx` | Providers: `QueryClientProvider` → `PortalContainerProvider` → `App` |
37
- | `src/App.tsx` | Entrypoint UI |
38
- | `src/lib/api.ts` | `unwrap<T>` envelope helper + re-export of `seq` |
39
- | `src/styles.css` | Tailwind v4 + `@sequenceholdings/atlas-ui/tokens.css` |
40
- | `vite.config.ts` | Vite + React + Tailwind v4 plugin |
41
-
42
- ## Defaults
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.
45
- - Routing: `HashRouter`. Mount routes at `/`, not at the Atlas app prefix.
46
- - Data: React Query is wired in `src/main.tsx`. Use `useQuery` / `useMutation` against `unwrap(seq.api.*)`.
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`.
48
-
49
- ## Bridge methods
50
-
51
- Two primitives on `seq.api`:
52
-
53
- - `seq.api.fetch(path, options?)` returns `{ ok, status, body }` — does not throw on non-2xx. Use when you need the status (e.g. handle 409) or the raw envelope with extra fields like `meta`.
54
- - `seq.api.request(method, path, body?)` throws on non-2xx and returns parsed JSON. Sugar wrappers `seq.api.get / .post / .patch / .delete` are thin shells around `request`, so they also throw.
55
-
56
- Wrap reads/writes that should auto-throw through `unwrap()` in `src/lib/api.ts`. Drop to `seq.api.fetch` when you need pagination `meta` or status inspection.
57
-
58
- ## Lattice embed context
59
-
60
- When the artifact is served from a Lattice human node (the **Tasks/Inbox** task surface, or the builder Artifacts tab), `seq.lattice.context()` tells you which run/node/step you're rendering for:
61
-
62
- ```ts
63
- const { runId, nodeId, stepId, processId } = seq.lattice.context()
64
- ```
65
-
66
- It's **synchronous** (the host injects this into the sandbox up front — no bridge round trip) and each field is `null` when unavailable. The Inbox provides all four; the builder Artifacts tab has no live `stepId`; a standalone open has no context at all. Use it instead of hand-parsing `window.location` — that's the sanctioned, stable accessor, and you don't need to declare anything in `artifact.bundle.yml` for it. To read step-scoped data, pass `runId`/`stepId` to the relevant Atlas endpoint (which still authorizes the caller server-side).
7
+ @AGENTS.md