Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 5 additions & 62 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -21,22 +15,19 @@ 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 &
PlaygroundCommandDeps &
TemplatesCommandDeps &
WebshellCommandDeps & {
checkForNewerVersion?: (options: VersionCheckOptions) => Promise<VersionCheckResult | undefined>
runCreate?: (options: CreateCommandOptions) => Promise<void>
}

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()

Expand All @@ -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 <packageManager>', 'Package manager to use', parsePackageManagerOption)
.option('-d, --dry-run', 'Dry run')
.option('-t, --template <templateName>', '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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -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<string>()
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
Expand Down
146 changes: 146 additions & 0 deletions src/create/create-feature.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

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 <packageManager>', 'Package manager to use', parsePackageManagerOption)
.option('-d, --dry-run', 'Dry run')
.option('-t, --template <templateName>', '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<string>()
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'
}
4 changes: 4 additions & 0 deletions src/create/data-access/template-catalog.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading