Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f60f27d
feat(config): branch on unstable_defineApp brand at config load
gu-stav May 29, 2026
cb2f202
chore: update auto-generated changeset for PR #1143
squiggler-app[bot] May 29, 2026
0ecd70b
chore: update auto-generated changeset for PR #1143
squiggler-app[bot] May 29, 2026
9195f05
feat(cli): re-export unstable_defineApp from @sanity/cli
gu-stav May 29, 2026
5705ffc
chore: update auto-generated changeset for PR #1143
squiggler-app[bot] May 29, 2026
a1d06a8
feat(cli): consume @sanity/federation preview for unstable_defineApp
gu-stav May 29, 2026
f6ef212
fix(cli-core): use .js extension in workbenchApp imports
gu-stav May 29, 2026
ee5d518
fix(cli): bump federation preview, allow new export in exports guard
gu-stav May 29, 2026
4cb163f
feat(cli): make unstable_defineApp the sole workbench opt-in
gu-stav May 29, 2026
11fa38e
refactor(cli): remove federation flag, gate workbench on unstable_def…
gu-stav May 29, 2026
36f88f2
feat(init): stop scaffolding federation by default — workbench is opt-in
gu-stav May 29, 2026
fdd17e2
chore(deps): use released @sanity/federation@0.1.0-alpha.9
gu-stav May 29, 2026
3cc2597
chore: update auto-generated changeset for PR #1143
squiggler-app[bot] May 29, 2026
de33420
fix(cli): drop orphaned --federation flag and fix federated-studio fi…
gu-stav May 29, 2026
41e984e
fix(ci): bust cli-test build cache when repo-root fixtures change
gu-stav May 29, 2026
8f2128e
fix(cli): drop --no-federation from e2e init tests
gu-stav May 29, 2026
8de95a1
refactor(cli): harden workbench config loading per review
gu-stav Jun 1, 2026
c496368
fix(cli): skip in-process federation build test on Windows
gu-stav Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pr-1143.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sanity/cli-core': minor
'@sanity/cli': minor
---

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.
1 change: 1 addition & 0 deletions fixtures/federated-studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
9 changes: 6 additions & 3 deletions fixtures/federated-studio/sanity.cli.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import {unstable_defineApp} from '@sanity/federation'
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
api: {
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,
},
})
1 change: 1 addition & 0 deletions packages/@sanity/cli-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@inquirer/prompts": "^8.3.0",
"@oclif/core": "catalog:",
"@sanity/client": "catalog:",
"@sanity/federation": "0.1.0-alpha.9",
"babel-plugin-react-compiler": "^1.0.0",
"boxen": "^8.0.1",
"debug": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
return Object.defineProperty({...input}, BRAND, {enumerable: false, value: true})
}

function setupSingleConfig(configPath = `${ROOT}/sanity.cli.ts`) {
mockFindPathForFiles.mockResolvedValue([
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<typeof import('node:fs')>('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})
}
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {tmpdir} from 'node:os'
import {join} from 'node:path'

import {describe, expect, test} from 'vitest'

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<string, unknown>) {
return Object.defineProperty({...input}, BRAND, {enumerable: false, value: true})
}

describe('parseWorkbenchCliConfig', () => {
test('keeps the identity fields and the brand on the resolved app', () => {
const app = brandedApp({
entry: './src/App.tsx',
name: 'drop-desk',
organizationId: 'o1',
title: 'Drop Desk',
})

const config = parseWorkbenchCliConfig({app, server: {port: 3333}}, APP_DIR)

expect((config.app as {name?: string}).name).toBe('drop-desk')
expect(BRAND in (config.app as object)).toBe(true)
})

test('resolves applicationType onto a clone without mutating the caller', () => {
const app = brandedApp({name: 'drop-desk', title: 'Drop Desk'})

const config = parseWorkbenchCliConfig({app}, APP_DIR)

// 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'})

const config = parseWorkbenchCliConfig({app}, join(APP_DIR, 'nope'))

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', () => {
const app = brandedApp({name: 'drop-desk', title: 'Drop Desk'})

expect(() => parseWorkbenchCliConfig({app, server: {port: 'nope'}}, APP_DIR)).toThrow(
/Invalid CLI config/,
)
})
})
10 changes: 10 additions & 0 deletions packages/@sanity/cli-core/src/config/cli/getCliConfig.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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'
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 {parseWorkbenchCliConfig} from './workbenchApp.js'

const cache = new Map<string, Promise<CliConfig>>()

Expand Down Expand Up @@ -97,6 +101,12 @@ export async function getCliConfigUncached(rootPath: string): Promise<CliConfig>
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, dirname(configPath))
}

const {data, error, success} = cliConfigSchema.safeParse(cliConfig)
if (!success) {
debug(`Invalid CLI config: ${error.message}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +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 {parseWorkbenchCliConfig} from './workbenchApp.js'

/**
* Get the CLI config for a project synchronously, given the root path.
Expand Down Expand Up @@ -47,6 +49,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, rootPath)
}

const {data, error, success} = cliConfigSchema.safeParse(cliConfig)
if (!success) {
throw new Error(`Invalid CLI config: ${error.message}`)
Expand Down
6 changes: 0 additions & 6 deletions packages/@sanity/cli-core/src/config/cli/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,6 @@ export const cliConfigSchema = z.object({
}),
),

federation: z.optional(
z.object({
enabled: z.boolean(),
}),
),

graphql: z.optional(
z.array(
z.object({
Expand Down
8 changes: 0 additions & 8 deletions packages/@sanity/cli-core/src/config/cli/types/cliConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions packages/@sanity/cli-core/src/config/cli/workbenchApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {existsSync} from 'node:fs'
import {join} from 'node:path'

import {cliConfigSchema} from './schemas.js'
import {type CliConfig} from './types/cliConfig.js'

const STUDIO_CONFIG_FILES = [
'sanity.config.ts',
'sanity.config.tsx',
'sanity.config.js',
'sanity.config.jsx',
'sanity.config.mjs',
'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): ApplicationType {
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` 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. 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<string, unknown> & {
app: Record<string, unknown> & {applicationType?: string}
}
const {data, error, success} = cliConfigSchema.safeParse(rest)
if (!success) {
throw new Error(`Invalid CLI config: ${error.message}`, {cause: error})
}

const explicit = app.applicationType
if (explicit !== undefined && !isApplicationType(explicit)) {
throw new Error(
`Invalid \`applicationType\` "${explicit}" in \`unstable_defineApp\` — expected one of: ${APPLICATION_TYPES.join(', ')}`,
)
}
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
}
1 change: 0 additions & 1 deletion packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ describe('sanity init - app', {timeout: 120_000}, () => {
tmp.path,
'--no-git',
'--no-mcp',
'--no-federation',
],
interactive: true,
})
Expand Down
Loading
Loading