@sequenceholdings/artifact-studio 0.1.15 → 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.
package/VERSION-PIN.md ADDED
@@ -0,0 +1,54 @@
1
+ # CLI atlas-ui stamp (DES-254)
2
+
3
+ A (re)built Artifact Studio app gets whatever `@sequenceholdings/atlas-ui` the
4
+ **building CLI** carries — not a semver the artifact's own `package.json`
5
+ declares.
6
+
7
+ ## What this means
8
+
9
+ `shared/services/artifact-studio/src/build.ts` force-aliases platform peers via
10
+ `PEER_RESOLVE_ALIAS`, including:
11
+
12
+ - `@sequenceholdings/atlas-ui` (+ tokens / charts subpaths)
13
+ - `@sequenceholdings/lattice-form-renderer`
14
+ - `react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`
15
+
16
+ So the deploy/build path always bundles the CLI package's copy. Listing
17
+ `@sequenceholdings/atlas-ui` in an artifact's `devDependencies` is for editor /
18
+ type-check only; it does not select the bundled version.
19
+
20
+ ## Why
21
+
22
+ - One React / atlas-ui instance per bundle (no context-breaking duplicates).
23
+ - No consumer version matrix — artifacts move when rebuilt with a newer CLI
24
+ (or a monorepo checkout whose workspace atlas-ui changed).
25
+ - Matches the product default: artifacts render as platform UI.
26
+
27
+ ## Observability
28
+
29
+ Each deployment records:
30
+
31
+ | Field | Meaning |
32
+ | --- | --- |
33
+ | `cliVersion` | `@sequenceholdings/artifact-studio` version that built the bundle |
34
+ | `atlasUiVersion` | Resolved `@sequenceholdings/atlas-ui` version from that CLI's dependency tree |
35
+
36
+ These feed blast-radius / "who is on which stamp?" questions (DES-259). They are
37
+ `package.json` versions, not content hashes — a `--link`'d local rebuild can
38
+ change code without bumping the version string.
39
+
40
+ `seq-studio artifact deploy --skip-unchanged` compares both `sourceHash` and
41
+ these peer versions before no-op'ing, so a newer CLI still rebuilds identical
42
+ source (and older rows with null peer fields are rebuilt once to populate them).
43
+ `seq-studio artifact plan` reports the same decision, so its `action:` line
44
+ never claims `no-op` for a deploy that would actually rebuild.
45
+
46
+ ## V2 CSS sheets (sandbox previews)
47
+
48
+ `seq-studio artifact build` idempotently injects `tokens.v2.css` + `theme.v2.css`
49
+ when the artifact CSS entry lacks them (iframes do not inherit Atlas CSSOM; the
50
+ host only stamps `data-tokens` / brand vars). Keep listing `tokens.css` for the
51
+ ungated standalone/`vite dev` fallback. **Do not** import `tokens.v2.tenants.css`.
52
+
53
+ **DES-227 cutover:** remove the build inject; make `tokens.css` the v2 default;
54
+ optionally sweep hand-written `@import …/tokens.v2.css` / `theme.v2.css` lines.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Official (active-channel) Artifact Studio deploys must come from a **clean
3
+ * `main` checkout with a resolved commit** — whether the source is a platform
4
+ * git-service repo (`--repo`) or a monorepo / local checkout. Preview deploys
5
+ * may use feature branches, dirty trees, or other refs.
6
+ *
7
+ * This checks reported provenance (branch name + explicit clean flag + commit).
8
+ * It does **not** independently verify that `gitCommit` equals the remote
9
+ * `origin/main` tip — callers still need to merge and pull/materialize `main`
10
+ * before deploying.
11
+ *
12
+ * Canonical module for the CLI (`@sequenceholdings/artifact-studio`). Atlas
13
+ * keeps a behavior-identical copy at
14
+ * `atlas/src/server/services/artifact-studio/active-deploy-policy.ts` so the
15
+ * API can enforce the same rule without depending on this package's heavy
16
+ * build graph — keep the two in sync.
17
+ */
18
+ export interface ActiveDeployProvenance {
19
+ gitCommit?: string | null;
20
+ gitBranch?: string | null;
21
+ gitDirty?: boolean | null;
22
+ }
23
+ /** Canonical default branch name for official artifact deploys. */
24
+ export declare const ACTIVE_DEPLOY_DEFAULT_BRANCH = "main";
25
+ export declare class ActiveDeployPolicyError extends Error {
26
+ readonly code: "ACTIVE_DEPLOY_POLICY";
27
+ constructor(message: string);
28
+ }
29
+ /** Strip `refs/heads/` so `refs/heads/main` and `main` compare equal. */
30
+ export declare function normalizeGitBranchName(branch: string | null | undefined): string | null;
31
+ export declare function isActiveDeployDefaultBranch(branch: string | null | undefined): boolean;
32
+ /**
33
+ * Throw if provenance is not eligible for an official (active) deploy.
34
+ * Preview callers must not invoke this.
35
+ */
36
+ export declare function assertActiveDeployProvenance(provenance: ActiveDeployProvenance): void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Official (active-channel) Artifact Studio deploys must come from a **clean
3
+ * `main` checkout with a resolved commit** — whether the source is a platform
4
+ * git-service repo (`--repo`) or a monorepo / local checkout. Preview deploys
5
+ * may use feature branches, dirty trees, or other refs.
6
+ *
7
+ * This checks reported provenance (branch name + explicit clean flag + commit).
8
+ * It does **not** independently verify that `gitCommit` equals the remote
9
+ * `origin/main` tip — callers still need to merge and pull/materialize `main`
10
+ * before deploying.
11
+ *
12
+ * Canonical module for the CLI (`@sequenceholdings/artifact-studio`). Atlas
13
+ * keeps a behavior-identical copy at
14
+ * `atlas/src/server/services/artifact-studio/active-deploy-policy.ts` so the
15
+ * API can enforce the same rule without depending on this package's heavy
16
+ * build graph — keep the two in sync.
17
+ */
18
+ /** Canonical default branch name for official artifact deploys. */
19
+ export const ACTIVE_DEPLOY_DEFAULT_BRANCH = 'main';
20
+ export class ActiveDeployPolicyError extends Error {
21
+ code = 'ACTIVE_DEPLOY_POLICY';
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = 'ActiveDeployPolicyError';
25
+ }
26
+ }
27
+ /** Strip `refs/heads/` so `refs/heads/main` and `main` compare equal. */
28
+ export function normalizeGitBranchName(branch) {
29
+ if (branch == null)
30
+ return null;
31
+ const trimmed = branch.trim();
32
+ if (!trimmed)
33
+ return null;
34
+ return trimmed.replace(/^refs\/heads\//, '');
35
+ }
36
+ export function isActiveDeployDefaultBranch(branch) {
37
+ return normalizeGitBranchName(branch) === ACTIVE_DEPLOY_DEFAULT_BRANCH;
38
+ }
39
+ /**
40
+ * Throw if provenance is not eligible for an official (active) deploy.
41
+ * Preview callers must not invoke this.
42
+ */
43
+ export function assertActiveDeployProvenance(provenance) {
44
+ // Require an explicit clean recording — omitting gitDirty must not pass.
45
+ if (provenance.gitDirty !== false) {
46
+ throw new ActiveDeployPolicyError('Active (official) artifact deploys require a clean git tree ' +
47
+ '(`gitDirty: false`). Commit or stash your changes, or use ' +
48
+ '`seq-studio artifact dev` / a preview deploy for dirty feature-branch work.');
49
+ }
50
+ const branch = normalizeGitBranchName(provenance.gitBranch);
51
+ if (!branch) {
52
+ throw new ActiveDeployPolicyError('Active (official) artifact deploys require git provenance on `main`. ' +
53
+ 'Check out `main` (monorepo or local clone) or deploy with ' +
54
+ '`seq-studio artifact deploy --repo <ns>/<slug>` (defaults to the repo default branch). ' +
55
+ 'Feature branches and detached HEAD (unless at the tip of `main`) are preview-only.');
56
+ }
57
+ if (branch !== ACTIVE_DEPLOY_DEFAULT_BRANCH) {
58
+ throw new ActiveDeployPolicyError(`Active (official) artifact deploys must use \`${ACTIVE_DEPLOY_DEFAULT_BRANCH}\` ` +
59
+ `(got branch/ref \`${branch}\`). Merge via a Pull, then redeploy from \`main\`; ` +
60
+ 'use a preview channel for feature-branch or dirty local work.');
61
+ }
62
+ if (!provenance.gitCommit?.trim()) {
63
+ throw new ActiveDeployPolicyError('Active (official) artifact deploys require a resolved git commit on `main`. ' +
64
+ 'Deploy from a git checkout or `--repo` so provenance is recorded.');
65
+ }
66
+ }
package/dist/api.js CHANGED
@@ -107,6 +107,14 @@ async function responseError(method, path, response) {
107
107
  serverMessage = body.detail;
108
108
  else if (typeof body.error === 'string' && body.error)
109
109
  serverMessage = body.error;
110
+ // Zod / registry payloads often put the actionable bits next to a generic
111
+ // detail ("Validation error"). Append a compact summary so deploys aren't
112
+ // undebuggable when the CLI only prints `detail`.
113
+ const extras = summarizeStructuredIssues(body.errors ?? body.issues);
114
+ if (serverMessage && extras)
115
+ serverMessage = `${serverMessage}: ${extras}`;
116
+ else if (!serverMessage && extras)
117
+ serverMessage = extras;
110
118
  }
111
119
  catch {
112
120
  // Non-JSON body (HTML error page, plain text) — fall back to raw text.
@@ -114,3 +122,31 @@ async function responseError(method, path, response) {
114
122
  const summary = serverMessage ?? (text.trim() ? text.trim().slice(0, 300) : '(empty response body)');
115
123
  return `${method} ${path} failed (HTTP ${response.status}): ${summary}`;
116
124
  }
125
+ function summarizeStructuredIssues(value) {
126
+ if (!Array.isArray(value) || value.length === 0)
127
+ return null;
128
+ const parts = [];
129
+ for (const issue of value.slice(0, 5)) {
130
+ // RegistryValidationError ships `issues` as string[]; Zod uses objects.
131
+ if (typeof issue === 'string' && issue.trim()) {
132
+ parts.push(issue.trim());
133
+ continue;
134
+ }
135
+ if (!issue || typeof issue !== 'object')
136
+ continue;
137
+ const row = issue;
138
+ const path = Array.isArray(row.path) ? row.path.map(String).join('.') : '';
139
+ const message = typeof row.message === 'string' ? row.message : null;
140
+ if (message) {
141
+ parts.push(path ? `${path}: ${message}` : message);
142
+ continue;
143
+ }
144
+ if (row.code === 'too_big' && typeof row.maximum === 'number' && path) {
145
+ parts.push(`${path}: at most ${row.maximum}`);
146
+ }
147
+ }
148
+ if (parts.length === 0)
149
+ return null;
150
+ const more = value.length > parts.length ? ` (+${value.length - parts.length} more)` : '';
151
+ return parts.join('; ') + more;
152
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `.artifactignore` — committed trees that must not ship in a deploy payload.
3
+ *
4
+ * Sidecar packs (BI catalogs, local agent workspaces, etc.) can live next to
5
+ * artifact source in git without counting against the API's 500-file / 10MB
6
+ * source caps. Patterns are path prefixes relative to the artifact root
7
+ * (gitignore-lite: comments, blanks, trailing slashes; no globs).
8
+ *
9
+ * Ignored paths are also rejected as Vite build dependencies — they are
10
+ * excluded from sourceHash, so an import under an ignored prefix would let
11
+ * `deploy --skip-unchanged` keep a stale bundle.
12
+ */
13
+ export declare const ARTIFACT_IGNORE_FILE = ".artifactignore";
14
+ export declare function parseArtifactIgnore(contents: string): string[];
15
+ /** True when `relPath` is exactly a pattern or under a patterned directory. */
16
+ export declare function isArtifactIgnoredPath(relPath: string, patterns: readonly string[]): boolean;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `.artifactignore` — committed trees that must not ship in a deploy payload.
3
+ *
4
+ * Sidecar packs (BI catalogs, local agent workspaces, etc.) can live next to
5
+ * artifact source in git without counting against the API's 500-file / 10MB
6
+ * source caps. Patterns are path prefixes relative to the artifact root
7
+ * (gitignore-lite: comments, blanks, trailing slashes; no globs).
8
+ *
9
+ * Ignored paths are also rejected as Vite build dependencies — they are
10
+ * excluded from sourceHash, so an import under an ignored prefix would let
11
+ * `deploy --skip-unchanged` keep a stale bundle.
12
+ */
13
+ export const ARTIFACT_IGNORE_FILE = '.artifactignore';
14
+ export function parseArtifactIgnore(contents) {
15
+ const patterns = [];
16
+ for (const rawLine of contents.split(/\r?\n/)) {
17
+ const line = rawLine.trim();
18
+ if (!line || line.startsWith('#'))
19
+ continue;
20
+ const normalized = line.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
21
+ if (normalized)
22
+ patterns.push(normalized);
23
+ }
24
+ return patterns;
25
+ }
26
+ /** True when `relPath` is exactly a pattern or under a patterned directory. */
27
+ export function isArtifactIgnoredPath(relPath, patterns) {
28
+ if (patterns.length === 0)
29
+ return false;
30
+ const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
31
+ if (!normalized)
32
+ return false;
33
+ for (const pattern of patterns) {
34
+ if (normalized === pattern || normalized.startsWith(`${pattern}/`))
35
+ return true;
36
+ }
37
+ return false;
38
+ }
@@ -0,0 +1,15 @@
1
+ import type { Plugin } from 'vite';
2
+ /**
3
+ * Idempotent v2 token sheets for Artifact Studio builds. Sandbox iframes do not
4
+ * inherit Atlas CSSOM; the host stamps `data-tokens` (floors to v2) but roles
5
+ * only resolve if the bundle includes these sheets. Skip when the entry already
6
+ * imports them. Remove this inject at DES-227 cutover when `tokens.css` *is* v2.
7
+ */
8
+ export declare function cssAlreadyImportsSheet(code: string, sheetFileName: string): boolean;
9
+ /** Absolute or package `@import` lines to prepend when the CSS entry lacks v2 sheets. */
10
+ export declare function planAtlasUiV2CssImports(code: string, _atlasUiDir: string): string[];
11
+ export declare function atlasUiCssResolverPlugin({ atlasUiDir, atlasUiDist, formRendererDist, }: {
12
+ atlasUiDir: string;
13
+ atlasUiDist: string;
14
+ formRendererDist: string | null;
15
+ }): Plugin;
@@ -0,0 +1,69 @@
1
+ import { existsSync } 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 cliDir = dirname(fileURLToPath(import.meta.url));
7
+ const tailwindCssPath = resolve(dirname(cliRequire.resolve('tailwindcss/package.json')), 'index.css');
8
+ const twAnimateCssPath = resolveTwAnimateCssPath();
9
+ function resolveTwAnimateCssPath() {
10
+ const candidates = [
11
+ resolve(cliDir, '..', 'node_modules', 'tw-animate-css', 'dist', 'tw-animate.css'),
12
+ resolve(cliDir, '..', '..', '..', 'tw-animate-css', 'dist', 'tw-animate.css'),
13
+ ];
14
+ const resolved = candidates.find((candidate) => existsSync(candidate));
15
+ if (!resolved) {
16
+ throw new Error(`Unable to resolve tw-animate-css dist file. Tried: ${candidates.join(', ')}`);
17
+ }
18
+ return resolved;
19
+ }
20
+ /**
21
+ * Idempotent v2 token sheets for Artifact Studio builds. Sandbox iframes do not
22
+ * inherit Atlas CSSOM; the host stamps `data-tokens` (floors to v2) but roles
23
+ * only resolve if the bundle includes these sheets. Skip when the entry already
24
+ * imports them. Remove this inject at DES-227 cutover when `tokens.css` *is* v2.
25
+ */
26
+ export function cssAlreadyImportsSheet(code, sheetFileName) {
27
+ return code.includes(sheetFileName);
28
+ }
29
+ /** Absolute or package `@import` lines to prepend when the CSS entry lacks v2 sheets. */
30
+ export function planAtlasUiV2CssImports(code, _atlasUiDir) {
31
+ const imports = [];
32
+ // Package subpaths — resolved via Vite alias (PEER_RESOLVE_ALIAS). Prefer
33
+ // package form over absolute paths so Tailwind/Vite follow the same path as
34
+ // hand-written author imports.
35
+ if (!cssAlreadyImportsSheet(code, 'tokens.v2.css')) {
36
+ imports.push('@import "@sequenceholdings/atlas-ui/tokens.v2.css";');
37
+ }
38
+ if (!cssAlreadyImportsSheet(code, 'theme.v2.css')) {
39
+ imports.push('@import "@sequenceholdings/atlas-ui/theme.v2.css";');
40
+ }
41
+ return imports;
42
+ }
43
+ export function atlasUiCssResolverPlugin({ atlasUiDir, atlasUiDist, formRendererDist, }) {
44
+ return {
45
+ name: 'atlas-ui-css-resolver',
46
+ enforce: 'pre',
47
+ transform(code, id) {
48
+ if (!id.endsWith('.css'))
49
+ return undefined;
50
+ let result = code;
51
+ if (result.includes('@import "tailwindcss"')) {
52
+ result = result.replace('@import "tailwindcss"', `@import ${JSON.stringify(tailwindCssPath)}`);
53
+ // Prebuilt dists whose class names Tailwind must scan: CLI-pinned
54
+ // atlas-ui plus the form renderer (field-scope.tsx emits Tailwind
55
+ // utility classes that only appear in the renderer's dist).
56
+ const sources = [`@source ${JSON.stringify(atlasUiDist)};`];
57
+ if (formRendererDist) {
58
+ sources.push(`@source ${JSON.stringify(formRendererDist)};`);
59
+ }
60
+ const v2Imports = planAtlasUiV2CssImports(result, atlasUiDir);
61
+ result = `${sources.join('\n')}\n${v2Imports.join('\n')}${v2Imports.length > 0 ? '\n' : ''}${result}`;
62
+ }
63
+ if (result.includes('@import "tw-animate-css"')) {
64
+ result = result.replace('@import "tw-animate-css"', `@import ${JSON.stringify(twAnimateCssPath)}`);
65
+ }
66
+ return result !== code ? result : undefined;
67
+ },
68
+ };
69
+ }
package/dist/build.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ArtifactStudioSourceFile } from './project.js';
2
2
  import type { ArtifactStudioManifest } from './manifest.js';
3
+ import { type ArtifactStudioPeerVersions } from './peer-versions.js';
3
4
  /**
4
5
  * Import aliases for the Vite build. Artifacts are built against their own
5
6
  * source tree, which usually has no installed node_modules, so every bare
@@ -8,16 +9,40 @@ import type { ArtifactStudioManifest } from './manifest.js';
8
9
  * react-query QueryClient context — so a duplicate copy can't silently break
9
10
  * hooks or the provider/consumer link. Exported so it can be regression-tested
10
11
  * (a missing/unresolvable peer must fail loudly).
12
+ *
13
+ * **CLI atlas-ui stamp (DES-254):** the atlas-ui a bundle gets is whatever
14
+ * *this* CLI package resolves — not a semver the artifact declares.
15
+ * See `VERSION-PIN.md`.
11
16
  */
12
17
  /** Platform + common artifact runtime peers — always aliased over node_modules. */
13
18
  export declare const PEER_RESOLVE_ALIAS: Record<string, string>;
19
+ export interface ArtifactStudioRuntimeChunk {
20
+ /** Vite/Rollup output path, always under `chunks/`. */
21
+ fileName: string;
22
+ code: string;
23
+ }
24
+ export type ArtifactStudioBundleFormat = 'esm';
14
25
  export interface ArtifactStudioBuildResult {
15
26
  manifest: ArtifactStudioManifest;
16
27
  files: ArtifactStudioSourceFile[];
28
+ /** Entry module (ESM). Lazy routes land in `chunks`. */
17
29
  bundle: string;
30
+ /**
31
+ * Async/shared sub-chunks emitted by Vite when the graph uses dynamic
32
+ * `import()`. Empty when the entry graph has no split points.
33
+ */
34
+ chunks: ArtifactStudioRuntimeChunk[];
35
+ /** Deploy/serve format. IIFE is gone — it cannot code-split (REP-87). */
36
+ bundleFormat: ArtifactStudioBundleFormat;
18
37
  sourceMap: string | null;
19
38
  sourceHash: string;
20
39
  bundleHash: string;
40
+ /** CLI + force-aliased atlas-ui versions that built this bundle (DES-254). */
41
+ peerVersions: ArtifactStudioPeerVersions;
21
42
  }
22
43
  export declare function buildArtifactStudioProject(rootDir: string): Promise<ArtifactStudioBuildResult>;
23
44
  export declare function writeBuildArtifact(result: ArtifactStudioBuildResult, outPath: string): Promise<void>;
45
+ export declare function hashBundleAndChunks({ bundle, chunks, }: {
46
+ bundle: string;
47
+ chunks: readonly ArtifactStudioRuntimeChunk[];
48
+ }): string;
package/dist/build.js CHANGED
@@ -1,15 +1,17 @@
1
- import { existsSync } from 'node:fs';
2
1
  import { mkdir, writeFile } from 'node:fs/promises';
3
2
  import { createRequire } from 'node:module';
4
- import { dirname, resolve } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
3
+ import { dirname, relative, resolve } from 'node:path';
6
4
  import { build as viteBuild } from 'vite';
5
+ import { atlasUiCssResolverPlugin } from './atlas-ui-css-plugin.js';
7
6
  import { sha256Hex } from './hash.js';
7
+ import { isArtifactIgnoredPath } from './artifact-ignore.js';
8
8
  import { readArtifactStudioSource } from './project.js';
9
+ import { normalizeArtifactPath } from './paths.js';
10
+ import { resolveArtifactStudioPeerVersions, } from './peer-versions.js';
11
+ import { ARTIFACT_RUNTIME_CHUNK_DIR, assertSafeRuntimeChunkFileName } from './runtime-chunks.js';
9
12
  /** Keep in sync with `atlas/src/server/services/artifact-studio/limits.ts`. */
10
13
  const MAX_DEPLOY_SOURCEMAP_BYTES = 10 * 1024 * 1024;
11
14
  const cliRequire = createRequire(import.meta.url);
12
- const cliDir = dirname(fileURLToPath(import.meta.url));
13
15
  const reactDir = dirname(cliRequire.resolve('react/package.json'));
14
16
  const reactDomDir = dirname(cliRequire.resolve('react-dom/package.json'));
15
17
  // Artifacts (and the `react-vite` scaffold) import `@tanstack/react-query`
@@ -29,8 +31,6 @@ const atlasUiDist = resolve(atlasUiDir, 'dist');
29
31
  // `seq-studio dev --link`) work.
30
32
  const formRendererDir = dirname(cliRequire.resolve('@sequenceholdings/lattice-form-renderer/package.json'));
31
33
  const formRendererDist = resolve(formRendererDir, 'dist');
32
- const tailwindCssPath = resolve(dirname(cliRequire.resolve('tailwindcss/package.json')), 'index.css');
33
- const twAnimateCssPath = resolveTwAnimateCssPath();
34
34
  /**
35
35
  * Import aliases for the Vite build. Artifacts are built against their own
36
36
  * source tree, which usually has no installed node_modules, so every bare
@@ -39,6 +39,10 @@ const twAnimateCssPath = resolveTwAnimateCssPath();
39
39
  * react-query QueryClient context — so a duplicate copy can't silently break
40
40
  * hooks or the provider/consumer link. Exported so it can be regression-tested
41
41
  * (a missing/unresolvable peer must fail loudly).
42
+ *
43
+ * **CLI atlas-ui stamp (DES-254):** the atlas-ui a bundle gets is whatever
44
+ * *this* CLI package resolves — not a semver the artifact declares.
45
+ * See `VERSION-PIN.md`.
42
46
  */
43
47
  /** Platform + common artifact runtime peers — always aliased over node_modules. */
44
48
  export const PEER_RESOLVE_ALIAS = {
@@ -50,6 +54,13 @@ export const PEER_RESOLVE_ALIAS = {
50
54
  '@tanstack/react-query': reactQueryDir,
51
55
  'react-router-dom': reactRouterDomDir,
52
56
  sonner: sonnerDir,
57
+ // v2 token entry points live at the PACKAGE ROOT, not in dist (the generator
58
+ // writes them there and `files` publishes them from there). Without these
59
+ // keys the imports fall through to the bare `atlas-ui` alias below — which
60
+ // prefix-matches and rewrites them to `<dist>/index.mjs/tokens.v2.css`, a
61
+ // path that doesn't exist, so the artifact build fails on a CSS import.
62
+ '@sequenceholdings/atlas-ui/tokens.v2.css': resolve(atlasUiDir, 'tokens.v2.css'),
63
+ '@sequenceholdings/atlas-ui/theme.v2.css': resolve(atlasUiDir, 'theme.v2.css'),
53
64
  '@sequenceholdings/atlas-ui/tokens.css': resolve(atlasUiDist, 'tokens.css'),
54
65
  '@sequenceholdings/atlas-ui/charts': resolve(atlasUiDist, 'charts.mjs'),
55
66
  '@sequenceholdings/atlas-ui': resolve(atlasUiDist, 'index.mjs'),
@@ -69,8 +80,13 @@ export async function buildArtifactStudioProject(rootDir) {
69
80
  configFile: false,
70
81
  logLevel: 'silent',
71
82
  plugins: [
83
+ rejectIgnoredSourcePlugin(root, source.ignorePatterns),
72
84
  artifactSdkVirtualModule(),
73
- atlasUiCssResolverPlugin(),
85
+ atlasUiCssResolverPlugin({
86
+ atlasUiDir,
87
+ atlasUiDist,
88
+ formRendererDist,
89
+ }),
74
90
  nextStubPlugin(),
75
91
  (await import('@tailwindcss/vite')).default(),
76
92
  ],
@@ -82,29 +98,77 @@ export async function buildArtifactStudioProject(rootDir) {
82
98
  sourcemap: true,
83
99
  cssCodeSplit: false,
84
100
  assetsInlineLimit: Number.MAX_SAFE_INTEGER,
101
+ modulePreload: false,
85
102
  rollupOptions: {
86
103
  input: resolve(root, 'index.html'),
87
104
  output: {
88
- format: 'iife',
89
- name: 'ArtifactStudioApp',
105
+ // ESM (not IIFE) so dynamic import() can emit async chunks (REP-87).
106
+ format: 'es',
107
+ entryFileNames: 'bundle.js',
108
+ chunkFileNames: `${ARTIFACT_RUNTIME_CHUNK_DIR}/[name]-[hash].js`,
109
+ assetFileNames: 'assets/[name]-[hash][extname]',
90
110
  },
91
111
  },
92
112
  },
93
113
  });
94
114
  const output = Array.isArray(result) ? result.flatMap((r) => r.output) : result.output;
95
- const js = collectEntryJavaScript(output);
96
- if (!js)
115
+ const entry = collectEntryJavaScript(output);
116
+ if (!entry)
97
117
  throw new Error('Build produced no JavaScript output');
98
118
  const css = collectCss(output);
99
- const bundle = css ? injectCssRuntime(css) + '\n' + js : js;
119
+ const bundle = css ? injectCssRuntime(css) + '\n' + entry.code : entry.code;
120
+ const chunks = collectRuntimeChunks(output);
100
121
  const sourceMap = fitSourceMapForDeploy(collectSourceMap(output));
101
122
  return {
102
123
  manifest: source.manifest,
103
124
  files: source.files,
104
125
  sourceHash: source.sourceHash,
105
126
  bundle,
127
+ chunks,
128
+ bundleFormat: 'esm',
106
129
  sourceMap,
107
- bundleHash: sha256Hex(bundle),
130
+ bundleHash: hashBundleAndChunks({ bundle, chunks }),
131
+ peerVersions: resolveArtifactStudioPeerVersions(),
132
+ };
133
+ }
134
+ /**
135
+ * `.artifactignore` drops paths from sourceHash / deploy payloads, but Vite
136
+ * still sees the full tree. Reject any resolved import under an ignored prefix
137
+ * so `deploy --skip-unchanged` cannot silently keep a stale bundle when an
138
+ * ignored-but-imported file changes.
139
+ */
140
+ function rejectIgnoredSourcePlugin(root, patterns) {
141
+ if (patterns.length === 0) {
142
+ return { name: 'reject-artifact-ignored-noop' };
143
+ }
144
+ const rootResolved = resolve(root);
145
+ return {
146
+ name: 'reject-artifact-ignored',
147
+ enforce: 'pre',
148
+ async resolveId(source, importer, options) {
149
+ const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
150
+ if (!resolved || resolved.external)
151
+ return resolved;
152
+ // Virtual modules (`\0…`) and query suffixes (`?worker`) are not source paths.
153
+ const filePath = resolved.id.split('\0').pop().split('?')[0];
154
+ if (!filePath.startsWith(rootResolved))
155
+ return resolved;
156
+ let rel;
157
+ try {
158
+ rel = normalizeArtifactPath(relative(rootResolved, filePath));
159
+ }
160
+ catch {
161
+ return resolved;
162
+ }
163
+ if (!isArtifactIgnoredPath(rel, patterns))
164
+ return resolved;
165
+ const from = importer
166
+ ? ` (imported from ${relative(rootResolved, importer.split('?')[0])})`
167
+ : '';
168
+ throw new Error(`Cannot import "${rel}"${from}: path is listed in .artifactignore. ` +
169
+ 'Ignored trees are excluded from sourceHash / deploy --skip-unchanged, ' +
170
+ 'so build dependencies must not live under them.');
171
+ },
108
172
  };
109
173
  }
110
174
  function fitSourceMapForDeploy(sourceMap) {
@@ -164,25 +228,30 @@ function nextStubPlugin() {
164
228
  },
165
229
  };
166
230
  }
167
- function resolveTwAnimateCssPath() {
168
- const candidates = [
169
- // Workspace layout: shared/services/artifact-studio/node_modules/...
170
- resolve(cliDir, '..', 'node_modules', 'tw-animate-css', 'dist', 'tw-animate.css'),
171
- // pnpm file-dependency layout: .../node_modules/@sequenceholdings/artifact-studio/dist
172
- // with dependencies hoisted beside the scope directory.
173
- resolve(cliDir, '..', '..', '..', 'tw-animate-css', 'dist', 'tw-animate.css'),
174
- ];
175
- const resolved = candidates.find((candidate) => existsSync(candidate));
176
- if (!resolved) {
177
- throw new Error(`Unable to resolve tw-animate-css dist file. Tried: ${candidates.join(', ')}`);
178
- }
179
- return resolved;
180
- }
181
231
  function collectEntryJavaScript(output) {
182
232
  const entryChunks = output.filter((chunk) => chunk.type === 'chunk' && chunk.isEntry);
183
233
  if (entryChunks.length === 0)
184
234
  return null;
185
- return entryChunks.map((chunk) => chunk.code).join('\n');
235
+ // HTML multi-page inputs can emit multiple entries; Artifact Studio has one.
236
+ const entry = entryChunks[0];
237
+ return { code: entry.code, fileName: entry.fileName };
238
+ }
239
+ function collectRuntimeChunks(output) {
240
+ return output
241
+ .filter((chunk) => chunk.type === 'chunk' && !chunk.isEntry)
242
+ .map((chunk) => ({
243
+ fileName: assertSafeRuntimeChunkFileName(chunk.fileName),
244
+ code: chunk.code,
245
+ }))
246
+ .sort((a, b) => a.fileName.localeCompare(b.fileName));
247
+ }
248
+ export function hashBundleAndChunks({ bundle, chunks, }) {
249
+ const h = sha256Hex([
250
+ 'artifact-studio-bundle-v2',
251
+ bundle,
252
+ ...chunks.flatMap((chunk) => [chunk.fileName, chunk.code]),
253
+ ].join('\0'));
254
+ return h;
186
255
  }
187
256
  function collectCss(output) {
188
257
  const cssAssets = output.filter((chunk) => isAssetOutput(chunk) && chunk.fileName.endsWith('.css'));
@@ -203,25 +272,3 @@ function collectSourceMap(output) {
203
272
  .map((chunk) => ({ fileName: chunk.fileName, map: chunk.map }));
204
273
  return maps.length > 0 ? JSON.stringify(maps) : null;
205
274
  }
206
- function atlasUiCssResolverPlugin() {
207
- return {
208
- name: 'atlas-ui-css-resolver',
209
- enforce: 'pre',
210
- transform(code, id) {
211
- if (!id.endsWith('.css'))
212
- return undefined;
213
- let result = code;
214
- if (result.includes('@import "tailwindcss"')) {
215
- result = result.replace('@import "tailwindcss"', `@import ${JSON.stringify(tailwindCssPath)}`);
216
- // Prebuilt dists whose class names Tailwind must scan: atlas-ui plus
217
- // the form renderer (field-scope.tsx emits Tailwind utility classes
218
- // that only appear in the renderer's dist, not the artifact source).
219
- result = `@source ${JSON.stringify(atlasUiDist)};\n@source ${JSON.stringify(formRendererDist)};\n${result}`;
220
- }
221
- if (result.includes('@import "tw-animate-css"')) {
222
- result = result.replace('@import "tw-animate-css"', `@import ${JSON.stringify(twAnimateCssPath)}`);
223
- }
224
- return result !== code ? result : undefined;
225
- },
226
- };
227
- }
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,28 @@
1
+ import { type ArtifactStudioPeerVersions } from './peer-versions.js';
1
2
  import { type SourceAuthMode } from './source-resolver.js';
2
3
  export declare function runCli(argv?: string[]): Promise<number>;
4
+ interface RemoteProject {
5
+ id: string;
6
+ slug: string;
7
+ title: string;
8
+ visibility?: string;
9
+ activeDeployment?: {
10
+ version: string;
11
+ sourceHash: string;
12
+ cliVersion?: string | null;
13
+ atlasUiVersion?: string | null;
14
+ } | null;
15
+ }
16
+ /**
17
+ * Whether `--skip-unchanged` may no-op: same source *and* same CLI/atlas-ui
18
+ * pin. Missing peer fields on older rows force a rebuild so the pin becomes
19
+ * observable (DES-254).
20
+ */
21
+ export declare function activeDeploymentMatchesSkipPin({ active, sourceHash, peerVersions, }: {
22
+ active: RemoteProject['activeDeployment'];
23
+ sourceHash: string;
24
+ peerVersions: ArtifactStudioPeerVersions;
25
+ }): boolean;
3
26
  export declare function getConfiguredDefaultEnv(): Promise<string | undefined>;
4
27
  /**
5
28
  * Optional refreshing token provider, injected by an embedder (e.g. seq-studio)
@@ -15,3 +38,4 @@ export interface ProvidedToken {
15
38
  token: string;
16
39
  }
17
40
  export declare function setTokenProvider(provider: ((options: TokenProviderOptions) => Promise<ProvidedToken | null>) | null): void;
41
+ export {};