diff --git a/src/app.ts b/src/app.ts index 7a0b88e..f5c4e6f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,13 +6,7 @@ import { type VersionCheckResult, } from './core/data-access/version-check.ts' import { formatUpdateWarning } from './core/ui/core-ui-update-warning.ts' -import { - type CreateCommandOptions, - extractTemplateOptions, - MINIMAL_TEMPLATE_NAME, - parsePackageManagerOption, - runCreate, -} from './create/create-feature-index.ts' +import { type CreateCommandDeps, createCreateCommand } from './create/create-feature.ts' import { createDeviceCommand, type DeviceCommandDeps } from './device/device-feature.ts' import { createDoctorCommand, type DoctorCommandDeps } from './doctor/doctor-feature.ts' import { createEmulatorCommand, type EmulatorCommandDeps } from './emulator/emulator-feature.ts' @@ -21,7 +15,8 @@ import { createPlaygroundCommand, type PlaygroundCommandDeps } from './playgroun import { createTemplatesCommand, type TemplatesCommandDeps } from './templates/templates-feature.ts' import { createWebshellCommand, type WebshellCommandDeps } from './webshell/webshell-feature.ts' -export type AppOptions = DeviceCommandDeps & +export type AppOptions = CreateCommandDeps & + DeviceCommandDeps & DoctorCommandDeps & EmulatorCommandDeps & LocalnetCommandDeps & @@ -29,14 +24,10 @@ export type AppOptions = DeviceCommandDeps & TemplatesCommandDeps & WebshellCommandDeps & { checkForNewerVersion?: (options: VersionCheckOptions) => Promise - runCreate?: (options: CreateCommandOptions) => Promise } export function createApp(appOptions: AppOptions = {}) { - const { - checkForNewerVersion: checkForNewerVersionFn = checkForNewerVersion, - runCreate: runCreateCommand = runCreate, - } = appOptions + const { checkForNewerVersion: checkForNewerVersionFn = checkForNewerVersion } = appOptions const metadata = readPackageMetadata() const app = new Command() @@ -62,58 +53,10 @@ export function createApp(appOptions: AppOptions = {}) { } }) - // Template options (e.g. `--reset-project`) are extracted from the raw arguments before - // commander parses them: commander drops the `--` separator and reroutes operands once it hits - // an unknown option, so the leftovers arrive too mangled to parse reliably. - let createTemplateOptions: string[] = [] - - const createCommand = app - .command('create [projectName]') - .description('Create a new Solana Mobile project') - .option('--pm, --package-manager ', 'Package manager to use', parsePackageManagerOption) - .option('-d, --dry-run', 'Dry run') - .option('-t, --template ', 'Use a template') - .option('--list-template-ids', 'List available template ids as JSON array') - .option('--list-templates', 'List available templates') - .option('--list-versions', 'Verify your versions of Anchor, AVM, Rust, and Solana') - .option('--minimal', 'Use the minimal template') - .option('--skip-git', 'Skip git initialization') - .option('--skip-init', 'Skip running the init script') - .option('--skip-install', 'Skip installing dependencies') - .option('-v, --verbose', 'Verbose output') - .addHelpText( - 'after', - '\nOptions declared by the selected template are passed through as boolean long flags, e.g.:\n $ solana-mobile create my-app --minimal --reset-project', - ) - .action(async (projectName: string | undefined, options: CreateCommandOptions) => { - if (options.minimal && options.template) { - createCommand.error( - `error: The --minimal flag can't be used in combination with --template. Please specify only one.`, - ) - } - - await runCreateCommand({ - ...options, - projectName, - template: options.template ?? (options.minimal ? MINIMAL_TEMPLATE_NAME : undefined), - templateOptions: createTemplateOptions, - }) - }) - - const parseCreateCommandOptions = createCommand.parseOptions.bind(createCommand) - createCommand.parseOptions = (argv: string[]) => { - try { - const extracted = extractTemplateOptions(createCommand, argv) - createTemplateOptions = extracted.templateOptions - return parseCreateCommandOptions(extracted.args) - } catch (error) { - return createCommand.error(`error: ${error instanceof Error ? error.message : String(error)}`) - } - } - // Registered in alphabetical order, which is the order they are listed in help output. Every // feature owns the wiring for its own command and picks the dependencies it needs out of // `appOptions`. + app.addCommand(createCreateCommand(appOptions)) app.addCommand(createDeviceCommand(appOptions)) app.addCommand(createDoctorCommand(appOptions)) app.addCommand(createEmulatorCommand(appOptions)) diff --git a/src/create/create-feature-index.ts b/src/create/create-feature-scaffold.ts similarity index 74% rename from src/create/create-feature-index.ts rename to src/create/create-feature-scaffold.ts index 9e6819f..ea2c6b0 100644 --- a/src/create/create-feature-index.ts +++ b/src/create/create-feature-scaffold.ts @@ -1,7 +1,6 @@ import { existsSync } from 'node:fs' import { isAbsolute, resolve } from 'node:path' import { cancel, intro, isCancel, log, note, outro, select, text } from '@clack/prompts' -import { type Command, InvalidArgumentError, type Option } from 'commander' import { type CreateAppArgs, createApp, @@ -17,13 +16,9 @@ import { type Template, type TemplateJsonTemplate, } from 'create-solana-dapp' +import { CUSTOM_TEMPLATES_URL } from './data-access/template-catalog.ts' import { projectNameSchema, validateProjectName } from './data-access/validate-project-name.ts' -export const CUSTOM_TEMPLATES_URL = 'https://raw.githubusercontent.com/solana-mobile/templates/main/templates.json' - -// Must match a template name in CUSTOM_TEMPLATES_URL, otherwise `--minimal` falls through to `gh:` resolution. -export const MINIMAL_TEMPLATE_NAME = 'expo-kit-minimal' - const SOLANA_MOBILE_MENU_CONFIG: MenuConfig = [ { description: 'Solana Mobile templates', @@ -203,91 +198,6 @@ export async function runCreate( } } -const templateOptionPattern = /^--([a-z][a-z0-9-]*)$/ - -/** - * Extracts template-defined option flags such as `--reset-project` from the create command's raw - * arguments before commander parses them, mirroring the extraction create-solana-dapp performs on - * its own argv. Working on the raw arguments is what keeps `--` semantics intact — commander drops - * the separator (or keeps it, depending on what precedes it) before leftovers are visible — and it - * leaves commander's own unknown-option and excess-argument checks active for everything that - * remains. create-solana-dapp validates the collected names against the options the cloned - * template declares. - */ -export function extractTemplateOptions( - command: Command, - args: string[], -): { args: string[]; templateOptions: string[] } { - const remaining: string[] = [] - const templateOptions = new Set() - let positionalOnly = false - let preserveNextArgument = false - - for (const arg of args) { - if (positionalOnly || preserveNextArgument) { - remaining.push(arg) - preserveNextArgument = false - continue - } - - if (arg === '--') { - positionalOnly = true - remaining.push(arg) - continue - } - - const knownOption = findKnownOption(command, arg) - - if (knownOption) { - remaining.push(arg) - preserveNextArgument = Boolean(knownOption.required) && !hasInlineValue(arg) - continue - } - - // The help option is registered outside `command.options`, so it needs its own pass-through - if (!arg.startsWith('-') || arg === '-h' || arg === '--help') { - remaining.push(arg) - continue - } - - const name = templateOptionPattern.exec(arg)?.[1] - - if (!name) { - throw new InvalidArgumentError( - `Template options must be boolean long flags such as --reset-project; received "${arg}".`, - ) - } - - templateOptions.add(name) - } - - return { args: remaining, templateOptions: [...templateOptions] } -} - -function findKnownOption(command: Command, arg: string): Option | undefined { - // Check both flags: a dual-flag option such as `--pm, --package-manager` stores `--pm` as `short` - const flag = arg.startsWith('--') ? (arg.split('=', 1)[0] ?? arg) : arg.slice(0, 2) - - return command.options.find((option) => option.short === flag || option.long === flag) -} - -// A value attached to the flag itself (`--pm=pnpm`, `-tvalue`) means the next argument is not its value -function hasInlineValue(arg: string): boolean { - return arg.startsWith('--') ? arg.includes('=') : arg.length > 2 -} - -export function parsePackageManagerOption(next: string): PackageManager { - if (!next || !isPackageManager(next)) { - throw new InvalidArgumentError(`Invalid package manager: ${next}`) - } - - return next -} - -function isPackageManager(value: string): value is PackageManager { - return value === 'bun' || value === 'npm' || value === 'pnpm' || value === 'yarn' -} - // Templates from the catalog are named with a plain slug, but external (`org/repo`) templates keep // their raw reference as the name, so take the last path segment. Anything that still isn't a valid // project name is dropped rather than rewritten: an invalid pre-fill is worse than none, because it diff --git a/src/create/create-feature.ts b/src/create/create-feature.ts new file mode 100644 index 0000000..1bbaaec --- /dev/null +++ b/src/create/create-feature.ts @@ -0,0 +1,146 @@ +import { Command, InvalidArgumentError, type Option } from 'commander' +import type { PackageManager } from 'create-solana-dapp' +import { type CreateCommandOptions, runCreate } from './create-feature-scaffold.ts' +import { MINIMAL_TEMPLATE_NAME } from './data-access/template-catalog.ts' + +export type CreateCommandDeps = { + runCreate?: (options: CreateCommandOptions) => Promise +} + +export function createCreateCommand({ runCreate: runCreateCommand = runCreate }: CreateCommandDeps = {}): Command { + // Template options (e.g. `--reset-project`) are extracted from the raw arguments before + // commander parses them: commander drops the `--` separator and reroutes operands once it hits + // an unknown option, so the leftovers arrive too mangled to parse reliably. + let templateOptions: string[] = [] + + const createCommand = new Command('create') + .argument('[projectName]') + .description('Create a new Solana Mobile project') + .option('--pm, --package-manager ', 'Package manager to use', parsePackageManagerOption) + .option('-d, --dry-run', 'Dry run') + .option('-t, --template ', 'Use a template') + .option('--list-template-ids', 'List available template ids as JSON array') + .option('--list-templates', 'List available templates') + .option('--list-versions', 'Verify your versions of Anchor, AVM, Rust, and Solana') + .option('--minimal', 'Use the minimal template') + .option('--skip-git', 'Skip git initialization') + .option('--skip-init', 'Skip running the init script') + .option('--skip-install', 'Skip installing dependencies') + .option('-v, --verbose', 'Verbose output') + .addHelpText( + 'after', + '\nOptions declared by the selected template are passed through as boolean long flags, e.g.:\n $ solana-mobile create my-app --minimal --reset-project', + ) + .action(async (projectName: string | undefined, options: CreateCommandOptions) => { + if (options.minimal && options.template) { + createCommand.error( + `error: The --minimal flag can't be used in combination with --template. Please specify only one.`, + ) + } + + await runCreateCommand({ + ...options, + projectName, + template: options.template ?? (options.minimal ? MINIMAL_TEMPLATE_NAME : undefined), + templateOptions, + }) + }) + + const parseCreateCommandOptions = createCommand.parseOptions.bind(createCommand) + createCommand.parseOptions = (argv: string[]) => { + try { + const extracted = extractTemplateOptions(createCommand, argv) + templateOptions = extracted.templateOptions + return parseCreateCommandOptions(extracted.args) + } catch (error) { + return createCommand.error(`error: ${error instanceof Error ? error.message : String(error)}`) + } + } + + return createCommand +} + +const templateOptionPattern = /^--([a-z][a-z0-9-]*)$/ + +/** + * Extracts template-defined option flags such as `--reset-project` from the create command's raw + * arguments before commander parses them, mirroring the extraction create-solana-dapp performs on + * its own argv. Working on the raw arguments is what keeps `--` semantics intact — commander drops + * the separator (or keeps it, depending on what precedes it) before leftovers are visible — and it + * leaves commander's own unknown-option and excess-argument checks active for everything that + * remains. create-solana-dapp validates the collected names against the options the cloned + * template declares. + */ +export function extractTemplateOptions( + command: Command, + args: string[], +): { args: string[]; templateOptions: string[] } { + const remaining: string[] = [] + const templateOptions = new Set() + let positionalOnly = false + let preserveNextArgument = false + + for (const arg of args) { + if (positionalOnly || preserveNextArgument) { + remaining.push(arg) + preserveNextArgument = false + continue + } + + if (arg === '--') { + positionalOnly = true + remaining.push(arg) + continue + } + + const knownOption = findKnownOption(command, arg) + + if (knownOption) { + remaining.push(arg) + preserveNextArgument = Boolean(knownOption.required) && !hasInlineValue(arg) + continue + } + + // The help option is registered outside `command.options`, so it needs its own pass-through + if (!arg.startsWith('-') || arg === '-h' || arg === '--help') { + remaining.push(arg) + continue + } + + const name = templateOptionPattern.exec(arg)?.[1] + + if (!name) { + throw new InvalidArgumentError( + `Template options must be boolean long flags such as --reset-project; received "${arg}".`, + ) + } + + templateOptions.add(name) + } + + return { args: remaining, templateOptions: [...templateOptions] } +} + +function findKnownOption(command: Command, arg: string): Option | undefined { + // Check both flags: a dual-flag option such as `--pm, --package-manager` stores `--pm` as `short` + const flag = arg.startsWith('--') ? (arg.split('=', 1)[0] ?? arg) : arg.slice(0, 2) + + return command.options.find((option) => option.short === flag || option.long === flag) +} + +// A value attached to the flag itself (`--pm=pnpm`, `-tvalue`) means the next argument is not its value +function hasInlineValue(arg: string): boolean { + return arg.startsWith('--') ? arg.includes('=') : arg.length > 2 +} + +export function parsePackageManagerOption(next: string): PackageManager { + if (!next || !isPackageManager(next)) { + throw new InvalidArgumentError(`Invalid package manager: ${next}`) + } + + return next +} + +function isPackageManager(value: string): value is PackageManager { + return value === 'bun' || value === 'npm' || value === 'pnpm' || value === 'yarn' +} diff --git a/src/create/data-access/template-catalog.ts b/src/create/data-access/template-catalog.ts new file mode 100644 index 0000000..d9b142c --- /dev/null +++ b/src/create/data-access/template-catalog.ts @@ -0,0 +1,4 @@ +export const CUSTOM_TEMPLATES_URL = 'https://raw.githubusercontent.com/solana-mobile/templates/main/templates.json' + +// Must match a template name in CUSTOM_TEMPLATES_URL, otherwise `--minimal` falls through to `gh:` resolution. +export const MINIMAL_TEMPLATE_NAME = 'expo-kit-minimal' diff --git a/test/core.test.ts b/test/core.test.ts index 9843d7c..1042b4a 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -1,36 +1,17 @@ import { describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import type { CreateAppArgs, Template, TemplateJsonTemplate } from 'create-solana-dapp' import { createApp, runApp } from '../src/app.ts' import { readPackageMetadata } from '../src/core/data-access/package-metadata.ts' import { checkForNewerVersion, isVersionGreater } from '../src/core/data-access/version-check.ts' import { formatUpdateWarning } from '../src/core/ui/core-ui-update-warning.ts' import { formatCliCommand } from '../src/core/util/format-cli-command.ts' import { readPackageString } from '../src/core/util/read-package-string.ts' -import type { CreateCommandOptions, CreateSolanaDappApi } from '../src/create/create-feature-index.ts' -import { getInitialProjectName, MINIMAL_TEMPLATE_NAME, runCreate } from '../src/create/create-feature-index.ts' -import { projectNameSchema, validateProjectName } from '../src/create/data-access/validate-project-name.ts' const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { description: string name: string version: string } -const template: TemplateJsonTemplate = { - description: 'A Solana Mobile template', - id: 'gh:solana-mobile/templates/mobile/expo-kit-wallet', - keywords: [], - name: 'expo-kit-wallet', - path: 'mobile/expo-kit-wallet', -} -const minimalTemplate: TemplateJsonTemplate = { - description: 'A minimal Solana Mobile template', - id: 'gh:solana-mobile/templates/mobile/expo-kit-minimal', - keywords: [], - name: 'expo-kit-minimal', - path: 'mobile/expo-kit-minimal', -} describe('core', () => { test('formats direct CLI commands', () => { @@ -375,559 +356,4 @@ describe('app', () => { expect(errors.join('')).toContain(`Usage: solana-mobile ${name}`) } }) - - test('registers create command options', () => { - const createCommand = createApp().commands.find((command) => command.name() === 'create') - - expect(createCommand?.options.map((option) => option.flags)).toEqual([ - '--pm, --package-manager ', - '-d, --dry-run', - '-t, --template ', - '--list-template-ids', - '--list-templates', - '--list-versions', - '--minimal', - '--skip-git', - '--skip-init', - '--skip-install', - '-v, --verbose', - '--skip-version-check', - ]) - }) - - test('delegates create command options', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync([ - 'node', - 'solana-mobile', - 'create', - 'my-app', - '--template', - 'mobile/expo-kit-wallet', - '--pm', - 'pnpm', - '--skip-git', - '--skip-init', - '--skip-install', - '--verbose', - '--dry-run', - ]) - - expect(createOptions).toEqual([ - { - dryRun: true, - packageManager: 'pnpm', - projectName: 'my-app', - skipGit: true, - skipInit: true, - skipInstall: true, - template: 'mobile/expo-kit-wallet', - templateOptions: [], - verbose: true, - }, - ]) - }) - - test('delegates the minimal template name for --minimal', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--dry-run']) - - expect(createOptions).toEqual([ - { dryRun: true, minimal: true, projectName: 'my-app', template: 'expo-kit-minimal', templateOptions: [] }, - ]) - }) - - test('passes template options through to runCreate', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--reset-project', '--dry-run']) - - expect(createOptions[0]).toMatchObject({ projectName: 'my-app', templateOptions: ['reset-project'] }) - }) - - test('recovers the project name when a template option precedes it', async () => { - // commander reroutes operands that follow an unknown option into the leftover args, so the - // declared [projectName] argument would resolve to '--reset-project' here. - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', 'my-app', '--dry-run']) - - expect(createOptions[0]).toMatchObject({ projectName: 'my-app', templateOptions: ['reset-project'] }) - }) - - test('passes the project name after the -- separator', async () => { - // commander keeps the literal `--` in the leftover args when an unknown option precedes it - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', '--', 'sentinel-app']) - - expect(createOptions[0]).toMatchObject({ projectName: 'sentinel-app', templateOptions: ['reset-project'] }) - }) - - test('treats dash-prefixed args after -- as positionals, not template options', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', '--', '--not-an-option']) - - // The invalid name is rejected later by project name validation, not silently collected as an option - expect(createOptions[0]).toMatchObject({ projectName: '--not-an-option', templateOptions: ['reset-project'] }) - }) - - test('rejects combining --minimal with --template', async () => { - const app = createAppWithSilencedCreateCommand() - - await expect( - app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--template', 'expo-kit-wallet']), - ).rejects.toThrow(`The --minimal flag can't be used in combination with --template`) - }) - - test('rejects template options that are not boolean long flags', async () => { - const app = createAppWithSilencedCreateCommand() - - await expect(app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--reset-project=yes'])).rejects.toThrow( - 'Template options must be boolean long flags', - ) - }) - - test('rejects extra arguments', async () => { - const app = createAppWithSilencedCreateCommand() - - await expect(app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', 'extra'])).rejects.toThrow( - 'too many arguments', - ) - }) - - test('rejects template options placed after the -- separator as excess arguments', async () => { - // Everything after `--` is positional, even when commander itself would have dropped the - // separator before the extraction could see it - const app = createAppWithSilencedCreateCommand() - - await expect( - app.parseAsync(['node', 'solana-mobile', 'create', '--', 'sentinel-app', '--reset-project']), - ).rejects.toThrow('too many arguments') - }) - - test('treats a lone dash-prefixed arg after -- as the project name', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', '--', '--not-an-option']) - - // Rejected later by project name validation, not silently collected as a template option - expect(createOptions[0]).toMatchObject({ projectName: '--not-an-option', templateOptions: [] }) - }) - - test('shows help for create --help instead of collecting it as a template option', async () => { - // The help option is registered outside `command.options`, so the extraction special-cases it - const app = createAppWithSilencedCreateCommand() - let helpText = '' - app.commands - .find((command) => command.name() === 'create') - ?.configureOutput({ - writeErr: () => {}, - writeOut: (text) => { - helpText += text - }, - }) - - await expect(app.parseAsync(['node', 'solana-mobile', 'create', '--help'])).rejects.toMatchObject({ - code: 'commander.helpDisplayed', - }) - expect(helpText).toContain('Usage: solana-mobile create') - }) - - test('resolves the minimal template name from the catalog', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: MINIMAL_TEMPLATE_NAME }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toMatchObject([{ template: minimalTemplate }]) - }) - - test('creates with selected template using create-solana-dapp API', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { projectName: 'my-app', skipInstall: true }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toMatchObject([ - { - dryRun: false, - name: 'my-app', - packageManager: 'bun', - // Detected rather than selected, so createApp may switch to a template-required manager - packageManagerExplicit: false, - skipGit: false, - skipInit: false, - skipInstall: true, - template, - verbose: false, - }, - ]) - }) - - test('marks an explicitly selected package manager as explicit', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { packageManager: 'pnpm', projectName: 'my-app', skipInstall: true }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toMatchObject([{ packageManager: 'pnpm', packageManagerExplicit: true }]) - }) - - test('rejects an invalid positional project name before creating', async () => { - // The positional name flows into the generated package.json and the rename search key, so it - // gets the same validation as the prompt - const previousExitCode = process.exitCode - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - try { - await runCreate( - { projectName: 'My_App', skipInstall: true, template: 'expo-kit-wallet' }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toEqual([]) - expect(process.exitCode).toBe(1) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) - - test('creates without selecting when template is provided', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - let selectCalled = false - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: 'expo-kit-wallet' }, - { - createSolanaDapp, - selectTemplate: async () => { - selectCalled = true - return template - }, - }, - ) - - expect(createAppArgs).toMatchObject([{ template }]) - expect(selectCalled).toBe(false) - }) - - test('forwards template options to create-solana-dapp', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { - projectName: 'my-app', - skipInstall: true, - template: 'expo-kit-wallet', - templateOptions: ['reset-project'], - }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toMatchObject([{ templateOptions: ['reset-project'] }]) - }) - - test('passes the selected template to the project name prompt', async () => { - // The prompt pre-fills the project name from the selected template, so it needs the selection - const createSolanaDapp = createMockCreateSolanaDapp() - let promptedTemplate: Template | undefined - - await runCreate( - { skipInstall: true }, - { - createSolanaDapp, - promptProjectName: async (_createSolanaDapp, template) => { - promptedTemplate = template - return 'my-app' - }, - selectTemplate: async () => template, - }, - ) - - expect(promptedTemplate).toBe(template) - }) - - test('derives the initial project name from the template name', () => { - expect(getInitialProjectName(template)).toBe('expo-kit-wallet') - // External templates keep their raw reference as the name; only the last segment is usable - expect(getInitialProjectName({ ...template, name: 'solana-mobile/templates' })).toBe('templates') - expect(getInitialProjectName({ ...template, name: 'solana-mobile/templates/' })).toBe('templates') - // An invalid candidate is dropped rather than rewritten - expect(getInitialProjectName({ ...template, name: 'My_Template' })).toBeUndefined() - }) - - test('passes an absolute local template path through as a local template', async () => { - // Without the local branch the path is prefixed with `gh:` and cloning fails - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - const localTemplate = resolve(process.cwd(), 'test/fixtures/template-repository/mobile/example') - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: localTemplate }, - { createSolanaDapp, selectTemplate: async () => template }, - ) - - expect(createAppArgs).toMatchObject([ - { template: { description: `${localTemplate} (local)`, id: `local:${localTemplate}`, name: localTemplate } }, - ]) - }) - - test('resolves a relative local template path against the working directory', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - const relativeTemplate = './test/fixtures/template-repository/mobile/example' - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: relativeTemplate }, - { createSolanaDapp, selectTemplate: async () => template }, - ) - - expect(createAppArgs).toMatchObject([ - { template: { id: `local:${resolve(process.cwd(), relativeTemplate)}`, name: relativeTemplate } }, - ]) - }) - - test('rejects a local template path that does not exist', async () => { - const previousExitCode = process.exitCode - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - try { - await runCreate( - { projectName: 'my-app', skipInstall: true, template: '/does-not-exist/solana-mobile-template' }, - { createSolanaDapp, selectTemplate: async () => template }, - ) - - expect(createAppArgs).toEqual([]) - expect(process.exitCode).toBe(1) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) - - test('keeps treating a bare owner/repo template as an external GitHub reference', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: 'solana-mobile/templates/mobile/expo-kit-anchor' }, - { createSolanaDapp, selectTemplate: async () => template }, - ) - - expect(createAppArgs).toMatchObject([{ template: { id: 'gh:solana-mobile/templates/mobile/expo-kit-anchor' } }]) - }) - - test('exits when createApp fails so the leftover spinner cannot hang the process', async () => { - // create-solana-dapp leaves its spinner running when a task throws, and the spinner keeps the - // event loop alive, so returning normally here would hang until the user interrupts - const previousExitCode = process.exitCode - const exitCodes: number[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppError: new Error('Error cloning the template') }) - - try { - await runCreate( - { projectName: 'my-app', skipInstall: true, template: 'expo-kit-wallet' }, - { createSolanaDapp, exit: (code) => exitCodes.push(code), selectTemplate: async () => template }, - ) - - expect(exitCodes).toEqual([1]) - expect(process.exitCode).toBe(1) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) - - test('returns without exiting when the failure happens before createApp', async () => { - // No spinner is running yet, so returning lets stdout flush instead of truncating it - const previousExitCode = process.exitCode - const exitCodes: number[] = [] - const createSolanaDapp = createMockCreateSolanaDapp() - - try { - await runCreate( - { projectName: 'My_App', skipInstall: true, template: 'expo-kit-wallet' }, - { createSolanaDapp, exit: (code) => exitCodes.push(code), selectTemplate: async () => template }, - ) - - expect(exitCodes).toEqual([]) - expect(process.exitCode).toBe(1) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) - - test('stops before prompting a project name when template selection is canceled', async () => { - const previousExitCode = process.exitCode - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - let promptCalled = false - - try { - await runCreate( - {}, - { - createSolanaDapp, - promptProjectName: async () => { - promptCalled = true - return 'my-app' - }, - selectTemplate: async () => undefined, - }, - ) - - expect(createAppArgs).toEqual([]) - expect(process.exitCode).toBe(1) - expect(promptCalled).toBe(false) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) -}) - -describe('validate project name', () => { - test.each(['a', 'app', 'my-app', 'my-app-2', 'web3'])('accepts %p', (name) => { - expect(validateProjectName(name)).toBeUndefined() - }) - - test.each([ - '-app', - '9lives', - '@scope/app', - 'My-App', - 'app-', - 'my app', - 'my--app', - 'my.app', - 'my_app', - ])('rejects %p', (name) => { - expect(validateProjectName(name)).toBe( - 'Please enter a valid project name (lowercase letters, numbers, and single dashes, starting with a letter)', - ) - }) - - test('rejects an empty name', () => { - expect(validateProjectName('')).toBe('Please enter at least 1 character') - }) - - test('rejects a name longer than 214 characters', () => { - expect(validateProjectName('a'.repeat(215))).toBe('Please enter a name with at most 214 characters') - }) - - test('accepts a name of exactly 214 characters', () => { - expect(validateProjectName('a'.repeat(214))).toBeUndefined() - }) - - test('rejects a valid name when the directory already exists', () => { - // bun test runs from the repo root, where `src` exists - expect(validateProjectName('src')).toBe('Directory already exists') - }) - - test('does not check the filesystem in the schema alone', () => { - expect(projectNameSchema.safeParse('src').success).toBe(true) - }) }) - -function createAppWithSilencedCreateCommand() { - const app = createApp({ runCreate: async () => {} }) - - app.exitOverride() - app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) - app.commands - .find((command) => command.name() === 'create') - ?.exitOverride() - .configureOutput({ writeErr: () => {}, writeOut: () => {} }) - - return app -} - -function createMockCreateSolanaDapp({ - createAppArgs = [], - createAppError, -}: { - createAppArgs?: CreateAppArgs[] - createAppError?: Error -} = {}) { - return { - createApp: async (args) => { - createAppArgs.push(args) - if (createAppError) { - throw createAppError - } - return ['Install dependencies:'] - }, - detectInvokedPackageManager: () => 'bun', - fetchTemplateData: async () => ({ items: [], templates: [minimalTemplate, template] }), - finalNote: () => 'Done', - getAppInfo: () => ({ name: 'create-solana-dapp', version: '4.8.5' }), - listTemplateIds: ({ templates }) => templates.map((template) => template.id), - listTemplates: () => {}, - listVersions: () => {}, - validateProjectName, - } satisfies CreateSolanaDappApi -} diff --git a/test/create.test.ts b/test/create.test.ts new file mode 100644 index 0000000..c392456 --- /dev/null +++ b/test/create.test.ts @@ -0,0 +1,600 @@ +import { describe, expect, test } from 'bun:test' +import { resolve } from 'node:path' +import type { CreateAppArgs, Template, TemplateJsonTemplate } from 'create-solana-dapp' +import { createApp } from '../src/app.ts' +import type { CreateCommandOptions, CreateSolanaDappApi } from '../src/create/create-feature-scaffold.ts' +import { getInitialProjectName, runCreate } from '../src/create/create-feature-scaffold.ts' +import { MINIMAL_TEMPLATE_NAME } from '../src/create/data-access/template-catalog.ts' +import { projectNameSchema, validateProjectName } from '../src/create/data-access/validate-project-name.ts' + +const template: TemplateJsonTemplate = { + description: 'A Solana Mobile template', + id: 'gh:solana-mobile/templates/mobile/expo-kit-wallet', + keywords: [], + name: 'expo-kit-wallet', + path: 'mobile/expo-kit-wallet', +} +const minimalTemplate: TemplateJsonTemplate = { + description: 'A minimal Solana Mobile template', + id: 'gh:solana-mobile/templates/mobile/expo-kit-minimal', + keywords: [], + name: 'expo-kit-minimal', + path: 'mobile/expo-kit-minimal', +} + +describe('create command', () => { + test('registers create command options', () => { + const createCommand = createApp().commands.find((command) => command.name() === 'create') + + expect(createCommand?.options.map((option) => option.flags)).toEqual([ + '--pm, --package-manager ', + '-d, --dry-run', + '-t, --template ', + '--list-template-ids', + '--list-templates', + '--list-versions', + '--minimal', + '--skip-git', + '--skip-init', + '--skip-install', + '-v, --verbose', + '--skip-version-check', + ]) + }) + + test('delegates create command options', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync([ + 'node', + 'solana-mobile', + 'create', + 'my-app', + '--template', + 'mobile/expo-kit-wallet', + '--pm', + 'pnpm', + '--skip-git', + '--skip-init', + '--skip-install', + '--verbose', + '--dry-run', + ]) + + expect(createOptions).toEqual([ + { + dryRun: true, + packageManager: 'pnpm', + projectName: 'my-app', + skipGit: true, + skipInit: true, + skipInstall: true, + template: 'mobile/expo-kit-wallet', + templateOptions: [], + verbose: true, + }, + ]) + }) + + test('delegates the minimal template name for --minimal', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--dry-run']) + + expect(createOptions).toEqual([ + { dryRun: true, minimal: true, projectName: 'my-app', template: 'expo-kit-minimal', templateOptions: [] }, + ]) + }) + + test('passes template options through to runCreate', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--reset-project', '--dry-run']) + + expect(createOptions[0]).toMatchObject({ projectName: 'my-app', templateOptions: ['reset-project'] }) + }) + + test('recovers the project name when a template option precedes it', async () => { + // commander reroutes operands that follow an unknown option into the leftover args, so the + // declared [projectName] argument would resolve to '--reset-project' here. + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', 'my-app', '--dry-run']) + + expect(createOptions[0]).toMatchObject({ projectName: 'my-app', templateOptions: ['reset-project'] }) + }) + + test('passes the project name after the -- separator', async () => { + // commander keeps the literal `--` in the leftover args when an unknown option precedes it + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', '--', 'sentinel-app']) + + expect(createOptions[0]).toMatchObject({ projectName: 'sentinel-app', templateOptions: ['reset-project'] }) + }) + + test('treats dash-prefixed args after -- as positionals, not template options', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', '--', '--not-an-option']) + + // The invalid name is rejected later by project name validation, not silently collected as an option + expect(createOptions[0]).toMatchObject({ projectName: '--not-an-option', templateOptions: ['reset-project'] }) + }) + + test('rejects combining --minimal with --template', async () => { + const app = createAppWithSilencedCreateCommand() + + await expect( + app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--template', 'expo-kit-wallet']), + ).rejects.toThrow(`The --minimal flag can't be used in combination with --template`) + }) + + test('prints usage after an error, so the settings copy reaches create too', async () => { + // `create` is registered with `addCommand` like every other feature command, so it depends on + // createApp copying the root's `showHelpAfterError`. It cannot join the app-level guard for the + // other commands: its `parseOptions` override collects unknown long flags as template options + // before commander can reject them, so the error has to come from the override itself. + const errors: string[] = [] + const app = createApp({ runCreate: async () => {} }) + + app.exitOverride() + app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) + app.commands + .find((command) => command.name() === 'create') + ?.exitOverride() + .configureOutput({ writeErr: (text) => errors.push(text), writeOut: () => {} }) + + await expect(app.parseAsync(['node', 'solana-mobile', 'create', '-x'])).rejects.toThrow() + + expect(errors.join('')).toContain('Usage: solana-mobile create [options] [projectName]') + }) + + test('rejects template options that are not boolean long flags', async () => { + const app = createAppWithSilencedCreateCommand() + + await expect(app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--reset-project=yes'])).rejects.toThrow( + 'Template options must be boolean long flags', + ) + }) + + test('rejects extra arguments', async () => { + const app = createAppWithSilencedCreateCommand() + + await expect(app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', 'extra'])).rejects.toThrow( + 'too many arguments', + ) + }) + + test('rejects template options placed after the -- separator as excess arguments', async () => { + // Everything after `--` is positional, even when commander itself would have dropped the + // separator before the extraction could see it + const app = createAppWithSilencedCreateCommand() + + await expect( + app.parseAsync(['node', 'solana-mobile', 'create', '--', 'sentinel-app', '--reset-project']), + ).rejects.toThrow('too many arguments') + }) + + test('treats a lone dash-prefixed arg after -- as the project name', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', '--', '--not-an-option']) + + // Rejected later by project name validation, not silently collected as a template option + expect(createOptions[0]).toMatchObject({ projectName: '--not-an-option', templateOptions: [] }) + }) + + test('shows help for create --help instead of collecting it as a template option', async () => { + // The help option is registered outside `command.options`, so the extraction special-cases it + const app = createAppWithSilencedCreateCommand() + let helpText = '' + app.commands + .find((command) => command.name() === 'create') + ?.configureOutput({ + writeErr: () => {}, + writeOut: (text) => { + helpText += text + }, + }) + + await expect(app.parseAsync(['node', 'solana-mobile', 'create', '--help'])).rejects.toMatchObject({ + code: 'commander.helpDisplayed', + }) + expect(helpText).toContain('Usage: solana-mobile create') + }) + + test('resolves the minimal template name from the catalog', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: MINIMAL_TEMPLATE_NAME }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toMatchObject([{ template: minimalTemplate }]) + }) + + test('creates with selected template using create-solana-dapp API', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { projectName: 'my-app', skipInstall: true }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toMatchObject([ + { + dryRun: false, + name: 'my-app', + packageManager: 'bun', + // Detected rather than selected, so createApp may switch to a template-required manager + packageManagerExplicit: false, + skipGit: false, + skipInit: false, + skipInstall: true, + template, + verbose: false, + }, + ]) + }) + + test('marks an explicitly selected package manager as explicit', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { packageManager: 'pnpm', projectName: 'my-app', skipInstall: true }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toMatchObject([{ packageManager: 'pnpm', packageManagerExplicit: true }]) + }) + + test('rejects an invalid positional project name before creating', async () => { + // The positional name flows into the generated package.json and the rename search key, so it + // gets the same validation as the prompt + const previousExitCode = process.exitCode + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + try { + await runCreate( + { projectName: 'My_App', skipInstall: true, template: 'expo-kit-wallet' }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toEqual([]) + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) + + test('creates without selecting when template is provided', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + let selectCalled = false + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: 'expo-kit-wallet' }, + { + createSolanaDapp, + selectTemplate: async () => { + selectCalled = true + return template + }, + }, + ) + + expect(createAppArgs).toMatchObject([{ template }]) + expect(selectCalled).toBe(false) + }) + + test('forwards template options to create-solana-dapp', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { + projectName: 'my-app', + skipInstall: true, + template: 'expo-kit-wallet', + templateOptions: ['reset-project'], + }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toMatchObject([{ templateOptions: ['reset-project'] }]) + }) + + test('passes the selected template to the project name prompt', async () => { + // The prompt pre-fills the project name from the selected template, so it needs the selection + const createSolanaDapp = createMockCreateSolanaDapp() + let promptedTemplate: Template | undefined + + await runCreate( + { skipInstall: true }, + { + createSolanaDapp, + promptProjectName: async (_createSolanaDapp, template) => { + promptedTemplate = template + return 'my-app' + }, + selectTemplate: async () => template, + }, + ) + + expect(promptedTemplate).toBe(template) + }) + + test('derives the initial project name from the template name', () => { + expect(getInitialProjectName(template)).toBe('expo-kit-wallet') + // External templates keep their raw reference as the name; only the last segment is usable + expect(getInitialProjectName({ ...template, name: 'solana-mobile/templates' })).toBe('templates') + expect(getInitialProjectName({ ...template, name: 'solana-mobile/templates/' })).toBe('templates') + // An invalid candidate is dropped rather than rewritten + expect(getInitialProjectName({ ...template, name: 'My_Template' })).toBeUndefined() + }) + + test('passes an absolute local template path through as a local template', async () => { + // Without the local branch the path is prefixed with `gh:` and cloning fails + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + const localTemplate = resolve(process.cwd(), 'test/fixtures/template-repository/mobile/example') + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: localTemplate }, + { createSolanaDapp, selectTemplate: async () => template }, + ) + + expect(createAppArgs).toMatchObject([ + { template: { description: `${localTemplate} (local)`, id: `local:${localTemplate}`, name: localTemplate } }, + ]) + }) + + test('resolves a relative local template path against the working directory', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + const relativeTemplate = './test/fixtures/template-repository/mobile/example' + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: relativeTemplate }, + { createSolanaDapp, selectTemplate: async () => template }, + ) + + expect(createAppArgs).toMatchObject([ + { template: { id: `local:${resolve(process.cwd(), relativeTemplate)}`, name: relativeTemplate } }, + ]) + }) + + test('rejects a local template path that does not exist', async () => { + const previousExitCode = process.exitCode + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + try { + await runCreate( + { projectName: 'my-app', skipInstall: true, template: '/does-not-exist/solana-mobile-template' }, + { createSolanaDapp, selectTemplate: async () => template }, + ) + + expect(createAppArgs).toEqual([]) + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) + + test('keeps treating a bare owner/repo template as an external GitHub reference', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: 'solana-mobile/templates/mobile/expo-kit-anchor' }, + { createSolanaDapp, selectTemplate: async () => template }, + ) + + expect(createAppArgs).toMatchObject([{ template: { id: 'gh:solana-mobile/templates/mobile/expo-kit-anchor' } }]) + }) + + test('exits when createApp fails so the leftover spinner cannot hang the process', async () => { + // create-solana-dapp leaves its spinner running when a task throws, and the spinner keeps the + // event loop alive, so returning normally here would hang until the user interrupts + const previousExitCode = process.exitCode + const exitCodes: number[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppError: new Error('Error cloning the template') }) + + try { + await runCreate( + { projectName: 'my-app', skipInstall: true, template: 'expo-kit-wallet' }, + { createSolanaDapp, exit: (code) => exitCodes.push(code), selectTemplate: async () => template }, + ) + + expect(exitCodes).toEqual([1]) + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) + + test('returns without exiting when the failure happens before createApp', async () => { + // No spinner is running yet, so returning lets stdout flush instead of truncating it + const previousExitCode = process.exitCode + const exitCodes: number[] = [] + const createSolanaDapp = createMockCreateSolanaDapp() + + try { + await runCreate( + { projectName: 'My_App', skipInstall: true, template: 'expo-kit-wallet' }, + { createSolanaDapp, exit: (code) => exitCodes.push(code), selectTemplate: async () => template }, + ) + + expect(exitCodes).toEqual([]) + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) + + test('stops before prompting a project name when template selection is canceled', async () => { + const previousExitCode = process.exitCode + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + let promptCalled = false + + try { + await runCreate( + {}, + { + createSolanaDapp, + promptProjectName: async () => { + promptCalled = true + return 'my-app' + }, + selectTemplate: async () => undefined, + }, + ) + + expect(createAppArgs).toEqual([]) + expect(process.exitCode).toBe(1) + expect(promptCalled).toBe(false) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) +}) + +describe('validate project name', () => { + test.each(['a', 'app', 'my-app', 'my-app-2', 'web3'])('accepts %p', (name) => { + expect(validateProjectName(name)).toBeUndefined() + }) + + test.each([ + '-app', + '9lives', + '@scope/app', + 'My-App', + 'app-', + 'my app', + 'my--app', + 'my.app', + 'my_app', + ])('rejects %p', (name) => { + expect(validateProjectName(name)).toBe( + 'Please enter a valid project name (lowercase letters, numbers, and single dashes, starting with a letter)', + ) + }) + + test('rejects an empty name', () => { + expect(validateProjectName('')).toBe('Please enter at least 1 character') + }) + + test('rejects a name longer than 214 characters', () => { + expect(validateProjectName('a'.repeat(215))).toBe('Please enter a name with at most 214 characters') + }) + + test('accepts a name of exactly 214 characters', () => { + expect(validateProjectName('a'.repeat(214))).toBeUndefined() + }) + + test('rejects a valid name when the directory already exists', () => { + // bun test runs from the repo root, where `src` exists + expect(validateProjectName('src')).toBe('Directory already exists') + }) + + test('does not check the filesystem in the schema alone', () => { + expect(projectNameSchema.safeParse('src').success).toBe(true) + }) +}) + +function createAppWithSilencedCreateCommand() { + const app = createApp({ runCreate: async () => {} }) + + app.exitOverride() + app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) + app.commands + .find((command) => command.name() === 'create') + ?.exitOverride() + .configureOutput({ writeErr: () => {}, writeOut: () => {} }) + + return app +} + +function createMockCreateSolanaDapp({ + createAppArgs = [], + createAppError, +}: { + createAppArgs?: CreateAppArgs[] + createAppError?: Error +} = {}) { + return { + createApp: async (args) => { + createAppArgs.push(args) + if (createAppError) { + throw createAppError + } + return ['Install dependencies:'] + }, + detectInvokedPackageManager: () => 'bun', + fetchTemplateData: async () => ({ items: [], templates: [minimalTemplate, template] }), + finalNote: () => 'Done', + getAppInfo: () => ({ name: 'create-solana-dapp', version: '4.8.5' }), + listTemplateIds: ({ templates }) => templates.map((template) => template.id), + listTemplates: () => {}, + listVersions: () => {}, + validateProjectName, + } satisfies CreateSolanaDappApi +} diff --git a/test/device.test.ts b/test/device.test.ts index 92e7a6c..078037a 100644 --- a/test/device.test.ts +++ b/test/device.test.ts @@ -1043,6 +1043,7 @@ describe('device command', () => { expect(deviceCommand?.commands.map((command) => command.name())).toEqual(['install', 'list', 'open', 'tune']) }) + test('delegates device tune command options', async () => { const deviceTuneOptions: DeviceTuneCommandOptions[] = [] const app = createApp({ @@ -1056,6 +1057,7 @@ describe('device command', () => { expect(deviceTuneOptions).toEqual([{ device: 'SM02E4072816572' }, { all: true, yes: true }]) }) + test('rejects device tune with both --all and --device', async () => { const app = createAppWithSilencedDeviceTuneCommand() diff --git a/test/emulator.test.ts b/test/emulator.test.ts index 9f413c0..d363444 100644 --- a/test/emulator.test.ts +++ b/test/emulator.test.ts @@ -2387,6 +2387,7 @@ describe('emulator command', () => { expect(emulatorTuneOptions).toEqual([{ nameOrSerial: undefined }, { nameOrSerial: 'Alpha', yes: true }]) }) + test('registers emulator alias and subcommands', () => { const emulatorCommand = createApp().commands.find((command) => command.name() === 'emulator') @@ -2402,6 +2403,7 @@ describe('emulator command', () => { 'tune', ]) }) + test('does not delegate emulator command to list', async () => { const emulatorListOptions: Array> = [] const app = createApp({ @@ -2425,6 +2427,7 @@ describe('emulator command', () => { expect(emulatorListOptions).toEqual([]) }) + test('delegates emulator list command options', async () => { const emulatorListOptions: Array> = [] const app = createApp({ @@ -2437,6 +2440,7 @@ describe('emulator command', () => { expect(emulatorListOptions).toEqual([{}]) }) + test('delegates emulator alias list command options', async () => { const emulatorListOptions: Array> = [] const app = createApp({ @@ -2449,6 +2453,7 @@ describe('emulator command', () => { expect(emulatorListOptions).toEqual([{}]) }) + test('does not delegate emulator images command to list', async () => { const emulatorImagesOptions: EmulatorImagesCommandOptions[] = [] const app = createApp({ @@ -2471,6 +2476,7 @@ describe('emulator command', () => { expect(emulatorImagesCommand?.commands.map((command) => command.name())).toEqual(['delete', 'install', 'list']) expect(emulatorImagesOptions).toEqual([]) }) + test('delegates emulator images delete command options', async () => { const emulatorImagesDeleteOptions: EmulatorImagesDeleteCommandOptions[] = [] const app = createApp({ @@ -2501,6 +2507,7 @@ describe('emulator command', () => { }, ]) }) + test('delegates emulator images install command options', async () => { const emulatorImagesInstallOptions: EmulatorImagesInstallCommandOptions[] = [] const app = createApp({ @@ -2531,6 +2538,7 @@ describe('emulator command', () => { }, ]) }) + test('delegates emulator images list command options', async () => { const emulatorImagesOptions: EmulatorImagesCommandOptions[] = [] const app = createApp({ @@ -2543,6 +2551,7 @@ describe('emulator command', () => { expect(emulatorImagesOptions).toEqual([{ sdkRoot: '/sdk' }]) }) + test('delegates emulator create command options', async () => { const emulatorCreateOptions: EmulatorCreateCommandOptions[] = [] const app = createApp({ @@ -2595,6 +2604,7 @@ describe('emulator command', () => { }, ]) }) + test('delegates emulator delete command options', async () => { const emulatorDeleteOptions: EmulatorDeleteCommandOptions[] = [] const app = createApp({ @@ -2607,6 +2617,7 @@ describe('emulator command', () => { expect(emulatorDeleteOptions).toEqual([{ names: ['Alpha', 'Beta'], sdkRoot: '/sdk' }]) }) + test('delegates emulator delete without names', async () => { const emulatorDeleteOptions: EmulatorDeleteCommandOptions[] = [] const app = createApp({ @@ -2619,6 +2630,7 @@ describe('emulator command', () => { expect(emulatorDeleteOptions).toEqual([{ names: [] }]) }) + test('delegates emulator start command options', async () => { const emulatorStartOptions: EmulatorStartCommandOptions[] = [] const app = createApp({ @@ -2631,6 +2643,7 @@ describe('emulator command', () => { expect(emulatorStartOptions).toEqual([{ name: 'Alpha', sdkRoot: '/sdk', tune: true }]) }) + test('delegates emulator start without name', async () => { const emulatorStartOptions: EmulatorStartCommandOptions[] = [] const app = createApp({ @@ -2643,6 +2656,7 @@ describe('emulator command', () => { expect(emulatorStartOptions).toEqual([{ name: undefined }]) }) + test('delegates emulator stop command options', async () => { const emulatorStopOptions: EmulatorStopCommandOptions[] = [] const app = createApp({ @@ -2655,6 +2669,7 @@ describe('emulator command', () => { expect(emulatorStopOptions).toEqual([{ nameOrSerial: 'Alpha' }]) }) + test('delegates emulator status command options', async () => { const emulatorStatusOptions: EmulatorStatusCommandOptions[] = [] const app = createApp({ @@ -2667,6 +2682,7 @@ describe('emulator command', () => { expect(emulatorStatusOptions).toEqual([{ nameOrSerial: 'Alpha' }]) }) + test('delegates emulator status without name or serial', async () => { const emulatorStatusOptions: EmulatorStatusCommandOptions[] = [] const app = createApp({ @@ -2679,6 +2695,7 @@ describe('emulator command', () => { expect(emulatorStatusOptions).toEqual([{ nameOrSerial: undefined }]) }) + test('delegates emulator stop without name or serial', async () => { const emulatorStopOptions: EmulatorStopCommandOptions[] = [] const app = createApp({ diff --git a/test/templates.test.ts b/test/templates.test.ts index b561f5b..bc6dc0e 100644 --- a/test/templates.test.ts +++ b/test/templates.test.ts @@ -1022,6 +1022,7 @@ describe('templates command', () => { expect(templatesCommand?.commands.map((command) => command.name())).toEqual(['check', 'generate', 'sync']) }) + test('does not delegate templates command to check', async () => { const templatesCheckOptions: TemplatesCheckCommandOptions[] = [] const app = createApp({ @@ -1040,6 +1041,7 @@ describe('templates command', () => { expect(templatesCheckOptions).toEqual([]) }) + test('delegates templates check command options', async () => { const templatesCheckOptions: TemplatesCheckCommandOptions[] = [] const app = createApp({ @@ -1052,6 +1054,7 @@ describe('templates command', () => { expect(templatesCheckOptions).toEqual([{ root: '/repo' }]) }) + test('delegates templates generate command options', async () => { const templatesGenerateOptions: TemplatesGenerateCommandOptions[] = [] const app = createApp({