diff --git a/packages/nuxt-cli/src/utils/install.ts b/packages/nuxt-cli/src/utils/install.ts index 1a0401db0..4b4721b44 100644 --- a/packages/nuxt-cli/src/utils/install.ts +++ b/packages/nuxt-cli/src/utils/install.ts @@ -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' @@ -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' @@ -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 { @@ -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 { - 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 { @@ -107,7 +115,7 @@ export async function runDedupe(options: DedupeOptions): Promise 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}` } } diff --git a/packages/nuxt-cli/src/utils/package-managers.ts b/packages/nuxt-cli/src/utils/package-managers.ts index 6f979e7ba..3b2d29d78 100644 --- a/packages/nuxt-cli/src/utils/package-managers.ts +++ b/packages/nuxt-cli/src/utils/package-managers.ts @@ -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' } diff --git a/packages/nuxt-cli/test/unit/utils/install.spec.ts b/packages/nuxt-cli/test/unit/utils/install.spec.ts index b7b2057cc..f505f9439 100644 --- a/packages/nuxt-cli/test/unit/utils/install.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/install.spec.ts @@ -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' @@ -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', () => { @@ -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({ diff --git a/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts b/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts index 5050d8740..da560f923 100644 --- a/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts @@ -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() { @@ -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)