@swell/cli
Version:
Swell's command line interface/utility
189 lines (188 loc) • 7.78 kB
JavaScript
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
/** Lock file to package manager mapping (priority order: bun > pnpm > yarn > npm) */
const LOCK_FILES = [
['bun.lockb', 'bun'],
['bun.lock', 'bun'],
['pnpm-lock.yaml', 'pnpm'],
['yarn.lock', 'yarn'],
['package-lock.json', 'npm'],
];
/** Command templates for each package manager */
const COMMANDS = {
npm: {
install: 'npm install',
run: (script) => `npm run ${script}`,
workspaceExec: (workspace, command) => `npm exec --workspace=${workspace} -- ${command}`,
workspaceRun: (workspace, script) => `npm run ${script} --workspace=${workspace}`,
exec: (command) => `npx ${command}`,
},
yarn: {
install: 'yarn install',
run: (script) => `yarn run ${script}`,
workspaceExec: (workspace, command) => `yarn workspace ${workspace} ${command}`,
workspaceRun: (workspace, script) => `yarn workspace ${workspace} run ${script}`,
exec: (command) => `yarn dlx ${command}`,
},
pnpm: {
install: 'pnpm install',
run: (script) => `pnpm run ${script}`,
workspaceExec: (workspace, command) => `pnpm --filter ${workspace} exec ${command}`,
workspaceRun: (workspace, script) => `pnpm --filter ${workspace} run ${script}`,
exec: (command) => `pnpm dlx ${command}`,
},
bun: {
install: 'bun install',
run: (script) => `bun run ${script}`,
// Use --cwd for executing binaries in a workspace (--filter is for scripts only)
workspaceExec: (workspace, command) => `bun --cwd ${workspace} x ${command}`,
workspaceRun: (workspace, script) => `bun run --filter ${workspace} ${script}`,
exec: (command) => `bunx ${command}`,
},
};
/**
* Find lock file in directory or parent directories
* @returns Lock file name and path, or null if not found
*/
export async function findLockFile(directory) {
let currentDir = path.resolve(directory);
const { root } = path.parse(currentDir);
while (currentDir !== root) {
for (const [lockFile, pm] of LOCK_FILES) {
const lockPath = path.join(currentDir, lockFile);
// eslint-disable-next-line no-await-in-loop
const exists = await fs.access(lockPath, fs.constants.R_OK).then(() => true, (_error) => false);
if (exists) {
return { name: lockFile, packageManager: pm, path: lockPath };
}
}
currentDir = path.dirname(currentDir);
}
return null;
}
/**
* Detect package manager from lock file in directory tree
* @returns Detected package manager, defaults to 'npm' if no lock file found
*/
export async function detectPackageManager(directory) {
const lockFile = await findLockFile(directory);
return lockFile?.packageManager ?? 'npm';
}
/**
* Get command templates for a package manager
*/
export function getPackageManagerCommands(pm) {
return COMMANDS[pm];
}
/**
* Detect package manager and get its commands
*/
export async function getPackageManager(directory) {
const lockFile = await findLockFile(directory);
const name = lockFile?.packageManager ?? 'npm';
return {
commands: COMMANDS[name],
lockFile: lockFile ? { name: lockFile.name, path: lockFile.path } : null,
name,
};
}
/**
* Transform an npm command to the equivalent for the detected package manager
* Handles common patterns:
* - 'npm run X' -> 'yarn run X' / 'pnpm run X' / 'bun run X'
* - 'npm run X --workspace=Y' -> 'yarn workspace Y run X' / 'pnpm --filter Y run X' / 'bun run --filter Y X'
* - 'npm exec --workspace=X -- Y' -> equivalent workspace command
* - 'npx X' -> 'yarn dlx X' / 'pnpm dlx X' / 'bunx X'
*/
export function transformCommand(command, pm) {
if (pm === 'npm') {
return command;
}
const commands = COMMANDS[pm];
// Transform 'npm exec --workspace=X -- Y'
const workspaceMatch = command.match(/^npm exec --workspace=(\S+) -- (.+)$/);
if (workspaceMatch) {
return commands.workspaceExec(workspaceMatch[1], workspaceMatch[2]);
}
// Transform 'npm run X --workspace=Y'
const workspaceRunMatch = command.match(/^npm run (\S+) --workspace=(\S+)$/);
if (workspaceRunMatch) {
return commands.workspaceRun(workspaceRunMatch[2], workspaceRunMatch[1]);
}
// Transform 'npm run X' (with optional flags)
const runMatch = command.match(/^npm run (\S.*)$/);
if (runMatch) {
return commands.run(runMatch[1]);
}
// Transform 'npx X' to 'bunx X' for bun (performance benefit from Bun runtime)
// For other package managers, keep npx as-is (universally available via Node.js)
if (pm === 'bun') {
const npxMatch = command.match(/^npx (.+)$/);
if (npxMatch) {
return `bunx ${npxMatch[1]}`;
}
}
// Warn if command looks like npm but wasn't transformed
if (command.startsWith('npm ')) {
console.warn(`Warning: Command "${command}" was not transformed for ${pm}.`);
}
return command;
}
/**
* Transform npm create commands to the equivalent for another package manager.
* Handles 'npm create X@version -- args' -> 'pm create X@version args'
* Also transforms --use-npm to --use-{pm} for CLIs that support it (e.g., Next.js)
* For yarn, removes @latest suffix since yarn classic (v1) may not handle it properly.
*
* @example
* transformCreateCommand('npm create cloudflare@latest -- frontend --framework=hono', 'bun')
* // Returns: 'bun create cloudflare@latest frontend --framework=hono'
*
* @example
* transformCreateCommand('npm create cloudflare@latest -- frontend --framework=next --use-npm', 'pnpm')
* // Returns: 'pnpm create cloudflare@latest frontend --framework=next --use-pnpm'
*
* @example
* transformCreateCommand('npm create cloudflare@latest -- frontend --framework=astro', 'yarn')
* // Returns: 'yarn create cloudflare -- frontend --framework=astro'
*/
export function transformCreateCommand(command, pm) {
if (pm === 'npm') {
return command;
}
// Match 'npm create X@version -- args' or 'npm create X -- args'
const createMatch = command.match(/^npm create (\S+) -- (.+)$/);
if (createMatch) {
let [, packageWithVersion, args] = createMatch;
// Replace --use-npm with --use-{pm} for Next.js and similar CLIs
const transformedArgs = args.replace(/--use-npm\b/, `--use-${pm}`);
// For yarn classic (v1), remove @latest since yarn create fetches latest by default
// and may not properly handle version specifiers. Also add -- separator for args.
if (pm === 'yarn') {
packageWithVersion = packageWithVersion.replace(/@latest$/, '');
return `yarn create ${packageWithVersion} -- ${transformedArgs}`;
}
return `${pm} create ${packageWithVersion} ${transformedArgs}`;
}
return command;
}
/**
* Get spawn arguments for executing a package binary.
* Returns the program and any prefix args needed before the command args.
* Useful for node's spawn() which needs program and args separately.
*
* Uses npx for most package managers (universally available via Node.js).
* Uses bunx for bun projects (performance benefit from Bun runtime).
*
* @example
* const { program, prefixArgs } = getExecSpawnArgs('npm');
* spawn(program, [...prefixArgs, 'wrangler', 'dev', '--port=3000']);
*/
export function getExecSpawnArgs(pm) {
// Use bunx for bun (performance benefit), npx for everything else
// npx is universally available via Node.js and avoids yarn dlx v1/v2 issues
if (pm === 'bun') {
return { prefixArgs: [], program: 'bunx' };
}
return { prefixArgs: [], program: 'npx' };
}