From d10b448590563dcb0548abf467c95ba361d7bba5 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Fri, 17 Apr 2026 11:30:02 -0400 Subject: [PATCH 1/6] feat(init): select project and dataset during app init Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/pr-968.md | 7 + .../__tests__/bootstrapLocalTemplate.test.ts | 89 +++++++ .../actions/init/bootstrapLocalTemplate.ts | 18 +- .../@sanity/cli/src/actions/init/initApp.ts | 28 ++- .../actions/init/templates/appQuickstart.ts | 1 + .../src/actions/init/templates/appSanityUi.ts | 1 + .../__tests__/init/init.bootstrap-app.test.ts | 2 + .../init/init.get-project-details.test.ts | 217 +++++++++++++++++- packages/@sanity/cli/src/commands/init.ts | 133 +++++++++-- .../cli/src/telemetry/init.telemetry.ts | 6 + .../cli/templates/app-quickstart/src/App.tsx | 4 +- .../cli/templates/app-sanity-ui/src/App.tsx | 4 +- 12 files changed, 471 insertions(+), 39 deletions(-) create mode 100644 .changeset/pr-968.md create mode 100644 packages/@sanity/cli/src/actions/init/__tests__/bootstrapLocalTemplate.test.ts diff --git a/.changeset/pr-968.md b/.changeset/pr-968.md new file mode 100644 index 000000000..13af12738 --- /dev/null +++ b/.changeset/pr-968.md @@ -0,0 +1,7 @@ + +--- +'@sanity/cli': minor +--- + +- Add project and dataset selection prompts to `sanity init` for app templates +- Fix crash when selecting "no" for TypeScript on app templates, which only ship `.tsx` files diff --git a/packages/@sanity/cli/src/actions/init/__tests__/bootstrapLocalTemplate.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/bootstrapLocalTemplate.test.ts new file mode 100644 index 000000000..cc5b3d8b2 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/__tests__/bootstrapLocalTemplate.test.ts @@ -0,0 +1,89 @@ +import {mkdtemp, readFile, rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import path from 'node:path' + +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +import {bootstrapLocalTemplate} from '../bootstrapLocalTemplate.js' + +vi.mock('../../../util/resolveLatestVersions.js', () => ({ + resolveLatestVersions: vi.fn().mockImplementation(async (deps: Record) => { + const resolved: Record = {} + for (const key of Object.keys(deps)) resolved[key] = '1.0.0' + return resolved + }), +})) + +vi.mock('../updateInitialTemplateMetadata.js', () => ({ + updateInitialTemplateMetadata: vi.fn().mockResolvedValue(undefined), +})) + +function makeOutput() { + return { + clear: vi.fn(), + error: vi.fn(), + log: vi.fn(), + print: vi.fn(), + spinner: vi.fn(() => ({ + start: () => ({fail: vi.fn(), succeed: vi.fn()}), + })), + warn: vi.fn(), + } as any +} + +describe('bootstrapLocalTemplate (app templates)', () => { + 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('renders projectId and dataset into App.tsx when provided', async () => { + await bootstrapLocalTemplate({ + output: makeOutput(), + outputPath: tmp, + packageName: 'my-app', + templateName: 'app-quickstart', + useTypeScript: true, + variables: { + autoUpdates: false, + dataset: 'production', + organizationId: 'org1', + projectId: 'abc123', + projectName: 'my-app', + }, + }) + + const appTsx = await readFile(path.join(tmp, 'src', 'App.tsx'), 'utf8') + expect(appTsx).toContain(`projectId: 'abc123'`) + expect(appTsx).toContain(`dataset: 'production'`) + expect(appTsx).not.toContain('%projectId%') + expect(appTsx).not.toContain('%dataset%') + }) + + test('renders empty strings into App.tsx when user skipped project selection', async () => { + await bootstrapLocalTemplate({ + output: makeOutput(), + outputPath: tmp, + packageName: 'my-app', + templateName: 'app-sanity-ui', + useTypeScript: true, + variables: { + autoUpdates: false, + dataset: '', + organizationId: 'org1', + projectId: '', + projectName: 'my-app', + }, + }) + + const appTsx = await readFile(path.join(tmp, 'src', 'App.tsx'), 'utf8') + expect(appTsx).toContain(`projectId: ''`) + expect(appTsx).toContain(`dataset: ''`) + expect(appTsx).not.toContain('%projectId%') + expect(appTsx).not.toContain('%dataset%') + }) +}) diff --git a/packages/@sanity/cli/src/actions/init/bootstrapLocalTemplate.ts b/packages/@sanity/cli/src/actions/init/bootstrapLocalTemplate.ts index cd6ebf357..79caf3863 100644 --- a/packages/@sanity/cli/src/actions/init/bootstrapLocalTemplate.ts +++ b/packages/@sanity/cli/src/actions/init/bootstrapLocalTemplate.ts @@ -13,6 +13,7 @@ import {createCliConfig} from './createCliConfig.js' import {createPackageManifest} from './createPackageManifest.js' import {createStudioConfig, type GenerateConfigOptions} from './createStudioConfig.js' import {determineAppTemplate} from './determineAppTemplate.js' +import {processTemplate} from './processTemplate.js' import {sdkAppDependencies} from './sdkAppDependencies.js' import {studioDependencies} from './studioDependencies.js' import templates from './templates/index.js' @@ -66,6 +67,19 @@ export async function bootstrapLocalTemplate( spin.succeed() + if (isAppTemplate) { + const appEntryPath = path.join(outputPath, 'src', 'App.tsx') + const raw = await fs.readFile(appEntryPath, 'utf8') + const rendered = processTemplate({ + template: raw, + variables: { + dataset: variables.dataset ?? '', + projectId: variables.projectId ?? '', + }, + }) + await fs.writeFile(appEntryPath, rendered) + } + // Merge global and template-specific plugins and dependencies // Resolve latest versions of Sanity-dependencies @@ -153,7 +167,9 @@ export async function bootstrapLocalTemplate( ) debug('Updating initial template metadata') - await updateInitialTemplateMetadata(variables.projectId, `cli-${templateName}`) + if (variables.projectId) { + await updateInitialTemplateMetadata(variables.projectId, `cli-${templateName}`) + } // Finish up by providing init process with template-specific info spin.succeed() diff --git a/packages/@sanity/cli/src/actions/init/initApp.ts b/packages/@sanity/cli/src/actions/init/initApp.ts index 528a31bef..421cab176 100644 --- a/packages/@sanity/cli/src/actions/init/initApp.ts +++ b/packages/@sanity/cli/src/actions/init/initApp.ts @@ -12,16 +12,18 @@ import {scaffoldAndInstall, selectTemplate} from './scaffoldTemplate.js' export async function initApp({ autoUpdates, + datasetName, defaults, error, git, - noGit, mcpConfigured, + noGit, organizationId, output, outputPath, overwriteFiles, packageManager, + projectId, remoteTemplateInfo, sluggedName, template, @@ -32,16 +34,18 @@ export async function initApp({ workDir, }: { autoUpdates: boolean + datasetName: string defaults: {projectName: string} error: Output['error'] git?: boolean | string - noGit?: boolean mcpConfigured: EditorName[] + noGit?: boolean organizationId: string | undefined output: Output outputPath: string overwriteFiles?: boolean packageManager?: string + projectId: string remoteTemplateInfo: RepoInfo | undefined sluggedName: string template?: string @@ -69,7 +73,7 @@ export async function initApp({ await scaffoldAndInstall({ autoUpdates, - datasetName: '', + datasetName, defaults, displayName: '', git, @@ -79,7 +83,7 @@ export async function initApp({ outputPath, overwriteFiles, packageManager, - projectId: '', + projectId, remoteTemplateInfo, sluggedName, templateName, @@ -98,10 +102,18 @@ export async function initApp({ `${logSymbols.success} ${styleText(['green', 'bold'], 'Success!')} Your custom app has been scaffolded.`, ) if (!isCurrentDir) output.log(goToProjectDir) - output.log( - `\n${styleText('bold', 'Next')}, configure the project(s) and dataset(s) your app should work with.`, - ) - output.log('\nGet started in `src/App.tsx`, or refer to our documentation for a walkthrough:') + + if (projectId && datasetName) { + output.log( + `\nConfigured with project ${styleText('cyan', projectId)} and dataset ${styleText('cyan', datasetName)}.`, + ) + output.log('Edit `src/App.tsx` to change these values or add more sources.') + } else { + output.log( + `\n${styleText('bold', 'Next')}, configure the project(s) and dataset(s) your app should work with in \`src/App.tsx\`.`, + ) + } + output.log('\nRefer to our documentation for a walkthrough:') output.log( styleText(['blue', 'underline'], 'https://www.sanity.io/docs/app-sdk/sdk-configuration'), ) diff --git a/packages/@sanity/cli/src/actions/init/templates/appQuickstart.ts b/packages/@sanity/cli/src/actions/init/templates/appQuickstart.ts index 31aaa902c..a523e8f5c 100644 --- a/packages/@sanity/cli/src/actions/init/templates/appQuickstart.ts +++ b/packages/@sanity/cli/src/actions/init/templates/appQuickstart.ts @@ -3,6 +3,7 @@ import {type ProjectTemplate} from '../types.js' const appTemplate: ProjectTemplate = { entry: './src/App.tsx', type: 'module', + typescriptOnly: true, } export default appTemplate diff --git a/packages/@sanity/cli/src/actions/init/templates/appSanityUi.ts b/packages/@sanity/cli/src/actions/init/templates/appSanityUi.ts index 3c1083b4f..e8acbcdcd 100644 --- a/packages/@sanity/cli/src/actions/init/templates/appSanityUi.ts +++ b/packages/@sanity/cli/src/actions/init/templates/appSanityUi.ts @@ -7,6 +7,7 @@ const appSanityUiTemplate: ProjectTemplate = { }, entry: './src/App.tsx', type: 'module', + typescriptOnly: true, } export default appSanityUiTemplate 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 89a32275a..3f3bb1f62 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 @@ -248,7 +248,9 @@ describe('#init: bootstrap-app-initialization', () => { }).reply(200, [{id: 'org-1', name: 'Org 1', slug: 'org-1'}]) // select is called once for organization selection (template comes from --template flag) + // then once for the app project setup prompt (skip = no project/dataset configured) mocks.select.mockResolvedValueOnce('org-1') // organization + mocks.select.mockResolvedValueOnce('skip') // promptForAppProjectSetup mockApi({ apiVersion: MCP_JOURNEY_API_VERSION, diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts index c788767c1..077878901 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts @@ -150,7 +150,10 @@ describe('#init: get project details', () => { }, ]) - mocks.select.mockResolvedValueOnce('org-123') + mocks.listProjects.mockResolvedValueOnce([]) + + mocks.select.mockResolvedValueOnce('org-123') // organization + mocks.select.mockResolvedValueOnce('skip') // promptForAppProjectSetup — skip project config const {error} = await testCommand( InitCommand, @@ -969,3 +972,215 @@ describe('#init: get project details', () => { expect(stdout).toContain('Dataset: staging') }) }) + +describe('#init: promptForAppProjectSetup', () => { + afterEach(() => { + vi.clearAllMocks() + const pending = nock.pendingMocks() + nock.cleanAll() + expect(pending, 'pending mocks').toEqual([]) + }) + + test('skip path: returns empty projectId/datasetName and does not fetch datasets or create anything', async () => { + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + query: {includeImplicitMemberships: 'true', includeMembers: 'true'}, + uri: '/organizations', + }).reply(200, [{id: 'org-123', name: 'Test Organization', slug: 'test-organization'}]) + + mocks.listProjects.mockResolvedValueOnce([]) + + mocks.select.mockResolvedValueOnce('org-123') // organization + mocks.select.mockResolvedValueOnce('skip') // promptForAppProjectSetup — skip + + const {error} = await testCommand( + InitCommand, + [ + '--template=app-quickstart', + '--output-path=./test-project', + '--no-typescript', + '--no-overwrite-files', + ], + { + mocks: { + ...defaultMocks, + isInteractive: true, + }, + }, + ) + + if (error) throw error + + // listProjects is called to populate the choice list, but no dataset or create APIs are invoked + expect(mocks.listDatasets).not.toHaveBeenCalled() + expect(mocks.createProject).not.toHaveBeenCalled() + expect(mocks.createDataset).not.toHaveBeenCalled() + }) + + test('existing path: picks existing project from inline list and its dataset, returns populated values', async () => { + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + query: {includeImplicitMemberships: 'true', includeMembers: 'true'}, + uri: '/organizations', + }).reply(200, [{id: 'org-123', name: 'Test Organization', slug: 'test-organization'}]) + + mocks.listProjects.mockResolvedValueOnce([ + {createdAt: '2024-01-01T00:00:00Z', displayName: 'Existing Project', id: 'existing-pid'}, + ]) + + mocks.listDatasets.mockResolvedValueOnce([{aclMode: 'public', name: 'production'}]) + + mockApi({ + apiVersion: PROJECT_FEATURES_API_VERSION, + method: 'get', + uri: '/features', + }).reply(200, ['privateDataset']) + + mocks.select.mockResolvedValueOnce('org-123') // organization + mocks.select.mockResolvedValueOnce('existing-pid') // inline project choice + mocks.select.mockResolvedValueOnce('production') // dataset choice + + const {error} = await testCommand( + InitCommand, + [ + '--template=app-quickstart', + '--output-path=./test-project', + '--no-typescript', + '--no-overwrite-files', + ], + { + mocks: { + ...defaultMocks, + isInteractive: true, + }, + }, + ) + + if (error) throw error + + expect(mocks.listProjects).toHaveBeenCalled() + expect(mocks.listDatasets).toHaveBeenCalled() + expect(mocks.createProject).not.toHaveBeenCalled() + }) + + test('create path: picks "Create new project" then enters a name and dataset', async () => { + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + query: {includeImplicitMemberships: 'true', includeMembers: 'true'}, + uri: '/organizations', + }).reply(200, [{id: 'org-123', name: 'Test Organization', slug: 'test-organization'}]) + + // POST /projects to create the new project + mockApi({ + apiVersion: CREATE_PROJECT_API_VERSION, + method: 'post', + uri: '/projects', + }).reply(200, {displayName: 'New App Project', projectId: 'new-app-pid'}) + + mocks.listProjects.mockResolvedValueOnce([]) // no existing projects + + mocks.listDatasets.mockResolvedValueOnce([{aclMode: 'public', name: 'production'}]) + + mockApi({ + apiVersion: PROJECT_FEATURES_API_VERSION, + method: 'get', + uri: '/features', + }).reply(200, ['privateDataset']) + + mocks.select.mockResolvedValueOnce('org-123') // organization + mocks.select.mockResolvedValueOnce('new') // promptForAppProjectSetup — create new + mocks.select.mockResolvedValueOnce('production') // dataset choice + mocks.input.mockResolvedValueOnce('New App Project') // project name + + const {error} = await testCommand( + InitCommand, + [ + '--template=app-quickstart', + '--output-path=./test-project', + '--no-typescript', + '--no-overwrite-files', + ], + { + mocks: { + ...defaultMocks, + isInteractive: true, + }, + }, + ) + + if (error) throw error + + expect(mocks.listProjects).toHaveBeenCalled() + expect(mocks.input).toHaveBeenCalledWith(expect.objectContaining({message: 'Project name:'})) + expect(mocks.listDatasets).toHaveBeenCalled() + }) + + test('unattended with --project-name: creates project then returns populated values without interactive prompts', async () => { + // createProjectFromName uses organization flag directly — no listOrganizations needed + // POST /projects to create the named project + mockApi({ + apiVersion: CREATE_PROJECT_API_VERSION, + method: 'post', + uri: '/projects', + }).reply(200, {displayName: 'My App Project', projectId: 'new-app-pid-456'}) + + // promptForAppProjectSetup (unattended + newProject set) → getOrCreateProject + mocks.listProjects.mockResolvedValueOnce([ + {createdAt: '2024-01-01T00:00:00Z', displayName: 'My App Project', id: 'new-app-pid-456'}, + ]) + + // getOrCreateProject calls listOrganizations (no params) in parallel with listProjects + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations', + }).reply(200, [{id: 'org-123', name: 'Test Organization', slug: 'test-organization'}]) + + // getOrCreateDataset (unattended + dataset flag provided → no API needed for dataset) + + const {error} = await testCommand( + InitCommand, + [ + '--yes', + '--template=app-quickstart', + '--organization=org-123', + '--project-name=My App Project', + '--dataset=production', + '--output-path=/tmp/test-app', + '--no-typescript', + '--no-overwrite-files', + ], + { + mocks: {...defaultMocks}, + }, + ) + + if (error) throw error + + // No interactive prompts — all driven by flags + expect(mocks.select).not.toHaveBeenCalled() + expect(mocks.listProjects).toHaveBeenCalled() + }) + + test('unattended without --project: returns empty strings without any project/dataset API calls', async () => { + const {error} = await testCommand( + InitCommand, + [ + '--yes', + '--template=app-quickstart', + '--organization=org-123', + '--output-path=/tmp/test-app', + '--no-typescript', + '--no-overwrite-files', + ], + { + mocks: {...defaultMocks}, + }, + ) + + if (error) throw error + + expect(mocks.select).not.toHaveBeenCalled() + expect(mocks.listProjects).not.toHaveBeenCalled() + expect(mocks.listDatasets).not.toHaveBeenCalled() + }) +}) diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index b8a64644f..4298b1474 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -513,8 +513,8 @@ export class InitCommand extends SanityCommand { defaults, error: this.error.bind(this) as typeof this.error, git: this.flags.git, - noGit: this.flags['no-git'], mcpConfigured, + noGit: this.flags['no-git'], organizationId, output: this.output, outputPath, @@ -531,7 +531,7 @@ export class InitCommand extends SanityCommand { } await (isAppTemplate - ? initApp(sharedParams) + ? initApp({...sharedParams, datasetName, projectId}) : initStudio({ ...sharedParams, datasetName, @@ -968,36 +968,32 @@ export class InitCommand extends SanityCommand { schemaUrl?: string }> { if (isAppTemplate) { - // If organization flag is provided, use it directly (skip prompt and API call) - if (this.flags.organization) { - return { - datasetName: '', - displayName: '', - isFirstProject: false, - organizationId: this.flags.organization, - projectId: '', - } + let organizationId: string | undefined = this.flags.organization + if (!organizationId) { + const organizations = await listOrganizations({ + includeImplicitMemberships: 'true', + includeMembers: 'true', + }) + organizationId = await this.promptUserForOrganization({ + isAppTemplate: true, + organizations, + user, + }) } - // Interactive mode: fetch orgs and prompt - // Note: unattended mode without --organization is rejected by checkFlagsInUnattendedMode - const organizations = await listOrganizations({ - includeImplicitMemberships: 'true', - includeMembers: 'true', - }) - - const appOrganizationId = await this.promptUserForOrganization({ - isAppTemplate: true, - organizations, + const {datasetName, displayName, projectId} = await this.promptForAppProjectSetup({ + newProject, + organizationId, + planId, user, }) return { - datasetName: '', - displayName: '', + datasetName, + displayName, isFirstProject: false, - organizationId: appOrganizationId, - projectId: '', + organizationId, + projectId, } } @@ -1053,6 +1049,93 @@ export class InitCommand extends SanityCommand { return absolutify(inputPath) } + private async promptForAppProjectSetup({ + newProject, + organizationId, + planId, + user, + }: { + newProject: string | undefined + organizationId: string | undefined + planId: string | undefined + user: SanityOrgUser + }): Promise<{datasetName: string; displayName: string; projectId: string}> { + if (this.isUnattended()) { + if (!this.flags.project && !newProject) { + return {datasetName: '', displayName: '', projectId: ''} + } + const project = await this.getOrCreateProject({newProject, planId, user}) + const dataset = await this.getOrCreateDataset({ + displayName: project.displayName, + projectId: project.projectId, + showDefaultConfigPrompt: false, + }) + return { + datasetName: dataset.datasetName, + displayName: project.displayName, + projectId: project.projectId, + } + } + + const projects = (await listProjects()).toSorted((a, b) => + b.createdAt.localeCompare(a.createdAt), + ) + + const projectChoices = projects.map((project) => ({ + name: `${project.displayName} (${project.id})`, + value: project.id, + })) + + const selected = await select({ + choices: [ + {name: "Skip — I'll configure later", value: 'skip'}, + {name: 'Create new project', value: 'new'}, + ...(projectChoices.length > 0 ? [new Separator(), ...projectChoices] : []), + ], + message: 'Configure a project for this app?', + }) + + if (selected === 'skip') { + this._trace.log({selectedOption: 'skip', step: 'configureAppProject'}) + return {datasetName: '', displayName: '', projectId: ''} + } + + this._trace.log({ + selectedOption: selected === 'new' ? 'create' : 'existing', + step: 'configureAppProject', + }) + + const project = + selected === 'new' + ? await this.promptForProjectCreation({ + isUsersFirstProject: projects.length === 0, + organizationId, + organizations: organizationId + ? [] + : await listOrganizations({ + includeImplicitMemberships: 'true', + includeMembers: 'true', + }), + planId, + user, + }) + : { + displayName: projects.find((p) => p.id === selected)?.displayName ?? '', + projectId: selected, + } + + const dataset = await this.getOrCreateDataset({ + displayName: project.displayName, + projectId: project.projectId, + showDefaultConfigPrompt: false, + }) + return { + datasetName: dataset.datasetName, + displayName: project.displayName, + projectId: project.projectId, + } + } + private async promptForProjectCreation({ isUsersFirstProject, organizationId, diff --git a/packages/@sanity/cli/src/telemetry/init.telemetry.ts b/packages/@sanity/cli/src/telemetry/init.telemetry.ts index e41a2bfe4..e828405c5 100644 --- a/packages/@sanity/cli/src/telemetry/init.telemetry.ts +++ b/packages/@sanity/cli/src/telemetry/init.telemetry.ts @@ -86,7 +86,13 @@ interface MCPSetupStep { step: 'mcpSetup' } +interface ConfigureAppProjectStep { + selectedOption: 'create' | 'existing' | 'skip' + step: 'configureAppProject' +} + export type InitStepResult = + | ConfigureAppProjectStep | CreateOrSelectDatasetStep | CreateOrSelectProjectStep | FetchJourneyConfigStep diff --git a/packages/@sanity/cli/templates/app-quickstart/src/App.tsx b/packages/@sanity/cli/templates/app-quickstart/src/App.tsx index cdd2bf36f..652efca5e 100644 --- a/packages/@sanity/cli/templates/app-quickstart/src/App.tsx +++ b/packages/@sanity/cli/templates/app-quickstart/src/App.tsx @@ -7,8 +7,8 @@ function App() { // apps can access many different projects or other sources of data const sanityConfigs: SanityConfig[] = [ { - projectId: '', - dataset: '', + projectId: '%projectId%', + dataset: '%dataset%', }, ] diff --git a/packages/@sanity/cli/templates/app-sanity-ui/src/App.tsx b/packages/@sanity/cli/templates/app-sanity-ui/src/App.tsx index 4a9bb5a6a..46bf07f39 100644 --- a/packages/@sanity/cli/templates/app-sanity-ui/src/App.tsx +++ b/packages/@sanity/cli/templates/app-sanity-ui/src/App.tsx @@ -8,8 +8,8 @@ function App() { // apps can access many different projects or other sources of data const sanityConfigs: SanityConfig[] = [ { - projectId: '', - dataset: '', + projectId: '%projectId%', + dataset: '%dataset%', }, ] From d3360ee01d3fb037d39a4b0b22778fcbb5d33218 Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:37:54 +0000 Subject: [PATCH 2/6] chore: update auto-generated changeset for PR #968 --- .changeset/pr-968.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/pr-968.md b/.changeset/pr-968.md index 13af12738..06c8fc442 100644 --- a/.changeset/pr-968.md +++ b/.changeset/pr-968.md @@ -3,5 +3,4 @@ '@sanity/cli': minor --- -- Add project and dataset selection prompts to `sanity init` for app templates -- Fix crash when selecting "no" for TypeScript on app templates, which only ship `.tsx` files +select project and dataset during app init From a704fef2587501940c35ed06d89c28ea113b090b Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Fri, 17 Apr 2026 11:46:21 -0400 Subject: [PATCH 3/6] chore: update changeset for PR #968 Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/pr-968.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/pr-968.md b/.changeset/pr-968.md index 06c8fc442..e7c8adb72 100644 --- a/.changeset/pr-968.md +++ b/.changeset/pr-968.md @@ -1,6 +1,6 @@ - --- '@sanity/cli': minor --- -select project and dataset during app init +- Add project and dataset selection prompts to `sanity init` for app templates +- Fix crash when selecting "no" for TypeScript on app templates, which only ship `.tsx` files From 9b5588917ba32643e18bf33f35b5a663e74136a8 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Fri, 17 Apr 2026 12:01:34 -0400 Subject: [PATCH 4/6] fix(init): use unique sentinel values for skip/new project choices Avoids collision with a project actually named "skip" or "new". Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/init/init.bootstrap-app.test.ts | 2 +- .../__tests__/init/init.get-project-details.test.ts | 6 +++--- packages/@sanity/cli/src/commands/init.ts | 13 ++++++++----- 3 files changed, 12 insertions(+), 9 deletions(-) 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 3f3bb1f62..01029ea92 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 @@ -250,7 +250,7 @@ describe('#init: bootstrap-app-initialization', () => { // select is called once for organization selection (template comes from --template flag) // then once for the app project setup prompt (skip = no project/dataset configured) mocks.select.mockResolvedValueOnce('org-1') // organization - mocks.select.mockResolvedValueOnce('skip') // promptForAppProjectSetup + mocks.select.mockResolvedValueOnce('__skip__') // promptForAppProjectSetup mockApi({ apiVersion: MCP_JOURNEY_API_VERSION, diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts index 077878901..95ec752ce 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts @@ -153,7 +153,7 @@ describe('#init: get project details', () => { mocks.listProjects.mockResolvedValueOnce([]) mocks.select.mockResolvedValueOnce('org-123') // organization - mocks.select.mockResolvedValueOnce('skip') // promptForAppProjectSetup — skip project config + mocks.select.mockResolvedValueOnce('__skip__') // promptForAppProjectSetup — skip project config const {error} = await testCommand( InitCommand, @@ -991,7 +991,7 @@ describe('#init: promptForAppProjectSetup', () => { mocks.listProjects.mockResolvedValueOnce([]) mocks.select.mockResolvedValueOnce('org-123') // organization - mocks.select.mockResolvedValueOnce('skip') // promptForAppProjectSetup — skip + mocks.select.mockResolvedValueOnce('__skip__') // promptForAppProjectSetup — skip const {error} = await testCommand( InitCommand, @@ -1088,7 +1088,7 @@ describe('#init: promptForAppProjectSetup', () => { }).reply(200, ['privateDataset']) mocks.select.mockResolvedValueOnce('org-123') // organization - mocks.select.mockResolvedValueOnce('new') // promptForAppProjectSetup — create new + mocks.select.mockResolvedValueOnce('__new__') // promptForAppProjectSetup — create new mocks.select.mockResolvedValueOnce('production') // dataset choice mocks.input.mockResolvedValueOnce('New App Project') // project name diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index 4298b1474..f78c5b6b1 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -1086,27 +1086,30 @@ export class InitCommand extends SanityCommand { value: project.id, })) + const SKIP_PROJECT = '__skip__' + const NEW_PROJECT = '__new__' + const selected = await select({ choices: [ - {name: "Skip — I'll configure later", value: 'skip'}, - {name: 'Create new project', value: 'new'}, + {name: "Skip — I'll configure later", value: SKIP_PROJECT}, + {name: 'Create new project', value: NEW_PROJECT}, ...(projectChoices.length > 0 ? [new Separator(), ...projectChoices] : []), ], message: 'Configure a project for this app?', }) - if (selected === 'skip') { + if (selected === SKIP_PROJECT) { this._trace.log({selectedOption: 'skip', step: 'configureAppProject'}) return {datasetName: '', displayName: '', projectId: ''} } this._trace.log({ - selectedOption: selected === 'new' ? 'create' : 'existing', + selectedOption: selected === NEW_PROJECT ? 'create' : 'existing', step: 'configureAppProject', }) const project = - selected === 'new' + selected === NEW_PROJECT ? await this.promptForProjectCreation({ isUsersFirstProject: projects.length === 0, organizationId, From 0c6030c76576b7519c0fa235d05b359a8cc67056 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Fri, 17 Apr 2026 12:40:29 -0400 Subject: [PATCH 5/6] fix(init): add error handling and remove dead code in app template path Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/@sanity/cli/src/commands/init.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index f78c5b6b1..3353476d9 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -970,10 +970,17 @@ export class InitCommand extends SanityCommand { if (isAppTemplate) { let organizationId: string | undefined = this.flags.organization if (!organizationId) { - const organizations = await listOrganizations({ - includeImplicitMemberships: 'true', - includeMembers: 'true', - }) + let organizations: ProjectOrganization[] + try { + organizations = await listOrganizations({ + includeImplicitMemberships: 'true', + includeMembers: 'true', + }) + } catch (err) { + this.error(`Failed to communicate with the Sanity API:\n${err.message}`, { + exit: 1, + }) + } organizationId = await this.promptUserForOrganization({ isAppTemplate: true, organizations, @@ -1113,12 +1120,7 @@ export class InitCommand extends SanityCommand { ? await this.promptForProjectCreation({ isUsersFirstProject: projects.length === 0, organizationId, - organizations: organizationId - ? [] - : await listOrganizations({ - includeImplicitMemberships: 'true', - includeMembers: 'true', - }), + organizations: [], planId, user, }) From c147127aa1cb37b21cf384e047c98bac9fb3aa58 Mon Sep 17 00:00:00 2001 From: Binoy Patel Date: Mon, 20 Apr 2026 11:23:32 -0400 Subject: [PATCH 6/6] fix(init): address PR review feedback on app template init Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/@sanity/cli/src/actions/init/initApp.ts | 4 +++- .../__tests__/init/init.bootstrap-app.test.ts | 6 +----- .../init/init.get-project-details.test.ts | 14 +++++--------- packages/@sanity/cli/src/commands/init.ts | 9 +++------ 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/@sanity/cli/src/actions/init/initApp.ts b/packages/@sanity/cli/src/actions/init/initApp.ts index 421cab176..264eb0b07 100644 --- a/packages/@sanity/cli/src/actions/init/initApp.ts +++ b/packages/@sanity/cli/src/actions/init/initApp.ts @@ -107,7 +107,9 @@ export async function initApp({ output.log( `\nConfigured with project ${styleText('cyan', projectId)} and dataset ${styleText('cyan', datasetName)}.`, ) - output.log('Edit `src/App.tsx` to change these values or add more sources.') + output.log( + 'Edit `src/App.tsx` to change these values or add more project / dataset pairs to your config.', + ) } else { output.log( `\n${styleText('bold', 'Next')}, configure the project(s) and dataset(s) your app should work with in \`src/App.tsx\`.`, 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 01029ea92..a1a8602c7 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 @@ -239,18 +239,14 @@ describe('#init: bootstrap-app-initialization', () => { // Reset select mock to clear any unconsumed mockResolvedValueOnce from prior tests mocks.select.mockReset() - // Mock organizations endpoint with app-specific query params mockApi({ apiVersion: ORGANIZATIONS_API_VERSION, method: 'get', - query: {includeImplicitMemberships: 'true', includeMembers: 'true'}, uri: '/organizations', }).reply(200, [{id: 'org-1', name: 'Org 1', slug: 'org-1'}]) - // select is called once for organization selection (template comes from --template flag) - // then once for the app project setup prompt (skip = no project/dataset configured) mocks.select.mockResolvedValueOnce('org-1') // organization - mocks.select.mockResolvedValueOnce('__skip__') // promptForAppProjectSetup + mocks.select.mockResolvedValueOnce('__skip__') // promptForAppTemplateSetup mockApi({ apiVersion: MCP_JOURNEY_API_VERSION, diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts index 95ec752ce..4680850d3 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts @@ -140,7 +140,6 @@ describe('#init: get project details', () => { test('prompts user for organization if provided template is app template', async () => { mockApi({ apiVersion: ORGANIZATIONS_API_VERSION, - query: {includeImplicitMemberships: 'true', includeMembers: 'true'}, uri: '/organizations', }).reply(200, [ { @@ -153,7 +152,7 @@ describe('#init: get project details', () => { mocks.listProjects.mockResolvedValueOnce([]) mocks.select.mockResolvedValueOnce('org-123') // organization - mocks.select.mockResolvedValueOnce('__skip__') // promptForAppProjectSetup — skip project config + mocks.select.mockResolvedValueOnce('__skip__') // promptForAppTemplateSetup — skip project config const {error} = await testCommand( InitCommand, @@ -973,7 +972,7 @@ describe('#init: get project details', () => { }) }) -describe('#init: promptForAppProjectSetup', () => { +describe('#init: promptForAppTemplateSetup', () => { afterEach(() => { vi.clearAllMocks() const pending = nock.pendingMocks() @@ -984,14 +983,13 @@ describe('#init: promptForAppProjectSetup', () => { test('skip path: returns empty projectId/datasetName and does not fetch datasets or create anything', async () => { mockApi({ apiVersion: ORGANIZATIONS_API_VERSION, - query: {includeImplicitMemberships: 'true', includeMembers: 'true'}, uri: '/organizations', }).reply(200, [{id: 'org-123', name: 'Test Organization', slug: 'test-organization'}]) mocks.listProjects.mockResolvedValueOnce([]) mocks.select.mockResolvedValueOnce('org-123') // organization - mocks.select.mockResolvedValueOnce('__skip__') // promptForAppProjectSetup — skip + mocks.select.mockResolvedValueOnce('__skip__') // promptForAppTemplateSetup — skip const {error} = await testCommand( InitCommand, @@ -1020,7 +1018,6 @@ describe('#init: promptForAppProjectSetup', () => { test('existing path: picks existing project from inline list and its dataset, returns populated values', async () => { mockApi({ apiVersion: ORGANIZATIONS_API_VERSION, - query: {includeImplicitMemberships: 'true', includeMembers: 'true'}, uri: '/organizations', }).reply(200, [{id: 'org-123', name: 'Test Organization', slug: 'test-organization'}]) @@ -1066,7 +1063,6 @@ describe('#init: promptForAppProjectSetup', () => { test('create path: picks "Create new project" then enters a name and dataset', async () => { mockApi({ apiVersion: ORGANIZATIONS_API_VERSION, - query: {includeImplicitMemberships: 'true', includeMembers: 'true'}, uri: '/organizations', }).reply(200, [{id: 'org-123', name: 'Test Organization', slug: 'test-organization'}]) @@ -1088,7 +1084,7 @@ describe('#init: promptForAppProjectSetup', () => { }).reply(200, ['privateDataset']) mocks.select.mockResolvedValueOnce('org-123') // organization - mocks.select.mockResolvedValueOnce('__new__') // promptForAppProjectSetup — create new + mocks.select.mockResolvedValueOnce('__new__') // promptForAppTemplateSetup — create new mocks.select.mockResolvedValueOnce('production') // dataset choice mocks.input.mockResolvedValueOnce('New App Project') // project name @@ -1124,7 +1120,7 @@ describe('#init: promptForAppProjectSetup', () => { uri: '/projects', }).reply(200, {displayName: 'My App Project', projectId: 'new-app-pid-456'}) - // promptForAppProjectSetup (unattended + newProject set) → getOrCreateProject + // promptForAppTemplateSetup (unattended + newProject set) → getOrCreateProject mocks.listProjects.mockResolvedValueOnce([ {createdAt: '2024-01-01T00:00:00Z', displayName: 'My App Project', id: 'new-app-pid-456'}, ]) diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index 3353476d9..42a8fa84e 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -972,10 +972,7 @@ export class InitCommand extends SanityCommand { if (!organizationId) { let organizations: ProjectOrganization[] try { - organizations = await listOrganizations({ - includeImplicitMemberships: 'true', - includeMembers: 'true', - }) + organizations = await listOrganizations() } catch (err) { this.error(`Failed to communicate with the Sanity API:\n${err.message}`, { exit: 1, @@ -988,7 +985,7 @@ export class InitCommand extends SanityCommand { }) } - const {datasetName, displayName, projectId} = await this.promptForAppProjectSetup({ + const {datasetName, displayName, projectId} = await this.promptForAppTemplateSetup({ newProject, organizationId, planId, @@ -1056,7 +1053,7 @@ export class InitCommand extends SanityCommand { return absolutify(inputPath) } - private async promptForAppProjectSetup({ + private async promptForAppTemplateSetup({ newProject, organizationId, planId,