Skip to content
Draft
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
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@types/debug",
"@bomb.sh/tab",
"@clack/prompts",
"@posva/clack-prompts",
"c12",
"confbox",
"debug",
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
},
"devDependencies": {
"@bomb.sh/tab": "^0.0.14",
"@clack/prompts": "^1.2.0",
"@clack/prompts": "npm:@posva/clack-prompts@^1.2.2",
"@nuxt/kit": "^4.4.2",
"@nuxt/schema": "^4.4.2",
"@nuxt/test-utils": "^4.0.0",
Expand Down
178 changes: 109 additions & 69 deletions packages/nuxi/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { TemplateData } from '../utils/starter-templates'
import { existsSync } from 'node:fs'
import process from 'node:process'

import { box, cancel, confirm, intro, isCancel, outro, select, spinner, tasks, text } from '@clack/prompts'
import { batch, box, cancel, confirm, intro, isCancel, once, outro, select, spinner, tasks, text } from '@clack/prompts'
import { defineCommand } from 'citty'
import { colors } from 'consola/utils'
import { downloadTemplate, startShell } from 'giget'
Expand Down Expand Up @@ -142,6 +142,7 @@ export default defineCommand({
let templateName = ctx.args.template
if (!templateName) {
const result = await select({
id: 'init:template',
message: 'Which template would you like to use?',
options: Object.entries(availableTemplates).map(([name, data]) => {
return {
Expand Down Expand Up @@ -173,6 +174,7 @@ export default defineCommand({
if (dir === '') {
const defaultDir = availableTemplates[templateName]?.defaultDir || 'nuxt-app'
const result = await text({
id: 'init:dir',
message: 'Where would you like to create your project?',
placeholder: `./${defaultDir}`,
defaultValue: defaultDir,
Expand All @@ -193,10 +195,12 @@ export default defineCommand({
let shouldForce = Boolean(ctx.args.force)

// Prompt the user if the template download directory already exists
// when no `--force` flag is provided
const shouldVerify = !shouldForce && existsSync(templateDownloadPath)
// when no `--force` flag is provided. Cache the existence check so agent
// replays don't see the directory created by a previous iteration.
const shouldVerify = !shouldForce && await once('init:target-dir-exists', () => existsSync(templateDownloadPath))
if (shouldVerify) {
const selectedAction = await select({
id: 'init:dir-exists-action',
message: `The directory ${colors.cyan(relativeToProcess(templateDownloadPath))} already exists. What would you like to do?`,
options: [
{ value: 'override', label: 'Override its contents' },
Expand All @@ -217,6 +221,7 @@ export default defineCommand({

case 'different': {
const result = await text({
id: 'init:different-dir',
message: 'Please specify a different directory:',
})

Expand All @@ -237,44 +242,46 @@ export default defineCommand({
}

// Download template
let template: DownloadTemplateResult

const downloadSpinner = spinner()
downloadSpinner.start(`Downloading ${colors.cyan(templateName)} template`)
let template: Pick<DownloadTemplateResult, 'name' | 'dir'>

try {
template = await downloadTemplate(templateName, {
dir: templateDownloadPath,
force: shouldForce,
offline: Boolean(ctx.args.offline),
preferOffline: Boolean(ctx.args.preferOffline),
registry: process.env.NUXI_INIT_REGISTRY || DEFAULT_REGISTRY,
})

if (dir.length > 0) {
const path = await findFile('package.json', {
startingFrom: join(templateDownloadPath, 'package.json'),
reverse: true,
template = await once('init:download-template', async () => {
const downloadSpinner = spinner()
downloadSpinner.start(`Downloading ${colors.cyan(templateName)} template`)

const downloaded = await downloadTemplate(templateName, {
dir: templateDownloadPath,
force: shouldForce,
offline: Boolean(ctx.args.offline),
preferOffline: Boolean(ctx.args.preferOffline),
registry: process.env.NUXI_INIT_REGISTRY || DEFAULT_REGISTRY,
})
if (path) {
const pkg = await readPackageJSON(path, { try: true })
if (pkg && pkg.name) {
const slug = basename(templateDownloadPath)
.replace(NON_WORD_RE, '-')
.replace(MULTI_DASH_RE, '-')
.replace(LEADING_TRAILING_DASH_RE, '')
if (slug) {
pkg.name = slug
await writePackageJSON(path, pkg)

if (dir.length > 0) {
const path = await findFile('package.json', {
startingFrom: join(templateDownloadPath, 'package.json'),
reverse: true,
})
if (path) {
const pkg = await readPackageJSON(path, { try: true })
if (pkg && pkg.name) {
const slug = basename(templateDownloadPath)
.replace(NON_WORD_RE, '-')
.replace(MULTI_DASH_RE, '-')
.replace(LEADING_TRAILING_DASH_RE, '')
if (slug) {
pkg.name = slug
await writePackageJSON(path, pkg)
}
}
}
}
}

downloadSpinner.stop(`Downloaded ${colors.cyan(template.name)} template`)
downloadSpinner.stop(`Downloaded ${colors.cyan(downloaded.name)} template`)
return { name: downloaded.name, dir: downloaded.dir }
})
}
catch (err) {
downloadSpinner.error('Template download failed')
if (process.env.DEBUG) {
throw err
}
Expand All @@ -283,40 +290,43 @@ export default defineCommand({
}

if (ctx.args.nightly !== undefined && !ctx.args.offline && !ctx.args.preferOffline) {
const nightlySpinner = spinner()
nightlySpinner.start('Fetching nightly version info')
await once('init:set-nightly', async () => {
const nightlySpinner = spinner()
nightlySpinner.start('Fetching nightly version info')

const response = await $fetch<{ 'dist-tags': Record<string, string> }>('https://registry.npmjs.org/nuxt-nightly')
const nightlyChannelTag = ctx.args.nightly || 'latest'
const response = await $fetch<{ 'dist-tags': Record<string, string> }>('https://registry.npmjs.org/nuxt-nightly')
const nightlyChannelTag = ctx.args.nightly || 'latest'

if (!nightlyChannelTag) {
nightlySpinner.error('Failed to get nightly channel tag')
logger.error(`Error getting nightly channel tag.`)
process.exit(1)
}
if (!nightlyChannelTag) {
nightlySpinner.error('Failed to get nightly channel tag')
logger.error(`Error getting nightly channel tag.`)
process.exit(1)
}

const nightlyChannelVersion = response['dist-tags'][nightlyChannelTag]
const nightlyChannelVersion = response['dist-tags'][nightlyChannelTag]

if (!nightlyChannelVersion) {
nightlySpinner.error('Nightly version not found')
logger.error(`Nightly channel version for tag ${colors.cyan(nightlyChannelTag)} not found.`)
process.exit(1)
}
if (!nightlyChannelVersion) {
nightlySpinner.error('Nightly version not found')
logger.error(`Nightly channel version for tag ${colors.cyan(nightlyChannelTag)} not found.`)
process.exit(1)
}

const nightlyNuxtPackageJsonVersion = `npm:nuxt-nightly@${nightlyChannelVersion}`
const packageJsonPath = resolve(cwd, dir)
const nightlyNuxtPackageJsonVersion = `npm:nuxt-nightly@${nightlyChannelVersion}`
const packageJsonPath = resolve(cwd, dir)

const packageJson = await readPackageJSON(packageJsonPath)
const packageJson = await readPackageJSON(packageJsonPath)

if (packageJson.dependencies && 'nuxt' in packageJson.dependencies) {
packageJson.dependencies.nuxt = nightlyNuxtPackageJsonVersion
}
else if (packageJson.devDependencies && 'nuxt' in packageJson.devDependencies) {
packageJson.devDependencies.nuxt = nightlyNuxtPackageJsonVersion
}
if (packageJson.dependencies && 'nuxt' in packageJson.dependencies) {
packageJson.dependencies.nuxt = nightlyNuxtPackageJsonVersion
}
else if (packageJson.devDependencies && 'nuxt' in packageJson.devDependencies) {
packageJson.devDependencies.nuxt = nightlyNuxtPackageJsonVersion
}

await writePackageJSON(join(packageJsonPath, 'package.json'), packageJson)
nightlySpinner.stop(`Updated to nightly version ${colors.cyan(nightlyChannelVersion)}`)
await writePackageJSON(join(packageJsonPath, 'package.json'), packageJson)
nightlySpinner.stop(`Updated to nightly version ${colors.cyan(nightlyChannelVersion)}`)
return nightlyChannelVersion
})
}

const currentPackageManager = detectCurrentPackageManager()
Expand All @@ -328,12 +338,37 @@ export default defineCommand({
hint: currentPackageManager === pm ? 'current' : undefined,
}))

let selectedPackageManager: PackageManagerName
if (packageManagerOptions.includes(packageManagerArg)) {
selectedPackageManager = packageManagerArg
let selectedPackageManager: PackageManagerName | undefined = packageManagerOptions.includes(packageManagerArg)
? packageManagerArg
: undefined
let gitInit: boolean | undefined = ctx.args.gitInit === 'false' as unknown ? false : ctx.args.gitInit

// Batch the two independent prompts when both are missing so agents answer them in one round-trip
if (selectedPackageManager === undefined && gitInit === undefined) {
const answers = await batch({
packageManager: batch.select<PackageManagerName>({
id: 'init:package-manager',
message: 'Which package manager would you like to use?',
options: packageManagerSelectOptions,
initialValue: currentPackageManager,
}),
gitInit: batch.confirm({
id: 'init:git-init',
message: 'Initialize git repository?',
}),
})

if (isCancel(answers.packageManager) || isCancel(answers.gitInit)) {
cancel('Operation cancelled.')
process.exit(1)
}

selectedPackageManager = answers.packageManager as PackageManagerName
gitInit = answers.gitInit as boolean
}
else {
else if (selectedPackageManager === undefined) {
const result = await select({
id: 'init:package-manager',
message: 'Which package manager would you like to use?',
options: packageManagerSelectOptions,
initialValue: currentPackageManager,
Expand All @@ -346,11 +381,9 @@ export default defineCommand({

selectedPackageManager = result
}

// Determine if we should init git
let gitInit: boolean | undefined = ctx.args.gitInit === 'false' as unknown ? false : ctx.args.gitInit
if (gitInit === undefined) {
else if (gitInit === undefined) {
const result = await confirm({
id: 'init:git-init',
message: 'Initialize git repository?',
})

Expand Down Expand Up @@ -407,7 +440,10 @@ export default defineCommand({
}

try {
await tasks(setupTasks)
await once('init:setup-tasks', async () => {
await tasks(setupTasks)
return null
})
}
catch (err) {
if (process.env.DEBUG) {
Expand Down Expand Up @@ -435,6 +471,7 @@ export default defineCommand({
else if (!ctx.args.offline && !ctx.args.preferOffline) {
const modulesPromise = fetchModules()
const wantsUserModules = await confirm({
id: 'init:browse-modules',
message: `Would you like to browse and install modules?`,
initialValue: false,
})
Expand Down Expand Up @@ -467,7 +504,7 @@ export default defineCommand({
logger.info('All modules are already included in this template.')
}
else {
const result = await selectModulesAutocomplete({ modules: allModules })
const result = await selectModulesAutocomplete({ id: 'init:modules-select', modules: allModules })

if (result.selected.length > 0) {
const modules = result.selected
Expand Down Expand Up @@ -498,7 +535,10 @@ export default defineCommand({
ctx.args.logLevel ? `--logLevel=${ctx.args.logLevel}` : '',
].filter(Boolean)

await runCommand(addModuleCommand, args)
await once('init:add-modules', async () => {
await runCommand(addModuleCommand, args)
return null
})
Comment on lines +538 to +541
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

can be simplified to () => runCommand()

}

outro(`✨ Nuxt project has been created with the ${colors.cyan(template.name)} template.`)
Expand Down
8 changes: 5 additions & 3 deletions packages/nuxi/src/commands/module/_autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Option } from '@clack/prompts'
import type { NuxtModule } from './_utils'

import { autocompleteMultiselect, isCancel } from '@clack/prompts'
import { autocompleteMultiselect, isAgent, isCancel } from '@clack/prompts'
import { byLengthAsc, Fzf } from 'fzf'
import { hasTTY } from 'std-env'

Expand All @@ -12,6 +12,7 @@
interface AutocompleteOptions {
modules: NuxtModule[]
message?: string
id?: string
}

interface AutocompleteResult {
Expand All @@ -24,9 +25,9 @@
* Returns object with selected module npm package names and cancellation status
*/
export async function selectModulesAutocomplete(options: AutocompleteOptions): Promise<AutocompleteResult> {
const { modules, message = 'Search and select modules:' } = options
const { modules, message = 'Search and select modules:', id } = options

if (!hasTTY) {
if (!hasTTY && !isAgent()) {

Check failure on line 30 in packages/nuxi/src/commands/module/_autocomplete.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

packages/nuxi/test/unit/commands/module/_autocomplete.spec.ts > selectModulesAutocomplete > tTY handling > should return empty result when not in TTY environment

Error: [vitest] No "isAgent" export is defined on the "@clack/prompts" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("@clack/prompts"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ selectModulesAutocomplete packages/nuxi/src/commands/module/_autocomplete.ts:30:19 ❯ packages/nuxi/test/unit/commands/module/_autocomplete.spec.ts:95:28

Check failure on line 30 in packages/nuxi/src/commands/module/_autocomplete.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

packages/nuxi/test/unit/commands/module/_autocomplete.spec.ts > selectModulesAutocomplete > tTY handling > should return empty result when not in TTY environment

Error: [vitest] No "isAgent" export is defined on the "@clack/prompts" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("@clack/prompts"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ selectModulesAutocomplete packages/nuxi/src/commands/module/_autocomplete.ts:30:19 ❯ packages/nuxi/test/unit/commands/module/_autocomplete.spec.ts:95:28

Check failure on line 30 in packages/nuxi/src/commands/module/_autocomplete.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

packages/nuxi/test/unit/commands/module/_autocomplete.spec.ts > selectModulesAutocomplete > tTY handling > should return empty result when not in TTY environment

Error: [vitest] No "isAgent" export is defined on the "@clack/prompts" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("@clack/prompts"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ selectModulesAutocomplete packages/nuxi/src/commands/module/_autocomplete.ts:30:19 ❯ packages/nuxi/test/unit/commands/module/_autocomplete.spec.ts:95:28
logger.warn('Interactive module selection requires a TTY. Skipping.')
return { selected: [], cancelled: false }
}
Expand Down Expand Up @@ -63,6 +64,7 @@
}

const result = await autocompleteMultiselect({
id,
message,
options: clackOptions,
filter,
Expand Down
Loading
Loading