Skip to content

feat: add workbench under unstable flags#907

Open
gu-stav wants to merge 82 commits into
mainfrom
feat/workbench
Open

feat: add workbench under unstable flags#907
gu-stav wants to merge 82 commits into
mainfrom
feat/workbench

Conversation

@gu-stav

@gu-stav gu-stav commented Apr 9, 2026

Copy link
Copy Markdown
Member

Description

Lands Workbench for the Sanity CLI: an opt-in way to develop and build federated applications (studios and SDK apps) behind a single dev server.

The shape. sanity dev for an opted-in app starts an extra dev server — the workbench shell — and loads the real app into it over Module Federation. Same delivery model as Dashboard: one host, many apps mounted as remotes. The app's own dev server registers itself against that shell as a federated remote; the shell renders it.

One server, many apps. Only one workbench runs per machine. The first opted-in sanity dev to start acquires a lockfile and hosts the shell; every other sanity dev finds it through the lock and registers its remote there. Run three apps locally and you get one workbench hosting three remotes.

Opt-in is a single identity function. Apps opt in by wrapping their config in unstable_defineApp(...) in sanity.cli.ts. That's the only signal — it stamps a brand symbol the CLI detects (isWorkbenchApp). Without it, dev/build/deploy take the exact paths they take on main. No flag, no env var, no behavioural change for anyone who doesn't call it.

Build + deploy. A branded app builds a Module Federation remote (mf-manifest.json, exposed ./App / ./views/* / ./services/*) through a sanity/federation Vite plugin stack, instead of a plain SPA. sanity deploy validates the federation manifest instead of index.html. Studios and SDK apps share the path.

Scaffolding. sanity init --unstable--workbench scaffolds a federated app (wraps the config in unstable_defineApp). The flag is hidden and undocumented — internal-only while the API carries the unstable_ prefix.

Experimental; should not surface in release notes.

What to review

Three areas, in order of how much I'd want eyes on them.

1. Nothing changes without opting in. Every workbench branch is gated on isWorkbenchApp() (the brand) or the internal SANITY_INTERNAL_IS_WORKBENCH_REMOTE env var. Config parsing only forks after the brand check; strictPort, entry-module, Vite plugins and auto-updates all keep main's logic on the non-workbench side; signal handlers attach only when there's a lock or registration to clean up; init --unstable--workbench defaults to off. The two new app fields (group, priority) are additive optionals. getCliConfig.ts and getViteConfig.ts sit on every command's hot path — worth a second read there.

2. Lockfile + process handling. The single-workbench model rests on workbenchLock.ts plus the registry. O_EXCL (wx) makes acquisition atomic, stale locks are pruned via liveness + OS start-time (PID reuse handled cross-platform, including Windows PowerShell), and the lock releases on normal exit / SIGINT / SIGTERM / throw-during-startup. Sharp edges to flag rather than block on:

  • updatePort() writes the real Vite port back after the server listens, so there's a tiny window where a registrant reads the placeholder port — and that write isn't wrapped, so a failed write leaves a stale port silently.
  • A short gap between the workbench server returning and the signal handlers attaching where a SIGINT can leak the lock (self-heals on next run via pruning).

Testing

Coverage spans the new surface — Vite config, build artifacts, dev orchestration, the lock + registry (PID-reuse and Windows paths included), and deploy validation — plus a federated-studio fixture. Non-workbench paths stay on the existing suites, unchanged and still green.


Note

High Risk
Large changes on every-command hot paths (getCliConfig, getViteConfig, devAction) plus multi-process dev (lock, registry, port stacking); mitigated by strict isWorkbenchApp gating but regressions would affect all CLI users if gating fails.

Overview
Workbench is an opt-in local dev and build model: wrapping app in unstable_defineApp(...) in sanity.cli.ts brands the config; the CLI treats that as the only workbench signal (isWorkbenchApp). Without it, behavior matches main.

For branded projects, sanity dev is orchestrated in devAction: it may start a singleton workbench host on the configured port, runs the studio/app Vite server on the next port when the host is active, registers the remote (views/services/entry → federation exposes), watches manifests, and rebuilds the app server when the declared interface set changes. The workbench remote itself uses SANITY_INTERNAL_IS_WORKBENCH_REMOTE to skip the shell and stay on the configured port.

Build/deploy switch workbench apps to @sanity/workbench-cli federation Vite plugins (skipping the normal SPA runtime/favicons path), support apps with no entry (stub runtime), relax strictPort for stacked servers, and adjust deploy validation (federation output, view payload stub, block external deploy for federated studios).

cli-core adds workbench config parsing (parseWorkbenchCliConfig, applicationType detection), shared manifest schemas, getCliConfigUncached for live reloads, and shared getSanityConfigDir / data dir helpers.

Also adds sanity init --unstable--workbench, a federated-studio fixture, packs @sanity/workbench-cli in e2e, exports unstable_defineApp, and broad unit/e2e coverage so plain studios/apps still bind their configured dev port.

Reviewed by Cursor Bugbot for commit 06ab142. Bugbot is set up for automated code reviews on this repo. Configure here.

@claude

claude Bot commented Apr 9, 2026

Copy link
Copy Markdown

Claude finished @gu-stav's task in 6m 1s —— View job


Claude PR Review

  • Read PR diff vs origin/main
  • Review changeset(s)
  • Review dev server / workbench code
  • Review build / manifest changes
  • Review cli-core schema/config changes
  • Review test coverage
  • Post review findings

A few issues worth fixing before this graduates from a tracking-only feature branch.

1. Typegen plugin is registered twice in dev mode (bug)

In packages/@sanity/cli/src/actions/build/getViteConfig.ts:164-172, the typegen plugin is added to sharedPlugins when typegen?.enabled. In packages/@sanity/cli/src/server/devServer.ts:75-86, the typegen plugin is also passed in via additionalPlugins. Since additionalPlugins is spread into the non-federation branch of plugins (getViteConfig.ts:235), running sanity dev (non-federated) with typegen.enabled: true registers sanityTypegenPlugin twice. The Vite plugin pipeline will then execute every hook twice — duplicate buildStart, watcher registrations, and writes to sanity.types.ts.

The existing tests use .find() and toHaveBeenCalledWith(...), which mask the duplicate; assert toHaveBeenCalledTimes(1) to make this visible.

Fix: stop passing additionalPlugins from devServer.ts for typegen (now that the plugin is centralized in getViteConfig), or pull it out of sharedPlugins and keep the original behaviour.

2. Workbench requires authentication when only api.projectId is configured

packages/@sanity/cli/src/actions/dev/startWorkbenchDevServer.ts:226-242 falls back to getProjectById(cliConfig.api.projectId) to resolve the organization id. That call goes through getProjectCliClient({requireUser: true}) (services/projects.ts:58), so:

  • Running sanity dev while logged out crashes with an auth error rather than the helpful message at line 239 telling the user to set app.organizationId. Catch the error (or check auth first) and surface a clearer message including the option to sanity login.
  • It also adds a synchronous API round-trip to every workbench-enabled dev startup. Consider caching the resolved organizationId (e.g. alongside the workbench lock or in ~/.sanity), or only resolving lazily when the workbench actually needs it.

3. appPort may be undefined and silently drop the registration

packages/@sanity/cli/src/actions/dev/startFederationRegistration.ts:37-47:

const addr = server.httpServer?.address()
const appPort = typeof addr === 'object' && addr ? addr.port : server.config.server.port

server.config.server.port is optional in Vite's types, so appPort can be undefined. registerDevServer writes that to disk; getRegisteredServers then fails schema validation (port: z.number()) and prunes the entry — silently. Either narrow the type with a runtime check + warning, or require httpServer.address() to have returned by this point.

4. Lock host can store binding-only addresses

When a user runs sanity dev --host 0.0.0.0, the workbench lock stores host: '0.0.0.0' (startWorkbenchDevServer.ts:77). Other studio processes reading the lock then build URLs like http://0.0.0.0:3333 for both the displayed dev URL and reactRefreshHost. The displayed URL is unfriendly; 0.0.0.0 works in browsers on most platforms but not all. Consider normalizing 0.0.0.0 / :: to localhost for the URL/refresh host (keep the bind address in the lock for actual listening).

5. Stale optimizeDeps exclusion

startWorkbenchDevServer.ts:166 excludes ['sanity', '@sanity/workbench'] from dep pre-bundling. The workbench is imported as sanity/workbench (subpath of sanity), and the externals changeset only mentions sanity and @sanity/workbench. If @sanity/workbench is not actually consumed at runtime, drop it from the list to avoid confusion when readers grep for the package.

6. Internal codename leaks into source comments

packages/@sanity/cli/src/actions/manifest/extractCoreAppManifest.ts:17: Brett sanitizes SVGs so it's skipped here. and packages/@sanity/cli/src/actions/deploy/deployApp.ts:112: // Sync app title from manifest when it has changed (Brett user-applications). "Brett" is an internal codename that won't mean anything to readers outside the team. Rewrite as the concrete reason (e.g. "the user-applications service sanitizes the SVG server-side").

7. Changeset summaries

  • .changeset/fancy-cycles-strive.md: externalize sanity and @sanity/workbench — vague and implementation-focused for a @sanity/cli patch. Reword to a user-facing effect, e.g. Federated builds now treat 'sanity' and '@sanity/workbench' as external dependencies so they are no longer bundled into each remote.
  • .changeset/bump-federation-alpha-6.md: pure dependency bump — fine as-is but consider chore framing if not user-facing.
  • The remaining workbench changesets read well.

8. Minor / nits

  • packages/@sanity/cli/src/actions/dev/devAction.ts:36: comment says Use workbenchPort + 1 when workbench claims the configured port — it actually uses the passed workbenchPort (which may itself have been bumped by Vite's strictPort: false). Document that desiredAppPort is "preferred" rather than "next available" since Vite may bump again.
  • packages/@sanity/cli/src/actions/dev/writeWorkbenchRuntime.ts:13: the template fallback 'undefined' produces {organizationId: undefined} in the generated JS, but the caller throws when org id is missing so that branch is unreachable. Either remove the fallback or remove the throw — keeping both is dead code.
  • packages/@sanity/cli/src/actions/dev/startFederationRegistration.ts:44: cliConfig?.api?.projectIdcliConfig is non-optional in the interface, drop the ?.
    · branch: feat/workbench

@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @sanity/cli

Compared against main (15636aa3)

@sanity/cli

Metric Value vs main (15636aa)
Internal (raw) 2.7 KB +539 B, +24.7%
Internal (gzip) 1.0 KB +257 B, +32.2%
Bundled (raw) 11.15 MB +19.2 KB, +0.2%
Bundled (gzip) 2.10 MB +5.3 KB, +0.2%
Import time 867ms -9ms, -1.0%

bin:sanity

Metric Value vs main (15636aa)
Internal (raw) 782 B -
Internal (gzip) 423 B -
Bundled (raw) 9.87 MB -
Bundled (gzip) 1.77 MB -
Import time 1.96s -80ms, -3.9%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @sanity/cli-core

Compared against main (15636aa3)

Metric Value vs main (15636aa)
Internal (raw) 106.7 KB +8.5 KB, +8.7%
Internal (gzip) 26.7 KB +3.4 KB, +14.5%
Bundled (raw) 21.71 MB +7.8 KB, +0.0%
Bundled (gzip) 3.45 MB +3.5 KB, +0.1%
Import time 769ms -17ms, -2.2%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — create-sanity

Compared against main (15636aa3)

Metric Value vs main (15636aa)
Internal (raw) 908 B -
Internal (gzip) 483 B -
Bundled (raw) 931 B -
Bundled (gzip) 491 B -
Import time ❌ ChildProcess denied: node -
Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Coverage Delta

File Statements
packages/@sanity/cli-build/src/actions/build/getEntryModule.ts 100.0% (±0%)
packages/@sanity/cli-build/src/actions/build/getViteConfig.ts 100.0% (±0%)
packages/@sanity/cli-build/src/actions/build/writeSanityRuntime.ts 96.2% (+ 0.7%)
packages/@sanity/cli-core/src/config/cli/getCliConfig.ts 100.0% (±0%)
packages/@sanity/cli-core/src/config/cli/getCliConfigSync.ts 81.0% (+ 38.9%)
packages/@sanity/cli-core/src/config/cli/workbenchApp.ts 100.0% (new)
packages/@sanity/cli-core/src/manifest.ts 100.0% (new)
packages/@sanity/cli-core/src/services/cliUserConfig.ts 100.0% (±0%)
packages/@sanity/cli-core/src/util/getSanityConfigDir.ts 66.7% (new)
packages/@sanity/cli/src/actions/build/buildApp.ts 95.4% (+ 0.1%)
packages/@sanity/cli/src/actions/build/buildStaticFiles.ts 97.7% (+ 0.9%)
packages/@sanity/cli/src/actions/build/buildStudio.ts 71.4% (+ 0.3%)
packages/@sanity/cli/src/actions/deploy/deployApp.ts 73.9% (- 6.7%)
packages/@sanity/cli/src/actions/deploy/deployStudio.ts 91.8% (- 0.7%)
packages/@sanity/cli/src/actions/deploy/viewDeployment.ts 66.7% (new)
packages/@sanity/cli/src/actions/dev/devAction.ts 97.6% (- 2.4%)
packages/@sanity/cli/src/actions/dev/registration/deriveInterfaces.ts 100.0% (new)
packages/@sanity/cli/src/actions/dev/registration/extractDevServerManifest.ts 20.0% (new)
packages/@sanity/cli/src/actions/dev/registration/interfaceSetId.ts 100.0% (new)
packages/@sanity/cli/src/actions/dev/registration/startDevManifestWatcher.ts 90.9% (new)
packages/@sanity/cli/src/actions/dev/registration/startDevServerRegistration.ts 100.0% (new)
packages/@sanity/cli/src/actions/dev/servers/getDashboardAppUrl.ts 100.0% (new)
packages/@sanity/cli/src/actions/dev/servers/getDevServerConfig.ts 100.0% (new)
packages/@sanity/cli/src/actions/dev/servers/startAppDevServer.ts 100.0% (new)
packages/@sanity/cli/src/actions/dev/servers/startStudioDevServer.ts 100.0% (new)
packages/@sanity/cli/src/actions/dev/workbench/startWorkbenchDevServer.ts 98.8% (new)
packages/@sanity/cli/src/actions/dev/workbench/writeWorkbenchRuntime.ts 100.0% (new)
packages/@sanity/cli/src/actions/init/bootstrapLocalTemplate.ts 89.7% (±0%)
packages/@sanity/cli/src/actions/init/bootstrapTemplate.ts 0.0% (±0%)
packages/@sanity/cli/src/actions/init/createAppCliConfig.ts 100.0% (±0%)
packages/@sanity/cli/src/actions/init/createCliConfig.ts 100.0% (+ 50.0%)
packages/@sanity/cli/src/actions/init/createStudioConfig.ts 83.3% (±0%)
packages/@sanity/cli/src/actions/init/initAction.ts 93.3% (+ 0.1%)
packages/@sanity/cli/src/actions/init/initApp.ts 96.0% (±0%)
packages/@sanity/cli/src/actions/init/initStudio.ts 87.0% (±0%)
packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts 100.0% (±0%)
packages/@sanity/cli/src/actions/init/types.ts 100.0% (±0%)
packages/@sanity/cli/src/actions/manifest/extractCoreAppManifest.ts 93.9% (new)
packages/@sanity/cli/src/actions/manifest/extractManifest.ts 100.0% (±0%)
packages/@sanity/cli/src/actions/manifest/iconResolver.tsx 100.0% (±0%)
packages/@sanity/cli/src/actions/manifest/sanitizeIcon.ts 100.0% (new)
packages/@sanity/cli/src/actions/manifest/types.ts 100.0% (±0%)
packages/@sanity/cli/src/actions/manifest/writeManifestFile.ts 100.0% (±0%)
packages/@sanity/cli/src/commands/dev.ts 100.0% (±0%)
packages/@sanity/cli/src/commands/init.ts 100.0% (±0%)
packages/@sanity/cli/src/commands/manifest/extract.ts 100.0% (±0%)
packages/@sanity/cli/src/server/devServer.ts 94.4% (±0%)
packages/@sanity/cli/src/util/determineIsApp.ts 100.0% (±0%)
packages/@sanity/cli/src/util/getSharedServerConfig.ts 100.0% (±0%)
packages/@sanity/cli/src/util/resolveReactStrictMode.ts 100.0% (new)
packages/@sanity/workbench-cli/src/actions/build/artifact.ts 66.7% (new)
packages/@sanity/workbench-cli/src/actions/build/render-remote.ts 100.0% (new)
packages/@sanity/workbench-cli/src/actions/build/services/artifact.ts 100.0% (new)
packages/@sanity/workbench-cli/src/actions/build/views/artifact.ts 100.0% (new)
packages/@sanity/workbench-cli/src/actions/build/vite/constants.ts 100.0% (new)
packages/@sanity/workbench-cli/src/actions/build/vite/plugin.ts 82.6% (new)
packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-module-federation.ts 92.8% (new)
packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-sanity-environment.ts 100.0% (new)
packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-sanity-extension-artifacts.ts 100.0% (new)
packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-sanity-federation-runtime.ts 34.3% (new)
packages/@sanity/workbench-cli/src/actions/build/vite/workbench-vite-plugins.ts 83.3% (new)
packages/@sanity/workbench-cli/src/actions/deploy/getWorkbench.ts 100.0% (new)
packages/@sanity/workbench-cli/src/actions/dev/canonicalizeWatchDir.ts 100.0% (new)
packages/@sanity/workbench-cli/src/actions/dev/processLiveness.ts 89.7% (new)
packages/@sanity/workbench-cli/src/actions/dev/registry.ts 95.7% (new)
packages/@sanity/workbench-cli/src/actions/init/cliConfig.ts 100.0% (new)
packages/@sanity/workbench-cli/src/contract.ts 100.0% (new)
packages/@sanity/workbench-cli/src/defineApp.ts 100.0% (new)
packages/@sanity/workbench-cli/src/defineService.ts 100.0% (new)
packages/@sanity/workbench-cli/src/defineView.ts 100.0% (new)
packages/@sanity/workbench-cli/src/resolveWorkbenchApp.ts 100.0% (new)

Comparing 71 changed files against main @ 15636aa32e62f81f357d5d7af348ef95debd7d7b

Overall Coverage

Metric Coverage
Statements 87.9% (+ 0.4%)
Branches 77.8% (+ 0.5%)
Functions 87.2% (+ 0.8%)
Lines 88.3% (+ 0.5%)

@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Preview this PR with pkg.pr.new

Run the Sanity CLI

npx https://pkg.pr.new/sanity-io/cli/@sanity/cli@06ab142 <command>

...Or upgrade project dependencies

📦 @sanity/cli
pnpm install https://pkg.pr.new/@sanity/cli@06ab142
📦 @sanity/cli-build
pnpm install https://pkg.pr.new/@sanity/cli-build@06ab142
📦 @sanity/cli-core
pnpm install https://pkg.pr.new/@sanity/cli-core@06ab142
📦 @sanity/cli-test
pnpm install https://pkg.pr.new/@sanity/cli-test@06ab142
📦 @sanity/eslint-config-cli
pnpm install https://pkg.pr.new/@sanity/eslint-config-cli@06ab142

View Commit (06ab142)

@socket-security

socket-security Bot commented Apr 9, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm adm-zip is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: pnpm-lock.yamlnpm/@module-federation/vite@1.16.0npm/@sanity/federation@0.1.0-alpha.10npm/adm-zip@0.5.10

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/adm-zip@0.5.10. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@gu-stav

gu-stav commented Apr 10, 2026

Copy link
Copy Markdown
Member Author

The axios security warning will be resolved by module-federation/core#4644 I think

@gu-stav

gu-stav commented Apr 13, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main

@joshuaellis joshuaellis force-pushed the feat/workbench branch 3 times, most recently from ffc8344 to f2b1294 Compare April 17, 2026 12:39
@gu-stav gu-stav force-pushed the feat/workbench branch 2 times, most recently from 5e50a1d to 1e221bd Compare April 23, 2026 12:19
@gu-stav gu-stav force-pushed the feat/workbench branch 2 times, most recently from 16e7dfe to 319ef83 Compare May 5, 2026 09:17
@joshuaellis joshuaellis force-pushed the feat/workbench branch 2 times, most recently from 905b492 to cf3839a Compare May 7, 2026 11:47
@gu-stav gu-stav force-pushed the feat/workbench branch 2 times, most recently from fcf69a8 to d73d551 Compare May 22, 2026 07:43
@socket-security

socket-security Bot commented May 27, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​sanity/​federation@​0.1.0-alpha.10771008195100
Addednpm/​@​module-federation/​vite@​1.16.09610010099100
Addednpm/​sanity@​6.1.0-workbench.202606121303479610010098100

View full report

joshuaellis and others added 24 commits June 16, 2026 09:17
…ion build path

Rebasing onto main brought in the single-vite vendor build (#1223):
buildVendorDependencies → resolveVendorBuildConfig, importMap →
AutoUpdatesBuildConfig, a 2-arg finalizeViteConfig, and getViteConfig moving
into @sanity/cli-build. Fold those into the workbench/federation build path:

- getViteConfig: adopt the autoUpdates vendor-chunk model on the client path,
  keep the isWorkbenchApp federation plugin branch.
- buildApp/buildStudio/buildStaticFiles: switch to resolveVendorBuildConfig +
  autoUpdates, keep the services/views/isWorkbenchApp passthrough.
- tests: getViteConfig.test.ts moved to the autoUpdates API (federation cases
  kept); restore main's buildStaticFiles vendor integration tests and move the
  federation unit tests to buildStaticFiles.federation.test.ts.
…orkbench projects (#1257)

* fix(dev): restore dashboard URL output for non-workbench apps

The workbench dev server work replaced the dashboard URL announcement
with a bare port log for every app. Non-federated apps still load
through the dashboard, so dropping the URL left users with nothing to
open. Bring back main's output for them and keep the port-only line
for workbench apps, where the workbench URL is announced instead.

* chore: update auto-generated changeset for PR #1257

* fix(dev): re-add --load-in-dashboard with main's semantics

The flag was dropped wholesale on this branch, changing studio and app
output relative to main. Restore main's behavior for non-workbench
projects (defaults, warnings, dashboard URL flows) and ignore the flag
for workbench apps — warning when it's passed explicitly, since they
never load through the dashboard.

Also consolidates the dev suites' duplicated test setup (output mock,
flags, dev-server config, vite-result factory, fetch stub) into the
shared testHelpers.

* chore: update auto-generated changeset for PR #1257

* fix(dev): satisfy mockResolvedValue arity in shared dev test helper

The pre-commit eslint --fix strips the explicit undefined argument,
which tsc then rejects. Use a plain resolved-promise mock instead so
the formatter has nothing to rewrite.

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
* feat(workbench): deploy federated builds and log what ships to Brett

`sanity deploy` required `index.html` in the build output, but federated
(`unstable_defineApp`) builds emit only a module federation remote — every
deploy died on a generic "Error checking directory" with the real cause
hidden behind a debug flag.

checkDir now validates the federation shape instead: the manifest must
parse, declare a remote entry and at least one exposed module, and every
referenced asset must exist on disk — catching partial or stale builds
before upload. checkDir failures surface verbatim, and both deploy paths
(studio and coreApp) log the upload target, metadata, and tarball contents
before posting to the user-applications service.

* chore: update auto-generated changeset for PR #1243

* chore(deploy): remove upload summary logging

Keeps the PR scoped to federated build validation. The endpoint and
tarball listing printed on every deploy was debugging aid, not product
output.

* chore: update auto-generated changeset for PR #1243

* chore(deploy): cut federation validation back to manifest presence and exposes

Checking the remote entry and every referenced asset against disk
re-verified what the bundler already guarantees and would break on any
manifest format change. An empty exposes list still fails: a federated
app must export at least one interface.

* refactor(deploy): split checkWorkbenchAppDir out of checkDir

A boolean option made checkDir's contract depend on the app shape.
Callers already know whether they deploy a workbench app, so each build
shape gets its own self-contained check and checkDir keeps its original
index.html contract.

* chore(deploy): check declared interfaces instead of built exposes

The interfaces a federated app ships are exactly what unstable_defineApp
declares (entry, views, services), so the at-least-one rule reads the
config and fails before any prompts or API calls instead of parsing
mf-manifest.json after the build. The manifest check shrinks to an
existence marker that a federation build ran. Workbench studios always
expose the studio config, so the rule only applies to the app path.

* chore(deploy): say views or services instead of interfaces in the no-interface error

"Interfaces" is application-service vocabulary; views and services are
the words the user typed into unstable_defineApp.

* refactor(deploy): gather workbench deploy checks in workbenchChecks

The config-level interface check and the build-dir check guard the same
deploy, so they live in one module: checkWorkbenchApp (throws like its
sibling) and checkWorkbenchAppDir.

* chore(deploy): mention entry in the no-interface deploy error

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
…ench projects (#1259)

* fix(build): restore strict-port for plain studio dev servers

The workbench refactor hardcoded strictPort: false, so a plain studio on a
busy port silently drifted to the next free one instead of failing like on
main. Only apps and workbench runs need the fallback.

* fix(dev): keep default signal handling for plain projects

devAction installed SIGINT/SIGTERM handlers for every project, changing
Ctrl-C semantics (and exit codes) for plain runs that have no workbench
lock or registry entry to clean up. Only register them when workbench
state exists.

* fix(dev): only override the port flag when the workbench runs

devAction pre-resolved the port and injected it into flags for every
project, taking resolution away from getDevServerConfig where main does
it. The override only exists so the app server can move off the port the
workbench claimed — without a workbench the flags now pass through
untouched.

* chore: update auto-generated changeset for PR #1259

* chore: update auto-generated changeset for PR #1259

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
…ver (#1262)

Everything the workbench vite server serves is either plain JS (the
generated workbench.js), compiled dist (sanity/workbench), or TypeScript
source that vite's own esbuild transforms (@sanity/workbench). plugin-react
excludes node_modules from fast refresh, so it never transformed a single
module here — its only output was a preamble script nothing consumed.
Removing it also drops @vitejs/plugin-react from @sanity/cli's
dependencies, keeping vite plugins contained to @sanity/cli-build.
…#1267)

* fix(dev): display localhost for non-routable workbench bind addresses

sanity dev --host 0.0.0.0 printed http://0.0.0.0:3333 as the workbench
URL — both for the binding process and for any process reusing the lock,
which stores the bind host verbatim. 0.0.0.0/:: don't open in every
browser (notably on Windows). Only the displayed URL is normalized; the
bind address and lock contents are untouched.

* chore: update auto-generated changeset for PR #1267

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
* feat(cli-build)!: require organizationId in unstable_defineApp

unstable_defineApp is the workbench opt-in and the organization ID is
what the workbench runs and deploys against, so it's part of the contract
— required in the schema and the DefineAppInput/DefineAppResult types,
with a pointed validation message. Ports sanity-io/workbench#250 to the
vendored extension API.

* chore: update auto-generated changeset for PR #1279

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
* fix(dev): release the workbench lock when server creation throws

createWorkbenchViteServer can throw after the lock is acquired (runtime-
file write failure, invalid remote URL); only the undefined-result path
released it, so a throw leaked the lock file until the next acquire pruned
the stale PID.

* chore: update auto-generated changeset for PR #1266

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
Residue of the main sync after the rebase: the regenerated lockfile
entries (sanity v6 fixture refs, @types/node 22 peer suffixes) and the
organizationId brand on deploy.studio.test.ts's unstable_defineApp call
required by #1279.
…rs (#1264)

* fix(build): apply caller-provided vite plugins to workbench dev servers

additionalPlugins (typegen in dev) was only spread into the non-workbench
plugin branch, so typegen.enabled was silently ignored for workbench apps.
The plugins aren't client-specific — hoist them out of the branch.

* chore: update auto-generated changeset for PR #1264

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
…pp (#1265)

unstable_defineApp is the workbench opt-in and carries the organization
ID, so the fallback that resolved it from the configured project is gone —
it needed an authenticated user (a logged-out sanity dev died on a raw
auth error) and an API round-trip on every startup for something the
opt-in already declares. A missing ID is now a readable error pointing at
unstable_defineApp in sanity.cli.ts.
* ci: revert snapshot-release.yml to main

The branch had gutted the forceBump full-release half of the workflow and
hardcoded the workbench dist-tag; merging that to main would have silently
removed the manual release capability. Restoring main's file verbatim
removes the merge delta entirely. Branch snapshots are published by
dispatching the workflow manually with 'workbench' as the tag input —
pushes to feat/workbench no longer auto-publish.

* chore: curate the workbench changesets for the main changelog

40 accumulated changesets — auto-generated per-PR entries, superseded
alpha bumps, and snapshot-publish-ordering notes — would have landed in
the public changelog verbatim at merge. Squashed into three human-written
entries; the resulting bump plan is unchanged (majors across the board,
driven by main's Node 20 drop), so branch snapshot releases version
identically.
… entry

The dts change ships as part of the workbench feature; the curated
workbench.md entry already covers it for the public changelog.
…1287)

* fix(build): fail fast when a workbench studio has no sanity config

An explicit applicationType: 'studio' wins over detection, so the
federation studio path is reachable without a sanity.config file. The
non-null assertion let null flow into the generated remote entry, which
then imports its config from "null" — a broken build with no hint at
the cause.

* chore: update auto-generated changeset for PR #1287

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
…1284)

* fix(workbench): stop wiping studio interfaces from the dev registry

The studio extract hardcoded interfaces: undefined while the registry
patch is a shallow merge — the panels/workers forwarded by the initial
registration were wiped on the first regeneration, which fires right at
watcher startup. Re-derive them from a fresh config read, the way the
core-app extract already does.

* chore: update auto-generated changeset for PR #1284

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
debug is imported in getViteConfig.ts for type checking only; without
the ignore knip flags @types/debug as unused once dependencies shift.
* revert(workbench): keep federated remote type generation off

Reverts the behavior change from #1282. Real TS apps fail with TYPE-001:
the exposes are generated .js/.jsx shims, which tsc refuses without
allowJs (TS6504) — no app template sets it. With allowJs worked around,
declaration emit pulls in the user's own noEmit app code, which was
never written to be declaration-emittable (TS2742 non-portable inferred
types under pnpm, TS4082 private names in default exports).

The #1282 verification only passed because the federated-studio fixture
set allowJs — removed here so the fixture matches real templates. The
recursive plugin scoping from #1282 stays: it's correct regardless.

* chore: update auto-generated changeset for PR #1288

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
…anges (#1286)

* fix(workbench): rebuild federated studios when their interface set changes

Studios declare views/services in unstable_defineApp like apps do, but
the dev flow assumed they couldn't: no rebuild hook, and the manifest
watcher only reacted to sanity.config.* (the resolved project root)
while the declarations live in sanity.cli.*. Adding or removing a
studio view/service required a manual dev-server restart to get an
expose for it.

* chore: update auto-generated changeset for PR #1286

---------

Co-authored-by: squiggler-app[bot] <265501495+squiggler-app[bot]@users.noreply.github.com>
#1306)

Studio scaffolds were missing the required organizationId, so a generated
studio config wouldn't validate. The rest shrinks the exposed surface and
aligns with existing conventions — no other behavior change.

- drop the unused 'system' application type (only 'studio' is ever branched on)
- resolve the registry/ barrel into direct imports (barrels defeat tree-shaking
  on the CLI hot path) and inline the two-use invariant helper
- viewDeployment -> zod/mini, getEntryModule entry -> string | undefined,
  checkWorkbenchApp -> checkCanDeployWorkbenchApp, restore the inline
  schemaExtraction field
- comments lead with why over what; drop 3 tests that only assert internal
  wiring; drop per-PR workbench changesets already covered by workbench.md
… main

main relocated `checkRequiredDependencies` into `@sanity/cli-build/_internal/build`
(#1301); point the studio dev-server test mock at the new module and regenerate
the lockfile against the rebased tree.
* refactor(workbench): extract into a removable @sanity/workbench-cli package

Rebased onto feat/workbench. Because feat/workbench predates the deepen-modules
refactors (#1307) this work builds on, this commit also carries those (fold lock
into the registry, expand extension artifacts once, typed applicationType, etc.)
as prerequisites.

The extraction itself: workbench lives in @sanity/workbench-cli, reached through
entries that mirror the commands it integrates with —
  .        authoring API (unstable_define*)
  /build   federation Vite plugins + the build accessor
  /deploy  the deploy guards
  /dev     the dev-server registry
  /init    the sanity.cli.ts config templates
Core (cli, cli-build) discriminates on the Symbol.for brand and reaches the
package only through those seams, so the feature stays removable.

* refactor(workbench): sharpen the @sanity/workbench-cli public surface

/build and /deploy both exported `getWorkbench` for different shapes — /build
aliased the bare resolver, /deploy added the deploy guards. Drop the alias so
/build exports `resolveWorkbenchApp` under its real name and `getWorkbench`
means one thing.

Retag the build-internal artifact and federation types `@internal`; they
carried `@public` but reach no entry, so the tag misrepresented the surface.

* test(workbench): run @sanity/workbench-cli in the root vitest suite

The extraction added the package with 12 test files the root `vitest run` never
executed — it wasn't in `test.projects` (nor coverage `include`). Register it so
the suite and coverage actually cover the package.

* chore(workbench): reconcile lockfile after rebase onto feat/workbench

* test(workbench): real-path coverage for dev, deploy + e2e fix (#1317)

* fix(cli-e2e): pack @sanity/workbench-cli for the e2e binary

The extraction made `@sanity/workbench-cli` a dependency of `@sanity/cli` and
`@sanity/cli-build`, but it isn't published to npm — so installing the packed
`@sanity/cli` tarball failed resolving it from the registry, breaking every e2e
run at globalSetup. Pack and install it alongside the others.

* test(cli-e2e): cover dev (studio, app, workbench) and workbench init

dev had no e2e coverage. These run the real binary end-to-end: the studio and app
servers serve over HTTP and shut down on Ctrl+C, and the federated-studio dev runs
the module-federation pipeline unmocked (the in-process test stubs it). init gains
a workbench case asserting the `unstable_defineApp` config is scaffolded.

* test(workbench): exercise real module-federation composition

The federation plugin tests mocked `@module-federation/vite` and only checked
call args / the scoping wrapper — they'd pass even if the real plugin shape
changed. Add a test that runs the real integration and asserts it composes
named, environment-scoped vite plugins. Reclassify the pure option-shaping test
as `.unit.test.ts` (and exclude unit tests from the package project, like
@sanity/cli) so the taxonomy reflects what actually integrates.

* test(deploy): assert the federation build satisfies the deploy gate

deploy.studio.test.ts mocks the build away, so nothing verified that a real
federation build produces output `checkBuiltOutput` accepts. Assert it against
the real dist right after the federated build, and that a non-federation
directory is rejected — closing the build→deploy seam.

* test(cli-e2e): exercise the real workbench host in dev

federated-studio pinned the stable `sanity` (no `sanity/workbench` export), so
`sanity dev` fell back to the federation-build path and never started the
workbench host. Pin the `sanity` workbench dist-tag so the runtime resolves, and
assert the host-started message (host on the configured port, studio pushed to
port+1) instead of the build-only manifest signal.

* test(cli-e2e): cover workbench SDK app init

Alongside the workbench studio case, scaffold an SDK app with
`--unstable--workbench` and assert its `sanity.cli.ts` is the workbench app
variant — `unstable_defineApp` with an `entry` (the navigable app view) and no
studio config.

* test(workbench): tidy federation-test casts and sharpen intent comments

Replace three `eslint-disable` / `as any` casts in the federation real-composition
test with a typed partial-`Environment` helper, and reword the build→deploy gate
negative assertion to say why it matters (proving `checkBuiltOutput` isn't a no-op).

* test(cli-e2e): guard that init/dev stay non-workbench without the flag

The plain init/dev tests exercised the non-workbench path but asserted only
fields the workbench variant also has, so a regression that enabled workbench
without `--unstable--workbench` would have slipped by. Assert the brand
(`unstable_defineApp`) is absent from default init configs, and that a plain
project's `sanity dev` never logs the workbench host startup.

* test(cli-e2e): pin the dev server port as the inverse workbench guard

Replace the explicit `not.toContain('Workbench dev server started')` with a
strict positive assertion: a plain project must report ready on the *configured*
port. If the workbench host started by mistake it would push the server to
port+1 (and suppress the studio's "running at" line), so the port-pinned
`waitForText` fails on its own — no separate negative assertion needed.

* docs(cli-e2e): drop misleading "shell" wording in the dev test header

"Shell" is workbench-host terminology; these cases serve a plain studio/app.
@gu-stav gu-stav requested a review from snocorp June 16, 2026 13:40

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 369173f. Configure here.

throw new Error(
'Workbench requires an organization ID. Pass "organizationId" to unstable_defineApp() in sanity.cli.ts.',
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workbench throws before app server can report missing org

Medium Severity

resolveOrganizationId in startWorkbenchDevServer throws unconditionally when organizationId is missing, but startAppDevServer has a graceful early-exit path for this case (returning {started: false, reason: 'missing-organization-id'}). Since startWorkbenchDevServer runs first in devAction, a branded workbench app that omits organizationId gets an unhandled throw instead of the user-friendly error message the app server path was designed to show.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 369173f. Configure here.

if (workbenchAvailable || registration) {
process.once('SIGINT', onSignal)
process.once('SIGTERM', onSignal)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signal handler uses once preventing graceful repeat termination

Low Severity

The onSignal handler is registered with process.once, which means it fires at most once per signal type. Since onSignal calls void close() (fire-and-forget), if cleanup is slow (e.g., a server takes time to close), the process appears unresponsive. A second SIGINT would use Node's default behavior (abrupt kill), bypassing any remaining cleanup. Using process.on with an internal guard and explicit process.exit() after cleanup would ensure deterministic shutdown.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 369173f. Configure here.

It must stay published — @sanity/cli and @sanity/cli-build depend on it at
^1.0.0 — so it can't be made private or added to the changesets `ignore` list
(that hard-errors, demanding its non-private dependents be ignored too). Reduce
its npm footprint instead: drop the keywords, mark the description and README as
an internal, not-for-direct-use detail, and keep the architecture notes repo-only
in ARCHITECTURE.md (excluded from the tarball by `files`). The publishable
surface (name/version/files/exports) is unchanged.

@snocorp snocorp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some questions and minor suggestions but nothing blocking.

import {schemaTypes} from './schemaTypes'

export default defineConfig({
title: 'Basic Studio',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title: 'Basic Studio',
title: 'Federated Studio',

reactStrictMode,
relativeConfigLocation,
})
await fs.writeFile(path.join(runtimeDir, 'app.js'), appJsContent)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this isn't new in this PR but does this cause the watcher to trigger unnecessarily?


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

import {parseWorkbenchCliConfig} from '../workbenchApp'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import {parseWorkbenchCliConfig} from '../workbenchApp'
import {parseWorkbenchCliConfig} from '../workbenchApp.js'

// which isn't published to npm — pack it too so the installed binary can
// resolve it from the co-installed tarball instead of hitting the registry.
console.log('Packing @sanity/workbench-cli...')
const workbenchCliTarball = packPackage('@sanity/workbench-cli')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this still be done after the new package is published to npm?

try {
const stats = await stat(sourceDir)
if (!stats.isDirectory()) {
throw new Error(`Directory ${sourceDir} is not a directory`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind of an awkward error message

expect(content).toContain('<script type="module" src="./workbench.js">')
})

test('is valid HTML with charset meta tag', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test('is valid HTML with charset meta tag', async () => {
test('starts with a DOCTYPE and has a charset meta tag', async () => {


afterEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this line

expect(manifest.startedAt).toBeDefined()
expect(manifest.version).toBe(1)

cleanup()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in a finally clause?

describe('watchRegistry', () => {
test('invokes callback when a manifest file is added', async () => {
const callback = vi.fn()
const watcher = watchRegistry(callback)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today I learned about using onTestFinished so you could add t to the params of this test callback and then use t.onTestFinished(() => watcher.close()) instead of closing the watcher at the end of the test logic to ensure it always gets called.


afterEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this line

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants