From 1e6514b936b12dc5de00f9be1044ac3bb222e875 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:30:30 +0000 Subject: [PATCH 1/3] feat: add upm support, refactor agent handling --- README.md | 9 +++- package.json | 3 +- pnpm-lock.yaml | 14 +++--- src/agents.ts | 133 +++++++++++++++++++++++++++++++++++++++++++++++++ src/fs.ts | 21 ++++---- src/ni.ts | 64 ++---------------------- src/nr.ts | 105 +++++++++++++++----------------------- src/nu.ts | 51 ++++--------------- src/storage.ts | 90 +++++++++------------------------ 9 files changed, 234 insertions(+), 256 deletions(-) create mode 100644 src/agents.ts diff --git a/README.md b/README.md index 582af0d..44bb83b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

- Run script auto-detect node_module manager npm, pnpm, yarn. bun + Run script auto-detect package manager: npm, yarn, pnpm, bun, upm

@@ -21,6 +21,10 @@ npm install -g run-script-cli ``` +## 🔍 Detection + +The package manager is detected from the `packageManager` field in `package.json`, then from lockfiles (`upm.lock`, `bun.lock(b)`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`), searching up from the current directory. If nothing is found you'll be asked to choose one. + ## 🚀 Commands - Run @@ -31,6 +35,9 @@ nr # Run script a specific script nr dev + +# Pass extra args to the script (`--` is added automatically for npm) +nr dev --port 3000 ``` - Install diff --git a/package.json b/package.json index 3fba44b..74a0e4a 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "author": "hunghg255", "devDependencies": { "@hunghg255/eslint-config-ts": "^0.0.8", - "@skarab/detect-package-manager": "^1.0.0", "@types/node": "^18.19.3", "bumpp": "^9.2.1", "esno": "^0.16.3", @@ -65,6 +64,8 @@ "npm", "yarn", "pnpm", + "bun", + "upm", "run", "script", "cli", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 529b3da..5453cd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ devDependencies: '@hunghg255/eslint-config-ts': specifier: ^0.0.8 version: 0.0.8(eslint@8.56.0)(typescript@5.3.3) - '@skarab/detect-package-manager': - specifier: ^1.0.0 - version: 1.0.0 '@types/node': specifier: ^18.19.3 version: 18.19.8 @@ -906,6 +903,7 @@ packages: /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead dependencies: '@humanwhocodes/object-schema': 2.0.2 debug: 4.3.4 @@ -921,6 +919,7 @@ packages: /@humanwhocodes/object-schema@2.0.2: resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + deprecated: Use @eslint/object-schema instead dev: true /@hunghg255/eslint-config-ts@0.0.8(eslint@8.56.0)(typescript@5.3.3): @@ -1234,11 +1233,6 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true - /@skarab/detect-package-manager@1.0.0: - resolution: {integrity: sha512-CuU1x6LgkJIF+H8FCnKijx6DbWVP1Y7kqPk88LSxNe0ulbe3BZ37qd3AaX3t5vnFZESLFMcjQd2dlPhdvWNSdQ==} - engines: {node: '>=14', pnpm: '>=7'} - dev: true - /@trysound/sax@0.2.0: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -1418,6 +1412,7 @@ packages: /@ungap/structured-clone@1.2.0: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher dev: true /@vitest/expect@0.34.6: @@ -2604,6 +2599,7 @@ packages: /eslint@8.56.0: resolution: {integrity: sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) @@ -2919,6 +2915,7 @@ packages: /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -4284,6 +4281,7 @@ packages: /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000..27e39ef --- /dev/null +++ b/src/agents.ts @@ -0,0 +1,133 @@ +/* eslint-disable unicorn/no-process-exit */ +import fs from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; + +// @ts-ignore +import { execa } from 'execa'; +// @ts-ignore +import c from 'kleur'; +import { cancel, intro, isCancel, select } from 'unprompts'; + +export const AGENTS = ['npm', 'yarn', 'pnpm', 'bun', 'upm'] as const; + +export type Agent = (typeof AGENTS)[number]; + +type Command = 'run' | 'install' | 'add' | 'remove'; + +// Lockfiles checked in order, the first match wins. +const LOCKS: [string, Agent][] = [ + ['upm.lock', 'upm'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['package-lock.json', 'npm'], + ['npm-shrinkwrap.json', 'npm'], +]; + +const COMMANDS: Record> = { + npm: { run: ['run'], install: ['install'], add: ['install'], remove: ['uninstall'] }, + yarn: { run: ['run'], install: ['install'], add: ['add'], remove: ['remove'] }, + pnpm: { run: ['run'], install: ['install'], add: ['add'], remove: ['remove'] }, + bun: { run: ['run'], install: ['install'], add: ['add'], remove: ['remove'] }, + upm: { run: ['run'], install: ['install'], add: ['add'], remove: ['remove'] }, +}; + +function isAgent(name: unknown): name is Agent { + return AGENTS.includes(name as Agent); +} + +function readPackageManagerField(dir: string): Agent | undefined { + const pkgPath = join(dir, 'package.json'); + if (!fs.existsSync(pkgPath)) { + return; + } + + try { + const pm = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).packageManager; + if (typeof pm === 'string') { + const name = pm.replace(/^\^/, '').split('@')[0]; + if (isAgent(name)) { + return name; + } + } + } catch {} +} + +/** + * Detect the package manager used by the project, walking up from `cwd`. + * The `packageManager` field in package.json takes priority over lockfiles. + */ +export function detectAgent(cwd: string = process.cwd()): Agent | undefined { + let dir = resolve(cwd); + + while (true) { + const fromField = readPackageManagerField(dir); + if (fromField) { + return fromField; + } + + for (const [lock, agent] of LOCKS) { + if (fs.existsSync(join(dir, lock))) { + return agent; + } + } + + const parent = dirname(dir); + if (parent === dir) { + return; + } + dir = parent; + } +} + +export async function selectAgent(): Promise { + const agent = await select({ + message: c.bgCyan(' Choose package manager '), + options: AGENTS.map((name) => ({ + label: c.yellow(name), + value: name, + })), + }); + + if (isCancel(agent)) { + cancel('Choose package manager cancelled'); + process.exit(0); + } + + return agent as Agent; +} + +/** + * Detect the agent, falling back to asking the user when nothing is found. + */ +export async function resolveAgent(cwd: string = process.cwd()): Promise { + return detectAgent(cwd) ?? (await selectAgent()); +} + +export function getCommand(agent: Agent, command: Command, args: string[] = []): string[] { + const base = COMMANDS[agent][command]; + + // npm treats `--flags` after the script name as its own options unless separated by `--` + if (command === 'run' && agent === 'npm' && args.length > 1) { + return [agent, ...base, ...args.slice(0, 1), '--', ...args.slice(1)]; + } + + return [agent, ...base, ...args]; +} + +export async function runCommand(cmd: string[], cwd: string = process.cwd()) { + const [bin, ...args] = cmd as [string, ...string[]]; + + intro(c.bold(c.green(`${cmd.join(' ')}\n`))); + + try { + await execa(bin, args, { stdio: 'inherit', cwd }); + } catch (error: any) { + if (error?.code === 'ENOENT') { + console.error(c.red(`\n${bin} is not installed or not in PATH`)); + } + process.exit(typeof error?.exitCode === 'number' ? error.exitCode : 1); + } +} diff --git a/src/fs.ts b/src/fs.ts index 7b05ce6..3eb97e5 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -3,18 +3,17 @@ import fs from 'node:fs'; import { resolve } from 'node:path'; import process from 'node:process'; -export function getPackageJSON(cwdParams?: any): any { - const cwd = cwdParams ?? process.cwd(); +export function getPackageJSON(cwd: string = process.cwd()): any { const path = resolve(cwd, 'package.json'); - if (fs.existsSync(path)) { - try { - const raw = fs.readFileSync(path, 'utf8'); - const data = JSON.parse(raw); - return data; - } catch { - console.warn('Failed to parse package.json'); - process.exit(1); - } + if (!fs.existsSync(path)) { + return; + } + + try { + return JSON.parse(fs.readFileSync(path, 'utf8')); + } catch { + console.warn('Failed to parse package.json'); + process.exit(1); } } diff --git a/src/ni.ts b/src/ni.ts index 9335fb7..c0e7373 100644 --- a/src/ni.ts +++ b/src/ni.ts @@ -1,68 +1,12 @@ -/* eslint-disable unicorn/no-process-exit */ import process from 'node:process'; -// @ts-ignore -import { detectAgent } from '@skarab/detect-package-manager'; -// @ts-ignore -import { execaCommand } from 'execa'; -// @ts-ignore -import c from 'kleur'; -import { cancel, isCancel, select, intro } from 'unprompts'; +import { getCommand, resolveAgent, runCommand } from './agents'; export const niCli = async (cwd: string = process.cwd(), argv = process.argv) => { - try { - const agent = await detectAgent(cwd); + const args = argv.slice(2); + const agent = await resolveAgent(cwd); - let nodeManager = agent?.name as string; - let scriptValue: string = ''; - - if (!nodeManager) { - nodeManager = (await select({ - message: c.bgCyan(' Choose npm manager '), - options: ['npm', 'pnpm', 'yarn', 'bun'].map((scriptItem) => ({ - label: `${c.yellow(scriptItem)}`, - value: scriptItem, - })), - })) as string; - - if (isCancel(nodeManager)) { - cancel('Choose npm manager cancelled'); - return process.exit(0); - } - } - - if (argv?.length > 2) { - scriptValue = argv.slice(2).join(' '); - } - - if (nodeManager === 'bun') { - const cmd = scriptValue ? `bun add ${scriptValue}` : 'bun install'; - intro(c.bold(c.green(`${cmd}\n`))); - - await execaCommand(`${cmd}`, { stdio: 'inherit', cwd }); - } - - if (nodeManager === 'pnpm') { - const cmd = scriptValue ? `pnpm add ${scriptValue}` : 'pnpm install'; - - intro(c.bold(c.green(`${cmd}\n`))); - await execaCommand(`${cmd}`, { stdio: 'inherit', cwd }); - } - - if (nodeManager === 'npm') { - intro(c.bold(c.green(`npm install ${scriptValue}\n`))); - - await execaCommand(`npm install ${scriptValue}`, { stdio: 'inherit', cwd }); - } - - if (nodeManager === 'yarn') { - const cmd = scriptValue ? `yarn add ${scriptValue}` : 'yarn install'; - - intro(c.bold(c.green(`${cmd}\n`))); - - await execaCommand(`${cmd}`, { stdio: 'inherit', cwd }); - } - } catch {} + await runCommand(getCommand(agent, args.length > 0 ? 'add' : 'install', args), cwd); }; niCli(); diff --git a/src/nr.ts b/src/nr.ts index b3c8428..8759281 100644 --- a/src/nr.ts +++ b/src/nr.ts @@ -1,95 +1,68 @@ /* eslint-disable unicorn/no-process-exit */ import process from 'node:process'; -// @ts-ignore -import { detectAgent } from '@skarab/detect-package-manager'; -// @ts-ignore -import { execaCommand } from 'execa'; // @ts-ignore import c from 'kleur'; -import { cancel, isCancel, select, intro } from 'unprompts'; +import { cancel, isCancel, select } from 'unprompts'; +import { getCommand, resolveAgent, runCommand } from './agents'; import { getPackageJSON } from './fs'; import { dump, load } from './storage'; import { limitText } from './utils'; -export const niCli = async (cwd: string = process.cwd(), argv = process.argv) => { - try { - const storage = await load(); +export const nrCli = async (cwd: string = process.cwd(), argv = process.argv) => { + const pkg = getPackageJSON(cwd); - const pkg = getPackageJSON(cwd); - const scripts = pkg.scripts || {}; - const scriptsInfo = pkg['scripts-info'] || {}; - let scriptValue: string = ''; + if (!pkg) { + console.error(c.red('No package.json found in current directory')); + return process.exit(1); + } - const names = Object.entries(scripts) as [string, string][]; - - if (names.length === 0) { - return process.exit(0); - } + const scripts: Record = pkg.scripts || {}; + const scriptsInfo: Record = pkg['scripts-info'] || {}; + let args = argv.slice(2); - const raw = names - .filter((i) => !i[0].startsWith('?')) + if (args.length === 0) { + const raw = Object.entries(scripts) + .filter(([key]) => !key.startsWith('?')) .map(([key, cmd]) => ({ key, - cmd, description: scriptsInfo[key] || scripts[`?${key}`] || cmd, })); - const agent = await detectAgent(cwd); - - if (!agent?.name) { + if (raw.length === 0) { + console.warn(c.yellow('No scripts found in package.json')); return process.exit(0); } - if (argv?.length > 2) { - scriptValue = argv.slice(2).join(' '); - } else { - const initialValue = storage.lastRunCommand; - - scriptValue = (await select({ - message: c.bgCyan(' Run script '), - options: raw.map((scriptItem) => ({ - label: `${c.green(scriptItem.key)}: ${c.dim(limitText(scriptItem.description, 50))}`, - value: scriptItem.key, - })), - initialValue, - })) as string; - - if (isCancel(scriptValue)) { - cancel('Run script cancelled'); - return process.exit(0); - } - } - - if (storage.lastRunCommand !== scriptValue) { - storage.lastRunCommand = scriptValue; - dump(); - } - - if (agent.name === 'bun') { - intro(c.bold(c.green(`bun run ${scriptValue}\n`))); - - await execaCommand(`bun run ${scriptValue}`, { stdio: 'inherit', cwd }); - } - - if (agent.name === 'pnpm') { - intro(c.bold(c.green(`pnpm ${scriptValue}\n`))); - await execaCommand(`pnpm ${scriptValue}`, { stdio: 'inherit', cwd }); + const storage = await load(); + const lastRunCommand = storage.lastRunCommands?.[cwd]; + + const scriptValue = await select({ + message: c.bgCyan(' Run script '), + options: raw.map((scriptItem) => ({ + label: `${c.green(scriptItem.key)}: ${c.dim(limitText(scriptItem.description, 50))}`, + value: scriptItem.key, + })), + initialValue: raw.some((i) => i.key === lastRunCommand) ? lastRunCommand : undefined, + }); + + if (isCancel(scriptValue)) { + cancel('Run script cancelled'); + return process.exit(0); } - if (agent.name === 'npm') { - intro(c.bold(c.green(`npm run ${scriptValue}\n`))); + args = [scriptValue as string]; - await execaCommand(`npm run ${scriptValue}`, { stdio: 'inherit', cwd }); + if (lastRunCommand !== scriptValue) { + storage.lastRunCommands = { ...storage.lastRunCommands, [cwd]: scriptValue as string }; + await dump(); } + } - if (agent.name === 'yarn') { - intro(c.bold(c.green(`yarn ${scriptValue}\n`))); + const agent = await resolveAgent(cwd); - await execaCommand(`yarn ${scriptValue}`, { stdio: 'inherit', cwd }); - } - } catch {} + await runCommand(getCommand(agent, 'run', args), cwd); }; -niCli(); +nrCli(); diff --git a/src/nu.ts b/src/nu.ts index 3f6773c..46a1ebd 100644 --- a/src/nu.ts +++ b/src/nu.ts @@ -1,55 +1,22 @@ -/* eslint-disable unicorn/no-process-exit */ import process from 'node:process'; -// @ts-ignore -import { detectAgent } from '@skarab/detect-package-manager'; -// @ts-ignore -import { execaCommand } from 'execa'; // @ts-ignore import c from 'kleur'; import { intro } from 'unprompts'; -export const nuCli = async (cwd: string = process.cwd(), argv = process.argv) => { - try { - const agent = await detectAgent(cwd); - let scriptValue: string = ''; - - if (!agent?.name) { - return process.exit(0); - } - - if (argv?.length > 2) { - scriptValue = argv.slice(2).join(' '); - } +import { getCommand, resolveAgent, runCommand } from './agents'; - if (!scriptValue) { - intro(c.bold(c.yellow('Please enter a package name\n'))); - return; - } - - if (agent.name === 'bun') { - intro(c.bold(c.green(`bun remove ${scriptValue}\n`))); - - await execaCommand(`bun remove ${scriptValue}`, { stdio: 'inherit', cwd }); - } - - if (agent.name === 'pnpm') { - intro(c.bold(c.green(`pnpm remove ${scriptValue}\n`))); - await execaCommand(`pnpm remove ${scriptValue}`, { stdio: 'inherit', cwd }); - } - - if (agent.name === 'npm') { - intro(c.bold(c.green(`npm uninstall ${scriptValue}\n`))); +export const nuCli = async (cwd: string = process.cwd(), argv = process.argv) => { + const args = argv.slice(2); - await execaCommand(`npm uninstall ${scriptValue}`, { stdio: 'inherit', cwd }); - } + if (args.length === 0) { + intro(c.bold(c.yellow('Please enter a package name\n'))); + return; + } - if (agent.name === 'yarn') { - intro(c.bold(c.green(`yarn remove ${scriptValue}\n`))); + const agent = await resolveAgent(cwd); - await execaCommand(`yarn remove ${scriptValue}`, { stdio: 'inherit', cwd }); - } - } catch {} + await runCommand(getCommand(agent, 'remove', args), cwd); }; nuCli(); diff --git a/src/storage.ts b/src/storage.ts index 1643603..eb176e0 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,88 +1,44 @@ -/* eslint-disable import/export */ -import type { Buffer } from 'node:buffer'; import { existsSync, promises as fs } from 'node:fs'; import os from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { join } from 'node:path'; import process from 'node:process'; const CLI_TEMP_DIR = join(os.tmpdir(), 'run-script-cli'); -const storagePath = resolve(CLI_TEMP_DIR, '_storage.json'); +const storagePath = join(CLI_TEMP_DIR, '_storage.json'); export interface Storage { - lastRunCommand?: string; + /** Last run script, keyed by project directory */ + lastRunCommands?: Record; } let storage: Storage | undefined; -interface TempFile { - path: string; - fd: fs.FileHandle; - cleanup: () => void; -} - -let counter = 0; - -async function openTemp(): Promise { - if (!existsSync(CLI_TEMP_DIR)) { - await fs.mkdir(CLI_TEMP_DIR, { recursive: true }); - } - - const competitivePath = join(CLI_TEMP_DIR, `.${process.pid}.${counter}`); - counter++; - - return fs - .open(competitivePath, 'wx') - .then((fd) => ({ - fd, - path: competitivePath, - cleanup() { - fd.close().then(() => { - if (existsSync(competitivePath)) { - fs.unlink(competitivePath); - } - }); - }, - })) - .catch((error: any) => { - return error && error.code === 'EEXIST' ? openTemp() : undefined; - }); -} - /** - * Write file safely avoiding conflicts + * Write file atomically: write to a temp file then rename it over the target */ -export async function writeFileSafe(path: string, data: string | Buffer = ''): Promise { - const temp = await openTemp(); +export async function writeFileSafe(path: string, data: string): Promise { + const temp = join(CLI_TEMP_DIR, `.${process.pid}.${Date.now()}.tmp`); - if (temp) { - fs.writeFile(temp.path, data) - .then(() => { - const directory = dirname(path); - if (!existsSync(directory)) { - fs.mkdir(directory, { recursive: true }); - } - - return fs - .rename(temp.path, path) - .then(() => true) - .catch(() => false); - }) - .catch(() => false) - .finally(temp.cleanup); + try { + await fs.mkdir(CLI_TEMP_DIR, { recursive: true }); + await fs.writeFile(temp, data); + await fs.rename(temp, path); + return true; + } catch { + await fs.unlink(temp).catch(() => {}); + return false; } - - return false; } -export async function load(fn?: (storage: Storage) => Promise | boolean) { +export async function load(): Promise { if (!storage) { - storage = existsSync(storagePath) - ? JSON.parse((await fs.readFile(storagePath, 'utf8')) || '{}') || {} - : {}; - } - - if (fn && (await fn(storage!))) { - await dump(); + try { + storage = existsSync(storagePath) + ? JSON.parse((await fs.readFile(storagePath, 'utf8')) || '{}') || {} + : {}; + } catch { + storage = {}; + } } return storage!; From 5076bff5167b67e8fa47c4e4830e1631b4b428da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:41:21 +0000 Subject: [PATCH 2/3] feat: add nx, nci, script search and workspaces --- .github/workflows/main.yml | 8 +- README.md | 47 +++++++++++- bin/nci.mjs | 3 + bin/nx.mjs | 3 + build.config.ts | 2 +- package.json | 12 ++- pnpm-lock.yaml | 80 +++++--------------- src/agents.ts | 56 +++++++++++--- src/index.ts | 4 + src/nci.ts | 11 +++ src/nr.ts | 89 ++++++++++++++++++---- src/nu.ts | 3 +- src/nx.ts | 20 +++++ src/workspaces.ts | 146 +++++++++++++++++++++++++++++++++++++ test/agents.test.ts | 97 ++++++++++++++++++++++++ test/utils.ts | 18 +++++ test/workspaces.test.ts | 57 +++++++++++++++ tsconfig.json | 10 +-- 18 files changed, 556 insertions(+), 110 deletions(-) create mode 100755 bin/nci.mjs create mode 100755 bin/nx.mjs create mode 100644 src/index.ts create mode 100644 src/nci.ts create mode 100644 src/nx.ts create mode 100644 src/workspaces.ts create mode 100644 test/agents.test.ts create mode 100644 test/utils.ts create mode 100644 test/workspaces.test.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 509bc4a..4d0d3e6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,11 +2,11 @@ name: CI on: push: branches: - - master + - main pull_request: branches: - - master + - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -22,4 +22,6 @@ jobs: node-version: 18.x - run: npm install - - run: npm run lint && npm run build + - run: npm run lint && npm run lint-es + - run: npm test + - run: npm run build diff --git a/README.md b/README.md index 44bb83b..d1ebcd3 100644 --- a/README.md +++ b/README.md @@ -30,33 +30,72 @@ The package manager is detected from the `packageManager` field in `package.json - Run ```bash -# Run script with select +# Select a script to run (type to filter by name or description) nr -# Run script a specific script +# Run a specific script nr dev # Pass extra args to the script (`--` is added automatically for npm) nr dev --port 3000 + +# Rerun the last script of this project +nr - + +# Monorepo: select a workspace package, then a script +nr -w +nr -w build ``` - Install ```bash -# Install script +# Install dependencies ni # Install a package ni react +ni -D vitest +``` + +- Clean install (for CI) + +```bash +# npm ci / pnpm install --frozen-lockfile / ... +nci ``` - Uninstall ```bash -# Uninstall script nu react ``` +- Execute a package without installing it + +```bash +# npx / pnpm dlx / bunx / upx +nx cowsay hello +``` + +| Command | npm | yarn | pnpm | bun | upm | +| ----------- | --------------------- | -------------------------------- | -------------------------------- | ------------------------------- | ------------------------------- | +| `nr dev` | `npm run dev` | `yarn run dev` | `pnpm run dev` | `bun run dev` | `upm run dev` | +| `ni` | `npm install` | `yarn install` | `pnpm install` | `bun install` | `upm install` | +| `ni react` | `npm install react` | `yarn add react` | `pnpm add react` | `bun add react` | `upm add react` | +| `nci` | `npm ci` | `yarn install --frozen-lockfile` | `pnpm install --frozen-lockfile` | `bun install --frozen-lockfile` | `upm install --frozen-lockfile` | +| `nu react` | `npm uninstall react` | `yarn remove react` | `pnpm remove react` | `bun remove react` | `upm remove react` | +| `nx cowsay` | `npx cowsay` | `npx cowsay` | `pnpm dlx cowsay` | `bunx cowsay` | `upx cowsay` | + +## 🧩 API + +```ts +import { detectAgent, getCommand } from 'run-script-cli'; + +const agent = detectAgent(process.cwd()); // 'npm' | 'yarn' | 'pnpm' | 'bun' | 'upm' | undefined +getCommand('pnpm', 'add', ['react']); // ['pnpm', 'add', 'react'] +``` + ## Preview

diff --git a/bin/nci.mjs b/bin/nci.mjs new file mode 100755 index 0000000..b484b6a --- /dev/null +++ b/bin/nci.mjs @@ -0,0 +1,3 @@ +#!/usr/bin/env node +'use strict'; +import('../dist/nci.mjs'); diff --git a/bin/nx.mjs b/bin/nx.mjs new file mode 100755 index 0000000..9b86543 --- /dev/null +++ b/bin/nx.mjs @@ -0,0 +1,3 @@ +#!/usr/bin/env node +'use strict'; +import('../dist/nx.mjs'); diff --git a/build.config.ts b/build.config.ts index fb89880..5cf776a 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,7 +1,7 @@ import { defineBuildConfig } from 'unbuild'; export default defineBuildConfig({ - entries: ['src/nr', 'src/ni', 'src/nu'], + entries: ['src/index', 'src/nr', 'src/ni', 'src/nu', 'src/nx', 'src/nci'], clean: true, declaration: true, diff --git a/package.json b/package.json index 74a0e4a..ffcae24 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "bin": { "nr": "./bin/nr.mjs", "ni": "./bin/ni.mjs", - "nu": "./bin/nu.mjs" + "nu": "./bin/nu.mjs", + "nx": "./bin/nx.mjs", + "nci": "./bin/nci.mjs" }, "files": [ "bin", @@ -33,7 +35,7 @@ "lint": "tsc --noEmit", "lint-es": "eslint --ext .ts,.tsx src --color", "start": "esno src/nr.ts", - "test": "vitest", + "test": "vitest run", "verify-commit": "verify-commit-msg", "prepare": "git-scm-hooks", "changelog": "npx changeloggithub@latest --all true && git add CHANGELOG.md && git commit -m 'chore: update CHANGELOG.md'", @@ -41,6 +43,7 @@ }, "author": "hunghg255", "devDependencies": { + "@clack/prompts": "1.0.0", "@hunghg255/eslint-config-ts": "^0.0.8", "@types/node": "^18.19.3", "bumpp": "^9.2.1", @@ -52,7 +55,6 @@ "tslib": "^2.6.2", "typescript": "^5.3.3", "unbuild": "^2.0.0", - "unprompts": "^1.0.11", "verify-commit-msg": "^0.0.10", "vitest": "^0.34.6" }, @@ -71,7 +73,9 @@ "cli", "nr", "ni", - "nu" + "nu", + "nx", + "nci" ], "homepage": "https://github.com/hunghg255/run-script-cli", "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5453cd4..009a435 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false devDependencies: + '@clack/prompts': + specifier: 1.0.0 + version: 1.0.0 '@hunghg255/eslint-config-ts': specifier: ^0.0.8 version: 0.0.8(eslint@8.56.0)(typescript@5.3.3) @@ -38,9 +41,6 @@ devDependencies: unbuild: specifier: ^2.0.0 version: 2.0.0(typescript@5.3.3) - unprompts: - specifier: ^1.0.11 - version: 1.0.11 verify-commit-msg: specifier: ^0.0.10 version: 0.0.10 @@ -260,6 +260,21 @@ packages: to-fast-properties: 2.0.0 dev: true + /@clack/core@1.0.0: + resolution: {integrity: sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==} + dependencies: + picocolors: 1.0.0 + sisteransi: 1.0.5 + dev: true + + /@clack/prompts@1.0.0: + resolution: {integrity: sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==} + dependencies: + '@clack/core': 1.0.0 + picocolors: 1.0.0 + sisteransi: 1.0.5 + dev: true + /@esbuild/aix-ppc64@0.19.11: resolution: {integrity: sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==} engines: {node: '>=12'} @@ -1486,11 +1501,6 @@ packages: engines: {node: '>=8'} dev: true - /ansi-regex@6.0.1: - resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} - engines: {node: '>=12'} - dev: true - /ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -1511,11 +1521,6 @@ packages: engines: {node: '>=10'} dev: true - /ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} - engines: {node: '>=12'} - dev: true - /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2120,18 +2125,10 @@ packages: engines: {node: '>=12'} dev: true - /eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - dev: true - /electron-to-chromium@1.4.634: resolution: {integrity: sha512-gQNahJfF5AE4MZo+pMSwmnwkzVZ+F4ZGGj4Z/MMddOXVQM0y9OHy6ts3W9SDzAJaiZM3p6eixn5ABCQ+AfXzcQ==} dev: true - /emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - dev: true - /enhanced-resolve@5.15.0: resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} engines: {node: '>=10.13.0'} @@ -3235,11 +3232,6 @@ packages: which-typed-array: 1.1.13 dev: true - /is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - dev: true - /is-weakref@1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} dependencies: @@ -4514,15 +4506,6 @@ packages: engines: {node: '>=0.6.19'} dev: true - /string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.0 - dev: true - /string.prototype.trim@1.2.8: resolution: {integrity: sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==} engines: {node: '>= 0.4'} @@ -4555,13 +4538,6 @@ packages: ansi-regex: 5.0.1 dev: true - /strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} - engines: {node: '>=12'} - dependencies: - ansi-regex: 6.0.1 - dev: true - /strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4881,17 +4857,6 @@ packages: engines: {node: '>= 10.0.0'} dev: true - /unprompts@1.0.11: - resolution: {integrity: sha512-zkpas4ea/JfgyDijhpyQ2UyvwmU7xkxGt+VNfYhZOzFBSppsmmp2L9DO0WHVLXtuXOiAR09CzFv9XH9qkRRRJw==} - dependencies: - is-unicode-supported: 1.3.0 - unbuild: 1.2.1 - wrap-ansi: 8.1.0 - transitivePeerDependencies: - - sass - - supports-color - dev: true - /untyped@1.4.0: resolution: {integrity: sha512-Egkr/s4zcMTEuulcIb7dgURS6QpN7DyqQYdf+jBtiaJvQ+eRsrtWUoX84SbvQWuLkXsOjM+8sJC9u6KoMK/U7Q==} hasBin: true @@ -5101,15 +5066,6 @@ packages: stackback: 0.0.2 dev: true - /wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - dependencies: - ansi-styles: 6.2.1 - string-width: 5.1.2 - strip-ansi: 7.1.0 - dev: true - /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true diff --git a/src/agents.ts b/src/agents.ts index 27e39ef..ce73a14 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -3,17 +3,15 @@ import fs from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import process from 'node:process'; -// @ts-ignore +import { cancel, intro, isCancel, select } from '@clack/prompts'; import { execa } from 'execa'; -// @ts-ignore import c from 'kleur'; -import { cancel, intro, isCancel, select } from 'unprompts'; export const AGENTS = ['npm', 'yarn', 'pnpm', 'bun', 'upm'] as const; export type Agent = (typeof AGENTS)[number]; -type Command = 'run' | 'install' | 'add' | 'remove'; +export type Command = 'run' | 'install' | 'frozen' | 'add' | 'remove' | 'execute'; // Lockfiles checked in order, the first match wins. const LOCKS: [string, Agent][] = [ @@ -26,12 +24,48 @@ const LOCKS: [string, Agent][] = [ ['npm-shrinkwrap.json', 'npm'], ]; +// First item is the binary, the rest are its arguments const COMMANDS: Record> = { - npm: { run: ['run'], install: ['install'], add: ['install'], remove: ['uninstall'] }, - yarn: { run: ['run'], install: ['install'], add: ['add'], remove: ['remove'] }, - pnpm: { run: ['run'], install: ['install'], add: ['add'], remove: ['remove'] }, - bun: { run: ['run'], install: ['install'], add: ['add'], remove: ['remove'] }, - upm: { run: ['run'], install: ['install'], add: ['add'], remove: ['remove'] }, + npm: { + run: ['npm', 'run'], + install: ['npm', 'install'], + frozen: ['npm', 'ci'], + add: ['npm', 'install'], + remove: ['npm', 'uninstall'], + execute: ['npx'], + }, + yarn: { + run: ['yarn', 'run'], + install: ['yarn', 'install'], + frozen: ['yarn', 'install', '--frozen-lockfile'], + add: ['yarn', 'add'], + remove: ['yarn', 'remove'], + execute: ['npx'], + }, + pnpm: { + run: ['pnpm', 'run'], + install: ['pnpm', 'install'], + frozen: ['pnpm', 'install', '--frozen-lockfile'], + add: ['pnpm', 'add'], + remove: ['pnpm', 'remove'], + execute: ['pnpm', 'dlx'], + }, + bun: { + run: ['bun', 'run'], + install: ['bun', 'install'], + frozen: ['bun', 'install', '--frozen-lockfile'], + add: ['bun', 'add'], + remove: ['bun', 'remove'], + execute: ['bunx'], + }, + upm: { + run: ['upm', 'run'], + install: ['upm', 'install'], + frozen: ['upm', 'install', '--frozen-lockfile'], + add: ['upm', 'add'], + remove: ['upm', 'remove'], + execute: ['upx'], + }, }; function isAgent(name: unknown): name is Agent { @@ -111,10 +145,10 @@ export function getCommand(agent: Agent, command: Command, args: string[] = []): // npm treats `--flags` after the script name as its own options unless separated by `--` if (command === 'run' && agent === 'npm' && args.length > 1) { - return [agent, ...base, ...args.slice(0, 1), '--', ...args.slice(1)]; + return [...base, ...args.slice(0, 1), '--', ...args.slice(1)]; } - return [agent, ...base, ...args]; + return [...base, ...args]; } export async function runCommand(cmd: string[], cwd: string = process.cwd()) { diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..81e28ca --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +export { AGENTS, detectAgent, getCommand } from './agents'; +export type { Agent, Command } from './agents'; +export { findWorkspaceRoot, getWorkspacePackages } from './workspaces'; +export type { WorkspacePackage } from './workspaces'; diff --git a/src/nci.ts b/src/nci.ts new file mode 100644 index 0000000..419ddd7 --- /dev/null +++ b/src/nci.ts @@ -0,0 +1,11 @@ +import process from 'node:process'; + +import { getCommand, resolveAgent, runCommand } from './agents'; + +export const nciCli = async (cwd: string = process.cwd(), argv = process.argv) => { + const agent = await resolveAgent(cwd); + + await runCommand(getCommand(agent, 'frozen', argv.slice(2)), cwd); +}; + +nciCli(); diff --git a/src/nr.ts b/src/nr.ts index 8759281..f517a85 100644 --- a/src/nr.ts +++ b/src/nr.ts @@ -1,26 +1,82 @@ /* eslint-disable unicorn/no-process-exit */ import process from 'node:process'; -// @ts-ignore +import { autocomplete, cancel, isCancel } from '@clack/prompts'; import c from 'kleur'; -import { cancel, isCancel, select } from 'unprompts'; import { getCommand, resolveAgent, runCommand } from './agents'; import { getPackageJSON } from './fs'; import { dump, load } from './storage'; import { limitText } from './utils'; +import { findWorkspaceRoot, getWorkspacePackages } from './workspaces'; + +function includes(text: string, search: string) { + return text.toLowerCase().includes(search.toLowerCase()); +} + +async function selectWorkspacePackage(cwd: string): Promise { + const root = findWorkspaceRoot(cwd); + + if (!root) { + console.warn(c.yellow('No workspace found, using current directory')); + return cwd; + } + + const packages = getWorkspacePackages(root); + const byDir = new Map(packages.map((p) => [p.dir, p])); + + const dir = await autocomplete({ + message: c.bgCyan(' Select package '), + options: packages.map((p) => ({ + label: `${c.green(p.name)} ${c.dim(p.path)}`, + value: p.dir, + })), + initialValue: byDir.has(cwd) ? cwd : undefined, + filter: (search, option) => { + const p = byDir.get(option.value)!; + return includes(p.name, search) || includes(p.path, search); + }, + }); + + if (isCancel(dir)) { + cancel('Select package cancelled'); + return process.exit(0); + } + + return dir; +} export const nrCli = async (cwd: string = process.cwd(), argv = process.argv) => { - const pkg = getPackageJSON(cwd); + let args = argv.slice(2); + let dir = cwd; + + if (args[0] === '-w' || args[0] === '--workspace') { + args = args.slice(1); + dir = await selectWorkspacePackage(cwd); + } + + const pkg = getPackageJSON(dir); if (!pkg) { - console.error(c.red('No package.json found in current directory')); + console.error(c.red(`No package.json found in ${dir}`)); return process.exit(1); } const scripts: Record = pkg.scripts || {}; const scriptsInfo: Record = pkg['scripts-info'] || {}; - let args = argv.slice(2); + + const storage = await load(); + const lastRunCommand = storage.lastRunCommands?.[dir]; + + // `nr -` reruns the last script of this project + if (args[0] === '-') { + if (lastRunCommand) { + args = [lastRunCommand, ...args.slice(1)]; + } else { + console.warn(c.yellow('No script has been run in this project yet')); + args = []; + } + } if (args.length === 0) { const raw = Object.entries(scripts) @@ -35,16 +91,17 @@ export const nrCli = async (cwd: string = process.cwd(), argv = process.argv) => return process.exit(0); } - const storage = await load(); - const lastRunCommand = storage.lastRunCommands?.[cwd]; + const descriptions = new Map(raw.map((i) => [i.key, i.description])); - const scriptValue = await select({ + const scriptValue = await autocomplete({ message: c.bgCyan(' Run script '), options: raw.map((scriptItem) => ({ label: `${c.green(scriptItem.key)}: ${c.dim(limitText(scriptItem.description, 50))}`, value: scriptItem.key, })), - initialValue: raw.some((i) => i.key === lastRunCommand) ? lastRunCommand : undefined, + initialValue: descriptions.has(lastRunCommand!) ? lastRunCommand : undefined, + filter: (search, option) => + includes(option.value, search) || includes(descriptions.get(option.value)!, search), }); if (isCancel(scriptValue)) { @@ -52,17 +109,17 @@ export const nrCli = async (cwd: string = process.cwd(), argv = process.argv) => return process.exit(0); } - args = [scriptValue as string]; + args = [scriptValue]; + } - if (lastRunCommand !== scriptValue) { - storage.lastRunCommands = { ...storage.lastRunCommands, [cwd]: scriptValue as string }; - await dump(); - } + if (lastRunCommand !== args[0]) { + storage.lastRunCommands = { ...storage.lastRunCommands, [dir]: args[0]! }; + await dump(); } - const agent = await resolveAgent(cwd); + const agent = await resolveAgent(dir); - await runCommand(getCommand(agent, 'run', args), cwd); + await runCommand(getCommand(agent, 'run', args), dir); }; nrCli(); diff --git a/src/nu.ts b/src/nu.ts index 46a1ebd..72a69e8 100644 --- a/src/nu.ts +++ b/src/nu.ts @@ -1,8 +1,7 @@ import process from 'node:process'; -// @ts-ignore +import { intro } from '@clack/prompts'; import c from 'kleur'; -import { intro } from 'unprompts'; import { getCommand, resolveAgent, runCommand } from './agents'; diff --git a/src/nx.ts b/src/nx.ts new file mode 100644 index 0000000..ac08ba1 --- /dev/null +++ b/src/nx.ts @@ -0,0 +1,20 @@ +import process from 'node:process'; + +import c from 'kleur'; + +import { getCommand, resolveAgent, runCommand } from './agents'; + +export const nxCli = async (cwd: string = process.cwd(), argv = process.argv) => { + const args = argv.slice(2); + + if (args.length === 0) { + console.warn(c.yellow('Please enter a package name, e.g. nx cowsay hello')); + return; + } + + const agent = await resolveAgent(cwd); + + await runCommand(getCommand(agent, 'execute', args), cwd); +}; + +nxCli(); diff --git a/src/workspaces.ts b/src/workspaces.ts new file mode 100644 index 0000000..26f357a --- /dev/null +++ b/src/workspaces.ts @@ -0,0 +1,146 @@ +import fs from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import process from 'node:process'; + +export interface WorkspacePackage { + name: string; + dir: string; + /** Path relative to the workspace root, `.` for the root itself */ + path: string; +} + +function readJSON(path: string): any { + try { + return JSON.parse(fs.readFileSync(path, 'utf8')); + } catch {} +} + +/** + * Read `packages:` globs from pnpm-workspace.yaml, supports the list syntax only + */ +function readPnpmWorkspace(dir: string): string[] | undefined { + const path = join(dir, 'pnpm-workspace.yaml'); + if (!fs.existsSync(path)) { + return; + } + + const patterns: string[] = []; + let inPackages = false; + + for (const line of fs.readFileSync(path, 'utf8').split(/\r?\n/)) { + if (/^packages\s*:/.test(line)) { + inPackages = true; + continue; + } + if (!inPackages || /^\s*(#.*)?$/.test(line)) { + continue; + } + + const match = line.match(/^\s+-\s*["']?([^"#']+?)["']?\s*(#.*)?$/); + if (!match) { + break; + } + patterns.push(match[1]!); + } + + return patterns; +} + +function readWorkspacePatterns(dir: string): string[] | undefined { + const fromPnpm = readPnpmWorkspace(dir); + if (fromPnpm) { + return fromPnpm; + } + + const workspaces = readJSON(join(dir, 'package.json'))?.workspaces; + if (Array.isArray(workspaces)) { + return workspaces; + } + if (Array.isArray(workspaces?.packages)) { + return workspaces.packages; + } +} + +/** + * Find the closest workspace root, walking up from `cwd` + */ +export function findWorkspaceRoot(cwd: string = process.cwd()): string | undefined { + let dir = resolve(cwd); + + while (true) { + if (readWorkspacePatterns(dir)) { + return dir; + } + + const parent = dirname(dir); + if (parent === dir) { + return; + } + dir = parent; + } +} + +function listDirs(dir: string, recursive: boolean): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + + const dirs: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name.startsWith('.')) { + continue; + } + const full = join(dir, entry.name); + dirs.push(full); + if (recursive) { + dirs.push(...listDirs(full, true)); + } + } + return dirs; +} + +/** + * Expand a workspace pattern. Supports `dir`, `dir/*` and `dir/**` + */ +function expandPattern(root: string, pattern: string): string[] { + const clean = pattern.replace(/^\.\//, '').replace(/\/$/, ''); + + if (clean.endsWith('/**')) { + return listDirs(join(root, clean.slice(0, -3)), true); + } + if (clean.endsWith('/*')) { + return listDirs(join(root, clean.slice(0, -2)), false); + } + return [join(root, clean)]; +} + +/** + * List all packages of the workspace at `root`, the root package first + */ +export function getWorkspacePackages(root: string): WorkspacePackage[] { + const patterns = readWorkspacePatterns(root) || []; + const include = patterns.filter((p) => !p.startsWith('!')); + const exclude = new Set( + patterns.filter((p) => p.startsWith('!')).flatMap((p) => expandPattern(root, p.slice(1))), + ); + + const dirs = new Set([root, ...include.flatMap((p) => expandPattern(root, p))]); + const packages: WorkspacePackage[] = []; + + for (const dir of dirs) { + if (exclude.has(dir)) { + continue; + } + const pkg = readJSON(join(dir, 'package.json')); + if (!pkg) { + continue; + } + const path = relative(root, dir) || '.'; + packages.push({ name: pkg.name || path, dir, path }); + } + + return packages; +} diff --git a/test/agents.test.ts b/test/agents.test.ts new file mode 100644 index 0000000..83ed65e --- /dev/null +++ b/test/agents.test.ts @@ -0,0 +1,97 @@ +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { detectAgent, getCommand } from '../src/agents'; +import { createFixture } from './utils'; + +describe('detectAgent', () => { + it.each([ + ['upm.lock', 'upm'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['package-lock.json', 'npm'], + ['npm-shrinkwrap.json', 'npm'], + ])('detects %s as %s', (lock, agent) => { + const root = createFixture({ 'package.json': {}, [lock]: '' }); + expect(detectAgent(root)).toBe(agent); + }); + + it('prefers the packageManager field over lockfiles', () => { + const root = createFixture({ + 'package.json': { packageManager: 'pnpm@9.0.0+sha512.abc' }, + 'yarn.lock': '', + }); + expect(detectAgent(root)).toBe('pnpm'); + }); + + it('ignores unknown packageManager values', () => { + const root = createFixture({ + 'package.json': { packageManager: 'foo@1.0.0' }, + 'upm.lock': '', + }); + expect(detectAgent(root)).toBe('upm'); + }); + + it('walks up to find the lockfile', () => { + const root = createFixture({ + 'package.json': {}, + 'pnpm-lock.yaml': '', + 'packages/a/package.json': {}, + }); + expect(detectAgent(join(root, 'packages/a'))).toBe('pnpm'); + }); + + it('returns undefined when nothing is found', () => { + const root = createFixture({ 'package.json': {} }); + expect(detectAgent(root)).toBeUndefined(); + }); +}); + +describe('getCommand', () => { + it('builds run commands', () => { + expect(getCommand('upm', 'run', ['dev'])).toEqual(['upm', 'run', 'dev']); + expect(getCommand('pnpm', 'run', ['dev', '--port', '3000'])).toEqual([ + 'pnpm', + 'run', + 'dev', + '--port', + '3000', + ]); + }); + + it('adds `--` for npm run with extra args', () => { + expect(getCommand('npm', 'run', ['dev'])).toEqual(['npm', 'run', 'dev']); + expect(getCommand('npm', 'run', ['dev', '--port', '3000'])).toEqual([ + 'npm', + 'run', + 'dev', + '--', + '--port', + '3000', + ]); + }); + + it('builds install / add / remove commands', () => { + expect(getCommand('upm', 'install')).toEqual(['upm', 'install']); + expect(getCommand('npm', 'add', ['react'])).toEqual(['npm', 'install', 'react']); + expect(getCommand('yarn', 'add', ['-D', 'vitest'])).toEqual(['yarn', 'add', '-D', 'vitest']); + expect(getCommand('npm', 'remove', ['react'])).toEqual(['npm', 'uninstall', 'react']); + expect(getCommand('upm', 'remove', ['react'])).toEqual(['upm', 'remove', 'react']); + }); + + it('builds frozen install commands', () => { + expect(getCommand('npm', 'frozen')).toEqual(['npm', 'ci']); + expect(getCommand('pnpm', 'frozen')).toEqual(['pnpm', 'install', '--frozen-lockfile']); + expect(getCommand('upm', 'frozen')).toEqual(['upm', 'install', '--frozen-lockfile']); + }); + + it('builds execute commands', () => { + expect(getCommand('npm', 'execute', ['cowsay'])).toEqual(['npx', 'cowsay']); + expect(getCommand('pnpm', 'execute', ['cowsay'])).toEqual(['pnpm', 'dlx', 'cowsay']); + expect(getCommand('bun', 'execute', ['cowsay'])).toEqual(['bunx', 'cowsay']); + expect(getCommand('upm', 'execute', ['cowsay'])).toEqual(['upx', 'cowsay']); + }); +}); diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..e4592e0 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import { dirname, join } from 'node:path'; + +/** + * Create a temp directory with the given files, a string is written as-is, objects as JSON + */ +export function createFixture(files: Record): string { + const root = fs.realpathSync(fs.mkdtempSync(join(os.tmpdir(), 'run-script-cli-'))); + + for (const [path, content] of Object.entries(files)) { + const full = join(root, path); + fs.mkdirSync(dirname(full), { recursive: true }); + fs.writeFileSync(full, typeof content === 'string' ? content : JSON.stringify(content)); + } + + return root; +} diff --git a/test/workspaces.test.ts b/test/workspaces.test.ts new file mode 100644 index 0000000..9f11825 --- /dev/null +++ b/test/workspaces.test.ts @@ -0,0 +1,57 @@ +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { findWorkspaceRoot, getWorkspacePackages } from '../src/workspaces'; +import { createFixture } from './utils'; + +describe('workspaces', () => { + it('reads pnpm-workspace.yaml', () => { + const root = createFixture({ + 'package.json': { name: 'root' }, + 'pnpm-workspace.yaml': + "packages:\n - 'packages/*'\n - \"apps/web\" # comment\n - '!packages/ignored'\n", + 'packages/a/package.json': { name: '@x/a' }, + 'packages/b/package.json': { name: '@x/b' }, + 'packages/ignored/package.json': { name: 'ignored' }, + 'packages/no-pkg/readme.md': '', + 'apps/web/package.json': { name: 'web' }, + 'apps/docs/package.json': { name: 'docs' }, + }); + + expect(findWorkspaceRoot(join(root, 'packages/a'))).toBe(root); + expect(getWorkspacePackages(root).map((p) => [p.name, p.path])).toEqual([ + ['root', '.'], + ['@x/a', 'packages/a'], + ['@x/b', 'packages/b'], + ['web', 'apps/web'], + ]); + }); + + it('reads package.json workspaces (array and object form)', () => { + for (const workspaces of [['libs/**'], { packages: ['libs/**'] }]) { + const root = createFixture({ + 'package.json': { name: 'root', workspaces }, + 'libs/a/package.json': { name: 'a' }, + 'libs/group/b/package.json': { name: 'b' }, + 'libs/a/node_modules/dep/package.json': { name: 'dep' }, + }); + + expect(getWorkspacePackages(root).map((p) => p.name)).toEqual(['root', 'a', 'b']); + } + }); + + it('falls back to the path when a package has no name', () => { + const root = createFixture({ + 'package.json': { workspaces: ['pkg'] }, + 'pkg/package.json': {}, + }); + + expect(getWorkspacePackages(root).map((p) => p.name)).toEqual(['.', 'pkg']); + }); + + it('returns undefined outside a workspace', () => { + const root = createFixture({ 'package.json': {} }); + expect(findWorkspaceRoot(root)).toBeUndefined(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 312a60f..8749b7c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,18 +9,14 @@ "verbatimModuleSyntax": false, "allowJs": true, "moduleDetection": "force", - "moduleResolution": "NodeNext", + "moduleResolution": "Bundler", /* Strictness */ "strict": true, "noUncheckedIndexedAccess": true, - /* If transpiling with TypeScript: */ - "module": "NodeNext", - - /* If NOT transpiling with TypeScript: */ - // "moduleResolution": "Bundler", - // "module": "ESNext", + /* Bundled with unbuild, not transpiled with TypeScript: */ + "module": "ESNext", "noEmit": true, /* If your code runs in the DOM: */ From 6e59a732183a6c1d8b5aa0ab5f8562bb46ea6b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:41:43 +0000 Subject: [PATCH 3/3] refactor: rename nx to nlx to avoid Nx conflict --- README.md | 18 +++++++++--------- bin/{nx.mjs => nlx.mjs} | 2 +- build.config.ts | 2 +- package.json | 4 ++-- src/{nx.ts => nlx.ts} | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) rename bin/{nx.mjs => nlx.mjs} (55%) rename src/{nx.ts => nlx.ts} (63%) diff --git a/README.md b/README.md index d1ebcd3..1129e2f 100644 --- a/README.md +++ b/README.md @@ -75,17 +75,17 @@ nu react ```bash # npx / pnpm dlx / bunx / upx -nx cowsay hello +nlx cowsay hello ``` -| Command | npm | yarn | pnpm | bun | upm | -| ----------- | --------------------- | -------------------------------- | -------------------------------- | ------------------------------- | ------------------------------- | -| `nr dev` | `npm run dev` | `yarn run dev` | `pnpm run dev` | `bun run dev` | `upm run dev` | -| `ni` | `npm install` | `yarn install` | `pnpm install` | `bun install` | `upm install` | -| `ni react` | `npm install react` | `yarn add react` | `pnpm add react` | `bun add react` | `upm add react` | -| `nci` | `npm ci` | `yarn install --frozen-lockfile` | `pnpm install --frozen-lockfile` | `bun install --frozen-lockfile` | `upm install --frozen-lockfile` | -| `nu react` | `npm uninstall react` | `yarn remove react` | `pnpm remove react` | `bun remove react` | `upm remove react` | -| `nx cowsay` | `npx cowsay` | `npx cowsay` | `pnpm dlx cowsay` | `bunx cowsay` | `upx cowsay` | +| Command | npm | yarn | pnpm | bun | upm | +| ------------ | --------------------- | -------------------------------- | -------------------------------- | ------------------------------- | ------------------------------- | +| `nr dev` | `npm run dev` | `yarn run dev` | `pnpm run dev` | `bun run dev` | `upm run dev` | +| `ni` | `npm install` | `yarn install` | `pnpm install` | `bun install` | `upm install` | +| `ni react` | `npm install react` | `yarn add react` | `pnpm add react` | `bun add react` | `upm add react` | +| `nci` | `npm ci` | `yarn install --frozen-lockfile` | `pnpm install --frozen-lockfile` | `bun install --frozen-lockfile` | `upm install --frozen-lockfile` | +| `nu react` | `npm uninstall react` | `yarn remove react` | `pnpm remove react` | `bun remove react` | `upm remove react` | +| `nlx cowsay` | `npx cowsay` | `npx cowsay` | `pnpm dlx cowsay` | `bunx cowsay` | `upx cowsay` | ## 🧩 API diff --git a/bin/nx.mjs b/bin/nlx.mjs similarity index 55% rename from bin/nx.mjs rename to bin/nlx.mjs index 9b86543..14ca567 100755 --- a/bin/nx.mjs +++ b/bin/nlx.mjs @@ -1,3 +1,3 @@ #!/usr/bin/env node 'use strict'; -import('../dist/nx.mjs'); +import('../dist/nlx.mjs'); diff --git a/build.config.ts b/build.config.ts index 5cf776a..5b0dfb1 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,7 +1,7 @@ import { defineBuildConfig } from 'unbuild'; export default defineBuildConfig({ - entries: ['src/index', 'src/nr', 'src/ni', 'src/nu', 'src/nx', 'src/nci'], + entries: ['src/index', 'src/nr', 'src/ni', 'src/nu', 'src/nlx', 'src/nci'], clean: true, declaration: true, diff --git a/package.json b/package.json index ffcae24..90972bb 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "nr": "./bin/nr.mjs", "ni": "./bin/ni.mjs", "nu": "./bin/nu.mjs", - "nx": "./bin/nx.mjs", + "nlx": "./bin/nlx.mjs", "nci": "./bin/nci.mjs" }, "files": [ @@ -74,7 +74,7 @@ "nr", "ni", "nu", - "nx", + "nlx", "nci" ], "homepage": "https://github.com/hunghg255/run-script-cli", diff --git a/src/nx.ts b/src/nlx.ts similarity index 63% rename from src/nx.ts rename to src/nlx.ts index ac08ba1..fba91b4 100644 --- a/src/nx.ts +++ b/src/nlx.ts @@ -4,11 +4,11 @@ import c from 'kleur'; import { getCommand, resolveAgent, runCommand } from './agents'; -export const nxCli = async (cwd: string = process.cwd(), argv = process.argv) => { +export const nlxCli = async (cwd: string = process.cwd(), argv = process.argv) => { const args = argv.slice(2); if (args.length === 0) { - console.warn(c.yellow('Please enter a package name, e.g. nx cowsay hello')); + console.warn(c.yellow('Please enter a package name, e.g. nlx cowsay hello')); return; } @@ -17,4 +17,4 @@ export const nxCli = async (cwd: string = process.cwd(), argv = process.argv) => await runCommand(getCommand(agent, 'execute', args), cwd); }; -nxCli(); +nlxCli();