From f60f27d895e8de03c7eb6824a266b43e9cb38281 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 11:58:03 +0200 Subject: [PATCH 01/18] feat(config): branch on unstable_defineApp brand at config load Calling `unstable_defineApp` opts a project into workbench behavior. Detect its brand right after the config module loads and route the branded `app` around the legacy `app` object schema, which would otherwise strip the helper's identity fields. Configs that don't call it are untouched. --- .../cli-core/src/config/cli/getCliConfig.ts | 7 ++++ .../src/config/cli/getCliConfigSync.ts | 7 ++++ .../cli-core/src/config/cli/workbenchApp.ts | 34 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 packages/@sanity/cli-core/src/config/cli/workbenchApp.ts diff --git a/packages/@sanity/cli-core/src/config/cli/getCliConfig.ts b/packages/@sanity/cli-core/src/config/cli/getCliConfig.ts index 40339da6e..0cb1d41bd 100644 --- a/packages/@sanity/cli-core/src/config/cli/getCliConfig.ts +++ b/packages/@sanity/cli-core/src/config/cli/getCliConfig.ts @@ -6,6 +6,7 @@ import {importModule} from '../../util/importModule.js' import {findPathForFiles} from '../util/findConfigsPaths.js' import {cliConfigSchema} from './schemas.js' import {type CliConfig} from './types/cliConfig.js' +import {isWorkbenchApp, parseWorkbenchCliConfig} from './workbenchApp.js' const cache = new Map>() @@ -97,6 +98,12 @@ export async function getCliConfigUncached(rootPath: string): Promise throw new Error('CLI config cannot be loaded', {cause: err}) } + // Branch as early as possible: a branded `unstable_defineApp(...)` opts into + // workbench behavior, so its `app` skips the legacy `app` schema entirely. + if (isWorkbenchApp(cliConfig?.app)) { + return parseWorkbenchCliConfig(cliConfig) + } + const {data, error, success} = cliConfigSchema.safeParse(cliConfig) if (!success) { debug(`Invalid CLI config: ${error.message}`) diff --git a/packages/@sanity/cli-core/src/config/cli/getCliConfigSync.ts b/packages/@sanity/cli-core/src/config/cli/getCliConfigSync.ts index d5f02e1a5..bfcaa8b75 100644 --- a/packages/@sanity/cli-core/src/config/cli/getCliConfigSync.ts +++ b/packages/@sanity/cli-core/src/config/cli/getCliConfigSync.ts @@ -8,6 +8,7 @@ import {NotFoundError} from '../../errors/NotFoundError.js' import {tryGetDefaultExport} from '../../util/tryGetDefaultExport.js' import {cliConfigSchema} from './schemas.js' import {type CliConfig} from './types/cliConfig.js' +import {isWorkbenchApp, parseWorkbenchCliConfig} from './workbenchApp.js' /** * Get the CLI config for a project synchronously, given the root path. @@ -47,6 +48,12 @@ export function getCliConfigSync(rootPath: string): CliConfig { unregister() } + // Branch as early as possible: a branded `unstable_defineApp(...)` opts into + // workbench behavior, so its `app` skips the legacy `app` schema entirely. + if (isWorkbenchApp(cliConfig?.app)) { + return parseWorkbenchCliConfig(cliConfig) + } + const {data, error, success} = cliConfigSchema.safeParse(cliConfig) if (!success) { throw new Error(`Invalid CLI config: ${error.message}`) diff --git a/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts new file mode 100644 index 000000000..b8caf85eb --- /dev/null +++ b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts @@ -0,0 +1,34 @@ +import {cliConfigSchema} from './schemas' +import {type CliConfig} from './types/cliConfig' + +/** + * Brand `@sanity/federation`'s `unstable_defineApp` stamps onto its result. + * Registered via the global symbol registry so it survives module-realm + * boundaries between the helper and the CLI. Keep the key in sync with + * `@sanity/federation`. + */ +const WORKBENCH_APP_BRAND = Symbol.for('sanity.workbench.defineApp') + +/** + * Whether a config's `app` is a branded `unstable_defineApp(...)` result. + * Calling `unstable_defineApp` is the opt-in for workbench behavior — configs + * without the brand take the existing codepath untouched. + */ +export function isWorkbenchApp(app: unknown): boolean { + return typeof app === 'object' && app !== null && WORKBENCH_APP_BRAND in app +} + +/** + * Parse a config whose `app` is a branded `unstable_defineApp(...)` result. + * The branded `app` is owned by the helper, so it bypasses the legacy `app` + * object schema (which would strip its identity fields). Every other field is + * still validated by the standard schema. + */ +export function parseWorkbenchCliConfig(cliConfig: unknown): CliConfig { + const {app, ...rest} = cliConfig as Record & {app: unknown} + const {data, error, success} = cliConfigSchema.safeParse(rest) + if (!success) { + throw new Error(`Invalid CLI config: ${error.message}`, {cause: error}) + } + return {...data, app} as CliConfig +} From cb2f20261dba1b634c6372b4d3cbc74e8a291513 Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 10:00:21 +0000 Subject: [PATCH 02/18] chore: update auto-generated changeset for PR #1143 --- .changeset/pr-1143.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pr-1143.md diff --git a/.changeset/pr-1143.md b/.changeset/pr-1143.md new file mode 100644 index 000000000..8e58a1427 --- /dev/null +++ b/.changeset/pr-1143.md @@ -0,0 +1,6 @@ + +--- +'@sanity/cli-core': minor +--- + +feat(config): accept unstable_defineApp name in app config \ No newline at end of file From 0ecd70b0ecda7dd25e510e66003c144ab91f86c3 Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 10:01:05 +0000 Subject: [PATCH 03/18] chore: update auto-generated changeset for PR #1143 --- .changeset/pr-1143.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-1143.md b/.changeset/pr-1143.md index 8e58a1427..f8426aee6 100644 --- a/.changeset/pr-1143.md +++ b/.changeset/pr-1143.md @@ -3,4 +3,4 @@ '@sanity/cli-core': minor --- -feat(config): accept unstable_defineApp name in app config \ No newline at end of file +feat(config): branch on unstable_defineApp brand at config load \ No newline at end of file From 9195f05803b3a5314f42442291ad678418a1a031 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 12:56:46 +0200 Subject: [PATCH 04/18] feat(cli): re-export unstable_defineApp from @sanity/cli Surfaces the workbench app extension API from the CLI's public entry so `sanity/cli` can pass it through to app authors. Pins @sanity/federation to 0.1.0-alpha.9 (the release adding the root export); resolves once that is published. --- packages/@sanity/cli/package.json | 2 +- packages/@sanity/cli/src/exports/index.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index 5846350c3..268ef7254 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -80,7 +80,7 @@ "@sanity/codegen": "catalog:", "@sanity/descriptors": "^1.3.0", "@sanity/export": "^6.1.0", - "@sanity/federation": "0.1.0-alpha.8", + "@sanity/federation": "0.1.0-alpha.9", "@sanity/generate-help-url": "^4.0.0", "@sanity/id-utils": "^1.0.0", "@sanity/import": "^6.0.1", diff --git a/packages/@sanity/cli/src/exports/index.ts b/packages/@sanity/cli/src/exports/index.ts index dc374dbf1..f9b0ec684 100644 --- a/packages/@sanity/cli/src/exports/index.ts +++ b/packages/@sanity/cli/src/exports/index.ts @@ -5,3 +5,11 @@ export type {CliApiConfig} from '../types.js' export {type CliClientOptions, getCliClient} from '../util/cliClient.js' export {loadEnv} from '../util/loadEnv.js' export type {CliConfig, UserViteConfig} from '@sanity/cli-core' + +// Workbench application extension API. Canonical implementation in +// `@sanity/federation`; re-exported here so `sanity/cli` can surface it to +// app authors via `import {unstable_defineApp} from 'sanity/cli'`. Pinned ahead +// of the `@sanity/federation@0.1.0-alpha.9` release that adds the root export — +// resolves once that's published. +// eslint-disable-next-line import-x/no-unresolved +export {type DefineAppInput, unstable_defineApp} from '@sanity/federation' From 5705ffceec47ce38524d488719ae22ef9e4655bf Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 10:57:16 +0000 Subject: [PATCH 05/18] chore: update auto-generated changeset for PR #1143 --- .changeset/pr-1143.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/pr-1143.md b/.changeset/pr-1143.md index f8426aee6..7c9ff2ce3 100644 --- a/.changeset/pr-1143.md +++ b/.changeset/pr-1143.md @@ -1,6 +1,7 @@ --- '@sanity/cli-core': minor +'@sanity/cli': minor --- feat(config): branch on unstable_defineApp brand at config load \ No newline at end of file From a1d06a867e55fcecc3969cf34acf20679d9bfd82 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 14:42:49 +0200 Subject: [PATCH 06/18] feat(cli): consume @sanity/federation preview for unstable_defineApp Point the federation dependency at the pkg.pr.new preview built from workbench#226 so the `unstable_defineApp` re-export resolves now, instead of pinning an unpublished version. Drops the eslint-disable that masked the previously-unresolvable import. --- packages/@sanity/cli/package.json | 2 +- packages/@sanity/cli/src/exports/index.ts | 5 +---- pnpm-lock.yaml | 12 +++++++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index 268ef7254..aaaa8f0de 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -80,7 +80,7 @@ "@sanity/codegen": "catalog:", "@sanity/descriptors": "^1.3.0", "@sanity/export": "^6.1.0", - "@sanity/federation": "0.1.0-alpha.9", + "@sanity/federation": "https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098", "@sanity/generate-help-url": "^4.0.0", "@sanity/id-utils": "^1.0.0", "@sanity/import": "^6.0.1", diff --git a/packages/@sanity/cli/src/exports/index.ts b/packages/@sanity/cli/src/exports/index.ts index f9b0ec684..adc1be882 100644 --- a/packages/@sanity/cli/src/exports/index.ts +++ b/packages/@sanity/cli/src/exports/index.ts @@ -8,8 +8,5 @@ export type {CliConfig, UserViteConfig} from '@sanity/cli-core' // Workbench application extension API. Canonical implementation in // `@sanity/federation`; re-exported here so `sanity/cli` can surface it to -// app authors via `import {unstable_defineApp} from 'sanity/cli'`. Pinned ahead -// of the `@sanity/federation@0.1.0-alpha.9` release that adds the root export — -// resolves once that's published. -// eslint-disable-next-line import-x/no-unresolved +// app authors via `import {unstable_defineApp} from 'sanity/cli'`. export {type DefineAppInput, unstable_defineApp} from '@sanity/federation' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48adf9c9b..da124421d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -531,8 +531,8 @@ importers: specifier: ^6.1.0 version: 6.1.0 '@sanity/federation': - specifier: 0.1.0-alpha.8 - version: 0.1.0-alpha.8(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + specifier: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098 + version: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) '@sanity/generate-help-url': specifier: ^4.0.0 version: 4.0.0 @@ -4859,8 +4859,9 @@ packages: engines: {node: '>=20.19 <22 || >=22.12'} hasBin: true - '@sanity/federation@0.1.0-alpha.8': - resolution: {integrity: sha512-FMVXDu9XM8+I6XO1jXE3AIfL399CP9aVDotL3KWipPppTuq7iexo0wuD6d7mcYOwrhMukET7Pooy2+Z3WwQ3Iw==} + '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098': + resolution: {integrity: sha512-8EI+crYnxtmDEQ5wYOfmtwpWIm+Mog+kd3N3Ait2H5J3kqzO2ibzCeyYSrvyEUdO7H0ShDKa1n901iHp1YFy6w==, tarball: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098} + version: 0.1.0-alpha.8 engines: {node: '>=20.19.1 <22 || >=22.12'} peerDependencies: vite: ^7.0.0 || ^8.0.0 @@ -14486,11 +14487,12 @@ snapshots: - react-native-b4a - supports-color - '@sanity/federation@0.1.0-alpha.8(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': + '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@module-federation/runtime': 2.5.0 '@module-federation/vite': 1.16.0(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) vite: 7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + zod: 4.3.6 transitivePeerDependencies: - bufferutil - node-fetch From f6ef212635e163e9730e9efa119527c2fe1cdc4b Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 14:46:13 +0200 Subject: [PATCH 07/18] fix(cli-core): use .js extension in workbenchApp imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli-core is NodeNext ESM — the compiled workbenchApp.js needs explicit .js specifiers or Node can't resolve ./schemas and ./types/cliConfig at runtime (MODULE_NOT_FOUND during build). Was masked while install failed. --- packages/@sanity/cli-core/src/config/cli/workbenchApp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts index b8caf85eb..d4fbf2b82 100644 --- a/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts +++ b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts @@ -1,5 +1,5 @@ -import {cliConfigSchema} from './schemas' -import {type CliConfig} from './types/cliConfig' +import {cliConfigSchema} from './schemas.js' +import {type CliConfig} from './types/cliConfig.js' /** * Brand `@sanity/federation`'s `unstable_defineApp` stamps onto its result. From ee5d5184d0e78fd8f8f58d194e978abbf37d9975 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 15:05:48 +0200 Subject: [PATCH 08/18] fix(cli): bump federation preview, allow new export in exports guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point @sanity/federation at the rebuilt pkg.pr.new preview whose root export no longer leaks src under the development condition (fixes the Node worker type-stripping crash). Allow `unstable_defineApp` in the v5↔v6 exports parity test as an intentional new value export. --- packages/@sanity/cli/package.json | 2 +- .../@sanity/cli/src/__tests__/exports.test.ts | 11 +++++--- pnpm-lock.yaml | 26 +++++-------------- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index aaaa8f0de..2bdee5c00 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -80,7 +80,7 @@ "@sanity/codegen": "catalog:", "@sanity/descriptors": "^1.3.0", "@sanity/export": "^6.1.0", - "@sanity/federation": "https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098", + "@sanity/federation": "https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847", "@sanity/generate-help-url": "^4.0.0", "@sanity/id-utils": "^1.0.0", "@sanity/import": "^6.0.1", diff --git a/packages/@sanity/cli/src/__tests__/exports.test.ts b/packages/@sanity/cli/src/__tests__/exports.test.ts index b02f3a377..0879a0a1a 100644 --- a/packages/@sanity/cli/src/__tests__/exports.test.ts +++ b/packages/@sanity/cli/src/__tests__/exports.test.ts @@ -144,12 +144,17 @@ async function getSanityPackageTypeExports() { return exportedTypes } +// Value exports intentionally added in v6 that the v5 CLI didn't have. +const NEW_EXPORTS = new Set(['unstable_defineApp']) + test('should match exports of the current cli package', async () => { const oldCliExports = await getSanityPackageExports() - expect(Object.keys(newExports).toSorted()).toStrictEqual( - Object.keys(oldCliExports.default).toSorted(), - ) + expect( + Object.keys(newExports) + .filter((name) => !NEW_EXPORTS.has(name)) + .toSorted(), + ).toStrictEqual(Object.keys(oldCliExports.default).toSorted()) }) test('should include type exports of the old (v5) cli package', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da124421d..daae84b45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -531,8 +531,8 @@ importers: specifier: ^6.1.0 version: 6.1.0 '@sanity/federation': - specifier: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098 - version: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + specifier: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847 + version: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) '@sanity/generate-help-url': specifier: ^4.0.0 version: 4.0.0 @@ -4859,8 +4859,8 @@ packages: engines: {node: '>=20.19 <22 || >=22.12'} hasBin: true - '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098': - resolution: {integrity: sha512-8EI+crYnxtmDEQ5wYOfmtwpWIm+Mog+kd3N3Ait2H5J3kqzO2ibzCeyYSrvyEUdO7H0ShDKa1n901iHp1YFy6w==, tarball: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098} + '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847': + resolution: {integrity: sha512-7ern+4ZYa0LksIGiVf+TCIGr5EZwaBah+Dt4cvxT4Kk+krV8KEry6YJWR6uu3ePTbY3cR6l02aUlDK0XqZDuoA==, tarball: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847} version: 0.1.0-alpha.8 engines: {node: '>=20.19.1 <22 || >=22.12'} peerDependencies: @@ -10278,18 +10278,6 @@ packages: utf-8-validate: optional: true - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.20.1: resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} @@ -14487,7 +14475,7 @@ snapshots: - react-native-b4a - supports-color - '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@4df83bd53be56433a8e0f92b2616dd65427d6098(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': + '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@module-federation/runtime': 2.5.0 '@module-federation/vite': 1.16.0(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) @@ -18207,7 +18195,7 @@ snapshots: webidl-conversions: 8.0.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - ws: 8.19.0 + ws: 8.20.1 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -20937,8 +20925,6 @@ snapshots: ws@8.18.0: {} - ws@8.19.0: {} - ws@8.20.1: {} wsl-utils@0.3.1: From 4cb163fcee3efe021b39c1f5063dac58213faf4d Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 15:23:51 +0200 Subject: [PATCH 09/18] feat(cli): make unstable_defineApp the sole workbench opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A branded config implies federation — force `federation: {enabled: true}` when `unstable_defineApp` is used, so the workbench dev server and federation registration start without a separate flag. Adds unit tests for the brand-branch (detection, auto-enable, branded-app preservation). --- .../config/cli/__tests__/workbenchApp.test.ts | 57 +++++++++++++++++++ .../cli-core/src/config/cli/workbenchApp.ts | 5 +- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts diff --git a/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts b/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts new file mode 100644 index 000000000..f66a59437 --- /dev/null +++ b/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts @@ -0,0 +1,57 @@ +import {describe, expect, test} from 'vitest' + +import {isWorkbenchApp, parseWorkbenchCliConfig} from '../workbenchApp' + +const BRAND = Symbol.for('sanity.workbench.defineApp') + +/** Mimics what `unstable_defineApp` returns: the input plus the brand. */ +function brandedApp(input: Record) { + return Object.defineProperty({...input}, BRAND, {enumerable: false, value: true}) +} + +describe('isWorkbenchApp', () => { + test('detects a branded app', () => { + expect(isWorkbenchApp(brandedApp({name: 'mini', title: 'Mini'}))).toBe(true) + }) + + test('ignores a plain app config', () => { + expect(isWorkbenchApp({organizationId: 'o1', title: 'Mini'})).toBe(false) + expect(isWorkbenchApp(null)).toBe(false) + expect(isWorkbenchApp(undefined)).toBe(false) + }) +}) + +describe('parseWorkbenchCliConfig', () => { + test('enables federation implicitly (no federation.enabled needed)', () => { + const app = brandedApp({ + entry: './src/App.tsx', + icon: './icon.svg', + name: 'mini-desk', + organizationId: 'o1', + title: 'Mini Desk', + }) + + const config = parseWorkbenchCliConfig({app, server: {port: 3337}}) + + expect(config.federation).toEqual({enabled: true}) + }) + + test('keeps the branded app untouched (identity fields survive)', () => { + const app = brandedApp({name: 'mini-desk', organizationId: 'o1', title: 'Mini Desk'}) + + const config = parseWorkbenchCliConfig({app}) + + // `name` would be stripped by the legacy `app` object schema — the branded + // app must bypass it. + expect(config.app).toBe(app) + expect((config.app as {name?: string}).name).toBe('mini-desk') + }) + + test('still validates the non-app fields', () => { + const app = brandedApp({name: 'mini', title: 'Mini'}) + + expect(() => parseWorkbenchCliConfig({app, server: {port: 'not-a-number'}})).toThrow( + /Invalid CLI config/, + ) + }) +}) diff --git a/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts index d4fbf2b82..f1c327c6a 100644 --- a/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts +++ b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts @@ -30,5 +30,8 @@ export function parseWorkbenchCliConfig(cliConfig: unknown): CliConfig { if (!success) { throw new Error(`Invalid CLI config: ${error.message}`, {cause: error}) } - return {...data, app} as CliConfig + // Calling `unstable_defineApp` is the sole workbench opt-in — there's no + // separate `federation: {enabled: true}` flag for branded apps. Force it on + // so the workbench dev server and federation registration start. + return {...data, app, federation: {enabled: true}} as CliConfig } From 11fa38ebb4a2d50aa58932b32dda7877668096b3 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 16:18:46 +0200 Subject: [PATCH 10/18] refactor(cli): remove federation flag, gate workbench on unstable_defineApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling `unstable_defineApp` is now the sole workbench opt-in — the `federation: {enabled}` config flag is removed entirely. Dev/build/config gating keys off the `isWorkbenchApp` identity (imported from `@sanity/federation`, not re-declared). `applicationType` is resolved at config load (explicit, else inferred from a `sanity.config.*` → studio) so studio-vs-app classification is settled once. Tests updated to opt in via a branded app. --- packages/@sanity/cli-core/package.json | 1 + .../config/cli/__tests__/workbenchApp.test.ts | 55 +++++++++-------- .../cli-core/src/config/cli/getCliConfig.ts | 7 ++- .../src/config/cli/getCliConfigSync.ts | 5 +- .../cli-core/src/config/cli/schemas.ts | 6 -- .../src/config/cli/types/cliConfig.ts | 8 --- .../cli-core/src/config/cli/workbenchApp.ts | 54 ++++++++++------- .../build/__tests__/buildStaticFiles.test.ts | 6 +- .../build/__tests__/getViteConfig.test.ts | 4 +- .../@sanity/cli/src/actions/build/buildApp.ts | 9 +-- .../cli/src/actions/build/buildStaticFiles.ts | 12 ++-- .../cli/src/actions/build/buildStudio.ts | 9 +-- .../cli/src/actions/build/getViteConfig.ts | 14 +++-- .../actions/dev/__tests__/devAction.test.ts | 12 ++-- .../startFederationRegistration.test.ts | 37 ++++++------ .../__tests__/startWorkbenchDevServer.test.ts | 59 ++++++++++--------- .../src/actions/dev/__tests__/testHelpers.ts | 25 ++++++++ .../@sanity/cli/src/actions/dev/devAction.ts | 6 +- .../cli/src/actions/dev/getDevServerConfig.ts | 6 +- .../actions/dev/startWorkbenchDevServer.ts | 6 +- packages/@sanity/cli/src/server/devServer.ts | 6 +- .../@sanity/cli/src/util/determineIsApp.ts | 13 +++- .../cli/src/util/getSharedServerConfig.ts | 3 +- pnpm-lock.yaml | 3 + 24 files changed, 213 insertions(+), 153 deletions(-) diff --git a/packages/@sanity/cli-core/package.json b/packages/@sanity/cli-core/package.json index 91d2fdca1..4ecce1a01 100644 --- a/packages/@sanity/cli-core/package.json +++ b/packages/@sanity/cli-core/package.json @@ -66,6 +66,7 @@ "@inquirer/prompts": "^8.3.0", "@oclif/core": "catalog:", "@sanity/client": "catalog:", + "@sanity/federation": "https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847", "babel-plugin-react-compiler": "^1.0.0", "boxen": "^8.0.1", "debug": "catalog:", diff --git a/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts b/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts index f66a59437..7a1348ee2 100644 --- a/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts +++ b/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts @@ -1,56 +1,55 @@ +import {tmpdir} from 'node:os' +import {join} from 'node:path' + import {describe, expect, test} from 'vitest' -import {isWorkbenchApp, parseWorkbenchCliConfig} from '../workbenchApp' +import {parseWorkbenchCliConfig} from '../workbenchApp' const BRAND = Symbol.for('sanity.workbench.defineApp') +// A dir with no `sanity.config.*`, so detection resolves to a core app. +const APP_DIR = tmpdir() /** Mimics what `unstable_defineApp` returns: the input plus the brand. */ function brandedApp(input: Record) { return Object.defineProperty({...input}, BRAND, {enumerable: false, value: true}) } -describe('isWorkbenchApp', () => { - test('detects a branded app', () => { - expect(isWorkbenchApp(brandedApp({name: 'mini', title: 'Mini'}))).toBe(true) - }) - - test('ignores a plain app config', () => { - expect(isWorkbenchApp({organizationId: 'o1', title: 'Mini'})).toBe(false) - expect(isWorkbenchApp(null)).toBe(false) - expect(isWorkbenchApp(undefined)).toBe(false) - }) -}) - describe('parseWorkbenchCliConfig', () => { - test('enables federation implicitly (no federation.enabled needed)', () => { + test('keeps the branded app untouched, brand and identity fields intact', () => { const app = brandedApp({ entry: './src/App.tsx', - icon: './icon.svg', - name: 'mini-desk', + name: 'drop-desk', organizationId: 'o1', - title: 'Mini Desk', + title: 'Drop Desk', }) - const config = parseWorkbenchCliConfig({app, server: {port: 3337}}) + const config = parseWorkbenchCliConfig({app, server: {port: 3333}}, APP_DIR) - expect(config.federation).toEqual({enabled: true}) + expect(config.app).toBe(app) + expect((config.app as {name?: string}).name).toBe('drop-desk') + expect(BRAND in (config.app as object)).toBe(true) }) - test('keeps the branded app untouched (identity fields survive)', () => { - const app = brandedApp({name: 'mini-desk', organizationId: 'o1', title: 'Mini Desk'}) + test('infers applicationType "coreApp" when there is no sanity.config', () => { + const app = brandedApp({name: 'drop-desk', title: 'Drop Desk'}) - const config = parseWorkbenchCliConfig({app}) + parseWorkbenchCliConfig({app}, APP_DIR) - // `name` would be stripped by the legacy `app` object schema — the branded - // app must bypass it. - expect(config.app).toBe(app) - expect((config.app as {name?: string}).name).toBe('mini-desk') + expect((app as {applicationType?: string}).applicationType).toBe('coreApp') + }) + + test('keeps an explicit applicationType (no detection)', () => { + const app = brandedApp({applicationType: 'media-library', name: 'media', title: 'Media'}) + + parseWorkbenchCliConfig({app}, join(APP_DIR, 'nope')) + + expect((app as {applicationType?: string}).applicationType).toBe('media-library') }) test('still validates the non-app fields', () => { - const app = brandedApp({name: 'mini', title: 'Mini'}) + const app = brandedApp({name: 'drop-desk', title: 'Drop Desk'}) - expect(() => parseWorkbenchCliConfig({app, server: {port: 'not-a-number'}})).toThrow( + expect(() => parseWorkbenchCliConfig({app, server: {port: 'nope'}}, APP_DIR)).toThrow( /Invalid CLI config/, ) }) diff --git a/packages/@sanity/cli-core/src/config/cli/getCliConfig.ts b/packages/@sanity/cli-core/src/config/cli/getCliConfig.ts index 0cb1d41bd..75e4cc64a 100644 --- a/packages/@sanity/cli-core/src/config/cli/getCliConfig.ts +++ b/packages/@sanity/cli-core/src/config/cli/getCliConfig.ts @@ -1,4 +1,7 @@ import {createRequire} from 'node:module' +import {dirname} from 'node:path' + +import {isWorkbenchApp} from '@sanity/federation' import {debug} from '../../debug.js' import {NotFoundError} from '../../errors/NotFoundError.js' @@ -6,7 +9,7 @@ import {importModule} from '../../util/importModule.js' import {findPathForFiles} from '../util/findConfigsPaths.js' import {cliConfigSchema} from './schemas.js' import {type CliConfig} from './types/cliConfig.js' -import {isWorkbenchApp, parseWorkbenchCliConfig} from './workbenchApp.js' +import {parseWorkbenchCliConfig} from './workbenchApp.js' const cache = new Map>() @@ -101,7 +104,7 @@ export async function getCliConfigUncached(rootPath: string): Promise // Branch as early as possible: a branded `unstable_defineApp(...)` opts into // workbench behavior, so its `app` skips the legacy `app` schema entirely. if (isWorkbenchApp(cliConfig?.app)) { - return parseWorkbenchCliConfig(cliConfig) + return parseWorkbenchCliConfig(cliConfig, dirname(configPath)) } const {data, error, success} = cliConfigSchema.safeParse(cliConfig) diff --git a/packages/@sanity/cli-core/src/config/cli/getCliConfigSync.ts b/packages/@sanity/cli-core/src/config/cli/getCliConfigSync.ts index bfcaa8b75..967c12f31 100644 --- a/packages/@sanity/cli-core/src/config/cli/getCliConfigSync.ts +++ b/packages/@sanity/cli-core/src/config/cli/getCliConfigSync.ts @@ -2,13 +2,14 @@ import {existsSync} from 'node:fs' import {createRequire} from 'node:module' import {join} from 'node:path' +import {isWorkbenchApp} from '@sanity/federation' import {register} from 'tsx/esm/api' import {NotFoundError} from '../../errors/NotFoundError.js' import {tryGetDefaultExport} from '../../util/tryGetDefaultExport.js' import {cliConfigSchema} from './schemas.js' import {type CliConfig} from './types/cliConfig.js' -import {isWorkbenchApp, parseWorkbenchCliConfig} from './workbenchApp.js' +import {parseWorkbenchCliConfig} from './workbenchApp.js' /** * Get the CLI config for a project synchronously, given the root path. @@ -51,7 +52,7 @@ export function getCliConfigSync(rootPath: string): CliConfig { // Branch as early as possible: a branded `unstable_defineApp(...)` opts into // workbench behavior, so its `app` skips the legacy `app` schema entirely. if (isWorkbenchApp(cliConfig?.app)) { - return parseWorkbenchCliConfig(cliConfig) + return parseWorkbenchCliConfig(cliConfig, rootPath) } const {data, error, success} = cliConfigSchema.safeParse(cliConfig) diff --git a/packages/@sanity/cli-core/src/config/cli/schemas.ts b/packages/@sanity/cli-core/src/config/cli/schemas.ts index bf2b9d43e..f9bfb031a 100644 --- a/packages/@sanity/cli-core/src/config/cli/schemas.ts +++ b/packages/@sanity/cli-core/src/config/cli/schemas.ts @@ -34,12 +34,6 @@ export const cliConfigSchema = z.object({ }), ), - federation: z.optional( - z.object({ - enabled: z.boolean(), - }), - ), - graphql: z.optional( z.array( z.object({ diff --git a/packages/@sanity/cli-core/src/config/cli/types/cliConfig.ts b/packages/@sanity/cli-core/src/config/cli/types/cliConfig.ts index 3a76c4389..b823c078e 100644 --- a/packages/@sanity/cli-core/src/config/cli/types/cliConfig.ts +++ b/packages/@sanity/cli-core/src/config/cli/types/cliConfig.ts @@ -59,14 +59,6 @@ export interface CliConfig { autoUpdates?: boolean } - /** - * Enable federated builds & dev environments for your studio or application. - * @experimental - */ - federation?: { - enabled: boolean - } - /** Define the GraphQL APIs that the CLI can deploy and interact with */ graphql?: Array<{ filterSuffix?: string diff --git a/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts index f1c327c6a..0bfb145b1 100644 --- a/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts +++ b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts @@ -1,37 +1,49 @@ +import {existsSync} from 'node:fs' +import {join} from 'node:path' + import {cliConfigSchema} from './schemas.js' import {type CliConfig} from './types/cliConfig.js' -/** - * Brand `@sanity/federation`'s `unstable_defineApp` stamps onto its result. - * Registered via the global symbol registry so it survives module-realm - * boundaries between the helper and the CLI. Keep the key in sync with - * `@sanity/federation`. - */ -const WORKBENCH_APP_BRAND = Symbol.for('sanity.workbench.defineApp') +const STUDIO_CONFIG_FILES = [ + 'sanity.config.ts', + 'sanity.config.tsx', + 'sanity.config.js', + 'sanity.config.jsx', + 'sanity.config.mjs', + 'sanity.config.cjs', +] /** - * Whether a config's `app` is a branded `unstable_defineApp(...)` result. - * Calling `unstable_defineApp` is the opt-in for workbench behavior — configs - * without the brand take the existing codepath untouched. + * Infer the application type for a workbench app when `unstable_defineApp` + * didn't set one: a project with a `sanity.config.*` is a studio, otherwise a + * core (SDK) app. An explicit `applicationType` always wins. */ -export function isWorkbenchApp(app: unknown): boolean { - return typeof app === 'object' && app !== null && WORKBENCH_APP_BRAND in app +function detectApplicationType(projectDir: string): 'coreApp' | 'studio' { + return STUDIO_CONFIG_FILES.some((file) => existsSync(join(projectDir, file))) + ? 'studio' + : 'coreApp' } /** * Parse a config whose `app` is a branded `unstable_defineApp(...)` result. - * The branded `app` is owned by the helper, so it bypasses the legacy `app` - * object schema (which would strip its identity fields). Every other field is - * still validated by the standard schema. + * The branded `app` bypasses the legacy `app` object schema (which would strip + * its identity fields and the brand symbol); every other field is still + * validated. The brand is preserved so downstream code relies on the + * `isWorkbenchApp` identity (from `@sanity/federation`) instead of a flag. + * + * Resolves `applicationType` here — as early as possible — so studio-vs-app + * classification is settled once and read off the app everywhere else. */ -export function parseWorkbenchCliConfig(cliConfig: unknown): CliConfig { - const {app, ...rest} = cliConfig as Record & {app: unknown} +export function parseWorkbenchCliConfig(cliConfig: unknown, projectDir: string): CliConfig { + const {app, ...rest} = cliConfig as Record & { + app: Record & {applicationType?: string} + } const {data, error, success} = cliConfigSchema.safeParse(rest) if (!success) { throw new Error(`Invalid CLI config: ${error.message}`, {cause: error}) } - // Calling `unstable_defineApp` is the sole workbench opt-in — there's no - // separate `federation: {enabled: true}` flag for branded apps. Force it on - // so the workbench dev server and federation registration start. - return {...data, app, federation: {enabled: true}} as CliConfig + if (!app.applicationType) { + app.applicationType = detectApplicationType(projectDir) + } + return {...data, app} as CliConfig } diff --git a/packages/@sanity/cli/src/actions/build/__tests__/buildStaticFiles.test.ts b/packages/@sanity/cli/src/actions/build/__tests__/buildStaticFiles.test.ts index b1c3e1a50..cd659c5da 100644 --- a/packages/@sanity/cli/src/actions/build/__tests__/buildStaticFiles.test.ts +++ b/packages/@sanity/cli/src/actions/build/__tests__/buildStaticFiles.test.ts @@ -72,7 +72,7 @@ describe('buildStaticFiles', () => { await buildStaticFiles({ basePath: '/', cwd, - federation: {enabled: true}, + isWorkbench: true, outputDir, vite: userVite, }) @@ -99,7 +99,7 @@ describe('buildStaticFiles', () => { await buildStaticFiles({ basePath: '/', cwd, - federation: {enabled: true}, + isWorkbench: true, outputDir, }) @@ -111,7 +111,7 @@ describe('buildStaticFiles', () => { await buildStaticFiles({ basePath: '/', cwd, - federation: {enabled: true}, + isWorkbench: true, outputDir, }) diff --git a/packages/@sanity/cli/src/actions/build/__tests__/getViteConfig.test.ts b/packages/@sanity/cli/src/actions/build/__tests__/getViteConfig.test.ts index 8eb31a53e..eaea529e0 100644 --- a/packages/@sanity/cli/src/actions/build/__tests__/getViteConfig.test.ts +++ b/packages/@sanity/cli/src/actions/build/__tests__/getViteConfig.test.ts @@ -486,7 +486,7 @@ describe('#getViteConfig', () => { const options = { cwd: mockTestCwd, entries: {relativeConfigLocation: '../../sanity.config.ts', relativeEntry: '../../src/App'}, - federation: {enabled: true}, + isWorkbench: true, mode: 'development' as const, reactCompiler: undefined, } @@ -510,7 +510,7 @@ describe('#getViteConfig', () => { const options = { cwd: mockTestCwd, entries: mockEntries, - federation: {enabled: false}, + isWorkbench: false, mode: 'development' as const, reactCompiler: undefined, } diff --git a/packages/@sanity/cli/src/actions/build/buildApp.ts b/packages/@sanity/cli/src/actions/build/buildApp.ts index 09ef92495..11d721db1 100644 --- a/packages/@sanity/cli/src/actions/build/buildApp.ts +++ b/packages/@sanity/cli/src/actions/build/buildApp.ts @@ -13,6 +13,7 @@ import { UserViteConfig, } from '@sanity/cli-core' import {confirm, logSymbols, spinner, type SpinnerInstance} from '@sanity/cli-core/ux' +import {isWorkbenchApp} from '@sanity/federation' import {parse as semverParse} from 'semver' import {AppBuildTrace} from '../../telemetry/build.telemetry.js' @@ -35,7 +36,7 @@ interface InternalBuildOptions { calledFromDeploy: boolean | undefined determineBasePath: () => string entry: string | undefined - federation: CliConfig['federation'] + isWorkbench: boolean minify: boolean outDir: string | undefined output: Output @@ -63,7 +64,7 @@ export async function buildApp(options: BuildOptions): Promise { calledFromDeploy: options.calledFromDeploy, determineBasePath: () => determineBasePath(cliConfig, 'app', output), entry: cliConfig && 'app' in cliConfig ? cliConfig.app?.entry : undefined, - federation: cliConfig.federation, + isWorkbench: isWorkbenchApp(cliConfig && 'app' in cliConfig ? cliConfig.app : undefined), minify: flags.minify, outDir, output, @@ -206,7 +207,7 @@ async function internalBuildApp(options: InternalBuildOptions): Promise { let importMap: {imports?: Record} | undefined - if (autoUpdatesEnabled && !options.federation?.enabled) { + if (autoUpdatesEnabled && !options.isWorkbench) { importMap = { imports: { ...(await buildVendorDependencies({basePath, cwd: workDir, isApp: true, outputDir})), @@ -224,9 +225,9 @@ async function internalBuildApp(options: InternalBuildOptions): Promise { basePath, cwd: workDir, entry: options.entry, - federation: options.federation, importMap, isApp: true, + isWorkbench: options.isWorkbench, minify: options.minify, outputDir, reactCompiler: options.reactCompiler, diff --git a/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts b/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts index e6ac88c8d..9c247c491 100644 --- a/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts +++ b/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts @@ -19,7 +19,7 @@ export interface ChunkStats { name: string } -interface StaticBuildOptions extends Pick { +interface StaticBuildOptions { basePath: string cwd: string outputDir: string @@ -29,6 +29,8 @@ interface StaticBuildOptions extends Pick { entry?: string importMap?: {imports?: Record} isApp?: boolean + /** Workbench app (opted in via `unstable_defineApp`) — drives the federation build. */ + isWorkbench?: boolean minify?: boolean profile?: boolean reactCompiler?: ReactCompilerConfig @@ -51,9 +53,9 @@ export async function buildStaticFiles( basePath, cwd, entry, - federation, importMap, isApp, + isWorkbench, minify = true, outputDir, reactCompiler, @@ -68,7 +70,7 @@ export async function buildStaticFiles( * (remote-entry, mf-manifest) — skip client-specific steps like * runtime generation, static file copies, and favicons. */ - if (federation?.enabled) { + if (isWorkbench) { buildDebug('Resolving entries for federation build') const entries = await resolveEntries({cwd, entry, isApp}) @@ -77,8 +79,8 @@ export async function buildStaticFiles( basePath, cwd, entries, - federation, isApp, + isWorkbench, minify, mode, outputDir, @@ -123,9 +125,9 @@ export async function buildStaticFiles( basePath, cwd, entries, - federation, importMap, isApp, + isWorkbench, minify, mode, outputDir, diff --git a/packages/@sanity/cli/src/actions/build/buildStudio.ts b/packages/@sanity/cli/src/actions/build/buildStudio.ts index 66a665dfe..8369dae7e 100644 --- a/packages/@sanity/cli/src/actions/build/buildStudio.ts +++ b/packages/@sanity/cli/src/actions/build/buildStudio.ts @@ -13,6 +13,7 @@ import { UserViteConfig, } from '@sanity/cli-core' import {confirm, logSymbols, select, spinner, type SpinnerInstance} from '@sanity/cli-core/ux' +import {isWorkbenchApp} from '@sanity/federation' import {parse as semverParse} from 'semver' import {StudioBuildTrace} from '../../telemetry/build.telemetry.js' @@ -37,8 +38,8 @@ interface InternalBuildOptions { autoUpdatesEnabled: boolean calledFromDeploy: boolean | undefined determineBasePath: () => string - federation: CliConfig['federation'] isApp: boolean + isWorkbench: boolean minify: boolean outDir: string | undefined output: Output @@ -78,8 +79,8 @@ export async function buildStudio(options: BuildOptions): Promise { autoUpdatesEnabled: options.autoUpdatesEnabled, calledFromDeploy, determineBasePath: () => determineBasePath(cliConfig, 'studio', output), - federation: cliConfig.federation, isApp: determineIsApp(cliConfig), + isWorkbench: isWorkbenchApp(cliConfig?.app), minify: Boolean(flags.minify), outDir, output, @@ -276,7 +277,7 @@ async function internalBuildStudio(options: InternalBuildOptions): Promise let importMap - if (autoUpdatesEnabled && !options.federation?.enabled) { + if (autoUpdatesEnabled && !options.isWorkbench) { importMap = { imports: { ...(await buildVendorDependencies({basePath, cwd: workDir, isApp: false, outputDir})), @@ -292,8 +293,8 @@ async function internalBuildStudio(options: InternalBuildOptions): Promise autoUpdatesCssUrls: autoUpdatesCssUrls.length > 0 ? autoUpdatesCssUrls : undefined, basePath, cwd: workDir, - federation: options.federation, importMap, + isWorkbench: options.isWorkbench, minify, outputDir, reactCompiler, diff --git a/packages/@sanity/cli/src/actions/build/getViteConfig.ts b/packages/@sanity/cli/src/actions/build/getViteConfig.ts index cc505aabf..75c515ac0 100644 --- a/packages/@sanity/cli/src/actions/build/getViteConfig.ts +++ b/packages/@sanity/cli/src/actions/build/getViteConfig.ts @@ -34,7 +34,7 @@ import {sanityBuildEntries} from './vite/plugin-sanity-build-entries.js' import {sanityFaviconsPlugin} from './vite/plugin-sanity-favicons.js' import {sanityRuntimeRewritePlugin} from './vite/plugin-sanity-runtime-rewrite.js' -interface ViteOptions extends Pick { +interface ViteOptions extends Pick { /** * Root path of the studio/sanity app */ @@ -72,6 +72,12 @@ interface ViteOptions extends Pick basePath: rawBasePath = '/', cwd, entries, - federation, importMap, isApp, + isWorkbench, minify, mode, outputDir, @@ -197,7 +203,7 @@ export async function getViteConfig(options: ViteOptions): Promise plugins: [ // Federation builds only need the federation plugin — skip client-specific // plugins (react, favicons, runtime rewrite, build entries, schema, typegen) - ...(federation?.enabled + ...(isWorkbench ? [ ...sharedPlugins, viteFederation({ @@ -251,7 +257,7 @@ export async function getViteConfig(options: ViteOptions): Promise // Federation builds don't produce a client bundle — the federation // plugin configures its own environment and build entry point. - if (mode === 'production' && !federation?.enabled) { + if (mode === 'production' && !isWorkbench) { viteConfig.build = { ...viteConfig.build, assetsDir: 'static', diff --git a/packages/@sanity/cli/src/actions/dev/__tests__/devAction.test.ts b/packages/@sanity/cli/src/actions/dev/__tests__/devAction.test.ts index e9fbcec5f..e925990e9 100644 --- a/packages/@sanity/cli/src/actions/dev/__tests__/devAction.test.ts +++ b/packages/@sanity/cli/src/actions/dev/__tests__/devAction.test.ts @@ -1,7 +1,7 @@ import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {devAction} from '../devAction.js' -import {createBaseDevOptions, createMockOutput} from './testHelpers.js' +import {createBaseDevOptions, createMockOutput, workbenchCliConfig} from './testHelpers.js' const mockStartWorkbenchDevServer = vi.hoisted(() => vi.fn()) const mockStartAppDevServer = vi.hoisted(() => vi.fn()) @@ -136,7 +136,7 @@ describe('devAction', () => { test('starts federation registration when federation is enabled', async () => { mockStartStudioDevServer.mockResolvedValue(mockServer({port: 3334})) - await devAction(createBaseDevOptions({cliConfig: {federation: {enabled: true}}})) + await devAction(createBaseDevOptions({cliConfig: workbenchCliConfig()})) expect(mockStartFederationRegistration).toHaveBeenCalledWith( expect.objectContaining({ @@ -157,7 +157,7 @@ describe('devAction', () => { test('passes isApp: true for app mode', async () => { mockStartAppDevServer.mockResolvedValue(mockServer({port: 3334})) - await devAction(createBaseDevOptions({cliConfig: {federation: {enabled: true}}, isApp: true})) + await devAction(createBaseDevOptions({cliConfig: workbenchCliConfig(), isApp: true})) expect(mockStartFederationRegistration).toHaveBeenCalledWith( expect.objectContaining({isApp: true}), @@ -168,7 +168,7 @@ describe('devAction', () => { const server = mockServer({port: 3334}) mockStartStudioDevServer.mockResolvedValue(server) - await devAction(createBaseDevOptions({cliConfig: {federation: {enabled: true}}})) + await devAction(createBaseDevOptions({cliConfig: workbenchCliConfig()})) expect(mockStartFederationRegistration).toHaveBeenCalledWith( expect.objectContaining({server: server.server}), @@ -180,9 +180,7 @@ describe('devAction', () => { mockStartFederationRegistration.mockResolvedValue({close: mockFederationClose}) mockStartStudioDevServer.mockResolvedValue(mockServer({port: 3334})) - const result = await devAction( - createBaseDevOptions({cliConfig: {federation: {enabled: true}}}), - ) + const result = await devAction(createBaseDevOptions({cliConfig: workbenchCliConfig()})) await result.close() expect(mockFederationClose).toHaveBeenCalled() diff --git a/packages/@sanity/cli/src/actions/dev/__tests__/startFederationRegistration.test.ts b/packages/@sanity/cli/src/actions/dev/__tests__/startFederationRegistration.test.ts index b174b3fc0..13036ade0 100644 --- a/packages/@sanity/cli/src/actions/dev/__tests__/startFederationRegistration.test.ts +++ b/packages/@sanity/cli/src/actions/dev/__tests__/startFederationRegistration.test.ts @@ -1,7 +1,7 @@ import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {startFederationRegistration} from '../startFederationRegistration.js' -import {createMockOutput} from './testHelpers.js' +import {createMockOutput, workbenchApp, workbenchCliConfig} from './testHelpers.js' const mockRegisterDevServer = vi.hoisted(() => vi.fn()) const mockStartDevManifestWatcher = vi.hoisted(() => vi.fn()) @@ -48,7 +48,7 @@ describe('startFederationRegistration', () => { test('registers studio in registry', async () => { await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -68,7 +68,7 @@ describe('startFederationRegistration', () => { mockGetAppId.mockReturnValue('app-abc') await startFederationRegistration({ - cliConfig: {deployment: {appId: 'app-abc'}, federation: {enabled: true}}, + cliConfig: {app: workbenchApp(), deployment: {appId: 'app-abc'}}, isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -80,7 +80,7 @@ describe('startFederationRegistration', () => { test('forwards api.projectId to registerDevServer', async () => { await startFederationRegistration({ - cliConfig: {api: {projectId: 'x1g7jygt'}, federation: {enabled: true}}, + cliConfig: {api: {projectId: 'x1g7jygt'}, app: workbenchApp()}, isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -94,7 +94,7 @@ describe('startFederationRegistration', () => { test('omits projectId when api.projectId is not configured', async () => { await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -109,7 +109,7 @@ describe('startFederationRegistration', () => { const output = createMockOutput() await startFederationRegistration({ - cliConfig: {app: {id: 'legacy-app'}, federation: {enabled: true}}, + cliConfig: {app: workbenchApp({id: 'legacy-app'})}, isApp: false, output, server: mockServer({port: 3334}) as any, @@ -121,7 +121,7 @@ describe('startFederationRegistration', () => { test('registers without icon/title — they are derived from the inlined manifest', async () => { await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -135,7 +135,7 @@ describe('startFederationRegistration', () => { test('registers app under the host applied by the vite dev server', async () => { await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({host: 'mydev.local', port: 3334}) as any, @@ -149,7 +149,7 @@ describe('startFederationRegistration', () => { test('falls back to localhost when the vite server host is not a string', async () => { await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({host: true, port: 3334}) as any, @@ -161,7 +161,7 @@ describe('startFederationRegistration', () => { test('registers app type when isApp is true', async () => { await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: true, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -173,7 +173,7 @@ describe('startFederationRegistration', () => { test('starts the manifest watcher for studios', async () => { await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -187,7 +187,7 @@ describe('startFederationRegistration', () => { test('starts the manifest watcher for core apps', async () => { await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: true, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -204,7 +204,7 @@ describe('startFederationRegistration', () => { mockExtractCoreAppManifest.mockResolvedValue(appManifest) await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: true, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -220,7 +220,7 @@ describe('startFederationRegistration', () => { test('wires extractStudioManifest into the studio watcher', async () => { await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -236,7 +236,7 @@ describe('startFederationRegistration', () => { mockRegisterDevServer.mockReturnValue({release: mockCleanup, update: vi.fn()}) const result = await startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -255,7 +255,7 @@ describe('startFederationRegistration', () => { await expect( startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -270,7 +270,7 @@ describe('startFederationRegistration', () => { await expect( startFederationRegistration({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), isApp: false, output: createMockOutput(), server: mockServer({port: 3334}) as any, @@ -288,9 +288,8 @@ describe('startFederationRegistration', () => { await startFederationRegistration({ cliConfig: { - app: {id: 'legacy-app'}, + app: workbenchApp({id: 'legacy-app'}), deployment: {appId: 'new-app'}, - federation: {enabled: true}, }, isApp: false, output, diff --git a/packages/@sanity/cli/src/actions/dev/__tests__/startWorkbenchDevServer.test.ts b/packages/@sanity/cli/src/actions/dev/__tests__/startWorkbenchDevServer.test.ts index 5f60fd2d1..71d6d1e28 100644 --- a/packages/@sanity/cli/src/actions/dev/__tests__/startWorkbenchDevServer.test.ts +++ b/packages/@sanity/cli/src/actions/dev/__tests__/startWorkbenchDevServer.test.ts @@ -1,7 +1,13 @@ import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {startWorkbenchDevServer} from '../startWorkbenchDevServer.js' -import {createDevOptions, createMockOutput} from './testHelpers.js' +import { + createDevOptions, + createMockOutput, + studioWorkbenchApp, + workbenchApp, + workbenchCliConfig, +} from './testHelpers.js' const mockResolveLocalPackage = vi.hoisted(() => vi.fn()) const mockCreateServer = vi.hoisted(() => vi.fn()) @@ -69,9 +75,7 @@ describe('startWorkbenchDevServer', () => { }) test('skips workbench when federation is explicitly disabled', async () => { - const result = await startWorkbenchDevServer( - createDevOptions({cliConfig: {federation: {enabled: false}}}), - ) + const result = await startWorkbenchDevServer(createDevOptions({cliConfig: {}})) expect(result.workbenchAvailable).toBe(false) expect(result.close).toBeTypeOf('function') @@ -93,7 +97,7 @@ describe('startWorkbenchDevServer', () => { mockResolveLocalPackage.mockRejectedValue(new Error('Cannot find package')) const result = await startWorkbenchDevServer( - createDevOptions({cliConfig: {federation: {enabled: true}}}), + createDevOptions({cliConfig: workbenchCliConfig()}), ) expect(result.workbenchAvailable).toBe(false) @@ -106,7 +110,7 @@ describe('startWorkbenchDevServer', () => { const result = await startWorkbenchDevServer( createDevOptions({ - cliConfig: {federation: {enabled: true}}, + cliConfig: workbenchCliConfig(), httpHost: '0.0.0.0', httpPort: 4000, }), @@ -119,8 +123,7 @@ describe('startWorkbenchDevServer', () => { describe('successful startup', () => { const federationConfig = { - app: {organizationId: 'org-test'}, - federation: {enabled: true}, + app: workbenchApp({organizationId: 'org-test'}), } as const test('returns workbenchAvailable: true and close when server starts', async () => { @@ -179,7 +182,7 @@ describe('startWorkbenchDevServer', () => { await startWorkbenchDevServer( createDevOptions({ - cliConfig: {app: {organizationId: 'org-123'}, federation: {enabled: true}}, + cliConfig: {app: workbenchApp({organizationId: 'org-123'})}, }), ) @@ -195,7 +198,10 @@ describe('startWorkbenchDevServer', () => { await startWorkbenchDevServer( createDevOptions({ - cliConfig: {api: {projectId: 'proj-123'}, federation: {enabled: true}}, + cliConfig: { + api: {projectId: 'proj-123'}, + app: studioWorkbenchApp({organizationId: undefined}), + }, }), ) @@ -213,8 +219,7 @@ describe('startWorkbenchDevServer', () => { createDevOptions({ cliConfig: { api: {projectId: 'proj-123'}, - app: {organizationId: 'org-explicit'}, - federation: {enabled: true}, + app: workbenchApp({organizationId: 'org-explicit'}), }, }), ) @@ -230,7 +235,9 @@ describe('startWorkbenchDevServer', () => { mockCreateServer.mockResolvedValue(createMockServer()) await expect( - startWorkbenchDevServer(createDevOptions({cliConfig: {federation: {enabled: true}}})), + startWorkbenchDevServer( + createDevOptions({cliConfig: {app: workbenchApp({organizationId: undefined})}}), + ), ).rejects.toThrow(/Unable to determine organization ID/) }) @@ -242,7 +249,10 @@ describe('startWorkbenchDevServer', () => { await expect( startWorkbenchDevServer( createDevOptions({ - cliConfig: {api: {projectId: 'proj-123'}, federation: {enabled: true}}, + cliConfig: { + api: {projectId: 'proj-123'}, + app: studioWorkbenchApp({organizationId: undefined}), + }, }), ), ).rejects.toThrow(/Unable to determine organization ID/) @@ -268,8 +278,7 @@ describe('startWorkbenchDevServer', () => { describe('remote-preload Link header', () => { const federationConfig = { - app: {organizationId: 'org-test'}, - federation: {enabled: true}, + app: workbenchApp({organizationId: 'org-test'}), } as const function getMiddleware(): (req: {url?: string}, res: ResLike, next: () => void) => void { @@ -422,8 +431,7 @@ describe('startWorkbenchDevServer', () => { await startWorkbenchDevServer( createDevOptions({ cliConfig: { - app: {organizationId: 'org-test'}, - federation: {enabled: true}, + app: workbenchApp({organizationId: 'org-test'}), reactStrictMode: false, }, }), @@ -442,8 +450,7 @@ describe('startWorkbenchDevServer', () => { await startWorkbenchDevServer( createDevOptions({ cliConfig: { - app: {organizationId: 'org-test'}, - federation: {enabled: true}, + app: workbenchApp({organizationId: 'org-test'}), reactStrictMode: true, }, }), @@ -461,8 +468,7 @@ describe('startWorkbenchDevServer', () => { await startWorkbenchDevServer( createDevOptions({ cliConfig: { - app: {organizationId: 'org-test'}, - federation: {enabled: true}, + app: workbenchApp({organizationId: 'org-test'}), reactStrictMode: true, }, }), @@ -476,8 +482,7 @@ describe('startWorkbenchDevServer', () => { describe('server startup failure', () => { const federationConfig = { - app: {organizationId: 'org-test'}, - federation: {enabled: true}, + app: workbenchApp({organizationId: 'org-test'}), } as const test('warns and returns without close when listen() throws', async () => { @@ -510,8 +515,7 @@ describe('startWorkbenchDevServer', () => { describe('singleton detection', () => { const federationConfig = { - app: {organizationId: 'org-test'}, - federation: {enabled: true}, + app: workbenchApp({organizationId: 'org-test'}), } as const test('skips starting server when lock is held by another process', async () => { @@ -544,8 +548,7 @@ describe('startWorkbenchDevServer', () => { describe('registry integration', () => { const federationConfig = { - app: {organizationId: 'org-test'}, - federation: {enabled: true}, + app: workbenchApp({organizationId: 'org-test'}), } as const test('updates lock with actual port after successful startup', async () => { diff --git a/packages/@sanity/cli/src/actions/dev/__tests__/testHelpers.ts b/packages/@sanity/cli/src/actions/dev/__tests__/testHelpers.ts index dcdcba694..e454ffa42 100644 --- a/packages/@sanity/cli/src/actions/dev/__tests__/testHelpers.ts +++ b/packages/@sanity/cli/src/actions/dev/__tests__/testHelpers.ts @@ -1,4 +1,5 @@ import {type CliConfig, type Output} from '@sanity/cli-core' +import {unstable_defineApp} from '@sanity/federation' // eslint-disable-next-line import-x/no-extraneous-dependencies import {vi} from 'vitest' @@ -7,6 +8,30 @@ import {type DevActionOptions} from '../types.js' /** Shared test helpers for dev-action test suites. */ +/** + * A CliConfig whose `app` is a branded `unstable_defineApp(...)` result — the + * workbench opt-in. Replaces the old `federation: {enabled: true}` test signal. + */ +export function workbenchApp(overrides: Record = {}): CliConfig['app'] { + return unstable_defineApp({ + name: 'test-app', + organizationId: 'org-123', + title: 'Test App', + ...overrides, + }) as unknown as CliConfig['app'] +} + +/** Branded workbench app explicitly typed as a studio. */ +export function studioWorkbenchApp(overrides: Record = {}): CliConfig['app'] { + const app = workbenchApp(overrides) + ;(app as {applicationType?: string}).applicationType = 'studio' + return app +} + +export function workbenchCliConfig(overrides: Partial = {}): CliConfig { + return {app: workbenchApp(), ...overrides} as CliConfig +} + export function createMockOutput(): Output { return { error: vi.fn(), diff --git a/packages/@sanity/cli/src/actions/dev/devAction.ts b/packages/@sanity/cli/src/actions/dev/devAction.ts index 427f644c1..2d5ac533d 100644 --- a/packages/@sanity/cli/src/actions/dev/devAction.ts +++ b/packages/@sanity/cli/src/actions/dev/devAction.ts @@ -1,5 +1,7 @@ import {styleText} from 'node:util' +import {isWorkbenchApp} from '@sanity/federation' + import {getSharedServerConfig} from '../../util/getSharedServerConfig.js' import {startAppDevServer} from './startAppDevServer.js' import {startFederationRegistration} from './startFederationRegistration.js' @@ -58,7 +60,9 @@ export async function devAction(options: DevActionOptions): Promise<{close: () = return {close: closeWorkbenchServer} } - const closeFederation = cliConfig?.federation?.enabled + // Workbench is opted into solely by calling `unstable_defineApp` — its + // branded identity is the only signal. + const closeFederation = isWorkbenchApp(cliConfig?.app) ? await startFederationRegistration({ cliConfig, isApp: options.isApp, diff --git a/packages/@sanity/cli/src/actions/dev/getDevServerConfig.ts b/packages/@sanity/cli/src/actions/dev/getDevServerConfig.ts index e4c0a09e0..3e769d1f7 100644 --- a/packages/@sanity/cli/src/actions/dev/getDevServerConfig.ts +++ b/packages/@sanity/cli/src/actions/dev/getDevServerConfig.ts @@ -2,8 +2,10 @@ import path from 'node:path' import {type CliConfig, getSanityEnvVar, type Output} from '@sanity/cli-core' import {spinner} from '@sanity/cli-core/ux' +import {isWorkbenchApp} from '@sanity/federation' import {type DevServerOptions} from '../../server/devServer.js' +import {determineIsApp} from '../../util/determineIsApp.js' import {getSharedServerConfig} from '../../util/getSharedServerConfig.js' import {resolveReactStrictMode} from '../../util/resolveReactStrictMode.js' import {type DevFlags} from './types.js' @@ -32,7 +34,7 @@ export function getDevServerConfig({ configSpinner.succeed() - const isApp = cliConfig && 'app' in cliConfig + const isApp = cliConfig ? determineIsApp(cliConfig) : false const reactStrictMode = resolveReactStrictMode(cliConfig) const envBasePath = getSanityEnvVar('BASEPATH', isApp ?? false) @@ -44,7 +46,7 @@ export function getDevServerConfig({ return { ...baseConfig, - federation: cliConfig?.federation, + isWorkbench: isWorkbenchApp(cliConfig?.app), reactCompiler: cliConfig && 'reactCompiler' in cliConfig ? cliConfig.reactCompiler : undefined, reactStrictMode, staticPath: path.join(workDir, 'static'), diff --git a/packages/@sanity/cli/src/actions/dev/startWorkbenchDevServer.ts b/packages/@sanity/cli/src/actions/dev/startWorkbenchDevServer.ts index 92047859a..381de12e7 100644 --- a/packages/@sanity/cli/src/actions/dev/startWorkbenchDevServer.ts +++ b/packages/@sanity/cli/src/actions/dev/startWorkbenchDevServer.ts @@ -1,4 +1,5 @@ import {resolveLocalPackage} from '@sanity/cli-core' +import {isWorkbenchApp} from '@sanity/federation' import viteReact from '@vitejs/plugin-react' import {createServer, type InlineConfig, type Plugin} from 'vite' import {z} from 'zod/mini' @@ -53,8 +54,9 @@ export async function startWorkbenchDevServer( ): Promise { const {cliConfig, httpHost, httpPort: workbenchPort, output, workDir} = options - if (!cliConfig?.federation?.enabled) { - devDebug('Federation not enabled, skipping workbench dev server') + // Workbench is opted into solely by calling `unstable_defineApp`. + if (!isWorkbenchApp(cliConfig?.app)) { + devDebug('Not a workbench app, skipping workbench dev server') return {close: noop, httpHost, workbenchAvailable: false, workbenchPort} } diff --git a/packages/@sanity/cli/src/server/devServer.ts b/packages/@sanity/cli/src/server/devServer.ts index c0a0f261f..8e112e705 100644 --- a/packages/@sanity/cli/src/server/devServer.ts +++ b/packages/@sanity/cli/src/server/devServer.ts @@ -22,9 +22,9 @@ export interface DevServerOptions { appTitle?: string entry?: string - federation?: CliConfig['federation'] httpHost?: string isApp?: boolean + isWorkbench?: boolean projectName?: string schemaExtraction?: CliConfig['schemaExtraction'] typegen?: CliConfig['typegen'] @@ -44,10 +44,10 @@ export async function startDevServer(options: DevServerOptions): Promise Date: Fri, 29 May 2026 16:46:22 +0200 Subject: [PATCH 11/18] =?UTF-8?q?feat(init):=20stop=20scaffolding=20federa?= =?UTF-8?q?tion=20by=20default=20=E2=80=94=20workbench=20is=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sanity init` no longer prompts for or writes any `federation` config; new projects are plain studios/apps. Authors opt into workbench by adding `unstable_defineApp` to `sanity.cli.ts`. Removes the federation prompt, the `federation` flag/variable threaded through the init flow, and the `federation: { enabled }` block from the generated config templates. --- .../__tests__/bootstrapLocalTemplate.test.ts | 83 ------------------- .../__tests__/bootstrapRemoteTemplate.test.ts | 1 - .../actions/init/bootstrapLocalTemplate.ts | 3 - .../cli/src/actions/init/bootstrapTemplate.ts | 3 - .../src/actions/init/createAppCliConfig.ts | 4 - .../cli/src/actions/init/createCliConfig.ts | 4 - .../src/actions/init/createStudioConfig.ts | 1 - .../cli/src/actions/init/initAction.ts | 7 -- .../@sanity/cli/src/actions/init/initApp.ts | 3 - .../cli/src/actions/init/initStudio.ts | 3 - .../cli/src/actions/init/scaffoldTemplate.ts | 3 - .../@sanity/cli/src/actions/init/types.ts | 3 - .../cli/src/prompts/init/federation.ts | 8 -- 13 files changed, 126 deletions(-) delete mode 100644 packages/@sanity/cli/src/prompts/init/federation.ts diff --git a/packages/@sanity/cli/src/actions/init/__tests__/bootstrapLocalTemplate.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/bootstrapLocalTemplate.test.ts index 24a1021df..bb1ba9530 100644 --- a/packages/@sanity/cli/src/actions/init/__tests__/bootstrapLocalTemplate.test.ts +++ b/packages/@sanity/cli/src/actions/init/__tests__/bootstrapLocalTemplate.test.ts @@ -5,7 +5,6 @@ import path from 'node:path' import {type Output} from '@sanity/cli-core' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' -import {resolveLatestVersions} from '../../../util/resolveLatestVersions.js' import {bootstrapLocalTemplate} from '../bootstrapLocalTemplate.js' vi.mock('../../../util/resolveLatestVersions.js', () => ({ @@ -53,7 +52,6 @@ describe('bootstrapLocalTemplate (app templates)', () => { variables: { autoUpdates: false, dataset: 'production', - federation: false, organizationId: 'org1', projectId: 'abc123', projectName: 'my-app', @@ -77,7 +75,6 @@ describe('bootstrapLocalTemplate (app templates)', () => { variables: { autoUpdates: false, dataset: '', - federation: false, organizationId: 'org1', projectId: '', projectName: 'my-app', @@ -91,83 +88,3 @@ describe('bootstrapLocalTemplate (app templates)', () => { expect(appTsx).not.toContain('%dataset%') }) }) - -describe('bootstrapLocalTemplate (federation)', () => { - let tmp: string - beforeEach(async () => { - tmp = await mkdtemp(path.join(tmpdir(), 'cli-bootstrap-')) - }) - afterEach(async () => { - await rm(tmp, {force: true, recursive: true}) - vi.clearAllMocks() - }) - - test('overrides the `sanity` dependency with the `workbench` dist-tag when federation is enabled', async () => { - await bootstrapLocalTemplate({ - output: makeOutput(), - outputPath: tmp, - packageName: 'my-studio', - templateName: 'clean', - useTypeScript: true, - variables: { - autoUpdates: false, - dataset: 'production', - federation: true, - organizationId: 'org1', - projectId: 'abc123', - projectName: 'my-studio', - }, - }) - - expect(resolveLatestVersions).toHaveBeenCalledOnce() - const resolvedDeps = vi.mocked(resolveLatestVersions).mock.calls[0][0] - expect(resolvedDeps.sanity).toBe('workbench') - - const pkgJson = JSON.parse(await readFile(path.join(tmp, 'package.json'), 'utf8')) - expect(pkgJson.dependencies.sanity).toBe('1.0.0') - }) - - test('keeps the `sanity` dependency on the `latest` dist-tag when federation is disabled', async () => { - await bootstrapLocalTemplate({ - output: makeOutput(), - outputPath: tmp, - packageName: 'my-studio', - templateName: 'clean', - useTypeScript: true, - variables: { - autoUpdates: false, - dataset: 'production', - federation: false, - organizationId: 'org1', - projectId: 'abc123', - projectName: 'my-studio', - }, - }) - - expect(resolveLatestVersions).toHaveBeenCalledOnce() - const resolvedDeps = vi.mocked(resolveLatestVersions).mock.calls[0][0] - expect(resolvedDeps.sanity).toBe('latest') - }) - - test('overrides the `sanity` devDependency for app templates when federation is enabled', async () => { - await bootstrapLocalTemplate({ - output: makeOutput(), - outputPath: tmp, - packageName: 'my-app', - templateName: 'app-quickstart', - useTypeScript: true, - variables: { - autoUpdates: false, - dataset: 'production', - federation: true, - organizationId: 'org1', - projectId: 'abc123', - projectName: 'my-app', - }, - }) - - expect(resolveLatestVersions).toHaveBeenCalledOnce() - const resolvedDeps = vi.mocked(resolveLatestVersions).mock.calls[0][0] - expect(resolvedDeps.sanity).toBe('workbench') - }) -}) diff --git a/packages/@sanity/cli/src/actions/init/__tests__/bootstrapRemoteTemplate.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/bootstrapRemoteTemplate.test.ts index 974b0df4d..ec40817bd 100644 --- a/packages/@sanity/cli/src/actions/init/__tests__/bootstrapRemoteTemplate.test.ts +++ b/packages/@sanity/cli/src/actions/init/__tests__/bootstrapRemoteTemplate.test.ts @@ -76,7 +76,6 @@ const baseOpts = { variables: { autoUpdates: false, dataset: 'production', - federation: true, projectId: 'test-project-id', }, } diff --git a/packages/@sanity/cli/src/actions/init/bootstrapLocalTemplate.ts b/packages/@sanity/cli/src/actions/init/bootstrapLocalTemplate.ts index 773d39829..79caf3863 100644 --- a/packages/@sanity/cli/src/actions/init/bootstrapLocalTemplate.ts +++ b/packages/@sanity/cli/src/actions/init/bootstrapLocalTemplate.ts @@ -89,7 +89,6 @@ export async function bootstrapLocalTemplate( ...(isAppTemplate ? sdkAppDependencies.devDependencies : studioDependencies.devDependencies), ...template.dependencies, ...template.devDependencies, - ...(variables.federation && {sanity: 'workbench'}), }) spin.succeed() @@ -142,13 +141,11 @@ export async function bootstrapLocalTemplate( const cliConfig = isAppTemplate ? createAppCliConfig({ entry: template.entry!, - federation: variables.federation, organizationId: variables.organizationId, }) : createCliConfig({ autoUpdates: variables.autoUpdates, dataset: variables.dataset, - federation: variables.federation, projectId: variables.projectId, }) diff --git a/packages/@sanity/cli/src/actions/init/bootstrapTemplate.ts b/packages/@sanity/cli/src/actions/init/bootstrapTemplate.ts index 73d37a347..a356535ea 100644 --- a/packages/@sanity/cli/src/actions/init/bootstrapTemplate.ts +++ b/packages/@sanity/cli/src/actions/init/bootstrapTemplate.ts @@ -9,7 +9,6 @@ interface BootstrapTemplateOptions { autoUpdates: boolean bearerToken: string | undefined dataset: string - federation: boolean organizationId: string | undefined output: Output outputPath: string @@ -28,7 +27,6 @@ export async function bootstrapTemplate({ autoUpdates, bearerToken, dataset, - federation, organizationId, output, outputPath, @@ -43,7 +41,6 @@ export async function bootstrapTemplate({ const bootstrapVariables: GenerateConfigOptions['variables'] = { autoUpdates, dataset, - federation, organizationId, projectId, projectName, diff --git a/packages/@sanity/cli/src/actions/init/createAppCliConfig.ts b/packages/@sanity/cli/src/actions/init/createAppCliConfig.ts index 185b3b9d8..7c8feebf0 100644 --- a/packages/@sanity/cli/src/actions/init/createAppCliConfig.ts +++ b/packages/@sanity/cli/src/actions/init/createAppCliConfig.ts @@ -8,15 +8,11 @@ export default defineCliConfig({ organizationId: '%organizationId%', entry: '%entry%', }, - federation: { - enabled: __BOOL__federation__, - }, }) ` interface GenerateCliConfigOptions { entry: string - federation: boolean organizationId?: string } diff --git a/packages/@sanity/cli/src/actions/init/createCliConfig.ts b/packages/@sanity/cli/src/actions/init/createCliConfig.ts index 167aff579..8ee5bc701 100644 --- a/packages/@sanity/cli/src/actions/init/createCliConfig.ts +++ b/packages/@sanity/cli/src/actions/init/createCliConfig.ts @@ -15,16 +15,12 @@ export default defineCliConfig({ */ autoUpdates: __BOOL__autoUpdates__, }, - federation: { - enabled: __BOOL__federation__, - }, }) ` interface GenerateCliConfigOptions { autoUpdates: boolean dataset: string - federation: boolean projectId: string } diff --git a/packages/@sanity/cli/src/actions/init/createStudioConfig.ts b/packages/@sanity/cli/src/actions/init/createStudioConfig.ts index 242656a9d..67a191466 100644 --- a/packages/@sanity/cli/src/actions/init/createStudioConfig.ts +++ b/packages/@sanity/cli/src/actions/init/createStudioConfig.ts @@ -31,7 +31,6 @@ export interface GenerateConfigOptions { variables: { autoUpdates: boolean dataset: string - federation: boolean organizationId?: string projectId: string projectName?: string diff --git a/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index f3b3ea176..a1fd41b74 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -6,7 +6,6 @@ import {type TelemetryTrace} from '@sanity/telemetry' import {type Framework, frameworks} from '@vercel/frameworks' import deburr from 'lodash-es/deburr.js' -import {promptForFederation} from '../../prompts/init/federation.js' import {promptForConfigFiles} from '../../prompts/init/nextjs.js' import {getCliUser} from '../../services/user.js' import {CLIInitStepCompleted, type InitStepResult} from '../../telemetry/init.telemetry.js' @@ -251,14 +250,8 @@ export async function initAction(options: InitOptions, context: InitContext): Pr return } - let federation = flagOrDefault(options.federation, true) - if (shouldPrompt(options.unattended, options.federation)) { - federation = await promptForFederation() - } - const sharedParams = { defaults, - federation, mcpConfigured, options, organizationId, diff --git a/packages/@sanity/cli/src/actions/init/initApp.ts b/packages/@sanity/cli/src/actions/init/initApp.ts index 188e3cb14..db4f2dd1c 100644 --- a/packages/@sanity/cli/src/actions/init/initApp.ts +++ b/packages/@sanity/cli/src/actions/init/initApp.ts @@ -15,7 +15,6 @@ import {type InitOptions} from './types.js' export async function initApp({ datasetName, defaults, - federation, mcpConfigured, options, organizationId, @@ -29,7 +28,6 @@ export async function initApp({ }: { datasetName: string defaults: {projectName: string} - federation: boolean mcpConfigured: EditorName[] options: InitOptions organizationId: string | undefined @@ -59,7 +57,6 @@ export async function initApp({ datasetName, defaults, displayName: '', - federation, options, organizationId, output, diff --git a/packages/@sanity/cli/src/actions/init/initStudio.ts b/packages/@sanity/cli/src/actions/init/initStudio.ts index ecbcee64d..a10d0c1a3 100644 --- a/packages/@sanity/cli/src/actions/init/initStudio.ts +++ b/packages/@sanity/cli/src/actions/init/initStudio.ts @@ -27,7 +27,6 @@ export async function initStudio({ datasetName, defaults, displayName, - federation, isFirstProject, mcpConfigured, options, @@ -43,7 +42,6 @@ export async function initStudio({ datasetName: string defaults: {projectName: string} displayName: string - federation: boolean isFirstProject: boolean mcpConfigured: EditorName[] options: InitOptions @@ -93,7 +91,6 @@ export async function initStudio({ datasetName, defaults, displayName, - federation, options, organizationId, output, diff --git a/packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts b/packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts index be880735b..343e65fc5 100644 --- a/packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts +++ b/packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts @@ -98,7 +98,6 @@ export async function scaffoldAndInstall({ datasetName, defaults, displayName, - federation, options, organizationId, output, @@ -114,7 +113,6 @@ export async function scaffoldAndInstall({ datasetName: string defaults: {projectName: string} displayName: string - federation: boolean options: InitOptions organizationId: string | undefined output: Output @@ -134,7 +132,6 @@ export async function scaffoldAndInstall({ autoUpdates, bearerToken: templateToken, dataset: datasetName, - federation, organizationId, output, outputPath, diff --git a/packages/@sanity/cli/src/actions/init/types.ts b/packages/@sanity/cli/src/actions/init/types.ts index 72cd0d68c..5a92e8ed9 100644 --- a/packages/@sanity/cli/src/actions/init/types.ts +++ b/packages/@sanity/cli/src/actions/init/types.ts @@ -31,7 +31,6 @@ export interface InitOptions { coupon?: string dataset?: string env?: string - federation?: boolean git?: boolean | string importDataset?: boolean nextjsAddConfigFiles?: boolean @@ -70,7 +69,6 @@ interface InitCommandFlags { 'create-project'?: string dataset?: string env?: string - federation?: boolean git?: string 'import-dataset'?: boolean 'nextjs-add-config-files'?: boolean @@ -123,7 +121,6 @@ export function flagsToInitOptions( dataset: flags.dataset, datasetDefault: flags['dataset-default'], env: flags.env, - federation: flags.federation, fromCreate: flags['from-create'], git: flags['no-git'] ? false : flags.git, importDataset: flags['import-dataset'], diff --git a/packages/@sanity/cli/src/prompts/init/federation.ts b/packages/@sanity/cli/src/prompts/init/federation.ts deleted file mode 100644 index 6f5f0e644..000000000 --- a/packages/@sanity/cli/src/prompts/init/federation.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {confirm} from '@sanity/cli-core/ux' - -export function promptForFederation(): Promise { - return confirm({ - default: true, - message: 'Would you like to enable federation for this project?', - }) -} From fdd17e205f80ccbca1579f6841b94c33e70f0d3f Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 17:14:06 +0200 Subject: [PATCH 12/18] chore(deps): use released @sanity/federation@0.1.0-alpha.9 Drop the pkg.pr.new preview override now that the federation changes (unstable_defineApp, isWorkbenchApp, root export without the src-leaking development condition) are published. --- packages/@sanity/cli-core/package.json | 2 +- packages/@sanity/cli/package.json | 2 +- pnpm-lock.yaml | 15 +++++++-------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/@sanity/cli-core/package.json b/packages/@sanity/cli-core/package.json index 4ecce1a01..dea754b58 100644 --- a/packages/@sanity/cli-core/package.json +++ b/packages/@sanity/cli-core/package.json @@ -66,7 +66,7 @@ "@inquirer/prompts": "^8.3.0", "@oclif/core": "catalog:", "@sanity/client": "catalog:", - "@sanity/federation": "https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847", + "@sanity/federation": "0.1.0-alpha.9", "babel-plugin-react-compiler": "^1.0.0", "boxen": "^8.0.1", "debug": "catalog:", diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index 2bdee5c00..268ef7254 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -80,7 +80,7 @@ "@sanity/codegen": "catalog:", "@sanity/descriptors": "^1.3.0", "@sanity/export": "^6.1.0", - "@sanity/federation": "https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847", + "@sanity/federation": "0.1.0-alpha.9", "@sanity/generate-help-url": "^4.0.0", "@sanity/id-utils": "^1.0.0", "@sanity/import": "^6.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc8ec5b29..aeb48ec2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -531,8 +531,8 @@ importers: specifier: ^6.1.0 version: 6.1.0 '@sanity/federation': - specifier: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847 - version: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + specifier: 0.1.0-alpha.9 + version: 0.1.0-alpha.9(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) '@sanity/generate-help-url': specifier: ^4.0.0 version: 4.0.0 @@ -896,8 +896,8 @@ importers: specifier: ^7.22.0 version: 7.22.0 '@sanity/federation': - specifier: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847 - version: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + specifier: 0.1.0-alpha.9 + version: 0.1.0-alpha.9(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -4862,9 +4862,8 @@ packages: engines: {node: '>=20.19 <22 || >=22.12'} hasBin: true - '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847': - resolution: {integrity: sha512-7ern+4ZYa0LksIGiVf+TCIGr5EZwaBah+Dt4cvxT4Kk+krV8KEry6YJWR6uu3ePTbY3cR6l02aUlDK0XqZDuoA==, tarball: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847} - version: 0.1.0-alpha.8 + '@sanity/federation@0.1.0-alpha.9': + resolution: {integrity: sha512-mSsRVzqSrniPtg50+hhbO2WE8vzX/PGkI123ysS8xrUECV0m/v/9YH3ha159dcuUxG0JzgjAtj1+gVy+TmqANQ==} engines: {node: '>=20.19.1 <22 || >=22.12'} peerDependencies: vite: ^7.0.0 || ^8.0.0 @@ -14478,7 +14477,7 @@ snapshots: - react-native-b4a - supports-color - '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@22d5aceac853d8279381fbfd23072d2a1a8b1847(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': + '@sanity/federation@0.1.0-alpha.9(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@module-federation/runtime': 2.5.0 '@module-federation/vite': 1.16.0(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) From 3cc2597ba923a744e84c417fcda3b65ad6d87a1d Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 15:19:59 +0000 Subject: [PATCH 13/18] chore: update auto-generated changeset for PR #1143 --- .changeset/pr-1143.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-1143.md b/.changeset/pr-1143.md index 7c9ff2ce3..846325148 100644 --- a/.changeset/pr-1143.md +++ b/.changeset/pr-1143.md @@ -4,4 +4,4 @@ '@sanity/cli': minor --- -feat(config): branch on unstable_defineApp brand at config load \ No newline at end of file +feat(workbench): application extension API \ No newline at end of file From de334208af855305627702dce6cb15115eefc393 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 17:30:09 +0200 Subject: [PATCH 14/18] fix(cli): drop orphaned --federation flag and fix federated-studio fixture The federation config flag is gone; `unstable_defineApp` is the sole workbench opt-in. Remove the leftover `--federation` CLI flag and the init tests that still passed/asserted it. Convert the federated-studio fixture to opt in via `unstable_defineApp` (its `sanity.config.ts` resolves it to a studio workbench build) and add `@sanity/federation` to the fixture deps so the import resolves. Reword the changeset to be user-facing. --- .changeset/pr-1143.md | 3 +- fixtures/federated-studio/package.json | 1 + fixtures/federated-studio/sanity.cli.ts | 9 ++++-- .../init/init.authentication.test.ts | 2 -- .../__tests__/init/init.bootstrap-app.test.ts | 6 ---- .../init/init.create-new-project.test.ts | 1 - .../__tests__/init/init.staging-env.test.ts | 3 -- packages/@sanity/cli/src/commands/init.ts | 5 --- pnpm-lock.yaml | 32 +++++++++++++++++++ 9 files changed, 40 insertions(+), 22 deletions(-) diff --git a/.changeset/pr-1143.md b/.changeset/pr-1143.md index 846325148..75890513e 100644 --- a/.changeset/pr-1143.md +++ b/.changeset/pr-1143.md @@ -1,7 +1,6 @@ - --- '@sanity/cli-core': minor '@sanity/cli': minor --- -feat(workbench): application extension API \ No newline at end of file +Add `unstable_defineApp`, exported from `sanity/cli`, as the opt-in for workbench apps. Calling it in `sanity.cli.ts` enables workbench for a studio or SDK app — the previous experimental `federation: { enabled }` config flag is removed. diff --git a/fixtures/federated-studio/package.json b/fixtures/federated-studio/package.json index d3d18cb18..58c615976 100644 --- a/fixtures/federated-studio/package.json +++ b/fixtures/federated-studio/package.json @@ -12,6 +12,7 @@ "build:fixture": "sanity build" }, "dependencies": { + "@sanity/federation": "0.1.0-alpha.9", "react": "^19.2.5", "react-dom": "^19.2.5", "sanity": "catalog:", diff --git a/fixtures/federated-studio/sanity.cli.ts b/fixtures/federated-studio/sanity.cli.ts index 2ab1cd728..d9bccb303 100644 --- a/fixtures/federated-studio/sanity.cli.ts +++ b/fixtures/federated-studio/sanity.cli.ts @@ -1,3 +1,4 @@ +import {unstable_defineApp} from '@sanity/federation' import {defineCliConfig} from 'sanity/cli' export default defineCliConfig({ @@ -5,10 +6,12 @@ export default defineCliConfig({ dataset: 'test', projectId: 'ppsg7ml5', }, + // Calling `unstable_defineApp` opts this studio into workbench (a + // `sanity.config.ts` is present, so it resolves to `applicationType: 'studio'`). + app: unstable_defineApp({ + name: 'federated-studio', + }), deployment: { autoUpdates: true, }, - federation: { - enabled: true, - }, }) diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.authentication.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.authentication.test.ts index 0829ddcbc..f0e6c6803 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.authentication.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.authentication.test.ts @@ -170,7 +170,6 @@ describe('#init: authentication', () => { '--output-path=/test/output', '--no-overwrite-files', '--template=clean', - '--federation', ], { mocks: { @@ -215,7 +214,6 @@ describe('#init: authentication', () => { '--output-path=/test/output', '--no-overwrite-files', '--template=clean', - '--federation', ], { mocks: { diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts index 76ab47c04..3f0a49e3f 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts @@ -169,7 +169,6 @@ describe('#init: bootstrap-app-initialization', () => { '--dataset=test', '--package-manager=npm', '--typescript', - '--federation', ], { mocks: { @@ -183,7 +182,6 @@ describe('#init: bootstrap-app-initialization', () => { autoUpdates: true, bearerToken: undefined, dataset: 'test', - federation: true, organizationId: undefined, output: expect.any(Object), outputPath: convertToSystemPath('/test/output'), @@ -258,7 +256,6 @@ describe('#init: bootstrap-app-initialization', () => { '--output-path=/test/output', '--package-manager=npm', '--typescript', - '--federation', ], { mocks: { @@ -272,7 +269,6 @@ describe('#init: bootstrap-app-initialization', () => { autoUpdates: true, bearerToken: undefined, dataset: '', - federation: true, organizationId: 'org-1', output: expect.any(Object), outputPath: convertToSystemPath('/test/output'), @@ -335,7 +331,6 @@ describe('#init: bootstrap-app-initialization', () => { autoUpdates: true, bearerToken: undefined, dataset: '', - federation: true, organizationId: 'org-1', output: expect.any(Object), outputPath: convertToSystemPath('/test/output'), @@ -406,7 +401,6 @@ describe('#init: bootstrap-app-initialization', () => { autoUpdates: true, bearerToken: undefined, dataset: '', - federation: true, organizationId: 'org-1', output: expect.any(Object), outputPath: convertToSystemPath('/test/output'), diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.create-new-project.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.create-new-project.test.ts index 25c419fdc..7aae59741 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.create-new-project.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.create-new-project.test.ts @@ -513,7 +513,6 @@ describe('#init: create new project', () => { '--no-overwrite-files', '--template=moviedb', '--no-import-dataset', - '--federation', ], {mocks: {...defaultMocks, isInteractive: true}}, ) diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts index 2d50818f9..0b3c0752a 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts @@ -223,7 +223,6 @@ describe('#init: staging env propagation', () => { '--dataset=test', '--package-manager=npm', '--typescript', - '--federation', ], { mocks: { @@ -261,7 +260,6 @@ describe('#init: staging env propagation', () => { '--dataset=test', '--package-manager=npm', '--typescript', - '--federation', ], { mocks: { @@ -291,7 +289,6 @@ describe('#init: staging env propagation', () => { '--dataset=test', '--package-manager=npm', '--typescript', - '--federation', ], { mocks: { diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index ebc7213f1..ca539ef9f 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -78,11 +78,6 @@ export class InitCommand extends SanityCommand { return input }, }), - federation: Flags.boolean({ - allowNo: true, - default: undefined, - description: 'Enable federation for this project', - }), 'from-create': Flags.boolean({ description: 'Internal flag to indicate that the command is run from create-sanity', hidden: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aeb48ec2a..cb0ed0ed5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,6 +261,9 @@ importers: fixtures/federated-studio: dependencies: + '@sanity/federation': + specifier: 0.1.0-alpha.9 + version: 0.1.0-alpha.9(typescript@5.9.3)(vite@7.3.3(@types/node@25.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) react: specifier: ^19.2.5 version: 19.2.5 @@ -13311,6 +13314,22 @@ snapshots: - utf-8-validate - vue-tsc + '@module-federation/vite@1.16.0(typescript@5.9.3)(vite@7.3.3(@types/node@25.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@module-federation/dts-plugin': 2.5.0(typescript@5.9.3) + '@module-federation/runtime': 2.5.0 + '@module-federation/sdk': 2.5.0 + es-module-lexer: 2.0.0 + estree-walker: 3.0.3 + pathe: 2.0.3 + vite: 7.3.3(@types/node@25.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + transitivePeerDependencies: + - bufferutil + - node-fetch + - typescript + - utf-8-validate + - vue-tsc + '@mswjs/interceptors@0.41.2': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -14490,6 +14509,19 @@ snapshots: - utf-8-validate - vue-tsc + '@sanity/federation@0.1.0-alpha.9(typescript@5.9.3)(vite@7.3.3(@types/node@25.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@module-federation/runtime': 2.5.0 + '@module-federation/vite': 1.16.0(typescript@5.9.3)(vite@7.3.3(@types/node@25.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + vite: 7.3.3(@types/node@25.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + zod: 4.3.6 + transitivePeerDependencies: + - bufferutil + - node-fetch + - typescript + - utf-8-validate + - vue-tsc + '@sanity/functions@1.3.1': {} '@sanity/generate-help-url@4.0.0': {} From 41e984e6d6196dbbc4605ee152195063bbe949dd Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 18:10:30 +0200 Subject: [PATCH 15/18] fix(ci): bust cli-test build cache when repo-root fixtures change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@sanity/cli-test` copies fixtures from the repo root via `copy:fixtures`, but the generic `build` task only keys on files inside the package. Editing a repo-root fixture left the cache key unchanged, so turbo replayed a stale bundled fixture — the federated-studio test then read the old config and failed. Key cli-test's build on `$TURBO_ROOT$/fixtures` and the workspace catalog so fixture edits invalidate it. --- turbo.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/turbo.json b/turbo.json index 00473fd74..ee7d61a7f 100644 --- a/turbo.json +++ b/turbo.json @@ -15,6 +15,11 @@ "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**", ".sanity/**", "lib/**", "fixtures/**"] }, + "@sanity/cli-test#build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/fixtures/**", "$TURBO_ROOT$/pnpm-workspace.yaml"], + "outputs": ["dist/**", "fixtures/**"] + }, "build:types": { "dependsOn": ["^build", "^build:types", "build"], "outputs": ["dist/**/*.d.ts"] From 8f2128eab5e68725e2e2b8516aece48cb2e611d3 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 29 May 2026 18:28:40 +0200 Subject: [PATCH 16/18] fix(cli): drop --no-federation from e2e init tests The federation flag is gone, so the e2e init flows that still passed `--no-federation` errored out with "Nonexistent flag" before reaching their prompts. --- packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts | 1 - .../cli-e2e/__tests__/init/init.studio-interactive.test.ts | 5 ----- 2 files changed, 6 deletions(-) diff --git a/packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts b/packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts index 601e8de78..15c24928f 100644 --- a/packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts +++ b/packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts @@ -100,7 +100,6 @@ describe('sanity init - app', {timeout: 120_000}, () => { tmp.path, '--no-git', '--no-mcp', - '--no-federation', ], interactive: true, }) diff --git a/packages/@sanity/cli-e2e/__tests__/init/init.studio-interactive.test.ts b/packages/@sanity/cli-e2e/__tests__/init/init.studio-interactive.test.ts index c8999164a..a8425574b 100644 --- a/packages/@sanity/cli-e2e/__tests__/init/init.studio-interactive.test.ts +++ b/packages/@sanity/cli-e2e/__tests__/init/init.studio-interactive.test.ts @@ -60,7 +60,6 @@ describe('sanity init - studio (interactive)', {timeout: 120_000}, () => { 'pnpm', '--no-mcp', '--no-git', - '--no-federation', ], interactive: true, }) @@ -91,7 +90,6 @@ describe('sanity init - studio (interactive)', {timeout: 120_000}, () => { tmp.path, '--no-mcp', '--no-git', - '--no-federation', ], interactive: true, }) @@ -131,7 +129,6 @@ describe('sanity init - studio (interactive)', {timeout: 120_000}, () => { '--typescript', '--no-mcp', '--no-git', - '--no-federation', ], interactive: true, }) @@ -162,7 +159,6 @@ describe('sanity init - studio (interactive)', {timeout: 120_000}, () => { '--package-manager', 'pnpm', '--no-git', - '--no-federation', ], interactive: true, }) @@ -195,7 +191,6 @@ describe('sanity init - studio (interactive)', {timeout: 120_000}, () => { '--package-manager', 'pnpm', '--no-git', - '--no-federation', ], interactive: true, }) From 8de95a13cfe6b26df6dc8ebbe1b94bcb069a0713 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Mon, 1 Jun 2026 09:00:11 +0200 Subject: [PATCH 17/18] refactor(cli): harden workbench config loading per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseWorkbenchCliConfig` no longer mutates the caller's `app` object — it resolves `applicationType` onto a clone (copying descriptors so the non-enumerable brand survives), so re-parsing the same app for a different directory can't inherit a stale inference. An explicit `applicationType` is now validated against the known set and fails fast, since `unstable_defineApp` is a pure identity wrapper that doesn't validate. Cover the workbench branch in both the async and sync loaders. --- .../src/config/__tests__/getCliConfig.test.ts | 21 +++++++++ .../config/__tests__/getCliConfigSync.test.ts | 35 +++++++++++++++ .../config/cli/__tests__/workbenchApp.test.ts | 22 +++++++--- .../cli-core/src/config/cli/workbenchApp.ts | 44 ++++++++++++++++--- 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/packages/@sanity/cli-core/src/config/__tests__/getCliConfig.test.ts b/packages/@sanity/cli-core/src/config/__tests__/getCliConfig.test.ts index 0dcb347cb..a757dd906 100644 --- a/packages/@sanity/cli-core/src/config/__tests__/getCliConfig.test.ts +++ b/packages/@sanity/cli-core/src/config/__tests__/getCliConfig.test.ts @@ -12,6 +12,12 @@ vi.mock('../util/findConfigsPaths.js', () => ({ })) const ROOT = '/mock/project' +const BRAND = Symbol.for('sanity.workbench.defineApp') + +/** Mimics what `unstable_defineApp` returns: the input plus the brand. */ +function brandedApp(input: Record) { + return Object.defineProperty({...input}, BRAND, {enumerable: false, value: true}) +} function setupSingleConfig(configPath = `${ROOT}/sanity.cli.ts`) { mockFindPathForFiles.mockResolvedValue([ @@ -70,6 +76,21 @@ describe('getCliConfig', () => { await expect(getCliConfig(ROOT)).rejects.toThrow('CLI config cannot be loaded') }) + test('routes a branded app through the workbench loader', async () => { + const getCliConfig = await freshImport() + setupSingleConfig() + const app = brandedApp({name: 'drop-desk', title: 'Drop Desk'}) + mockImportModule.mockResolvedValue({api: {projectId: 'abc'}, app}) + + const config = await getCliConfig(ROOT) + + // `/mock/project` has no `sanity.config.*`, so it resolves to a core app — + // proving the branch ran parseWorkbenchCliConfig and kept the brand. + expect((config.app as {applicationType?: string}).applicationType).toBe('coreApp') + expect(BRAND in (config.app as object)).toBe(true) + expect('applicationType' in app).toBe(false) + }) + test('throws on schema validation failure', async () => { const getCliConfig = await freshImport() setupSingleConfig() diff --git a/packages/@sanity/cli-core/src/config/__tests__/getCliConfigSync.test.ts b/packages/@sanity/cli-core/src/config/__tests__/getCliConfigSync.test.ts index 97115c6e8..29840bc63 100644 --- a/packages/@sanity/cli-core/src/config/__tests__/getCliConfigSync.test.ts +++ b/packages/@sanity/cli-core/src/config/__tests__/getCliConfigSync.test.ts @@ -1,3 +1,7 @@ +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {getCliConfigSync} from '../cli/getCliConfigSync' @@ -37,4 +41,35 @@ describe('getCliConfigSync', () => { expect(() => getCliConfigSync(mockRootPath)).toThrow('Multiple CLI config files found') }) + + test('routes a branded app through the workbench loader', async () => { + const {existsSync} = await import('node:fs') + const realFs = await vi.importActual('node:fs') + vi.mocked(existsSync).mockImplementation((path) => realFs.existsSync(path)) + + // A self-contained config: brand the app via the global `Symbol.for` the + // same way `unstable_defineApp` does, so it needs no external import. + const dir = mkdtempSync(join(tmpdir(), 'cli-sync-cfg-')) + writeFileSync( + join(dir, 'sanity.cli.ts'), + [ + `const app = {name: 'drop-desk', title: 'Drop Desk'}`, + `Object.defineProperty(app, Symbol.for('sanity.workbench.defineApp'), {`, + ` enumerable: false, value: true,`, + `})`, + `export default {api: {projectId: 'abc'}, app}`, + ].join('\n'), + ) + + try { + const config = getCliConfigSync(dir) + + // No `sanity.config.*` in the temp dir, so it resolves to a core app — + // proving the branch ran parseWorkbenchCliConfig and kept the brand. + expect((config.app as {applicationType?: string}).applicationType).toBe('coreApp') + expect(Symbol.for('sanity.workbench.defineApp') in (config.app as object)).toBe(true) + } finally { + rmSync(dir, {force: true, recursive: true}) + } + }) }) diff --git a/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts b/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts index 7a1348ee2..8b0decc88 100644 --- a/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts +++ b/packages/@sanity/cli-core/src/config/cli/__tests__/workbenchApp.test.ts @@ -15,7 +15,7 @@ function brandedApp(input: Record) { } describe('parseWorkbenchCliConfig', () => { - test('keeps the branded app untouched, brand and identity fields intact', () => { + test('keeps the identity fields and the brand on the resolved app', () => { const app = brandedApp({ entry: './src/App.tsx', name: 'drop-desk', @@ -25,25 +25,33 @@ describe('parseWorkbenchCliConfig', () => { const config = parseWorkbenchCliConfig({app, server: {port: 3333}}, APP_DIR) - expect(config.app).toBe(app) expect((config.app as {name?: string}).name).toBe('drop-desk') expect(BRAND in (config.app as object)).toBe(true) }) - test('infers applicationType "coreApp" when there is no sanity.config', () => { + test('resolves applicationType onto a clone without mutating the caller', () => { const app = brandedApp({name: 'drop-desk', title: 'Drop Desk'}) - parseWorkbenchCliConfig({app}, APP_DIR) + const config = parseWorkbenchCliConfig({app}, APP_DIR) - expect((app as {applicationType?: string}).applicationType).toBe('coreApp') + // Caller's object is untouched; the resolved value lives on the returned clone. + expect('applicationType' in app).toBe(false) + expect(config.app).not.toBe(app) + expect((config.app as {applicationType?: string}).applicationType).toBe('coreApp') }) test('keeps an explicit applicationType (no detection)', () => { const app = brandedApp({applicationType: 'media-library', name: 'media', title: 'Media'}) - parseWorkbenchCliConfig({app}, join(APP_DIR, 'nope')) + const config = parseWorkbenchCliConfig({app}, join(APP_DIR, 'nope')) - expect((app as {applicationType?: string}).applicationType).toBe('media-library') + expect((config.app as {applicationType?: string}).applicationType).toBe('media-library') + }) + + test('rejects an unknown applicationType', () => { + const app = brandedApp({applicationType: 'Studio', name: 'typo', title: 'Typo'}) + + expect(() => parseWorkbenchCliConfig({app}, APP_DIR)).toThrow(/Invalid `applicationType`/) }) test('still validates the non-app fields', () => { diff --git a/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts index 0bfb145b1..29e26f41d 100644 --- a/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts +++ b/packages/@sanity/cli-core/src/config/cli/workbenchApp.ts @@ -13,12 +13,29 @@ const STUDIO_CONFIG_FILES = [ 'sanity.config.cjs', ] +// Mirrors `@sanity/federation`'s `ApplicationType` enum. `unstable_defineApp` +// is a pure identity wrapper that doesn't validate its input, so the loader is +// the first place an explicit `applicationType` can be checked. +const APPLICATION_TYPES = [ + 'coreApp', + 'studio', + 'canvas', + 'dashboard', + 'media-library', + 'system', +] as const +type ApplicationType = (typeof APPLICATION_TYPES)[number] + +function isApplicationType(value: unknown): value is ApplicationType { + return typeof value === 'string' && (APPLICATION_TYPES as readonly string[]).includes(value) +} + /** * Infer the application type for a workbench app when `unstable_defineApp` * didn't set one: a project with a `sanity.config.*` is a studio, otherwise a * core (SDK) app. An explicit `applicationType` always wins. */ -function detectApplicationType(projectDir: string): 'coreApp' | 'studio' { +function detectApplicationType(projectDir: string): ApplicationType { return STUDIO_CONFIG_FILES.some((file) => existsSync(join(projectDir, file))) ? 'studio' : 'coreApp' @@ -32,7 +49,9 @@ function detectApplicationType(projectDir: string): 'coreApp' | 'studio' { * `isWorkbenchApp` identity (from `@sanity/federation`) instead of a flag. * * Resolves `applicationType` here — as early as possible — so studio-vs-app - * classification is settled once and read off the app everywhere else. + * classification is settled once and read off the app everywhere else. The + * resolved value lands on a clone, never the caller's object, so re-parsing the + * same `app` for a different directory can't inherit a stale inference. */ export function parseWorkbenchCliConfig(cliConfig: unknown, projectDir: string): CliConfig { const {app, ...rest} = cliConfig as Record & { @@ -42,8 +61,23 @@ export function parseWorkbenchCliConfig(cliConfig: unknown, projectDir: string): if (!success) { throw new Error(`Invalid CLI config: ${error.message}`, {cause: error}) } - if (!app.applicationType) { - app.applicationType = detectApplicationType(projectDir) + + const explicit = app.applicationType + if (explicit !== undefined && !isApplicationType(explicit)) { + throw new Error( + `Invalid \`applicationType\` "${explicit}" in \`unstable_defineApp\` — expected one of: ${APPLICATION_TYPES.join(', ')}`, + ) } - return {...data, app} as CliConfig + const applicationType = explicit ?? detectApplicationType(projectDir) + + // Clone the branded app rather than mutating the caller's object. Copying own + // property descriptors carries over the non-enumerable `unstable_defineApp` + // brand, which a spread would drop. + const resolvedApp = Object.defineProperties({}, Object.getOwnPropertyDescriptors(app)) as Record< + string, + unknown + > + resolvedApp.applicationType = applicationType + + return {...data, app: resolvedApp} as CliConfig } From c496368bc246d7adb528f84ba9a14e5d1f95fb6c Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Mon, 1 Jun 2026 09:29:02 +0200 Subject: [PATCH 18/18] fix(cli): skip in-process federation build test on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forcing `@module-federation/vite`'s plugins to run inside a vitest worker (via MFE_VITE_NO_TEST_ENV_CHECK) crashes the worker on Windows — esbuild's service pipe dies with "Unexpected end of JSON input [plugin onEnd]". Real `sanity build` runs as its own process and the federation-artifact shape is platform-independent, so Linux coverage is enough. --- .../commands/__tests__/build.studio.test.ts | 86 ++++++++++--------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/packages/@sanity/cli/src/commands/__tests__/build.studio.test.ts b/packages/@sanity/cli/src/commands/__tests__/build.studio.test.ts index a9d601d57..c8093a2dd 100644 --- a/packages/@sanity/cli/src/commands/__tests__/build.studio.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/build.studio.test.ts @@ -85,45 +85,53 @@ describe('#build studio', {timeout: (platform() === 'win32' ? 120 : 60) * 1000}, expect(files).not.toContain('mf-manifest.json') }) - test('should build the "federated-studio" with only federation artifacts', async () => { - const cwd = await testFixture('federated-studio') - process.chdir(cwd) - - // `@module-federation/vite` short-circuits to an empty plugin array when - // it detects vitest/jest in the env, which leaves the federation env without - // its plugins and skips emitting `remote-entry.js` / `mf-manifest.json`. - // Opt out of that guard for this in-process build. - vi.stubEnv('MFE_VITE_NO_TEST_ENV_CHECK', 'true') - - const {error, stderr} = await testCommand(BuildCommand, ['--yes'], { - config: {root: cwd}, - }) - - // 1. Build succeeds - if (error) throw error - expect(stderr).toContain('✔ Build Sanity Studio') - - const distFiles = await readdir(join(cwd, 'dist')) - - // 2. No client artifacts - expect(distFiles).not.toContain('index.html') - expect(distFiles).not.toContain('static') - expect(distFiles).not.toContain('vendor') - - // 3. Stable remote entry (unhashed) - expect(distFiles).toContain('remote-entry.js') - - // 4. Federation manifest (valid JSON) - expect(distFiles).toContain('mf-manifest.json') - const manifest = JSON.parse(await readFile(join(cwd, 'dist', 'mf-manifest.json'), 'utf8')) - expect(manifest).toHaveProperty('id') - expect(manifest).toHaveProperty('name') - - // 5. Hashed federation chunks - expect(distFiles).toContain('assets') - const assetFiles = await readdir(join(cwd, 'dist', 'assets')) - expect(assetFiles.some((f) => /^remote-entry-.+\.js$/.test(f))).toBe(true) - }) + // Skipped on Windows: forcing `@module-federation/vite`'s plugins to run + // in-process (via MFE_VITE_NO_TEST_ENV_CHECK) crashes the vitest worker there — + // esbuild's service pipe dies with "Unexpected end of JSON input [plugin onEnd]". + // It's a test-harness limitation (real `sanity build` runs as its own process); + // the federation-artifact shape is platform-independent, so Linux coverage suffices. + test.skipIf(platform() === 'win32')( + 'should build the "federated-studio" with only federation artifacts', + async () => { + const cwd = await testFixture('federated-studio') + process.chdir(cwd) + + // `@module-federation/vite` short-circuits to an empty plugin array when + // it detects vitest/jest in the env, which leaves the federation env without + // its plugins and skips emitting `remote-entry.js` / `mf-manifest.json`. + // Opt out of that guard for this in-process build. + vi.stubEnv('MFE_VITE_NO_TEST_ENV_CHECK', 'true') + + const {error, stderr} = await testCommand(BuildCommand, ['--yes'], { + config: {root: cwd}, + }) + + // 1. Build succeeds + if (error) throw error + expect(stderr).toContain('✔ Build Sanity Studio') + + const distFiles = await readdir(join(cwd, 'dist')) + + // 2. No client artifacts + expect(distFiles).not.toContain('index.html') + expect(distFiles).not.toContain('static') + expect(distFiles).not.toContain('vendor') + + // 3. Stable remote entry (unhashed) + expect(distFiles).toContain('remote-entry.js') + + // 4. Federation manifest (valid JSON) + expect(distFiles).toContain('mf-manifest.json') + const manifest = JSON.parse(await readFile(join(cwd, 'dist', 'mf-manifest.json'), 'utf8')) + expect(manifest).toHaveProperty('id') + expect(manifest).toHaveProperty('name') + + // 5. Hashed federation chunks + expect(distFiles).toContain('assets') + const assetFiles = await readdir(join(cwd, 'dist', 'assets')) + expect(assetFiles.some((f) => /^remote-entry-.+\.js$/.test(f))).toBe(true) + }, + ) test("should build the 'worst-case-studio' example", async () => { const cwd = await testFixture('worst-case-studio')