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 582af0d..1129e2f 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,35 +21,81 @@
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
```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
+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` |
+| `nlx 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/nlx.mjs b/bin/nlx.mjs
new file mode 100755
index 0000000..14ca567
--- /dev/null
+++ b/bin/nlx.mjs
@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+'use strict';
+import('../dist/nlx.mjs');
diff --git a/build.config.ts b/build.config.ts
index fb89880..5b0dfb1 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/nlx', 'src/nci'],
clean: true,
declaration: true,
diff --git a/package.json b/package.json
index 3fba44b..90972bb 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",
+ "nlx": "./bin/nlx.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,8 +43,8 @@
},
"author": "hunghg255",
"devDependencies": {
+ "@clack/prompts": "1.0.0",
"@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",
@@ -53,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"
},
@@ -65,12 +66,16 @@
"npm",
"yarn",
"pnpm",
+ "bun",
+ "upm",
"run",
"script",
"cli",
"nr",
"ni",
- "nu"
+ "nu",
+ "nlx",
+ "nci"
],
"homepage": "https://github.com/hunghg255/run-script-cli",
"repository": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 529b3da..009a435 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5,12 +5,12 @@ 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)
- '@skarab/detect-package-manager':
- specifier: ^1.0.0
- version: 1.0.0
'@types/node':
specifier: ^18.19.3
version: 18.19.8
@@ -41,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
@@ -263,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'}
@@ -906,6 +918,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 +934,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 +1248,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 +1427,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:
@@ -1491,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'}
@@ -1516,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'}
@@ -2125,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'}
@@ -2604,6 +2596,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 +2912,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
@@ -3238,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:
@@ -4284,6 +4273,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
@@ -4516,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'}
@@ -4557,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'}
@@ -4883,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
@@ -5103,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
new file mode 100644
index 0000000..ce73a14
--- /dev/null
+++ b/src/agents.ts
@@ -0,0 +1,167 @@
+/* eslint-disable unicorn/no-process-exit */
+import fs from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import process from 'node:process';
+
+import { cancel, intro, isCancel, select } from '@clack/prompts';
+import { execa } from 'execa';
+import c from 'kleur';
+
+export const AGENTS = ['npm', 'yarn', 'pnpm', 'bun', 'upm'] as const;
+
+export type Agent = (typeof AGENTS)[number];
+
+export type Command = 'run' | 'install' | 'frozen' | 'add' | 'remove' | 'execute';
+
+// 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'],
+];
+
+// First item is the binary, the rest are its arguments
+const COMMANDS: Record> = {
+ 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 {
+ 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 [...base, ...args.slice(0, 1), '--', ...args.slice(1)];
+ }
+
+ return [...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/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/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/nlx.ts b/src/nlx.ts
new file mode 100644
index 0000000..fba91b4
--- /dev/null
+++ b/src/nlx.ts
@@ -0,0 +1,20 @@
+import process from 'node:process';
+
+import c from 'kleur';
+
+import { getCommand, resolveAgent, runCommand } from './agents';
+
+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. nlx cowsay hello'));
+ return;
+ }
+
+ const agent = await resolveAgent(cwd);
+
+ await runCommand(getCommand(agent, 'execute', args), cwd);
+};
+
+nlxCli();
diff --git a/src/nr.ts b/src/nr.ts
index b3c8428..f517a85 100644
--- a/src/nr.ts
+++ b/src/nr.ts
@@ -1,95 +1,125 @@
/* 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 { autocomplete, cancel, isCancel } from '@clack/prompts';
import c from 'kleur';
-import { cancel, isCancel, select, intro } 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();
-
- const pkg = getPackageJSON(cwd);
- const scripts = pkg.scripts || {};
- const scriptsInfo = pkg['scripts-info'] || {};
- let scriptValue: string = '';
-
- const names = Object.entries(scripts) as [string, string][];
-
- if (names.length === 0) {
- return process.exit(0);
+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) => {
+ 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 ${dir}`));
+ return process.exit(1);
+ }
+
+ const scripts: Record = pkg.scripts || {};
+ const scriptsInfo: Record = pkg['scripts-info'] || {};
+
+ 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 = [];
}
+ }
- 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 descriptions = new Map(raw.map((i) => [i.key, i.description]));
+
+ 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: descriptions.has(lastRunCommand!) ? lastRunCommand : undefined,
+ filter: (search, option) =>
+ includes(option.value, search) || includes(descriptions.get(option.value)!, search),
+ });
+
+ 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];
+ }
- await execaCommand(`npm run ${scriptValue}`, { stdio: 'inherit', cwd });
- }
+ if (lastRunCommand !== args[0]) {
+ storage.lastRunCommands = { ...storage.lastRunCommands, [dir]: args[0]! };
+ await dump();
+ }
- if (agent.name === 'yarn') {
- intro(c.bold(c.green(`yarn ${scriptValue}\n`)));
+ const agent = await resolveAgent(dir);
- await execaCommand(`yarn ${scriptValue}`, { stdio: 'inherit', cwd });
- }
- } catch {}
+ await runCommand(getCommand(agent, 'run', args), dir);
};
-niCli();
+nrCli();
diff --git a/src/nu.ts b/src/nu.ts
index 3f6773c..72a69e8 100644
--- a/src/nu.ts
+++ b/src/nu.ts
@@ -1,55 +1,21 @@
-/* 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 { intro } from '@clack/prompts';
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(' ');
- }
-
- if (!scriptValue) {
- intro(c.bold(c.yellow('Please enter a package name\n')));
- return;
- }
+import { getCommand, resolveAgent, runCommand } from './agents';
- 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!;
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: */