diff --git a/.changeset/pr-968.md b/.changeset/pr-968.md new file mode 100644 index 000000000..e7c8adb72 --- /dev/null +++ b/.changeset/pr-968.md @@ -0,0 +1,6 @@ +--- +'@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..264eb0b07 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,20 @@ 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 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\`.`, + ) + } + 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..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,16 +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) mocks.select.mockResolvedValueOnce('org-1') // organization + 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 c788767c1..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, [ { @@ -150,7 +149,10 @@ describe('#init: get project details', () => { }, ]) - mocks.select.mockResolvedValueOnce('org-123') + mocks.listProjects.mockResolvedValueOnce([]) + + mocks.select.mockResolvedValueOnce('org-123') // organization + mocks.select.mockResolvedValueOnce('__skip__') // promptForAppTemplateSetup — skip project config const {error} = await testCommand( InitCommand, @@ -969,3 +971,212 @@ describe('#init: get project details', () => { expect(stdout).toContain('Dataset: staging') }) }) + +describe('#init: promptForAppTemplateSetup', () => { + 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, + 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__') // promptForAppTemplateSetup — 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, + 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, + 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__') // promptForAppTemplateSetup — 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'}) + + // promptForAppTemplateSetup (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..42a8fa84e 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,36 @@ 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) { + let organizations: ProjectOrganization[] + try { + organizations = await listOrganizations() + } catch (err) { + this.error(`Failed to communicate with the Sanity API:\n${err.message}`, { + exit: 1, + }) } + 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.promptForAppTemplateSetup({ + newProject, + organizationId, + planId, user, }) return { - datasetName: '', - displayName: '', + datasetName, + displayName, isFirstProject: false, - organizationId: appOrganizationId, - projectId: '', + organizationId, + projectId, } } @@ -1053,6 +1053,91 @@ export class InitCommand extends SanityCommand { return absolutify(inputPath) } + private async promptForAppTemplateSetup({ + 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 SKIP_PROJECT = '__skip__' + const NEW_PROJECT = '__new__' + + const selected = await select({ + choices: [ + {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_PROJECT) { + this._trace.log({selectedOption: 'skip', step: 'configureAppProject'}) + return {datasetName: '', displayName: '', projectId: ''} + } + + this._trace.log({ + selectedOption: selected === NEW_PROJECT ? 'create' : 'existing', + step: 'configureAppProject', + }) + + const project = + selected === NEW_PROJECT + ? await this.promptForProjectCreation({ + isUsersFirstProject: projects.length === 0, + organizationId, + organizations: [], + 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%', }, ]