@sequenceholdings/artifact-studio 0.1.6 → 0.1.9

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 (49) hide show
  1. package/dist/api.js +66 -9
  2. package/dist/auth.js +1 -1
  3. package/dist/build-subprocess.d.ts +15 -0
  4. package/dist/build-subprocess.js +65 -0
  5. package/dist/build-worker.d.ts +1 -0
  6. package/dist/build-worker.js +28 -0
  7. package/dist/build.d.ts +11 -0
  8. package/dist/build.js +85 -14
  9. package/dist/cli.d.ts +1 -0
  10. package/dist/cli.js +486 -118
  11. package/dist/config.d.ts +1 -0
  12. package/dist/config.js +4 -1
  13. package/dist/dev-link.d.ts +60 -0
  14. package/dist/dev-link.js +155 -0
  15. package/dist/dev-lock.d.ts +5 -0
  16. package/dist/dev-lock.js +83 -0
  17. package/dist/git-clone.d.ts +41 -0
  18. package/dist/git-clone.js +178 -0
  19. package/dist/git-service-client.d.ts +32 -0
  20. package/dist/git-service-client.js +122 -0
  21. package/dist/hash.d.ts +12 -0
  22. package/dist/hash.js +13 -1
  23. package/dist/lockfile-origin.d.ts +6 -0
  24. package/dist/lockfile-origin.js +41 -0
  25. package/dist/manifest-custom-roles.d.ts +63 -0
  26. package/dist/manifest-custom-roles.js +194 -0
  27. package/dist/manifest.d.ts +31 -0
  28. package/dist/manifest.js +77 -0
  29. package/dist/prepare-build.d.ts +21 -0
  30. package/dist/prepare-build.js +94 -0
  31. package/dist/project.js +2 -1
  32. package/dist/sandbox-lint.d.ts +21 -0
  33. package/dist/sandbox-lint.js +95 -0
  34. package/dist/sanitize-remote-tree.d.ts +19 -0
  35. package/dist/sanitize-remote-tree.js +52 -0
  36. package/dist/sdk.d.ts +49 -0
  37. package/dist/source-resolver.d.ts +68 -0
  38. package/dist/source-resolver.js +202 -0
  39. package/dist/templates/react-vite/CLAUDE.md +18 -7
  40. package/dist/templates/react-vite/package.json +8 -7
  41. package/dist/trusted-install.d.ts +13 -0
  42. package/dist/trusted-install.js +186 -0
  43. package/dist/watch-loop.d.ts +26 -0
  44. package/dist/watch-loop.js +31 -0
  45. package/package.json +29 -9
  46. package/templates/react-vite/CLAUDE.md +18 -7
  47. package/templates/react-vite/package.json +8 -7
  48. package/dist/bin.d.ts +0 -2
  49. package/dist/bin.js +0 -8
@@ -0,0 +1,94 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { stripInstallControlFiles } from './sanitize-remote-tree.js';
7
+ import { assertTrustedArtifactInstallTree } from './trusted-install.js';
8
+ /** Chainguard registry routing for ephemeral remote builds. */
9
+ export const ARTIFACT_BUILD_NPMRC = `; Ephemeral install config for seq-studio artifact builds.
10
+ registry=https://libraries.cgr.dev/javascript/
11
+ //libraries.cgr.dev/javascript/:always-auth=true
12
+ //libraries.cgr.dev/javascript-upstream/:always-auth=true
13
+ ignore-scripts=true
14
+ manage-package-manager-versions=false
15
+ `;
16
+ const CHAINGUARD_AUTH_LINE_RE = /^\/\/libraries\.cgr\.dev\/[^:]+:(_auth|_authToken|always-auth)=/;
17
+ /** Pull only Chainguard registry auth lines from the developer ~/.npmrc. */
18
+ export function extractChainguardAuthLines(userNpmrc) {
19
+ const lines = [];
20
+ for (const raw of userNpmrc.split('\n')) {
21
+ const line = raw.trim();
22
+ if (!line || line.startsWith(';') || line.startsWith('#'))
23
+ continue;
24
+ if (CHAINGUARD_AUTH_LINE_RE.test(line))
25
+ lines.push(line);
26
+ }
27
+ return lines;
28
+ }
29
+ /** Merge trusted Chainguard auth from ~/.npmrc with pinned install settings. */
30
+ export function buildControlledArtifactNpmrc(userNpmrcPath = join(homedir(), '.npmrc')) {
31
+ let authBlock = '';
32
+ if (existsSync(userNpmrcPath)) {
33
+ const authLines = extractChainguardAuthLines(readFileSync(userNpmrcPath, 'utf8'));
34
+ if (authLines.length > 0)
35
+ authBlock = `${authLines.join('\n')}\n`;
36
+ }
37
+ return `${authBlock}${ARTIFACT_BUILD_NPMRC}`;
38
+ }
39
+ /**
40
+ * Whether the build should run `pnpm install` before Vite bundles the tree.
41
+ * Only remote materializations (--repo / --git-url) are bare and need install.
42
+ */
43
+ export function shouldInstallArtifactDependencies(dir, opts = {}) {
44
+ if (!opts.remote)
45
+ return false;
46
+ return existsSync(join(dir, 'package.json'));
47
+ }
48
+ /**
49
+ * Install artifact runtime deps into the build root. Platform peers (react,
50
+ * atlas-ui, react-query, artifact-studio SDK) are still force-aliased at build
51
+ * time — this step resolves app-specific imports like recharts or reactflow.
52
+ */
53
+ export async function prepareArtifactBuildRoot(dir, opts = {}) {
54
+ if (!shouldInstallArtifactDependencies(dir, opts))
55
+ return;
56
+ if (!existsSync(join(dir, 'pnpm-lock.yaml'))) {
57
+ throw new Error('Remote artifact deploy requires pnpm-lock.yaml — run `pnpm install` locally and commit the lockfile.');
58
+ }
59
+ // Materialized git-service trees may still carry historical `.npmrc` /
60
+ // `pnpm-workspace.yaml` from early scaffolds. Strip them from the temp
61
+ // build root before the trust check so `--repo` deploy works; authors
62
+ // should still delete those files on main (see artifact-git-deploy skill).
63
+ const stripped = stripInstallControlFiles(dir);
64
+ if (stripped.length > 0) {
65
+ console.warn(`[seq-studio] stripped install-control files from remote tree (not committed): ${stripped.join(', ')}`);
66
+ }
67
+ assertTrustedArtifactInstallTree(dir);
68
+ const configDir = await mkdtemp(join(tmpdir(), 'artifact-pnpm-'));
69
+ const controlledNpmrc = join(configDir, '.npmrc');
70
+ const args = ['install', '--frozen-lockfile', '--ignore-scripts', '--prod'];
71
+ const userNpmrcPath = process.env.npm_config_userconfig ?? join(homedir(), '.npmrc');
72
+ console.log('[seq-studio] installing dependencies (frozen lockfile, prod only)…');
73
+ try {
74
+ await writeFile(controlledNpmrc, buildControlledArtifactNpmrc(userNpmrcPath), { mode: 0o600 });
75
+ execFileSync('pnpm', args, {
76
+ cwd: dir,
77
+ stdio: 'inherit',
78
+ env: {
79
+ ...process.env,
80
+ npm_config_userconfig: controlledNpmrc,
81
+ COREPACK_ENABLE_AUTO_PIN: '0',
82
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: '0',
83
+ },
84
+ });
85
+ }
86
+ catch (error) {
87
+ const hint = 'Lockfile out of sync or registry auth missing — run `pnpm install` locally and commit pnpm-lock.yaml.';
88
+ const detail = error instanceof Error ? error.message : String(error);
89
+ throw new Error(`pnpm install failed for artifact build. ${hint} (${detail})`);
90
+ }
91
+ finally {
92
+ await rm(configDir, { recursive: true, force: true });
93
+ }
94
+ }
package/dist/project.js CHANGED
@@ -24,7 +24,8 @@ export async function readArtifactStudioSource(rootDir) {
24
24
  root,
25
25
  manifest,
26
26
  files,
27
- sourceHash: computeSourceHash({ files, manifest }),
27
+ // Hash the RAW manifest, not the parsed one — see computeSourceHash.
28
+ sourceHash: computeSourceHash({ files, manifest: rawManifest }),
28
29
  };
29
30
  }
30
31
  async function collectSourceFiles(root) {
@@ -0,0 +1,21 @@
1
+ import type { ArtifactStudioSourceFile } from './project.js';
2
+ /**
3
+ * Static lint for the iframe-sandbox traps that neither the type checker nor
4
+ * `vite build` can see — APIs that work under standalone `vite` dev but silently
5
+ * fail in the deployed artifact (no `allow-modals`/`allow-popups`, CSP
6
+ * `font-src data:` + `connect-src 'self'`, request/response bridge only).
7
+ *
8
+ * Findings are warnings, not errors: they're heuristics (a regex can't prove a
9
+ * `confirm(` is `window.confirm`), so they inform rather than block. Full
10
+ * reference: build-artifact-studio-app/references/sandbox-gotchas.md.
11
+ */
12
+ export interface SandboxWarning {
13
+ file: string;
14
+ line: number;
15
+ rule: string;
16
+ message: string;
17
+ }
18
+ /** Scan artifact source for sandbox-incompatible APIs. Pure; safe to call often. */
19
+ export declare function lintArtifactSandbox(files: readonly ArtifactStudioSourceFile[]): SandboxWarning[];
20
+ /** Print warnings to stderr in a compact, actionable form. Returns the count. */
21
+ export declare function reportSandboxWarnings(warnings: readonly SandboxWarning[]): number;
@@ -0,0 +1,95 @@
1
+ const RULES = [
2
+ {
3
+ name: 'no-native-modals',
4
+ // window.confirm/alert/prompt, or the bare global call (not a method like foo.confirm()).
5
+ pattern: /(?<![\w.])(?:window\.)?(confirm|alert|prompt)\s*\(/g,
6
+ message: 'window.confirm/alert/prompt are blocked in the sandbox (no allow-modals) and silently return — use AlertDialog/ConfirmDialog from @sequenceholdings/atlas-ui.',
7
+ },
8
+ {
9
+ name: 'no-popups',
10
+ pattern: /(?<![\w.])window\.open\s*\(/g,
11
+ message: 'window.open is blocked in the sandbox (no allow-popups) — navigate in-app or surface the URL as copyable text.',
12
+ },
13
+ {
14
+ name: 'no-fullscreen',
15
+ pattern: /\.(?:webkit)?requestFullscreen\s*\(/g,
16
+ message: 'The Fullscreen API is blocked in the sandbox — use a CSS overlay (fixed inset-0 z-50) toggled by state.',
17
+ },
18
+ {
19
+ name: 'no-remote-fonts',
20
+ pattern: /fonts\.(?:googleapis|gstatic)\.com/g,
21
+ message: 'Remote web fonts will not load (CSP font-src data:), and the deploy ignores index.html. Use the system stack, or inline a base64 data: @font-face.',
22
+ },
23
+ {
24
+ name: 'no-remote-fontface',
25
+ extensions: ['.css'],
26
+ pattern: /@font-face[^}]*url\(\s*['"]?https?:/gis,
27
+ message: 'A @font-face with an http(s) src is blocked by CSP (font-src data:) — inline the font as a base64 data: URI instead.',
28
+ },
29
+ {
30
+ name: 'no-sse',
31
+ pattern: /(?<![\w.])new\s+EventSource\s*\(/g,
32
+ message: 'EventSource/SSE is unavailable — the seq.api bridge is request/response only. Poll on an interval instead.',
33
+ },
34
+ {
35
+ name: 'no-websocket',
36
+ pattern: /(?<![\w.])new\s+WebSocket\s*\(/g,
37
+ message: 'WebSocket is unavailable in the sandbox — the seq.api bridge is request/response only. Poll on an interval instead.',
38
+ },
39
+ {
40
+ name: 'no-direct-external-fetch',
41
+ pattern: /(?<![\w.])fetch\s*\(\s*['"`]https?:\/\//g,
42
+ message: "Direct fetch() to an external origin is blocked (CSP connect-src 'self') — call Atlas routes via seq.api.fetch.",
43
+ },
44
+ ];
45
+ const LINTABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.css', '.html']);
46
+ function extensionOf(path) {
47
+ return path.includes('.') ? path.slice(path.lastIndexOf('.')) : '';
48
+ }
49
+ function lineNumberAt(content, index) {
50
+ let line = 1;
51
+ for (let i = 0; i < index && i < content.length; i++) {
52
+ if (content[i] === '\n')
53
+ line += 1;
54
+ }
55
+ return line;
56
+ }
57
+ /** Scan artifact source for sandbox-incompatible APIs. Pure; safe to call often. */
58
+ export function lintArtifactSandbox(files) {
59
+ const warnings = [];
60
+ for (const file of files) {
61
+ const ext = extensionOf(file.path);
62
+ if (!LINTABLE_EXTENSIONS.has(ext))
63
+ continue;
64
+ for (const rule of RULES) {
65
+ if (rule.extensions && !rule.extensions.includes(ext))
66
+ continue;
67
+ // Fresh lastIndex per file since the rule regexes are global/stateful.
68
+ rule.pattern.lastIndex = 0;
69
+ let match;
70
+ while ((match = rule.pattern.exec(file.content)) !== null) {
71
+ warnings.push({
72
+ file: file.path,
73
+ line: lineNumberAt(file.content, match.index),
74
+ rule: rule.name,
75
+ message: rule.message,
76
+ });
77
+ if (match.index === rule.pattern.lastIndex)
78
+ rule.pattern.lastIndex += 1;
79
+ }
80
+ }
81
+ }
82
+ return warnings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
83
+ }
84
+ /** Print warnings to stderr in a compact, actionable form. Returns the count. */
85
+ export function reportSandboxWarnings(warnings) {
86
+ if (warnings.length === 0)
87
+ return 0;
88
+ const noun = warnings.length === 1 ? 'warning' : 'warnings';
89
+ console.warn(`\n[seq-studio] ${warnings.length} sandbox ${noun} (these work in vite dev but fail in the deployed artifact):`);
90
+ for (const warning of warnings) {
91
+ console.warn(` ${warning.file}:${warning.line} [${warning.rule}] ${warning.message}`);
92
+ }
93
+ console.warn(' Reference: build-artifact-studio-app/references/sandbox-gotchas.md\n');
94
+ return warnings.length;
95
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Install-control files that must never influence a remote `--repo` /
3
+ * `--git-url` install. seq-studio pins Chainguard registry + ignore-scripts
4
+ * itself; a committed `.npmrc` / workspace file / pnpm hook would either
5
+ * fail the trust check or let the artifact redirect resolution.
6
+ *
7
+ * We strip them from the *materialized temp tree* (not from the git repo)
8
+ * so a dirty historical tip can still deploy while authors clean main via
9
+ * a Pull that deletes the files for real.
10
+ *
11
+ * The hook/workspace matchers are shared with `assertTrustedArtifactInstallTree`
12
+ * so the strip set cannot drift from the reject set.
13
+ */
14
+ export declare function listInstallControlFiles(dir: string, prefix?: string): string[];
15
+ /**
16
+ * Delete install-control files from a materialized remote build root.
17
+ * Returns the relative paths that were removed (for logging).
18
+ */
19
+ export declare function stripInstallControlFiles(dir: string): string[];
@@ -0,0 +1,52 @@
1
+ import { existsSync, readdirSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PNPM_HOOK_FILE_RE, PNPM_WORKSPACE_FILE_RE } from './trusted-install.js';
4
+ /**
5
+ * Install-control files that must never influence a remote `--repo` /
6
+ * `--git-url` install. seq-studio pins Chainguard registry + ignore-scripts
7
+ * itself; a committed `.npmrc` / workspace file / pnpm hook would either
8
+ * fail the trust check or let the artifact redirect resolution.
9
+ *
10
+ * We strip them from the *materialized temp tree* (not from the git repo)
11
+ * so a dirty historical tip can still deploy while authors clean main via
12
+ * a Pull that deletes the files for real.
13
+ *
14
+ * The hook/workspace matchers are shared with `assertTrustedArtifactInstallTree`
15
+ * so the strip set cannot drift from the reject set.
16
+ */
17
+ export function listInstallControlFiles(dir, prefix = '') {
18
+ const found = [];
19
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
20
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
21
+ if (entry.isDirectory()) {
22
+ if (entry.name === 'node_modules' || entry.name === '.git')
23
+ continue;
24
+ found.push(...listInstallControlFiles(join(dir, entry.name), rel));
25
+ continue;
26
+ }
27
+ if (!entry.isFile())
28
+ continue;
29
+ if (rel === '.npmrc' || rel.endsWith('/.npmrc'))
30
+ found.push(rel);
31
+ else if (PNPM_WORKSPACE_FILE_RE.test(rel))
32
+ found.push(rel);
33
+ else if (PNPM_HOOK_FILE_RE.test(rel))
34
+ found.push(rel);
35
+ }
36
+ return found;
37
+ }
38
+ /**
39
+ * Delete install-control files from a materialized remote build root.
40
+ * Returns the relative paths that were removed (for logging).
41
+ */
42
+ export function stripInstallControlFiles(dir) {
43
+ const removed = [];
44
+ for (const rel of listInstallControlFiles(dir)) {
45
+ const abs = join(dir, rel);
46
+ if (!existsSync(abs))
47
+ continue;
48
+ unlinkSync(abs);
49
+ removed.push(rel);
50
+ }
51
+ return removed;
52
+ }
package/dist/sdk.d.ts CHANGED
@@ -36,10 +36,57 @@ export interface SequenceEnvironment {
36
36
  export interface SequenceAuth {
37
37
  hasPermission(permission: string): Promise<boolean>;
38
38
  getPermissions(): Promise<string[]>;
39
+ /**
40
+ * The current user's custom-role capabilities on THIS artifact (e.g.
41
+ * `['view', 'edit']` for a reporting tool), resolved server-side via the
42
+ * generic `/api/custom-roles/artifact/:projectId/resolve` endpoint. The host
43
+ * supplies the project id from its trusted context — artifacts cannot point
44
+ * this at another host. Distinct from `getPermissions()`, which returns the
45
+ * user's platform-wide permissions rather than per-artifact role grants.
46
+ * Rejects when the artifact has no project context or the user has no access
47
+ * to the host.
48
+ */
49
+ getCapabilities(): Promise<string[]>;
39
50
  }
40
51
  export interface SequenceFlags {
41
52
  isEnabled(flagName: string): Promise<boolean>;
42
53
  }
54
+ /**
55
+ * The Lattice run/node/step this artifact is being served for. Populated by the
56
+ * embedding surface (the Lattice Inbox provides all four; the builder Artifacts
57
+ * tab has no live step). Each field is `null` when the artifact is not embedded
58
+ * in that surface — e.g. a standalone preview has no context at all.
59
+ */
60
+ export interface SequenceLatticeContext {
61
+ /** The Lattice run the embed belongs to. */
62
+ runId: string | null;
63
+ /** The human node id the artifact is attached to (tells you which node you're serving). */
64
+ nodeId: string | null;
65
+ /** The specific step run (the assignee's task) — the step-scoped id reviewers hold access to. */
66
+ stepId: string | null;
67
+ /** The process the run belongs to. */
68
+ processId: string | null;
69
+ }
70
+ export interface SequenceLattice {
71
+ /**
72
+ * Read the embed context synchronously (no network round trip — the host
73
+ * injects it into the sandbox URL up front). Returns all-null fields when the
74
+ * artifact is not embedded in a Lattice surface.
75
+ */
76
+ context(): SequenceLatticeContext;
77
+ }
78
+ export interface SequenceHost {
79
+ /**
80
+ * Navigate the parent Atlas shell to an in-app route — e.g.
81
+ * `seq.host.navigate('/crm#/customers/<uid>')`. Fire-and-forget: the artifact
82
+ * sandbox cannot navigate the top frame itself (no `allow-top-navigation`),
83
+ * so this posts the request to the host, which validates it and drives the
84
+ * shell router. The host ignores anything that isn't a same-app relative
85
+ * path (must start with a single `/`), so an artifact can never push the
86
+ * user to an external origin.
87
+ */
88
+ navigate(path: string): void;
89
+ }
43
90
  export interface SequenceArtifactSdk {
44
91
  api: SequenceApi;
45
92
  ai: {
@@ -50,6 +97,8 @@ export interface SequenceArtifactSdk {
50
97
  env: SequenceEnvironment;
51
98
  auth: SequenceAuth;
52
99
  flags: SequenceFlags;
100
+ lattice: SequenceLattice;
101
+ host: SequenceHost;
53
102
  }
54
103
  declare global {
55
104
  interface Window {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Build/deploy source is pluggable. A `deploy`/`build`/`plan` invocation reads
3
+ * its source tree from one of three places, all of which produce the same
4
+ * on-disk tree the rest of the pipeline (build → upload) already consumes:
5
+ *
6
+ * - local folder (default, unchanged): `deploy [dir]`
7
+ * - a platform git-service repo: `deploy --repo <ns>/<name> [--ref <r>]`
8
+ * - any external git URL: `deploy --git-url <url> [--ref <r>]`
9
+ *
10
+ * Remote sources are materialized to a temp dir and torn down after the build.
11
+ */
12
+ /** Provenance recorded on the deployment row (git_commit/git_branch/git_dirty). */
13
+ export interface SourceProvenance {
14
+ gitCommit: string | null;
15
+ gitBranch: string | null;
16
+ gitDirty: boolean | null;
17
+ }
18
+ export type SourceSpec = {
19
+ kind: 'local';
20
+ dir: string;
21
+ } | {
22
+ kind: 'git-service';
23
+ namespace: string;
24
+ name: string;
25
+ ref?: string;
26
+ } | {
27
+ kind: 'git-url';
28
+ url: string;
29
+ ref?: string;
30
+ };
31
+ export interface ResolvedSource {
32
+ /** Absolute, canonical (realpath'd) directory the build should run against. */
33
+ dir: string;
34
+ /** True for --repo / --git-url materializations (bare tree, needs install). */
35
+ remote: boolean;
36
+ provenance: SourceProvenance;
37
+ /** Remove any temp materialization. No-op for a local source. Never throws. */
38
+ cleanup: () => Promise<void>;
39
+ }
40
+ export interface ParsedFlags {
41
+ positional: string[];
42
+ flags: Record<string, string | true>;
43
+ }
44
+ /** True for sources that must be fetched from a remote (git-service / git-url). */
45
+ export declare function isRemoteSpec(spec: SourceSpec): boolean;
46
+ /**
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.
50
+ */
51
+ export declare function redactGitUrl(url: string): string;
52
+ /**
53
+ * Derive the source spec from parsed CLI args. Enforces mutual exclusivity:
54
+ * a `[dir]` positional, `--repo`, and `--git-url` are three ways to name the
55
+ * same input, so at most one may be given.
56
+ */
57
+ export declare function parseSourceSpec(input: ParsedFlags): SourceSpec;
58
+ /**
59
+ * 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.
62
+ */
63
+ export declare function resolveArtifactSource(spec: SourceSpec, opts?: {
64
+ baseUrl?: string;
65
+ token?: string | null;
66
+ }): Promise<ResolvedSource>;
67
+ /** Working-tree git provenance for a local source (current behavior). */
68
+ export declare function localGitMetadata(dir: string): SourceProvenance;
@@ -0,0 +1,202 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdirSync, rmSync } from 'node:fs';
3
+ import { mkdtemp, realpath, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { materializeRepo, resolveCommitSha, resolveRepo } from './git-service-client.js';
7
+ import { gitServiceCloneUrl, resolveGitPatFromEnv, runGitClone } from './git-clone.js';
8
+ /** True for sources that must be fetched from a remote (git-service / git-url). */
9
+ export function isRemoteSpec(spec) {
10
+ return spec.kind !== 'local';
11
+ }
12
+ /**
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.
16
+ */
17
+ export function redactGitUrl(url) {
18
+ try {
19
+ 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;
26
+ }
27
+ 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
+ }
31
+ }
32
+ function flagString(flags, key) {
33
+ const value = flags[key];
34
+ if (value === true)
35
+ throw new Error(`--${key} requires a value.`);
36
+ return value;
37
+ }
38
+ /**
39
+ * Derive the source spec from parsed CLI args. Enforces mutual exclusivity:
40
+ * a `[dir]` positional, `--repo`, and `--git-url` are three ways to name the
41
+ * same input, so at most one may be given.
42
+ */
43
+ export function parseSourceSpec(input) {
44
+ const repo = flagString(input.flags, 'repo');
45
+ const gitUrl = flagString(input.flags, 'git-url');
46
+ const ref = flagString(input.flags, 'ref');
47
+ const dir = input.positional[0];
48
+ if (repo && gitUrl)
49
+ throw new Error('Pass only one of --repo or --git-url.');
50
+ if ((repo || gitUrl) && dir)
51
+ throw new Error('A [dir] argument cannot be combined with --repo / --git-url.');
52
+ if (ref && !repo && !gitUrl)
53
+ throw new Error('--ref only applies together with --repo or --git-url.');
54
+ if (ref && ref.startsWith('-'))
55
+ throw new Error(`--ref must not start with "-" (got "${ref}").`);
56
+ if (repo) {
57
+ const parts = repo.split('/');
58
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
59
+ throw new Error(`--repo must be "<namespace>/<name>" (got "${repo}").`);
60
+ }
61
+ return { kind: 'git-service', namespace: parts[0], name: parts[1], ref };
62
+ }
63
+ 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}").`);
67
+ return { kind: 'git-url', url: gitUrl, ref };
68
+ }
69
+ return { kind: 'local', dir: resolve(dir ?? '.') };
70
+ }
71
+ /**
72
+ * 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.
75
+ */
76
+ export async function resolveArtifactSource(spec, opts = {}) {
77
+ if (spec.kind === 'local') {
78
+ return { dir: spec.dir, remote: false, provenance: localGitMetadata(spec.dir), cleanup: async () => { } };
79
+ }
80
+ const dest = await makeTempDir();
81
+ const cleanup = () => removeDir(dest);
82
+ try {
83
+ if (spec.kind === 'git-service') {
84
+ if (!opts.baseUrl || !opts.token) {
85
+ throw new Error('Deploying from --repo needs a target environment and auth. Pass --env and run `seqapi login`.');
86
+ }
87
+ // resolveRepo + resolveCommitSha use the Auth0/M2M token: the clone URL
88
+ // is id-addressed (no by-path endpoint), and we pin the build to the
89
+ // resolved SHA — the same commit recorded as provenance — rather than the
90
+ // mutable ref, matching the prior materialize behavior.
91
+ const repo = await resolveRepo({ baseUrl: opts.baseUrl, token: opts.token, namespace: spec.namespace, name: spec.name });
92
+ const ref = spec.ref ?? repo.defaultBranch;
93
+ const sha = await resolveCommitSha({ baseUrl: opts.baseUrl, token: opts.token, repoId: repo.id, ref });
94
+ // Human path: materialize via a smart-HTTP `git clone` (one pack
95
+ // transfer). The earlier per-blob JSON walk was O(files) serial HTTP
96
+ // round-trips and stalled/failed on large artifacts. Smart-HTTP
97
+ // authenticates by PAT (Basic-auth password), NOT the Auth0 bearer — the
98
+ // same token you clone the repo with. No PAT, no git workflow.
99
+ const pat = resolveGitPatFromEnv();
100
+ if (pat) {
101
+ const cloneUrl = gitServiceCloneUrl(opts.baseUrl, repo.id);
102
+ await runGitClone({ cloneUrl, destDir: dest, pat, ref: sha });
103
+ console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, git clone)`);
104
+ return { dir: dest, remote: true, provenance: { gitCommit: sha, gitBranch: ref, gitDirty: false }, cleanup };
105
+ }
106
+ // CI/system path: the trusted M2M service account can't own a PAT (PAT
107
+ // issuance is gated on a human Auth0 user row), so it keeps the JSON
108
+ // materialize path it has always used, authenticated by its M2M token.
109
+ // Interactive callers (no PAT, no M2M) fall through to the hard error.
110
+ // TODO: teach the git-service smart-HTTP endpoint to accept the trusted
111
+ // M2M identity so CI can use the fast clone path too.
112
+ if (process.env.AUTH0_M2M_CLIENT_SECRET?.trim()) {
113
+ const count = await materializeRepo({ baseUrl: opts.baseUrl, token: opts.token, repoId: repo.id, ref: sha, destDir: dest });
114
+ console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, ${count} file(s), M2M)`);
115
+ return { dir: dest, remote: true, provenance: { gitCommit: sha, gitBranch: ref, gitDirty: false }, cleanup };
116
+ }
117
+ throw new Error('Deploying from --repo requires a git PAT. Set ATLAS_GIT_PAT to a token with the ' +
118
+ 'repo:read scope (create one in Atlas → Settings → Tokens, or `seq-studio auth pat create`). ' +
119
+ 'The git-service clone uses smart-HTTP, which authenticates by PAT — the same token you clone the repo with.');
120
+ }
121
+ const { sha, branch } = cloneGitUrl(spec.url, spec.ref, dest);
122
+ console.log(`[seq-studio] source: ${redactGitUrl(spec.url)}${spec.ref ? `@${spec.ref}` : ''} (${sha.slice(0, 10)})`);
123
+ return { dir: dest, remote: true, provenance: { gitCommit: sha, gitBranch: branch, gitDirty: false }, cleanup };
124
+ }
125
+ catch (error) {
126
+ await cleanup();
127
+ throw error;
128
+ }
129
+ }
130
+ /** Working-tree git provenance for a local source (current behavior). */
131
+ export function localGitMetadata(dir) {
132
+ const gitOpts = {
133
+ cwd: dir,
134
+ encoding: 'utf8',
135
+ stdio: ['ignore', 'pipe', 'ignore'],
136
+ };
137
+ try {
138
+ const gitCommit = String(execFileSync('git', ['rev-parse', 'HEAD'], gitOpts)).trim();
139
+ const gitBranch = String(execFileSync('git', ['branch', '--show-current'], gitOpts)).trim();
140
+ const gitDirty = String(execFileSync('git', ['status', '--porcelain'], gitOpts)).trim().length > 0;
141
+ return { gitCommit, gitBranch, gitDirty };
142
+ }
143
+ catch {
144
+ return { gitCommit: null, gitBranch: null, gitDirty: null };
145
+ }
146
+ }
147
+ async function makeTempDir() {
148
+ const dir = await mkdtemp(join(tmpdir(), 'artifact-src-'));
149
+ // Canonicalize: the Vite html plugin emits the entry file name relative to
150
+ // process.cwd() and rejects `..` segments. os.tmpdir() resolves through
151
+ // symlinks on macOS (/tmp -> /private/tmp, /var -> /private/var), so without
152
+ // realpath the build root and the realpath'd html input mismatch and the
153
+ // build fails with a "must not be a relative path" error.
154
+ return realpath(dir);
155
+ }
156
+ async function removeDir(dir) {
157
+ try {
158
+ await rm(dir, { recursive: true, force: true });
159
+ }
160
+ catch (error) {
161
+ console.warn(`[seq-studio] could not remove temp source dir ${dir}: ${error instanceof Error ? error.message : error}`);
162
+ }
163
+ }
164
+ function cloneGitUrl(url, ref, dest) {
165
+ const opts = {
166
+ stdio: ['ignore', 'pipe', 'pipe'],
167
+ encoding: 'utf8',
168
+ };
169
+ const git = (args) => String(execFileSync('git', args, opts)).trim();
170
+ try {
171
+ if (ref) {
172
+ try {
173
+ // Fast path: shallow single-branch clone (works for a branch or tag).
174
+ git(['clone', '--depth', '1', '--single-branch', '--branch', ref, '--', url, dest]);
175
+ }
176
+ catch {
177
+ // A commit SHA can't be `--branch`ed — full clone, then checkout. Reset
178
+ // the destination first: a failed shallow attempt can leave partial
179
+ // contents, which would make `git clone` reject a non-empty directory.
180
+ rmSync(dest, { recursive: true, force: true });
181
+ mkdirSync(dest, { recursive: true });
182
+ git(['clone', '--', url, dest]);
183
+ git(['-C', dest, 'checkout', ref]);
184
+ }
185
+ }
186
+ else {
187
+ git(['clone', '--depth', '1', '--', url, dest]);
188
+ }
189
+ }
190
+ catch (error) {
191
+ // Never surface the raw URL — an https git URL can carry a PAT in userinfo.
192
+ // Scrub any occurrence of it from git's stderr/message too.
193
+ const safeUrl = redactGitUrl(url);
194
+ const detail = error.stderr?.trim() || (error instanceof Error ? error.message : String(error));
195
+ const safeDetail = detail.split(url).join(safeUrl);
196
+ throw new Error(`git clone failed for ${safeUrl}${ref ? `@${ref}` : ''}: ${safeDetail}`);
197
+ }
198
+ const sha = git(['-C', dest, 'rev-parse', 'HEAD']);
199
+ const abbrev = git(['-C', dest, 'rev-parse', '--abbrev-ref', 'HEAD']);
200
+ const branch = ref ?? (abbrev && abbrev !== 'HEAD' ? abbrev : null);
201
+ return { sha, branch };
202
+ }