Skip to content
Closed
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
24 changes: 16 additions & 8 deletions packages/nuxt-cli/src/utils/install.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Buffer } from 'node:buffer'
import type { ChildProcess, SpawnOptions } from 'node:child_process'
import type { DetectResult } from 'package-manager-detector'
import type { Agent, Command, DetectResult } from 'package-manager-detector'

import { spawn } from 'node:child_process'
import { existsSync, rmSync, statSync } from 'node:fs'
Expand All @@ -9,7 +9,7 @@ import process from 'node:process'
import { styleText } from 'node:util'

import { log, S_BAR } from '@clack/prompts'
import { resolveCommand } from 'package-manager-detector/commands'
import { COMMANDS, resolveCommand } from 'package-manager-detector/commands'
import { normalizeSpawnCommand } from 'tinyexec'

import { getLockFiles } from './package-managers'
Expand All @@ -28,16 +28,21 @@ const ANSI_RE = /\u001B\[[\d;]*[A-Z]/gi

const DENO_SPECIFIER_RE = /^(?:npm|jsr|file):/

/** Like `resolveCommand`, but `null` rather than a throw for an agent `package-manager-detector` does not know. */
function resolveAgentCommand(agent: Agent, command: Command, args: string[]) {
return Object.hasOwn(COMMANDS, agent) ? resolveCommand(agent, command, args) : null
}

function getInstallCommand({ cwd, packageManager: { agent, name }, dependencies = [], dev, uninstall }: InstallOptions) {
if (!dependencies.length) {
return resolveCommand(agent, 'install', [])!
return resolveAgentCommand(agent, 'install', [])
}
return resolveCommand(agent, uninstall ? 'uninstall' : 'add', [
return resolveAgentCommand(agent, uninstall ? 'uninstall' : 'add', [
// pnpm refuses to add to a workspace root without this flag.
...name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : [],
...dev && !uninstall ? ['-D'] : [],
...name === 'deno' ? dependencies.map(dep => DENO_SPECIFIER_RE.test(dep) ? dep : `npm:${dep}`) : dependencies,
])!
])
}

export interface InstallOptions {
Expand Down Expand Up @@ -78,8 +83,11 @@ export interface InstallResult {
* instead of hanging behind a spinner where its question is invisible.
*/
export async function runInstall(options: InstallOptions): Promise<InstallResult> {
const { command, args } = getInstallCommand(options)
return await execute(command, [...args, ...nonInteractiveArgs(options.packageManager)], options)
const exec = getInstallCommand(options)
if (!exec) {
return { success: false, output: '', command: '', ignoredBuilds: [], error: `Installing dependencies is not supported for ${options.packageManager.name}` }
}
return await execute(exec.command, [...exec.args, ...nonInteractiveArgs(options.packageManager)], options)
}

export interface DedupeOptions extends Omit<InstallOptions, 'dependencies' | 'dev' | 'uninstall'> {
Expand Down Expand Up @@ -107,7 +115,7 @@ export async function runDedupe(options: DedupeOptions): Promise<InstallResult>

const { agent, name } = options.packageManager
// Yarn 1 deduplicates as part of a regular install.
const exec = resolveCommand(agent, agent === 'yarn' ? 'install' : 'dedupe', [])
const exec = resolveAgentCommand(agent, agent === 'yarn' ? 'install' : 'dedupe', [])
if (!exec) {
return { success: false, output: '', command: '', ignoredBuilds: [], error: `Deduplication is not supported for ${name}` }
}
Expand Down
12 changes: 10 additions & 2 deletions packages/nuxt-cli/src/utils/package-managers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ import type { AgentName, DetectResult } from 'package-manager-detector'
import { execFileSync } from 'node:child_process'
import process from 'node:process'

import { COMMANDS } from 'package-manager-detector/commands'
import { LOCKS } from 'package-manager-detector/constants'
import { detect } from 'package-manager-detector/detect'

/** Supported package managers, in the order they are offered to the user. */
export const packageManagerNames: AgentName[] = ['npm', 'pnpm', 'yarn', 'bun', 'deno', 'aube', 'nub']
const preferredOrder = ['npm', 'pnpm', 'yarn', 'bun', 'deno', 'aube', 'nub', 'upm']

/**
* Supported package managers, in the order they are offered to the user.
*
* Only those `package-manager-detector` has commands for are kept, so a name it
* does not support yet (such as `upm`) is never offered and then fails to install.
*/
export const packageManagerNames = preferredOrder.filter((name): name is AgentName => Object.hasOwn(COMMANDS, name))

/** Used when no package manager can be detected. */
export const defaultPackageManager: DetectResult = { name: 'npm', agent: 'npm' }
Expand Down
16 changes: 16 additions & 0 deletions packages/nuxt-cli/test/unit/utils/install.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { DetectResult } from 'package-manager-detector'
import { existsSync } from 'node:fs'
import { chmod, mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
Expand Down Expand Up @@ -141,6 +142,13 @@ describe.skipIf(process.platform === 'win32')('runInstall arguments', () => {
const result = await runInstall({ cwd: dir, packageManager: { name: 'deno', agent: 'deno' }, dependencies: ['a', 'jsr:@std/path'] })
expect(result.command).toBe('deno add npm:a jsr:@std/path')
})

it('should report a package manager without commands as a failure', async () => {
const packageManager = { name: 'unknown-pm', agent: 'unknown-pm' } as unknown as DetectResult
const result = await runInstall({ cwd: process.cwd(), packageManager, dependencies: ['a'] })

expect(result).toEqual({ success: false, output: '', command: '', ignoredBuilds: [], error: 'Installing dependencies is not supported for unknown-pm' })
})
})

describe('runDedupe', () => {
Expand Down Expand Up @@ -199,6 +207,14 @@ describe('runDedupe', () => {
expect(result.error).toBe('Deduplication is not supported for bun')
})

it('should report a package manager without commands as a failure', async () => {
const packageManager = { name: 'unknown-pm', agent: 'unknown-pm' } as unknown as DetectResult
const result = await runDedupe({ cwd: process.cwd(), packageManager })

expect(result.success).toBe(false)
expect(result.error).toBe('Deduplication is not supported for unknown-pm')
})

it('should report a missing package manager instead of throwing', async () => {
vi.stubEnv('PATH', join(tmpdir(), 'nuxt-cli-nonexistent-bin'))
const result = await runDedupe({
Expand Down
30 changes: 28 additions & 2 deletions packages/nuxt-cli/test/unit/utils/package-managers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { AgentName } from 'package-manager-detector'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'

import process from 'node:process'
import { resolveCommand } from 'package-manager-detector/commands'
import { describe, expect, it } from 'vitest'

import { detectPackageManager, getLockFiles, getPackageManagerVersion } from '../../../src/utils/package-managers'
import { detectPackageManager, getLockFiles, getPackageManagerVersion, isPackageManagerName, packageManagerNames } from '../../../src/utils/package-managers'

describe('detectPackageManager', () => {
async function createNestedProject() {
Expand Down Expand Up @@ -37,6 +39,30 @@ describe('getLockFiles', () => {
})
})

describe('packageManagerNames', () => {
it('should only offer package managers that can install', () => {
for (const name of packageManagerNames) {
expect(resolveCommand(name, 'install', []), name).not.toBeNull()
expect(resolveCommand(name, 'add', ['a']), name).not.toBeNull()
expect(resolveCommand(name, 'uninstall', ['a']), name).not.toBeNull()
}
})
})

// upm is offered once `package-manager-detector` supports it.
describe.skipIf(!isPackageManagerName('upm'))('upm', () => {
it('should be detected from `upm.lock`', async () => {
const dir = await mkdtemp(join(tmpdir(), 'nuxt-pm-test-'))
await writeFile(join(dir, 'upm.lock'), '')

expect(await detectPackageManager(dir)).toEqual({ name: 'upm', agent: 'upm' })
})

it('should own `upm.lock`', () => {
expect(getLockFiles('upm' as AgentName)).toEqual(['upm.lock'])
})
})

describe('getPackageManagerVersion', () => {
it('returns the command version', () => {
expect(getPackageManagerVersion(process.execPath)).toBe(process.version)
Expand Down
Loading