Repository: antfu-collective/ni Branch: main Commit: f96fe50f9669 Files: 115 Total size: 124.3 KB Directory structure: gitextract_i7fr5ot5/ ├── .github/ │ └── workflows/ │ ├── publish-commit.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.md ├── bin/ │ ├── na.mjs │ ├── nci.mjs │ ├── nd.mjs │ ├── ni.mjs │ ├── nlx.mjs │ ├── nr.mjs │ ├── nun.mjs │ └── nup.mjs ├── eslint.config.js ├── package.json ├── pnpm-workspace.yaml ├── src/ │ ├── catalog/ │ │ ├── detect.ts │ │ ├── handler.ts │ │ ├── package-json.ts │ │ ├── pnpm.ts │ │ ├── prompt.ts │ │ └── types.ts │ ├── commands/ │ │ ├── index.ts │ │ ├── na.ts │ │ ├── nci.ts │ │ ├── nd.ts │ │ ├── ni.ts │ │ ├── nlx.ts │ │ ├── nr.ts │ │ ├── nun.ts │ │ └── nup.ts │ ├── completion.ts │ ├── config.ts │ ├── detect.ts │ ├── environment.ts │ ├── fetch.ts │ ├── fs.ts │ ├── index.ts │ ├── monorepo.ts │ ├── package.ts │ ├── parse.ts │ ├── runner.ts │ ├── storage.ts │ └── utils.ts ├── taze.config.ts ├── test/ │ ├── config/ │ │ ├── .nirc │ │ └── config.test.ts │ ├── fixtures/ │ │ ├── catalog/ │ │ │ ├── pnpm/ │ │ │ │ ├── package.json │ │ │ │ ├── packages/ │ │ │ │ │ └── app/ │ │ │ │ │ └── package.json │ │ │ │ └── pnpm-workspace.yaml │ │ │ └── pnpm-default-only/ │ │ │ ├── package.json │ │ │ └── pnpm-workspace.yaml │ │ ├── lockfile/ │ │ │ ├── bun/ │ │ │ │ └── bun.lockb │ │ │ └── unknown/ │ │ │ └── future-package-manager.json │ │ └── packager/ │ │ ├── bun/ │ │ │ └── package.json │ │ ├── deno/ │ │ │ └── deno.json │ │ ├── npm/ │ │ │ └── package.json │ │ ├── pnpm/ │ │ │ └── package.json │ │ ├── pnpm-version-range/ │ │ │ └── package.json │ │ ├── pnpm@6/ │ │ │ └── package.json │ │ ├── unknown/ │ │ │ └── package.json │ │ ├── yarn/ │ │ │ └── package.json │ │ └── yarn@berry/ │ │ └── package.json │ ├── na/ │ │ ├── bun.spec.ts │ │ ├── deno.spec.ts │ │ ├── npm.spec.ts │ │ ├── pnpm.spec.ts │ │ ├── yarn.spec.ts │ │ └── yarn@berry.spec.ts │ ├── ng.spec.ts │ ├── ni/ │ │ ├── bun.spec.ts │ │ ├── catalog.spec.ts │ │ ├── deno.spec.ts │ │ ├── interactive.spec.ts │ │ ├── npm.spec.ts │ │ ├── pnpm.spec.ts │ │ ├── yarn.spec.ts │ │ └── yarn@berry.spec.ts │ ├── nlx/ │ │ ├── bun.spec.ts │ │ ├── deno.spec.ts │ │ ├── npm.spec.ts │ │ ├── pnpm.spec.ts │ │ ├── yarn.spec.ts │ │ └── yarn@berry.spec.ts │ ├── nr/ │ │ ├── bun.spec.ts │ │ ├── deno.spec.ts │ │ ├── nodeRunAgent.spec.ts │ │ ├── npm.spec.ts │ │ ├── pnpm.spec.ts │ │ ├── yarn.spec.ts │ │ └── yarn@berry.spec.ts │ ├── nun/ │ │ ├── bun.spec.ts │ │ ├── deno.spec.ts │ │ ├── npm.spec.ts │ │ ├── pnpm.spec.ts │ │ ├── yarn.spec.ts │ │ └── yarn@berry.spec.ts │ ├── nup/ │ │ ├── bun.spec.ts │ │ ├── deno.spec.ts │ │ ├── npm.spec.ts │ │ ├── pnpm.spec.ts │ │ ├── yarn.spec.ts │ │ └── yarn@berry.spec.ts │ ├── programmatic/ │ │ ├── __snapshots__/ │ │ │ ├── detect.spec.ts.snap │ │ │ └── runCli.spec.ts.snap │ │ ├── catalog.spec.ts │ │ ├── detect.spec.ts │ │ └── runCli.spec.ts │ ├── runner/ │ │ └── runCli.test.ts │ └── sfw/ │ └── sfw.spec.ts ├── tsconfig.json ├── tsdown.config.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/publish-commit.yml ================================================ name: Publish Any Commit on: pull_request: push: branches: - main tags: - '!**' jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@v4.0.0 - name: Install dependencies run: pnpm install - name: Build run: pnpm build - run: pnpm dlx pkg-pr-new publish --pnpm ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' jobs: release: uses: sxzz/workflows/.github/workflows/release.yml@v1 with: publish: true permissions: contents: write id-token: write ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: - main pull_request: branches: - main jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x, lts/*] steps: - uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - name: Install run: pnpm install - name: Lint run: pnpm run lint - name: Typecheck run: pnpm run typecheck - name: Test run: pnpm run test ================================================ FILE: .gitignore ================================================ _storage.json .cache .DS_Store .env .eslintcache .ghfs .grunt .idea .lock-wscript .next .node_repl_history .npm .nuxt .nyc_output .serverless .vuepress/dist .yarn-integrity *.log *.pid *.pid.lock *.seed bower_components build/Release coverage dist jspm_packages lib-cov logs node_modules npm-debug.log* pids typings yarn-debug.log* yarn-error.log* ================================================ FILE: .vscode/settings.json ================================================ { // Enable the ESlint flat config support "eslint.experimental.useFlatConfig": true, // Disable the default formatter, use eslint instead "prettier.enable": false, "editor.formatOnSave": false, // Auto fix "editor.codeActionsOnSave": { "source.fixAll": "explicit", "source.organizeImports": "never" }, // Silent the stylistic rules in you IDE, but still auto fix them "eslint.rules.customizations": [ { "rule": "style/*", "severity": "off" }, { "rule": "*-indent", "severity": "off" }, { "rule": "*-spacing", "severity": "off" }, { "rule": "*-spaces", "severity": "off" }, { "rule": "*-order", "severity": "off" }, { "rule": "*-dangle", "severity": "off" }, { "rule": "*-newline", "severity": "off" }, { "rule": "*quotes", "severity": "off" }, { "rule": "*semi", "severity": "off" } ], // Enable eslint for all supported languages "eslint.validate": [ "javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "html", "markdown", "json", "jsonc", "yaml" ] } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Anthony Fu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ni ~~*`npm i` in a yarn project, again? F\*\*k!*~~ **ni** - use the right package manager

npm i -g @antfu/ni

npm · yarn · pnpm · bun · deno
### `ni` - install ```bash ni # npm install # yarn install # pnpm install # bun install # deno install ``` ```bash ni vite # npm i vite # yarn add vite # pnpm add vite # bun add vite # deno add vite ``` ```bash ni @types/node -D # npm i @types/node -D # yarn add @types/node -D # pnpm add -D @types/node # bun add -d @types/node # deno add -D @types/node ``` ```bash ni -P # npm i --omit=dev # yarn install --production # pnpm i --production # bun install --production # (deno not supported) ``` ```bash ni --frozen # npm ci # yarn install --frozen-lockfile (Yarn 1) # yarn install --immutable (Yarn Berry) # pnpm install --frozen-lockfile # bun install --frozen-lockfile # deno install --frozen ``` ```bash ni -g eslint # npm i -g eslint # yarn global add eslint (Yarn 1) # pnpm add -g eslint # bun add -g eslint # deno install eslint # this uses default agent, regardless your current working directory ``` ```bash ni -i # interactively select the dependency to install # search for packages by name ```
catalogs support > Since v29.0.0 When working in a pnpm workspace with [catalogs](https://pnpm.io/catalogs) configured in `pnpm-workspace.yaml`, `ni` automatically enters **catalog mode**. Instead of adding packages with pinned versions, it writes `catalog:` references into `package.json` and updates the workspace catalog. ```bash # Given pnpm-workspace.yaml with: # catalogs: # prod: # react: ^18.3.0 ni react # → detects react in "prod" catalog # → writes "react": "catalog:prod" to package.json # → runs pnpm install ni lodash # → lodash not in any catalog # → prompts to select a catalog (or skip) # → fetches latest version, updates pnpm-workspace.yaml # → writes "lodash": "catalog:prod" to package.json # → runs pnpm install ``` When only a default catalog (`catalog:` top-level) is used, new packages are added directly without prompting. When only named catalogs exist, the default catalog is never offered. Flags like `-D` are respected — the catalog ref is written to the correct `package.json` section: ```bash ni typescript -D # → writes "typescript": "catalog:dev" to devDependencies ``` Use `-w` / `--workspace` to target the workspace root `package.json`: ```bash ni react -w # → writes catalog ref to workspace root package.json ``` To disable catalog mode, set `catalog=false` in `~/.nirc` or `NI_CATALOG=false` environment variable.

### `nr` - run ```bash nr dev --port=3000 # npm run dev -- --port=3000 # yarn run dev --port=3000 # pnpm run dev --port=3000 # bun run dev --port=3000 # deno task dev --port=3000 ``` ```bash nr # interactively select the script to run # supports https://www.npmjs.com/package/npm-scripts-info convention ``` ```bash nr - # rerun the last command ``` ```bash nr -p nr -p dev # interactively select the package and script to run ```
shell completion scripts ```bash # Add completion script for bash nr --completion-bash >> ~/.bashrc # Add completion script for zsh # For zim:fw mkdir -p ~/.zim/custom/ni-completions nr --completion-zsh > ~/.zim/custom/ni-completions/_ni echo "zmodule $HOME/.zim/custom/ni-completions --fpath ." >> ~/.zimrc zimfw install # Add completion script for fish mkdir -p ~/.config/fish/completions nr --completion-fish > ~/.config/fish/completions/nr.fish ```

### `nlx` - download & execute ```bash nlx vitest # npx vitest # yarn dlx vitest # pnpm dlx vitest # bunx vitest # deno run npm:vitest ```
### `nup` - upgrade ```bash nup # npm upgrade # yarn upgrade (Yarn 1) # yarn up (Yarn Berry) # pnpm update # bun update # deno upgrade ``` ```bash nup -i # (not available for npm) # yarn upgrade-interactive (Yarn 1) # yarn up -i (Yarn Berry) # pnpm update -i # bun update -i # deno outdated -u -i ```
### `nun` - uninstall ```bash nun webpack # npm uninstall webpack # yarn remove webpack # pnpm remove webpack # bun remove webpack # deno remove webpack ``` ```bash nun # interactively multi-select # the dependencies to remove ``` ```bash nun -g silent # npm uninstall -g silent # yarn global remove silent # pnpm remove -g silent # bun remove -g silent # deno uninstall -g silent ```
### `nci` - clean install ```bash nci # npm ci # yarn install --frozen-lockfile # pnpm install --frozen-lockfile # bun install --frozen-lockfile # deno cache --reload ```
### `nd` - dedupe dependencies ```bash nd # npm dedupe # yarn dedupe # pnpm dedupe ```
### `na` - agent alias ```bash na # npm # yarn # pnpm # bun # deno ``` ```bash na run foo # npm run foo # yarn run foo # pnpm run foo # bun run foo # deno task foo ```
### Global Flags ```bash # ? | Print the command execution depends on the agent ni vite ? # -C | Change directory before running the command ni -C packages/foo vite nr -C playground dev # -v, --version | Show version number ni -v # -h, --help | Show help ni -h ```
### Config ```ini ; ~/.nirc ; fallback when no lock found defaultAgent=npm # default "prompt" ; for global installs globalAgent=npm ; use node --run instead of package manager run command (requires Node.js 22+) runAgent=node ; prefix commands with sfw useSfw=true ; use catalog mode when catalogs are detected (default true) catalog=true ``` ```bash # ~/.bashrc # custom configuration file path export NI_CONFIG_FILE="$HOME/.config/ni/nirc" # environment variables have higher priority than config file if presented export NI_DEFAULT_AGENT="npm" # default "prompt" export NI_GLOBAL_AGENT="npm" export NI_USE_SFW="true" export NI_CATALOG="false" # disable catalog mode ``` ```ps # for Windows # custom configuration file path in PowerShell accessible within the `$profile` path $Env:NI_CONFIG_FILE = 'C:\to\your\config\location' ```
### Automatic installation You can set `NI_AUTO_INSTALL=true` to enable automatic installation. If the corresponding package manager (**npm**, **yarn**, **pnpm**, **bun**, or **deno**) is not installed, it will install it globally before running the command. ### Integrations #### Homebrew You can install ni with [Homebrew](https://brew.sh/): ```bash brew install ni ``` #### asdf You can also install ni via the [3rd-party asdf-plugin](https://github.com/CanRau/asdf-ni.git) maintained by [CanRau](https://github.com/CanRau) ```bash # first add the plugin asdf plugin add ni https://github.com/CanRau/asdf-ni.git # then install the latest version asdf install ni latest # and make it globally available asdf global ni latest ``` ### How? **ni** assumes that you work with lock-files (and you should). Before `ni` runs the command, it detects your `yarn.lock` / `pnpm-lock.yaml` / `package-lock.json` / `bun.lock` / `bun.lockb` / `deno.json` / `deno.jsonc` to know the current package manager (or `packageManager` field in your packages.json if specified) using the [package-manager-detector](https://github.com/antfu-collective/package-manager-detector) package and then runs the corresponding [package-manager-detector command](https://github.com/antfu-collective/package-manager-detector/blob/main/src/commands.ts). ### Trouble shooting #### Conflicts with PowerShell PowerShell comes with a built-in alias `ni` for the `New-Item` cmdlet. To remove the alias in your current PowerShell session in favor of this package, use the following command: ```PowerShell 'Remove-Item Alias:ni -Force -ErrorAction Ignore' ``` If you want to persist the changes, you can add them to your PowerShell profile. The profile path is accessible within the `$profile` variable. The ps1 profile file can normally be found at - PowerShell 5 (Windows PowerShell): `C:\Users\USERNAME\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1` - PowerShell 7: `C:\Users\USERNAME\Documents\PowerShell\Microsoft.PowerShell_profile.ps1` - VSCode: `C:\Users\USERNAME\Documents\PowerShell\Microsoft.VSCode_profile.ps1` You can use the following script to remove the alias at shell start by adding the above command to your profile: ```PowerShell if (-not (Test-Path $profile)) { New-Item -ItemType File -Path (Split-Path $profile) -Force -Name (Split-Path $profile -Leaf) } $profileEntry = 'Remove-Item Alias:ni -Force -ErrorAction Ignore' $profileContent = Get-Content $profile if ($profileContent -notcontains $profileEntry) { ("`n" + $profileEntry) | Out-File $profile -Append -Force -Encoding UTF8 } ``` #### `nx`, `nix` and `nu` are no longer available We renamed `nx`/`nix` and `nu` to `nlx` and `nup` to avoid conflicts with the other existing tools - [nx](https://nx.dev/), [nix](https://nixos.org/) and [nushell](https://www.nushell.sh/). You can always alias them back on your shell configuration file (`.zshrc`, `.bashrc`, etc). ```bash alias nx="nlx" # or alias nix="nlx" # or alias nu="nup" ``` ================================================ FILE: bin/na.mjs ================================================ #!/usr/bin/env node 'use strict' import '../dist/na.mjs' ================================================ FILE: bin/nci.mjs ================================================ #!/usr/bin/env node 'use strict' import '../dist/nci.mjs' ================================================ FILE: bin/nd.mjs ================================================ #!/usr/bin/env node 'use strict' import '../dist/nd.mjs' ================================================ FILE: bin/ni.mjs ================================================ #!/usr/bin/env node 'use strict' import '../dist/ni.mjs' ================================================ FILE: bin/nlx.mjs ================================================ #!/usr/bin/env node 'use strict' import '../dist/nlx.mjs' ================================================ FILE: bin/nr.mjs ================================================ #!/usr/bin/env node 'use strict' import '../dist/nr.mjs' ================================================ FILE: bin/nun.mjs ================================================ #!/usr/bin/env node 'use strict' import '../dist/nun.mjs' ================================================ FILE: bin/nup.mjs ================================================ #!/usr/bin/env node 'use strict' import '../dist/nup.mjs' ================================================ FILE: eslint.config.js ================================================ // @ts-check import antfu from '@antfu/eslint-config' export default antfu({ pnpm: true, }) .removeRules( 'markdown/heading-increment', 'e18e/prefer-static-regex', ) ================================================ FILE: package.json ================================================ { "name": "@antfu/ni", "type": "module", "version": "29.0.0", "packageManager": "pnpm@10.32.1", "description": "Use the right package manager", "author": "Anthony Fu ", "license": "MIT", "homepage": "https://github.com/antfu-collective/ni#readme", "repository": { "type": "git", "url": "git+https://github.com/antfu-collective/ni.git" }, "bugs": { "url": "https://github.com/antfu-collective/ni/issues" }, "exports": { ".": "./dist/index.mjs", "./na": "./dist/na.mjs", "./nci": "./dist/nci.mjs", "./nd": "./dist/nd.mjs", "./ni": "./dist/ni.mjs", "./nlx": "./dist/nlx.mjs", "./nr": "./dist/nr.mjs", "./nun": "./dist/nun.mjs", "./nup": "./dist/nup.mjs", "./package.json": "./package.json" }, "types": "./dist/index.d.mts", "bin": { "ni": "bin/ni.mjs", "nci": "bin/nci.mjs", "nr": "bin/nr.mjs", "nup": "bin/nup.mjs", "nd": "bin/nd.mjs", "nlx": "bin/nlx.mjs", "na": "bin/na.mjs", "nun": "bin/nun.mjs" }, "files": [ "bin", "dist" ], "engines": { "node": ">=20.19.0" }, "scripts": { "prepublishOnly": "npm run build", "dev": "tsx src/commands/ni.ts", "nr": "tsx src/commands/nr.ts", "build": "tsdown", "release": "bumpp", "typecheck": "tsc --noEmit", "prepare": "simple-git-hooks", "lint": "eslint", "test": "vitest" }, "dependencies": { "fzf": "catalog:prod", "package-manager-detector": "catalog:prod", "tinyexec": "catalog:prod", "tinyglobby": "catalog:prod" }, "inlinedDependencies": { "fast-npm-meta": "1.4.2", "yaml": "2.8.2", "pnpm-workspace-yaml": "1.6.0", "ini": "6.0.0", "kleur": "4.1.5", "@posva/prompts": "2.4.4", "sisteransi": "1.0.5", "isexe": "4.0.0", "which": "6.0.1" }, "devDependencies": { "@antfu/eslint-config": "catalog:dev", "@posva/prompts": "catalog:prod-inlined", "@types/ini": "catalog:dev", "@types/node": "catalog:dev", "@types/which": "catalog:dev", "bumpp": "catalog:dev", "eslint": "catalog:dev", "fast-npm-meta": "catalog:prod-inlined", "ini": "catalog:prod-inlined", "lint-staged": "catalog:dev", "pnpm-workspace-yaml": "catalog:prod-inlined", "simple-git-hooks": "catalog:dev", "taze": "catalog:dev", "tsdown": "catalog:dev", "tsx": "catalog:dev", "typescript": "catalog:dev", "vitest": "catalog:dev", "which": "catalog:prod-inlined" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" }, "lint-staged": { "*": "eslint --fix" } } ================================================ FILE: pnpm-workspace.yaml ================================================ shellEmulator: true trustPolicy: no-downgrade trustPolicyIgnoreAfter: 604800 # 7 days packages: [] catalogs: dev: '@antfu/eslint-config': ^7.7.2 '@types/ini': ^4.1.1 '@types/node': ^25.5.0 '@types/which': ^3.0.4 bumpp: ^10.4.1 eslint: ^10.0.3 lint-staged: ^16.4.0 simple-git-hooks: ^2.13.1 taze: ^19.10.0 tsdown: ^0.21.2 tsx: ^4.21.0 typescript: ^5.9.3 vitest: ^4.1.0 prod: fzf: ^0.5.2 package-manager-detector: ^1.6.0 tinyexec: ^1.0.4 tinyglobby: ^0.2.15 prod-inlined: '@posva/prompts': ^2.4.4 fast-npm-meta: ^1.4.2 ini: ^6.0.0 pnpm-workspace-yaml: ^1.6.0 which: ^6.0.1 onlyBuiltDependencies: - esbuild - simple-git-hooks - unrs-resolver ================================================ FILE: src/catalog/detect.ts ================================================ import type { Agent } from 'package-manager-detector' import type { CatalogProvider } from './types' import { pnpmCatalogProvider } from './pnpm' export function getCatalogProvider(agent: Agent): CatalogProvider | null { if (agent === 'pnpm') return pnpmCatalogProvider return null } ================================================ FILE: src/catalog/handler.ts ================================================ import type { Agent } from 'package-manager-detector' import type { ExtendedResolvedCommand, RunnerContext } from '../runner' import type { DepType } from './package-json' import type { CatalogConfig, CatalogProvider } from './types' import path from 'node:path' import process from 'node:process' import { styleText } from 'node:util' import { getLatestVersion } from 'fast-npm-meta' import { getCatalog } from '../config' import { getCommand } from '../parse' import { getCatalogProvider } from './detect' import { findClosestPackageJson, updatePackageJsonCatalogRefs } from './package-json' import { promptSelectCatalog } from './prompt' import { getCatalogRef } from './types' function splitPackagesAndFlags(args: string[]): { packages: string[], flags: string[] } { const packages: string[] = [] const flags: string[] = [] for (const arg of args) { if (arg.startsWith('-')) flags.push(arg) else packages.push(arg) } return { packages, flags } } function getDepType(flags: string[]): DepType { if (flags.includes('-D') || flags.includes('-d')) return 'devDependencies' if (flags.includes('--save-peer')) return 'peerDependencies' return 'dependencies' } async function resolveVersion(pkgName: string): Promise { const meta = await getLatestVersion(pkgName) return `^${meta.version}` } async function resolveCatalogForPackage( provider: CatalogProvider, config: CatalogConfig, pkgName: string, programmatic?: boolean, ): Promise<{ catalogName: string | undefined, version?: string }> { // Check if already in a catalog const existing = provider.findPackage(config, pkgName) if (existing) { return { catalogName: existing.name } } // Prompt user to select catalog const { catalogName } = await promptSelectCatalog(config, pkgName, programmatic) if (!catalogName) return { catalogName: undefined } // Fetch latest version for new catalog entry const version = await resolveVersion(pkgName) return { catalogName, version } } export async function handleCatalogInstall( agent: Agent, args: string[], ctx?: RunnerContext, ): Promise { const catalogEnabled = await getCatalog() if (!catalogEnabled) return undefined const provider = getCatalogProvider(agent) if (!provider) return undefined // Check for workspace flag const hasWorkspaceFlag = args.includes('-w') || args.includes('--workspace') const cleanArgs = args.filter(a => a !== '-w' && a !== '--workspace') const { packages, flags } = splitPackagesAndFlags(cleanArgs) // No packages to add (bare install, frozen, etc.) if (packages.length === 0) return undefined const cwd = ctx?.cwd ?? process.cwd() const config = await provider.detect(cwd) if (!config) return undefined const depType = getDepType(flags) const catalogEntries: { name: string, catalogRef: string }[] = [] const skippedPackages: string[] = [] for (const pkg of packages) { const result = await resolveCatalogForPackage(provider, config, pkg, ctx?.programmatic) if (result.catalogName) { // Add to catalog file if it's a new entry if (result.version) { await provider.addPackage(config, result.catalogName, pkg, result.version) if (!ctx?.programmatic) { // eslint-disable-next-line no-console console.log(`${styleText('green', '+')} ${styleText('cyan', pkg)} ${styleText('dim', `→ ${result.catalogName} catalog (${result.version})`)}`) } } else if (!ctx?.programmatic) { const existingCatalog = provider.findPackage(config, pkg) // eslint-disable-next-line no-console console.log(`${styleText('green', '✓')} ${styleText('cyan', pkg)} ${styleText('dim', `→ found in ${existingCatalog!.name} catalog`)}`) } catalogEntries.push({ name: pkg, catalogRef: getCatalogRef(result.catalogName) }) } else { skippedPackages.push(pkg) } } if (catalogEntries.length === 0) return undefined // Determine target package.json let pkgJsonPath: string | null if (hasWorkspaceFlag) { pkgJsonPath = path.join(path.dirname(config.filePath), 'package.json') } else { pkgJsonPath = findClosestPackageJson(cwd) } if (!pkgJsonPath) { if (!ctx?.programmatic) { console.error(styleText('red', '✗ No package.json found')) process.exit(1) } throw new Error('No package.json found') } // Update package.json with catalog refs updatePackageJsonCatalogRefs(pkgJsonPath, catalogEntries, depType) // If some packages were skipped, add them normally alongside install if (skippedPackages.length > 0) { return getCommand(agent, 'add', [...skippedPackages, ...flags]) } // All packages handled via catalogs, just run install return getCommand(agent, 'install', []) } ================================================ FILE: src/catalog/package-json.ts ================================================ import fs from 'node:fs' import path from 'node:path' export function findClosestPackageJson(cwd: string): string | null { let dir = path.resolve(cwd) while (true) { const filePath = path.join(dir, 'package.json') if (fs.existsSync(filePath)) return filePath const parent = path.dirname(dir) if (parent === dir) return null dir = parent } } function detectIndent(content: string): string { const match = content.match(/^(\s+)"/m) return match?.[1] ?? ' ' } export type DepType = 'dependencies' | 'devDependencies' | 'peerDependencies' export function updatePackageJsonCatalogRefs( pkgJsonPath: string, entries: { name: string, catalogRef: string }[], depType: DepType, ): void { const content = fs.readFileSync(pkgJsonPath, 'utf-8') const indent = detectIndent(content) const data = JSON.parse(content) if (!data[depType]) data[depType] = {} for (const entry of entries) { data[depType][entry.name] = entry.catalogRef } fs.writeFileSync(pkgJsonPath, `${JSON.stringify(data, null, indent)}\n`) } ================================================ FILE: src/catalog/pnpm.ts ================================================ import type { CatalogConfig, CatalogInfo, CatalogProvider } from './types' import fs from 'node:fs' import path from 'node:path' import { parsePnpmWorkspaceYaml } from 'pnpm-workspace-yaml' function findPnpmWorkspaceYaml(cwd: string): string | null { let dir = path.resolve(cwd) while (true) { const filePath = path.join(dir, 'pnpm-workspace.yaml') if (fs.existsSync(filePath)) return filePath const parent = path.dirname(dir) if (parent === dir) return null dir = parent } } export const pnpmCatalogProvider: CatalogProvider = { async detect(cwd: string): Promise { const filePath = findPnpmWorkspaceYaml(cwd) if (!filePath) return null const content = fs.readFileSync(filePath, 'utf-8') const workspace = parsePnpmWorkspaceYaml(content) const json = workspace.toJSON() const catalogs: CatalogInfo[] = [] const hasDefaultCatalog = json.catalog != null && Object.keys(json.catalog).length > 0 const hasNamedCatalogs = json.catalogs != null && Object.keys(json.catalogs).length > 0 if (!hasDefaultCatalog && !hasNamedCatalogs) return null if (hasDefaultCatalog) { catalogs.push({ name: 'default', packages: json.catalog!, }) } if (hasNamedCatalogs) { for (const [name, packages] of Object.entries(json.catalogs!)) { catalogs.push({ name, packages }) } } return { filePath, catalogs, hasDefaultCatalog, hasNamedCatalogs, } }, findPackage(config: CatalogConfig, pkgName: string): CatalogInfo | undefined { return config.catalogs.find(c => pkgName in c.packages) }, async addPackage(config: CatalogConfig, catalogName: string, pkgName: string, version: string): Promise { const content = fs.readFileSync(config.filePath, 'utf-8') const workspace = parsePnpmWorkspaceYaml(content) workspace.setPackage(catalogName, pkgName, version) fs.writeFileSync(config.filePath, workspace.toString()) // Update the in-memory config const existing = config.catalogs.find(c => c.name === catalogName) if (existing) { existing.packages[pkgName] = version } else { config.catalogs.push({ name: catalogName, packages: { [pkgName]: version } }) } }, } ================================================ FILE: src/catalog/prompt.ts ================================================ import type { CatalogConfig } from './types' import { styleText } from 'node:util' import prompts from '@posva/prompts' const SKIP = '__skip__' const CREATE_NEW = '__create_new__' export interface CatalogSelection { catalogName: string | undefined } export async function promptSelectCatalog( config: CatalogConfig, pkgName: string, programmatic?: boolean, ): Promise { // Only default catalog: no prompt needed if (config.hasDefaultCatalog && !config.hasNamedCatalogs) { return { catalogName: 'default' } } if (programmatic) { return { catalogName: undefined } } const catalogChoices = config.catalogs.map(c => ({ title: c.name, value: c.name, })) const { catalog } = await prompts({ type: 'select', name: 'catalog', message: `select catalog for ${styleText('yellow', pkgName)}`, choices: [ ...catalogChoices, { title: styleText('dim', 'create new catalog'), value: CREATE_NEW }, { title: styleText('dim', 'skip (install without catalog)'), value: SKIP }, ], }) if (catalog === undefined || catalog === SKIP) { return { catalogName: undefined } } if (catalog === CREATE_NEW) { const newName = await promptNewCatalogName() return { catalogName: newName } } return { catalogName: catalog } } async function promptNewCatalogName(): Promise { const { name } = await prompts({ type: 'text', name: 'name', message: 'new catalog name', }) return name || undefined } ================================================ FILE: src/catalog/types.ts ================================================ export interface CatalogInfo { name: string packages: Record } export interface CatalogConfig { filePath: string catalogs: CatalogInfo[] hasDefaultCatalog: boolean hasNamedCatalogs: boolean } export interface CatalogProvider { detect: (cwd: string) => Promise findPackage: (config: CatalogConfig, pkgName: string) => CatalogInfo | undefined addPackage: (config: CatalogConfig, catalogName: string, pkgName: string, version: string) => Promise } export function getCatalogRef(catalogName: string): string { return catalogName === 'default' ? 'catalog:' : `catalog:${catalogName}` } ================================================ FILE: src/commands/index.ts ================================================ export * from '../index' ================================================ FILE: src/commands/na.ts ================================================ import { parseNa } from '../parse' import { runCli } from '../runner' runCli(parseNa) ================================================ FILE: src/commands/nci.ts ================================================ import { parseNi } from '../parse' import { runCli } from '../runner' runCli( (agent, args, hasLock) => parseNi(agent, [...args, '--frozen-if-present'], hasLock), { autoInstall: true }, ) ================================================ FILE: src/commands/nd.ts ================================================ import { parseNd } from '../parse' import { runCli } from '../runner' runCli(parseNd) ================================================ FILE: src/commands/ni.ts ================================================ import type { Choice } from '@posva/prompts' import process from 'node:process' import { styleText } from 'node:util' import prompts from '@posva/prompts' import { Fzf } from 'fzf' import { handleCatalogInstall } from '../catalog/handler' import { fetchNpmPackages } from '../fetch' import { parseNi } from '../parse' import { runCli } from '../runner' import { exclude } from '../utils' runCli(async (agent, args, ctx) => { const isInteractive = args[0] === '-i' if (isInteractive) { let fetchPattern: string if (args[1] && !args[1].startsWith('-')) { fetchPattern = args[1] } else { const { pattern } = await prompts({ type: 'text', name: 'pattern', message: 'search for package', }) fetchPattern = pattern } if (!fetchPattern) { process.exitCode = 1 return } const packages = await fetchNpmPackages(fetchPattern) if (!packages.length) { console.error('No results found') process.exitCode = 1 return } const fzf = new Fzf(packages, { selector: (item: Choice) => item.title, casing: 'case-insensitive', }) const { dependency } = await prompts({ type: 'autocomplete', name: 'dependency', choices: packages, instructions: false, message: 'choose a package to install', limit: 15, async suggest(input: string, choices: Choice[]) { const results = fzf.find(input) return results.map(r => choices.find((c: any) => c.value === r.item.value)) }, }) if (!dependency) { process.exitCode = 1 return } args = exclude(args, '-d', '-p', '-i') /** * yarn and bun do not support * the installation of peers programmatically */ const canInstallPeers = ['npm', 'pnpm'].includes(agent) const { mode } = await prompts({ type: 'select', name: 'mode', message: `install ${styleText('yellow', dependency.name)} as`, choices: [ { title: 'prod', value: '', selected: true, }, { title: 'dev', value: '-D', }, { title: `peer`, value: '--save-peer', disabled: !canInstallPeers, }, ], }) if (mode === undefined) { process.exitCode = 1 return } args.push(dependency.name, mode) } // Catalog mode: intercept before normal add if (!args.includes('-g')) { const catalogCmd = await handleCatalogInstall(agent, args, ctx) if (catalogCmd !== undefined) return catalogCmd } return parseNi(agent, args, ctx) }) ================================================ FILE: src/commands/nlx.ts ================================================ import { parseNlx } from '../parse' import { runCli } from '../runner' runCli(parseNlx) ================================================ FILE: src/commands/nr.ts ================================================ import type { Choice } from '@posva/prompts' import type { PackageScript } from '../package' import process from 'node:process' import prompts from '@posva/prompts' import { byLengthAsc, Fzf } from 'fzf' import { getCompletionSuggestions, rawBashCompletionScript, rawFishCompletionScript, rawZshCompletionScript } from '../completion' import { readPackageScripts, readWorkspaceScripts } from '../package' import { parseNr } from '../parse' import { runCli } from '../runner' import { dump, load } from '../storage' import { limitText } from '../utils' runCli(async (agent, args, ctx) => { const storage = await load() const promptSelectScript = async (raw: PackageScript[]) => { const terminalColumns = process.stdout?.columns || 80 const last = storage.lastRunCommand const choices = raw.reduce((acc, { key, description }) => { const item = { title: key, value: key, description: limitText(description, terminalColumns - 15), } if (last && key === last) { return [item, ...acc] } return [...acc, item] }, []) const fzf = new Fzf(raw, { selector: item => `${item.key} ${item.description}`, casing: 'case-insensitive', tiebreakers: [byLengthAsc], }) // Workaround for @posva/prompts autocomplete bug where ESC key submits instead of canceling // https://github.com/terkelg/prompts/issues/362 let isExited = false try { const { fn } = await prompts({ name: 'fn', message: 'script to run', type: 'autocomplete', choices, async suggest(input: string, choices: Choice[]) { if (!input) return choices const results = fzf.find(input) return results.map(r => choices.find(c => c.value === r.item.key)) }, onState(state) { if (state.exited) isExited = true }, }) if (!fn || isExited) process.exit(1) args.push(fn) } catch { process.exit(1) } } // Use --completion to generate completion script and do completion logic // (No package manager would have an argument named --completion) if (args[0] === '--completion') { const compLine = process.env.COMP_LINE const rawCompCword = process.env.COMP_CWORD // In bash if (compLine !== undefined && rawCompCword !== undefined) { const compCword = Number.parseInt(rawCompCword, 10) const compWords = args.slice(1) // Only complete the second word (nr __here__ ...) if (compCword === 1) { const suggestions = getCompletionSuggestions(compWords, ctx) // eslint-disable-next-line no-console console.log(suggestions.join('\n')) } } // In other shells, return suggestions directly else { const suggestions = getCompletionSuggestions(args, ctx) // eslint-disable-next-line no-console console.log(suggestions.join('\n')) } return } // -p is a flag attempt to read scripts from monorepo if (args[0] === '-p') { const raw = await readWorkspaceScripts(ctx, args) // Show prompt if there are multiple scripts if (raw.length > 1) { await promptSelectScript(raw) } } if (args[0] === '-') { if (!storage.lastRunCommand) { if (!ctx?.programmatic) { console.error('No last command found') process.exit(1) } throw new Error('No last command found') } args[0] = storage.lastRunCommand } if (args.length === 0 && !ctx?.programmatic) { const raw = readPackageScripts(ctx) await promptSelectScript(raw) } if (storage.lastRunCommand !== args[0]) { storage.lastRunCommand = args[0] dump() } return parseNr(agent, args, ctx) }, { onBeforeCommand: (args, ctx) => { // Print ZSH completion script. if (args[0] === '--completion-zsh') { // eslint-disable-next-line no-console console.log(rawZshCompletionScript) return ctx.exit() } // Print Bash completion script if (args[0] === '--completion-bash') { // eslint-disable-next-line no-console console.log(rawBashCompletionScript) return ctx.exit() } // Print Fish completion script if (args[0] === '--completion-fish') { // eslint-disable-next-line no-console console.log(rawFishCompletionScript) return ctx.exit() } }, }) ================================================ FILE: src/commands/nun.ts ================================================ import type { Choice } from '@posva/prompts' import process from 'node:process' import prompts from '@posva/prompts' import { Fzf } from 'fzf' import { getPackageJSON } from '../fs' import { parseNun } from '../parse' import { runCli } from '../runner' import { exclude } from '../utils' runCli(async (agent, args, ctx) => { const isMultiple = args[0] === '-m' // Compatible with issue/311 const isGlobal = args.includes('-g') const isInteractive = !args.length && !ctx?.programmatic if ((isInteractive || isMultiple) && !isGlobal) { const pkg = getPackageJSON(ctx) const allDependencies = { ...pkg.dependencies, ...pkg.devDependencies } const raw = Object.entries(allDependencies) as [string, string][] if (!raw.length) { console.error('No dependencies found') return } const fzf = new Fzf(raw, { selector: ([dep, version]) => `${dep} ${version}`, casing: 'case-insensitive', }) const choices: Choice[] = raw.map(([dependency, version]) => ({ title: dependency, value: dependency, description: version, })) if (isMultiple) args = exclude(args, '-m') try { const { depsToRemove } = await prompts({ type: 'autocompleteMultiselect', name: 'depsToRemove', choices, min: 1, instructions: false, message: 'remove dependencies', async suggest(input: string, choices: Choice[]) { const results = fzf.find(input) return results.map(r => choices.find(c => c.value === r.item[0])) }, }) if (Array.isArray(depsToRemove)) args.push(...depsToRemove) } catch { process.exit(1) } } return parseNun(agent, args, ctx) }) ================================================ FILE: src/commands/nup.ts ================================================ import { parseNup } from '../parse' import { runCli } from '../runner' runCli(parseNup) ================================================ FILE: src/completion.ts ================================================ import type { RunnerContext } from '.' import { byLengthAsc, Fzf } from 'fzf' import { readPackageScripts } from './package' // Print completion script export const rawBashCompletionScript = ` ###-begin-nr-completion-### if type complete &>/dev/null; then _nr_completion() { local words local cur local cword _get_comp_words_by_ref -n =: cur words cword IFS=$'\\n' COMPREPLY=($(COMP_CWORD=$cword COMP_LINE=$cur nr --completion \${words[@]})) } complete -F _nr_completion nr fi ###-end-nr-completion-### `.trim() export const rawZshCompletionScript = ` #compdef nr _nr_completion() { local -a completions completions=("\${(f)$(nr --completion $words[2,-1])}") compadd -a completions } _nr_completion `.trim() export const rawFishCompletionScript = ` function _nr_completion set -l tokens (commandline -xpc) if test (count $tokens) -ge 1 set tokens $tokens[2..-1] end nr --completion $tokens 2>/dev/null end complete -c nr -f -a '(_nr_completion)' -d 'package.json scripts' `.trim() export function getCompletionSuggestions(args: string[], ctx: RunnerContext | undefined) { const raw = readPackageScripts(ctx) const fzf = new Fzf(raw, { selector: item => item.key, casing: 'case-insensitive', tiebreakers: [byLengthAsc], }) const results = fzf.find(args[1] || '') return results.map(r => r.item.key) } ================================================ FILE: src/config.ts ================================================ import type { Agent } from 'package-manager-detector' import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import ini from 'ini' import { detect } from './detect' const customRcPath = process.env.NI_CONFIG_FILE const home = process.platform === 'win32' ? process.env.USERPROFILE : process.env.HOME const defaultRcPath = path.join(home || '~/', '.nirc') const rcPath = customRcPath || defaultRcPath interface Config { defaultAgent: Agent | 'prompt' globalAgent: Agent runAgent: 'node' | undefined useSfw: boolean catalog: boolean } const defaultConfig: Config = { defaultAgent: 'prompt', globalAgent: 'npm', runAgent: undefined, useSfw: false, catalog: true, } let config: Config | undefined export async function getConfig(): Promise { if (!config) { config = { ...defaultConfig, ...fs.existsSync(rcPath) ? ini.parse(fs.readFileSync(rcPath, 'utf-8')) : null } if (process.env.NI_DEFAULT_AGENT) config.defaultAgent = process.env.NI_DEFAULT_AGENT as Agent if (process.env.NI_GLOBAL_AGENT) config.globalAgent = process.env.NI_GLOBAL_AGENT as Agent if (process.env.NI_RUN_AGENT === 'node') config.runAgent = process.env.NI_RUN_AGENT if (process.env.NI_USE_SFW !== undefined) config.useSfw = process.env.NI_USE_SFW === 'true' if (process.env.NI_CATALOG !== undefined) config.catalog = process.env.NI_CATALOG !== 'false' const agent = await detect({ programmatic: true }) if (agent) config.defaultAgent = agent } return config } export async function getDefaultAgent(programmatic?: boolean) { const { defaultAgent } = await getConfig() if (defaultAgent === 'prompt' && (programmatic || process.env.CI)) return 'npm' return defaultAgent } export async function getGlobalAgent() { const { globalAgent } = await getConfig() return globalAgent } export async function getRunAgent() { const { runAgent } = await getConfig() return runAgent } export async function getUseSfw() { const { useSfw } = await getConfig() return useSfw } export async function getCatalog() { const { catalog } = await getConfig() return catalog } ================================================ FILE: src/detect.ts ================================================ import { existsSync } from 'node:fs' import path from 'node:path' import process from 'node:process' import prompts from '@posva/prompts' import { detect as detectPM } from 'package-manager-detector' import { INSTALL_PAGE } from 'package-manager-detector/constants' import { x } from 'tinyexec' import { cmdExists, terminalLink } from './utils' export interface DetectOptions { autoInstall?: boolean programmatic?: boolean cwd?: string /** * Should use Volta when present * * @see https://volta.sh/ * @default true */ detectVolta?: boolean } export async function detect({ autoInstall, programmatic, cwd }: DetectOptions = {}) { const targetDir = cwd ?? process.cwd() // Check for deno.json or deno.jsonc before using package-manager-detector if (existsSync(path.join(targetDir, 'deno.json')) || existsSync(path.join(targetDir, 'deno.jsonc'))) { // Return early with deno agent if deno.json/deno.jsonc is present return 'deno' } const { name, agent, version, } = await detectPM({ cwd, onUnknown: (packageManager) => { if (!programmatic) { console.warn('[ni] Unknown packageManager:', packageManager) } return undefined }, }) || {} // auto install if (name && !cmdExists(name) && !programmatic) { if (!autoInstall) { console.warn(`[ni] Detected ${name} but it doesn't seem to be installed.\n`) if (process.env.CI) process.exit(1) const link = terminalLink(name, INSTALL_PAGE[name]) const { tryInstall } = await prompts({ name: 'tryInstall', type: 'confirm', message: `Would you like to globally install ${link}?`, }) if (!tryInstall) process.exit(1) } await x( 'npm', ['i', '-g', `${name}${version ? `@${version}` : ''}`], { nodeOptions: { stdio: 'inherit', cwd, }, throwOnError: true, }, ) } return agent } ================================================ FILE: src/environment.ts ================================================ import process from 'node:process' export interface EnvironmentOptions { autoInstall: boolean } const DEFAULT_ENVIRONMENT_OPTIONS: EnvironmentOptions = { autoInstall: false, } export function getEnvironmentOptions(): EnvironmentOptions { return { ...DEFAULT_ENVIRONMENT_OPTIONS, autoInstall: process.env.NI_AUTO_INSTALL === 'true', } } ================================================ FILE: src/fetch.ts ================================================ import type { Choice } from '@posva/prompts' import process from 'node:process' import { styleText } from 'node:util' import { formatPackageWithUrl } from './utils' export interface NpmPackage { name: string description: string version: string keywords: string[] date: string links: { npm: string homepage: string repository: string } } interface NpmRegistryResponse { objects: { package: NpmPackage }[] } export async function fetchNpmPackages(pattern: string): Promise { const registryLink = (pattern: string) => `https://registry.npmjs.com/-/v1/search?text=${pattern}&size=35` const terminalColumns = process.stdout?.columns || 80 try { const result = await fetch(registryLink(pattern)) .then(res => res.json()) as NpmRegistryResponse return result.objects.map(({ package: pkg }) => ({ title: formatPackageWithUrl( `${pkg.name.padEnd(30, ' ')} ${styleText('blue', `v${pkg.version}`)}`, pkg.links.repository ?? pkg.links.npm, terminalColumns, ), value: pkg, })) } catch { console.error('Error when fetching npm registry') process.exit(1) } } ================================================ FILE: src/fs.ts ================================================ import type { RunnerContext } from './runner' import fs from 'node:fs' import { resolve } from 'node:path' import process from 'node:process' export function getPackageJSON(ctx?: RunnerContext): any { const cwd = ctx?.cwd ?? process.cwd() const path = resolve(cwd, 'package.json') if (fs.existsSync(path)) { try { const raw = fs.readFileSync(path, 'utf-8') const data = JSON.parse(raw) return data } catch (e) { if (!ctx?.programmatic) { console.warn('Failed to parse package.json') process.exit(1) } throw e } } } ================================================ FILE: src/index.ts ================================================ export * from './config' export * from './detect' export * from './parse' export * from './runner' export * from './utils' export * from 'package-manager-detector/commands' export * from 'package-manager-detector/constants' ================================================ FILE: src/monorepo.ts ================================================ import type { Choice } from '@posva/prompts' import type { RunnerContext } from './runner' import { existsSync } from 'node:fs' import { dirname, resolve } from 'node:path' import process from 'node:process' import prompts from '@posva/prompts' import { byLengthAsc, Fzf } from 'fzf' import { globSync } from 'tinyglobby' import { getPackageJSON } from './fs' export const IGNORE_PATHS = [ '**/node_modules/**', '**/dist/**', '**/public/**', '**/fixture/**', '**/fixtures/**', ] export function findPackages(ctx?: RunnerContext) { const { cwd = process.cwd() } = ctx ?? {} const packagePath = resolve(cwd, 'package.json') if (!existsSync(packagePath)) return [] const pkgs = globSync('**/package.json', { ignore: IGNORE_PATHS, cwd, onlyFiles: true, dot: false, expandDirectories: false, }) if (pkgs.length <= 1) return [packagePath] return pkgs } export async function promptSelectPackage(ctx?: RunnerContext, command?: string): Promise { const cwd = ctx?.cwd ?? process.cwd() const packagePaths = findPackages(ctx) if (packagePaths.length <= 1) { return ctx } const blank = ' '.repeat(process.stdout?.columns || 80) // Prompt the user to select a package let choices: (Choice & { scripts: Record })[] = packagePaths.map((item) => { const filePath = resolve(cwd, item) const dir = dirname(filePath) const pkg = getPackageJSON({ ...ctx, cwd: dir, programmatic: true }) return { title: pkg.name ?? item, value: dir, description: `${pkg.description ?? filePath}${blank}`, scripts: pkg.scripts, } }) // Filter packages that have the command if (command) { choices = choices.filter(c => c.scripts?.[command]) } if (!choices.length) { return ctx } if (choices.length === 1) { return { ...ctx, cwd: choices[0].value } } const fzf = new Fzf(choices, { selector: item => `${item.title} ${item.description}`, casing: 'case-insensitive', tiebreakers: [byLengthAsc], }) let res: string try { const { pkg } = await prompts({ name: 'pkg', message: 'select a package', type: 'autocomplete', choices, async suggest(input: string, choices: Choice[]) { if (!input) return choices const results = fzf.find(input) return results.map(r => choices.find(c => c.value === r.item.value)) }, }) if (!pkg) throw new Error('No package selected') res = pkg } catch (error) { if (!ctx?.programmatic) process.exit(1) throw error } return { ...ctx, cwd: res } } ================================================ FILE: src/package.ts ================================================ import type { RunnerContext } from '.' import { getPackageJSON } from './fs' import { promptSelectPackage } from './monorepo' export interface PackageScript { key: string cmd: string description: string } export async function readWorkspaceScripts(ctx: RunnerContext | undefined, args: string[]): Promise { const index = args.findIndex(i => i === '-p') let command: string = '' if (index !== -1) { command = args[index + 1] } const context = await promptSelectPackage(ctx, command) // Change cwd to the selected package if (ctx && context?.cwd) { ctx.cwd = context.cwd } const scripts = readPackageScripts(context) const cmdIndex = scripts.findIndex(i => i.key === command) if (command && cmdIndex !== -1) { return [scripts[cmdIndex]] } return scripts } export function readPackageScripts(ctx: RunnerContext | undefined): PackageScript[] { // support https://www.npmjs.com/package/npm-scripts-info conventions const pkg = getPackageJSON(ctx) const rawScripts = pkg.scripts || {} const scriptsInfo = pkg['scripts-info'] || {} const scripts = Object.entries(rawScripts) .filter(i => !i[0].startsWith('?')) .map(([key, cmd]) => ({ key, cmd, description: scriptsInfo[key] || rawScripts[`?${key}`] || cmd, })) if (scripts.length === 0 && !ctx?.programmatic) { console.warn('No scripts found in package.json') } return scripts as PackageScript[] } ================================================ FILE: src/parse.ts ================================================ import type { Agent, Command, ResolvedCommand } from 'package-manager-detector' import type { ExtendedResolvedCommand, Runner } from './runner' import process from 'node:process' import { COMMANDS, constructCommand, getRunAgent } from '.' import { exclude } from './utils' export class UnsupportedCommand extends Error { constructor({ agent, command }: { agent: Agent, command: Command }) { super(`Command "${command}" is not support by agent "${agent}"`) } } export function getCommand( agent: Agent, command: Command, args: string[] = [], ): ExtendedResolvedCommand { if (!COMMANDS[agent]) throw new Error(`Unsupported agent "${agent}"`) if (!COMMANDS[agent][command]) throw new UnsupportedCommand({ agent, command }) return constructCommand(COMMANDS[agent][command], args)! } export const parseNi = ((agent, args, ctx) => { // bun use `-d` instead of `-D`, #90 if (agent === 'bun') args = args.map(i => i === '-D' ? '-d' : i) // npm use `--omit=dev` instead of `--production` if (agent === 'npm') args = args.map(i => i === '-P' ? '--omit=dev' : i) if (args.includes('-P')) args = args.map(i => i === '-P' ? '--production' : i) if (args.includes('-g')) return getCommand(agent, 'global', exclude(args, '-g')) if (args.includes('--frozen-if-present')) { args = exclude(args, '--frozen-if-present') return getCommand(agent, ctx?.hasLock ? 'frozen' : 'install', args) } if (args.includes('--frozen')) return getCommand(agent, 'frozen', exclude(args, '--frozen')) if (args.length === 0 || args.every(i => i.startsWith('-'))) return getCommand(agent, 'install', args) return getCommand(agent, 'add', args) }) export const parseNr = (async (agent, args, ctx) => { if (args.length === 0) args.push('start') const runAgent = await getRunAgent() let runWithNode = false if (runAgent === 'node') { const [majorNodeVersion] = process.versions.node.split('.').map(Number) if (majorNodeVersion < 22) { throw new Error('The runAgent "node" requires Node.js 22.0.0 or higher') } runWithNode = true args = ['--run', ...args] } let hasIfPresent = false if (args.includes('--if-present')) { args = exclude(args, '--if-present') hasIfPresent = true } if (args.includes('-p')) args = exclude(args, '-p') const cmd = runWithNode ? { command: 'node', args } : getCommand(agent, 'run', args) if (ctx?.cwd) cmd.cwd = ctx.cwd if (!cmd) return cmd if (hasIfPresent && !runWithNode) cmd.args.splice(1, 0, '--if-present') return cmd }) export const parseNup = ((agent, args) => { if (args.includes('-i')) return getCommand(agent, 'upgrade-interactive', exclude(args, '-i')) return getCommand(agent, 'upgrade', args) }) export const parseNd = ((agent, args) => { // https://yarnpkg.com/cli/dedupe#options // https://pnpm.io/cli/dedupe#--check if (agent === 'pnpm') args = args.map(i => i === '-c' ? '--check' : i) // https://docs.npmjs.com/cli/v11/commands/npm-dedupe#dry-run if (agent === 'npm') args = args.map(i => i === '-c' ? '--dry-run' : i) return getCommand(agent, 'dedupe', args) }) export const parseNun = ((agent, args) => { if (args.includes('-g')) return getCommand(agent, 'global_uninstall', exclude(args, '-g')) return getCommand(agent, 'uninstall', args) }) export const parseNlx = ((agent, args) => { return getCommand(agent, 'execute', args) }) export const parseNa = ((agent, args) => { return getCommand(agent, 'agent', args) }) export function serializeCommand(command?: ResolvedCommand) { if (!command) return undefined if (command.args.length === 0) return command.command return `${command.command} ${command.args.map(i => i.includes(' ') ? `"${i}"` : i).join(' ')}` } ================================================ FILE: src/runner.ts ================================================ import type { Agent, ResolvedCommand } from 'package-manager-detector' import type { Options as TinyExecOptions } from 'tinyexec' import type { DetectOptions } from './detect' /* eslint-disable no-console */ import { resolve } from 'node:path' import process from 'node:process' import { styleText } from 'node:util' import prompts from '@posva/prompts' import { AGENTS } from 'package-manager-detector' import { x } from 'tinyexec' import { version } from '../package.json' import { getDefaultAgent, getGlobalAgent, getUseSfw } from './config' import { detect } from './detect' import { getEnvironmentOptions } from './environment' import { getCommand, UnsupportedCommand } from './parse' import { cmdExists, remove } from './utils' const DEBUG_SIGN = '?' const PROGRAMMATIC_SIGN = '--programmatic' export interface RunnerContext { programmatic?: boolean hasLock?: boolean cwd?: string } export interface ExtendedResolvedCommand extends ResolvedCommand { cwd?: string } interface RunOptions { /** * Called before agent detection and command execution. * * Useful for performing concrete, agent-agnostic operations. */ onBeforeCommand?: (args: string[], ctx: Pick & { /** * Skips subsequent command execution. * * This is useful for operations such as generating shell-completion scripts. */ exit: () => void }) => void | Promise } export type Runner = (agent: Agent, args: string[], ctx?: RunnerContext) => Promise | ExtendedResolvedCommand | undefined export async function runCli(fn: Runner, options: DetectOptions & RunOptions & { args?: string[] } = {}) { options = { ...getEnvironmentOptions(), ...options, } const { args = process.argv.slice(2).filter(Boolean), } = options try { await run(fn, args, options) } catch (error) { if (error instanceof UnsupportedCommand && !options.programmatic) console.log(styleText('red', `\u2717 ${error.message}`)) if (!options.programmatic) process.exit(1) throw error } } export async function getCliCommand( fn: Runner, args: string[], options: DetectOptions = {}, cwd: string = options.cwd ?? process.cwd(), ) { const isGlobal = args.includes('-g') if (isGlobal) return await fn(await getGlobalAgent(), args) let agent = (await detect({ ...options, cwd })) || (await getDefaultAgent(options.programmatic)) if (agent === 'prompt') { agent = ( await prompts({ name: 'agent', type: 'select', message: 'Choose the agent', choices: AGENTS.filter(i => !i.includes('@')).map(value => ({ title: value, value })), }) ).agent if (!agent) return } return await fn(agent as Agent, args, { programmatic: options.programmatic, hasLock: Boolean(agent), cwd, }) } export async function run(fn: Runner, args: string[], options: DetectOptions & RunOptions = {}) { const { detectVolta = true } = options const debug = args.includes(DEBUG_SIGN) if (debug) remove(args, DEBUG_SIGN) const programmaticFromArgs = args.includes(PROGRAMMATIC_SIGN) if (programmaticFromArgs) remove(args, PROGRAMMATIC_SIGN) const programmatic = options.programmatic || programmaticFromArgs let cwd = options.cwd ?? process.cwd() if (args[0] === '-C') { cwd = resolve(cwd, args[1]) args.splice(0, 2) } if (args.length === 1 && (args[0]?.toLowerCase() === '-v' || args[0] === '--version')) { const getCmd = (a: Agent) => AGENTS.includes(a) ? getCommand(a, 'agent', ['-v']) : { command: a, args: ['-v'] } const xVersionOptions = { nodeOptions: { cwd, }, throwOnError: true, } satisfies Partial const getV = (a: string) => { const { command, args } = getCmd(a as Agent) return x(command, args, xVersionOptions) .then(e => e.stdout) .then(e => e.startsWith('v') ? e : `v${e}`) } const globalAgentPromise = getGlobalAgent() const globalAgentVersionPromise = globalAgentPromise.then(getV) const agentPromise = detect({ ...options, cwd }).then(a => a || '') const agentVersionPromise = agentPromise.then(a => a && getV(a)) const nodeVersionPromise = getV('node') console.log(`@antfu/ni ${styleText('cyan', `v${version}`)}`) console.log(`node ${styleText('green', await nodeVersionPromise)}`) const [agent, agentVersion] = await Promise.all([agentPromise, agentVersionPromise]) if (agent) console.log(`${agent.padEnd(10)} ${styleText('blue', agentVersion)}`) else console.log('agent no lock file') const [globalAgent, globalAgentVersion] = await Promise.all([globalAgentPromise, globalAgentVersionPromise]) console.log(`${(`${globalAgent} -g`).padEnd(10)} ${styleText('blue', globalAgentVersion)}`) return } if (args.length === 1 && ['-h', '--help'].includes(args[0])) { const dash = styleText('dim', '-') console.log(`${styleText(['green', 'bold'], '@antfu/ni')} ${styleText('dim', `use the right package manager v${version}`)}\n`) console.log(`ni ${dash} install`) console.log(`nr ${dash} run`) console.log(`nlx ${dash} execute`) console.log(`nup ${dash} upgrade`) console.log(`nun ${dash} uninstall`) console.log(`nci ${dash} clean install`) console.log(`na ${dash} agent alias`) console.log(`nd ${dash} dedupe dependencies`) console.log(`ni -v ${dash} show used agent`) console.log(`ni -i ${dash} interactive package management`) console.log(styleText('yellow', '\ncheck https://github.com/antfu/ni for more documentation.')) return } if (options.onBeforeCommand) { let shouldExit = false await options.onBeforeCommand(args, { cwd, programmatic, exit: () => { shouldExit = true }, }) if (shouldExit) return } const command = await getCliCommand(fn, args, { ...options, programmatic }, cwd) if (!command) return const useSfw = await getUseSfw() if (useSfw && cmdExists('sfw')) { command.args = [command.command, ...command.args] command.command = 'sfw' } else if (useSfw) { if (programmatic) throw new Error('sfw is enabled but not installed') console.error('[ni] sfw is enabled but not installed.') console.error('[ni] Install it with: npm install -g sfw') process.exit(1) } if (detectVolta && cmdExists('volta')) { command.args = ['run', command.command, ...command.args] command.command = 'volta' } if (debug) { const commandStr = [command.command, ...command.args].join(' ') console.log(commandStr) return } const proc = x( command.command, command.args, { nodeOptions: { stdio: 'inherit', cwd: command.cwd ?? cwd, }, throwOnError: true, }, ) process.once('SIGINT', async () => { // Ensure the proc finishes cleanup before exiting await proc process.exit(proc.exitCode) }) await proc } ================================================ FILE: src/storage.ts ================================================ import { existsSync, promises as fs } from 'node:fs' import { resolve } from 'node:path' import { CLI_TEMP_DIR, writeFileSafe } from './utils' export interface Storage { lastRunCommand?: string } let storage: Storage | undefined const storagePath = resolve(CLI_TEMP_DIR, '_storage.json') export async function load(fn?: (storage: Storage) => Promise | boolean) { if (!storage) { storage = existsSync(storagePath) ? (JSON.parse(await fs.readFile(storagePath, 'utf-8') || '{}') || {}) : {} } if (fn) { if (await fn(storage!)) await dump() } return storage! } export async function dump() { if (storage) await writeFileSafe(storagePath, JSON.stringify(storage)) } ================================================ FILE: src/utils.ts ================================================ import type { Buffer } from 'node:buffer' import { existsSync, promises as fs } from 'node:fs' import os from 'node:os' import { dirname, join } from 'node:path' import process from 'node:process' import { styleText } from 'node:util' import which from 'which' export const CLI_TEMP_DIR = join(os.tmpdir(), 'antfu-ni') export function remove(arr: T[], v: T) { const index = arr.indexOf(v) if (index >= 0) arr.splice(index, 1) return arr } export function exclude(arr: T[], ...v: T[]) { return arr.slice().filter(item => !v.includes(item)) } export function cmdExists(cmd: string) { return which.sync(cmd, { nothrow: true }) !== null } 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 += 1 return fs.open(competitivePath, 'wx') .then(fd => ({ fd, path: competitivePath, cleanup() { fd.close().then(() => { if (existsSync(competitivePath)) fs.unlink(competitivePath) }) }, })) .catch((error: any) => { if (error && error.code === 'EEXIST') return openTemp() else return undefined }) } /** * Write file safely avoiding conflicts */ export async function writeFileSafe( path: string, data: string | Buffer = '', ): Promise { const temp = await openTemp() 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) } return false } export function limitText(text: string, maxWidth: number) { if (text.length <= maxWidth) return text return `${text.slice(0, maxWidth)}${styleText('dim', '…')}` } export function terminalLink(text: string, url: string, options?: { fallback?: (text: string, url: string) => string }): string { // Use OSC 8 hyperlink escape sequence // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda if ( process.env.FORCE_HYPERLINK || (process.stdout.isTTY && !process.env.NO_HYPERLINK) ) { return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007` } if (options?.fallback) return options.fallback(text, url) return `${text} (${url})` } export function formatPackageWithUrl(pkg: string, url?: string, limits = 80) { return url ? terminalLink( pkg, url, { fallback: (_, url) => (pkg.length + url.length > limits) ? pkg : `${pkg} ${styleText('dim', `- ${url}`)}`, }, ) : pkg } ================================================ FILE: taze.config.ts ================================================ import { defineConfig } from 'taze' export default defineConfig({ ignorePaths: [ 'test/fixtures', ], }) ================================================ FILE: test/config/.nirc ================================================ defaultAgent=npm globalAgent=pnpm useSfw=true ================================================ FILE: test/config/config.test.ts ================================================ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { beforeEach, expect, it, vi } from 'vitest' const __dirname = dirname(fileURLToPath(import.meta.url)) beforeEach(() => { vi.unstubAllEnvs() vi.resetModules() }) vi.mock('../../src/detect', () => ({ detect: vi.fn(), })) it('has correct defaults', async () => { const { getConfig } = await import('../../src/config') const config = await getConfig() expect(config).toEqual({ defaultAgent: 'prompt', globalAgent: 'npm', runAgent: undefined, useSfw: false, catalog: true, }) }) it('loads .nirc', async () => { vi.stubEnv('NI_CONFIG_FILE', join(__dirname, './.nirc')) const { getConfig } = await import('../../src/config') const config = await getConfig() expect(config).toEqual({ defaultAgent: 'npm', globalAgent: 'pnpm', runAgent: undefined, useSfw: true, catalog: true, }) }) it('reads environment variable config', async () => { vi.stubEnv('NI_DEFAULT_AGENT', 'npm') vi.stubEnv('NI_GLOBAL_AGENT', 'pnpm') vi.stubEnv('NI_USE_SFW', 'true') const { getConfig } = await import('../../src/config') const config = await getConfig() expect(config).toEqual({ defaultAgent: 'npm', globalAgent: 'pnpm', runAgent: undefined, useSfw: true, catalog: true, }) }) ================================================ FILE: test/fixtures/catalog/pnpm/package.json ================================================ { "name": "test-workspace", "private": true, "dependencies": {} } ================================================ FILE: test/fixtures/catalog/pnpm/packages/app/package.json ================================================ { "name": "test-app", "private": true, "dependencies": {} } ================================================ FILE: test/fixtures/catalog/pnpm/pnpm-workspace.yaml ================================================ packages: - packages/* # Named catalogs catalogs: prod: react: ^18.3.0 express: ^4.21.0 dev: typescript: ^5.6.0 vitest: ^2.1.0 ================================================ FILE: test/fixtures/catalog/pnpm-default-only/package.json ================================================ { "name": "test-workspace-default", "private": true, "dependencies": {} } ================================================ FILE: test/fixtures/catalog/pnpm-default-only/pnpm-workspace.yaml ================================================ packages: - packages/* catalog: react: ^18.3.0 express: ^4.21.0 ================================================ FILE: test/fixtures/lockfile/bun/bun.lockb ================================================ ================================================ FILE: test/fixtures/lockfile/unknown/future-package-manager.json ================================================ {} ================================================ FILE: test/fixtures/packager/bun/package.json ================================================ { "packageManager": "bun@0" } ================================================ FILE: test/fixtures/packager/deno/deno.json ================================================ {} ================================================ FILE: test/fixtures/packager/npm/package.json ================================================ { "packageManager": "npm@7" } ================================================ FILE: test/fixtures/packager/pnpm/package.json ================================================ { "packageManager": "pnpm@8" } ================================================ FILE: test/fixtures/packager/pnpm-version-range/package.json ================================================ { "packageManager": "^pnpm@8.0.0" } ================================================ FILE: test/fixtures/packager/pnpm@6/package.json ================================================ { "packageManager": "pnpm@6" } ================================================ FILE: test/fixtures/packager/unknown/package.json ================================================ { "packageManager": "future-package-manager" } ================================================ FILE: test/fixtures/packager/yarn/package.json ================================================ { "packageManager": "yarn@1" } ================================================ FILE: test/fixtures/packager/yarn@berry/package.json ================================================ { "packageManager": "yarn@3" } ================================================ FILE: test/na/bun.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNa, serializeCommand } from '../../src/commands' const agent = 'bun' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNa(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'bun')) it('foo', _('foo', 'bun foo')) it('run test', _('run test', 'bun run test')) ================================================ FILE: test/na/deno.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNa, serializeCommand } from '../../src/commands' const agent = 'deno' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNa(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'deno')) it('foo', _('foo', 'deno foo')) it('run test', _('run test', 'deno run test')) it('task dev', _('task dev', 'deno task dev')) it('install', _('install', 'deno install')) it('add package', _('add package', 'deno add package')) ================================================ FILE: test/na/npm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNa, serializeCommand } from '../../src/commands' const agent = 'npm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNa(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'npm')) it('foo', _('foo', 'npm foo')) it('run test', _('run test', 'npm run test')) ================================================ FILE: test/na/pnpm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNa, serializeCommand } from '../../src/commands' const agent = 'pnpm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNa(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'pnpm')) it('foo', _('foo', 'pnpm foo')) it('run test', _('run test', 'pnpm run test')) ================================================ FILE: test/na/yarn.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNa, serializeCommand } from '../../src/commands' const agent = 'yarn' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNa(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'yarn')) it('foo', _('foo', 'yarn foo')) it('run test', _('run test', 'yarn run test')) ================================================ FILE: test/na/yarn@berry.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNa, serializeCommand } from '../../src/commands' const agent = 'yarn@berry' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNa(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'yarn')) it('foo', _('foo', 'yarn foo')) it('run test', _('run test', 'yarn run test')) ================================================ FILE: test/ng.spec.ts ================================================ import { expect, it } from 'vitest' import { getCommand } from '../src/commands' it('wrong agent', () => { expect(() => { getCommand('idk' as any, 'install', []) }).toThrow('Unsupported agent "idk"') }) ================================================ FILE: test/ni/bun.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNi, serializeCommand } from '../../src/commands' const agent = 'bun' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNi(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'bun install')) it('single add', _('axios', 'bun add axios')) it('add dev', _('vite -D', 'bun add vite -d')) it('multiple', _('eslint @types/node', 'bun add eslint @types/node')) it('global', _('eslint -g', 'bun add -g eslint')) it('frozen', _('--frozen', 'bun install --frozen-lockfile')) it('production', _('-P', 'bun install --production')) it('frozen production', _('--frozen -P', 'bun install --frozen-lockfile --production')) ================================================ FILE: test/ni/catalog.spec.ts ================================================ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { pnpmCatalogProvider } from '../../src/catalog/pnpm' import { getCatalogRef } from '../../src/catalog/types' const __dirname = dirname(fileURLToPath(import.meta.url)) const fixtureDir = join(__dirname, '..', 'fixtures', 'catalog', 'pnpm') const defaultOnlyFixtureDir = join(__dirname, '..', 'fixtures', 'catalog', 'pnpm-default-only') describe('getCatalogRef', () => { it('returns "catalog:" for default', () => { expect(getCatalogRef('default')).toBe('catalog:') }) it('returns "catalog:name" for named', () => { expect(getCatalogRef('dev')).toBe('catalog:dev') expect(getCatalogRef('prod')).toBe('catalog:prod') }) }) describe('pnpmCatalogProvider.detect', () => { it('detects named catalogs', async () => { const config = await pnpmCatalogProvider.detect(fixtureDir) expect(config).not.toBeNull() expect(config!.hasDefaultCatalog).toBe(false) expect(config!.hasNamedCatalogs).toBe(true) expect(config!.catalogs).toHaveLength(2) expect(config!.catalogs.map(c => c.name)).toEqual(['prod', 'dev']) }) it('detects default catalog only', async () => { const config = await pnpmCatalogProvider.detect(defaultOnlyFixtureDir) expect(config).not.toBeNull() expect(config!.hasDefaultCatalog).toBe(true) expect(config!.hasNamedCatalogs).toBe(false) expect(config!.catalogs).toHaveLength(1) expect(config!.catalogs[0].name).toBe('default') }) it('detects from subdirectory', async () => { const subDir = join(fixtureDir, 'packages', 'app') const config = await pnpmCatalogProvider.detect(subDir) expect(config).not.toBeNull() expect(config!.catalogs).toHaveLength(2) }) it('returns null when no workspace file', async () => { const config = await pnpmCatalogProvider.detect('/tmp') expect(config).toBeNull() }) }) describe('pnpmCatalogProvider.findPackage', () => { it('finds package in named catalog', async () => { const config = (await pnpmCatalogProvider.detect(fixtureDir))! const result = pnpmCatalogProvider.findPackage(config, 'react') expect(result).not.toBeUndefined() expect(result!.name).toBe('prod') }) it('finds package in dev catalog', async () => { const config = (await pnpmCatalogProvider.detect(fixtureDir))! const result = pnpmCatalogProvider.findPackage(config, 'typescript') expect(result).not.toBeUndefined() expect(result!.name).toBe('dev') }) it('returns undefined for unknown package', async () => { const config = (await pnpmCatalogProvider.detect(fixtureDir))! const result = pnpmCatalogProvider.findPackage(config, 'unknown-pkg') expect(result).toBeUndefined() }) it('finds package in default catalog', async () => { const config = (await pnpmCatalogProvider.detect(defaultOnlyFixtureDir))! const result = pnpmCatalogProvider.findPackage(config, 'react') expect(result).not.toBeUndefined() expect(result!.name).toBe('default') }) }) ================================================ FILE: test/ni/deno.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNi, serializeCommand } from '../../src/commands' const agent = 'deno' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNi(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'deno install')) it('single add', _('axios', 'deno add axios')) it('multiple', _('eslint @types/node', 'deno add eslint @types/node')) it('-D', _('eslint @types/node -D', 'deno add eslint @types/node -D')) it('global', _('eslint -g', 'deno install -g eslint')) it('frozen', _('--frozen', 'deno install --frozen')) it('production', _('-P', 'deno install --production')) it('frozen production', _('--frozen -P', 'deno install --frozen --production')) ================================================ FILE: test/ni/interactive.spec.ts ================================================ import type { Agent } from 'package-manager-detector' import type { RunnerContext } from '../../src' import process from 'node:process' import prompts from '@posva/prompts' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fetchNpmPackages } from '../../src/fetch' import { parseNi } from '../../src/parse' import { exclude } from '../../src/utils' vi.mock('@posva/prompts') vi.mock('../../src/fetch') describe('interactive mode - Ctrl+C cancellation', () => { let originalExitCode: typeof process.exitCode beforeEach(() => { originalExitCode = process.exitCode process.exitCode = 0 vi.clearAllMocks() }) afterEach(() => { process.exitCode = originalExitCode }) async function niRunner(agent: Agent, args: string[], ctx?: RunnerContext) { const isInteractive = args[0] === '-i' if (isInteractive) { let fetchPattern: string if (args[1] && !args[1].startsWith('-')) { fetchPattern = args[1] } else { const { pattern } = await prompts({ type: 'text', name: 'pattern', message: 'search for package', }) fetchPattern = pattern } if (!fetchPattern) { process.exitCode = 1 return } const packages = await fetchNpmPackages(fetchPattern) if (!packages.length) { console.error('No results found') process.exitCode = 1 return } const { dependency } = await prompts({ type: 'autocomplete', name: 'dependency', choices: packages, instructions: false, message: 'choose a package to install', limit: 15, }) if (!dependency) { process.exitCode = 1 return } args = exclude(args, '-d', '-p', '-i') const canInstallPeers = ['npm', 'pnpm'].includes(agent) const { mode } = await prompts({ type: 'select', name: 'mode', message: `install ${dependency.name} as`, choices: [ { title: 'prod', value: '', selected: true, }, { title: 'dev', value: '-D', }, { title: `peer`, value: '--save-peer', disabled: !canInstallPeers, }, ], }) if (mode === undefined) { process.exitCode = 1 return } args.push(dependency.name, mode) } return parseNi(agent, args, ctx) } it('should exit gracefully when user cancels package selection with Ctrl+C', async () => { vi.mocked(prompts) .mockResolvedValueOnce({ pattern: 'react' }) // First prompt: search pattern .mockResolvedValueOnce({ dependency: undefined }) // Second prompt: cancelled with Ctrl+C vi.mocked(fetchNpmPackages).mockResolvedValue([ { title: 'react', value: 'react' }, { title: 'react-dom', value: 'react-dom' }, ]) const result = await niRunner('npm', ['-i']) expect(process.exitCode).toBe(1) expect(result).toBeUndefined() }) it('should exit gracefully when user cancels installation mode selection with Ctrl+C', async () => { vi.mocked(prompts) .mockResolvedValueOnce({ pattern: 'react' }) // First prompt: search pattern .mockResolvedValueOnce({ dependency: { name: 'react', value: 'react' } }) // Second prompt: select package .mockResolvedValueOnce({ mode: undefined }) // Third prompt: cancelled with Ctrl+C vi.mocked(fetchNpmPackages).mockResolvedValue([ { title: 'react', value: 'react' }, ]) const result = await niRunner('npm', ['-i']) expect(process.exitCode).toBe(1) expect(result).toBeUndefined() }) it('should exit gracefully when user cancels initial search pattern with Ctrl+C', async () => { vi.mocked(prompts) .mockResolvedValueOnce({ pattern: undefined }) // First prompt: cancelled with Ctrl+C const result = await niRunner('npm', ['-i']) expect(process.exitCode).toBe(1) expect(result).toBeUndefined() }) }) ================================================ FILE: test/ni/npm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNi, serializeCommand } from '../../src/commands' const agent = 'npm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNi(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'npm i')) it('single add', _('axios', 'npm i axios')) it('multiple', _('eslint @types/node', 'npm i eslint @types/node')) it('-D', _('eslint @types/node -D', 'npm i eslint @types/node -D')) it('global', _('eslint -g', 'npm i -g eslint')) it('frozen', _('--frozen', 'npm ci')) it('production', _('-P', 'npm i --omit=dev')) it('frozen production', _('--frozen -P', 'npm ci --omit=dev')) ================================================ FILE: test/ni/pnpm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNi, serializeCommand } from '../../src/commands' const agent = 'pnpm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNi(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'pnpm i')) it('single add', _('axios', 'pnpm add axios')) it('multiple', _('eslint @types/node', 'pnpm add eslint @types/node')) it('-D', _('-D eslint @types/node', 'pnpm add -D eslint @types/node')) it('global', _('eslint -g', 'pnpm add -g eslint')) it('frozen', _('--frozen', 'pnpm i --frozen-lockfile')) it('forward1', _('--anything', 'pnpm i --anything')) it('forward2', _('-a', 'pnpm i -a')) it('production', _('-P', 'pnpm i --production')) it('frozen production', _('--frozen -P', 'pnpm i --frozen-lockfile --production')) ================================================ FILE: test/ni/yarn.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNi, serializeCommand } from '../../src/commands' const agent = 'yarn' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNi(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'yarn install')) it('single add', _('axios', 'yarn add axios')) it('multiple', _('eslint @types/node', 'yarn add eslint @types/node')) it('-D', _('eslint @types/node -D', 'yarn add eslint @types/node -D')) it('global', _('eslint ni -g', 'yarn global add eslint ni')) it('frozen', _('--frozen', 'yarn install --frozen-lockfile')) it('production', _('-P', 'yarn install --production')) it('frozen production', _('--frozen -P', 'yarn install --frozen-lockfile --production')) ================================================ FILE: test/ni/yarn@berry.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNi, serializeCommand } from '../../src/commands' const agent = 'yarn@berry' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNi(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'yarn install')) it('single add', _('axios', 'yarn add axios')) it('multiple', _('eslint @types/node', 'yarn add eslint @types/node')) it('-D', _('eslint @types/node -D', 'yarn add eslint @types/node -D')) it('global', _('eslint ni -g', 'npm i -g eslint ni')) it('frozen', _('--frozen', 'yarn install --immutable')) it('production', _('-P', 'yarn install --production')) it('frozen production', _('--frozen -P', 'yarn install --immutable --production')) ================================================ FILE: test/nlx/bun.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNlx, serializeCommand } from '../../src/commands' const agent = 'bun' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNlx(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('esbuild', 'bun x esbuild')) it('multiple', _('esbuild --version', 'bun x esbuild --version')) ================================================ FILE: test/nlx/deno.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNlx, serializeCommand } from '../../src/commands' const agent = 'deno' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNlx(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('esbuild', 'deno run npm:esbuild')) it('multiple', _('esbuild --version', 'deno run npm:esbuild --version')) it('vitest', _('vitest', 'deno run npm:vitest')) it('with args', _('typescript --version', 'deno run npm:typescript --version')) ================================================ FILE: test/nlx/npm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNlx, serializeCommand } from '../../src/commands' const agent = 'npm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNlx(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('esbuild', 'npx esbuild')) it('multiple', _('esbuild --version', 'npx esbuild --version')) ================================================ FILE: test/nlx/pnpm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNlx, serializeCommand } from '../../src/commands' const agent = 'pnpm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNlx(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('esbuild', 'pnpm dlx esbuild')) it('multiple', _('esbuild --version', 'pnpm dlx esbuild --version')) ================================================ FILE: test/nlx/yarn.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNlx, serializeCommand } from '../../src/commands' const agent = 'yarn' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNlx(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('esbuild', 'npx esbuild')) it('multiple', _('esbuild --version', 'npx esbuild --version')) ================================================ FILE: test/nlx/yarn@berry.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNlx, serializeCommand } from '../../src/commands' const agent = 'yarn@berry' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNlx(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('esbuild', 'yarn dlx esbuild')) it('multiple', _('esbuild --version', 'yarn dlx esbuild --version')) ================================================ FILE: test/nr/bun.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNr, serializeCommand } from '../../src/commands' const agent = 'bun' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNr(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'bun run start')) it('script', _('dev', 'bun run dev')) it('script with arguments', _('build --watch -o', 'bun run build --watch -o')) it('colon', _('build:dev', 'bun run build:dev')) ================================================ FILE: test/nr/deno.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNr, serializeCommand } from '../../src/commands' const agent = 'deno' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNr(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'deno task start')) it('if-present', _('test --if-present', 'deno task --if-present test')) it('script', _('dev', 'deno task dev')) it('script with arguments', _('build --watch -o', 'deno task build --watch -o')) it('colon', _('build:dev', 'deno task build:dev')) ================================================ FILE: test/nr/nodeRunAgent.spec.ts ================================================ import type { ResolvedCommand } from 'package-manager-detector' import { beforeEach, expect, it, vi } from 'vitest' import { parseNr } from '../../src/commands' const agent = 'npm' const [majorNodeVersion] = process.versions.node.split('.').map(Number) const supportsNodeRun = majorNodeVersion >= 22 function _(arg: string, expected: ResolvedCommand) { return async () => { expect( await parseNr(agent, arg.split(' ').filter(Boolean)), ).toEqual( expected, ) } } function expectError(arg: string) { return async () => { await expect( parseNr(agent, arg.split(' ').filter(Boolean)), ).rejects.toThrow('requires Node.js 22.0.0 or higher') } } beforeEach(() => { vi.stubEnv('NI_RUN_AGENT', 'node') }) it('empty', supportsNodeRun ? _('', { command: 'node', args: ['--run', 'start'] }) : expectError('')) it('if-present', supportsNodeRun ? _('test --if-present', { command: 'node', args: ['--run', 'test'] }) : expectError('test --if-present')) it('script', supportsNodeRun ? _('dev', { command: 'node', args: ['--run', 'dev'] }) : expectError('dev')) it('script with arguments', supportsNodeRun ? _('build --watch -o', { command: 'node', args: ['--run', 'build', '--watch', '-o'] }) : expectError('build --watch -o')) it('colon', supportsNodeRun ? _('build:dev', { command: 'node', args: ['--run', 'build:dev'] }) : expectError('build:dev')) ================================================ FILE: test/nr/npm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNr, serializeCommand } from '../../src/commands' const agent = 'npm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNr(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'npm run start')) it('if-present', _('test --if-present', 'npm run --if-present test')) it('script', _('dev', 'npm run dev')) it('script with arguments', _('build --watch -o', 'npm run build -- --watch -o')) it('colon', _('build:dev', 'npm run build:dev')) ================================================ FILE: test/nr/pnpm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNr, serializeCommand } from '../../src/commands' const agent = 'pnpm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNr(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'pnpm run start')) it('if-present', _('test --if-present', 'pnpm run --if-present test')) it('script', _('dev', 'pnpm run dev')) it('script with arguments', _('build --watch -o', 'pnpm run build --watch -o')) it('colon', _('build:dev', 'pnpm run build:dev')) ================================================ FILE: test/nr/yarn.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNr, serializeCommand } from '../../src/commands' const agent = 'yarn' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNr(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'yarn run start')) it('if-present', _('test --if-present', 'yarn run --if-present test')) it('script', _('dev', 'yarn run dev')) it('script with arguments', _('build --watch -o', 'yarn run build --watch -o')) it('colon', _('build:dev', 'yarn run build:dev')) ================================================ FILE: test/nr/yarn@berry.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNr, serializeCommand } from '../../src/commands' const agent = 'yarn@berry' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNr(agent, arg.split(/\s/g).filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'yarn run start')) it('if-present', _('test --if-present', 'yarn run --if-present test')) it('script', _('dev', 'yarn run dev')) it('script with arguments', _('build --watch -o', 'yarn run build --watch -o')) it('colon', _('build:dev', 'yarn run build:dev')) ================================================ FILE: test/nun/bun.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNun, serializeCommand } from '../../src/commands' const agent = 'bun' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNun(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('axios', 'bun remove axios')) it('multiple', _('eslint @types/node', 'bun remove eslint @types/node')) it('global', _('eslint ni -g', 'bun remove -g eslint ni')) ================================================ FILE: test/nun/deno.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNun, serializeCommand } from '../../src/commands' const agent = 'deno' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNun(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('webpack', 'deno remove webpack')) it('multiple', _('webpack eslint', 'deno remove webpack eslint')) it('global', _('webpack -g', 'deno uninstall -g webpack')) it('forward', _('webpack --save-dev', 'deno remove webpack --save-dev')) ================================================ FILE: test/nun/npm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNun, serializeCommand } from '../../src/commands' const agent = 'npm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNun(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('axios', 'npm uninstall axios')) it('multiple', _('eslint @types/node', 'npm uninstall eslint @types/node')) it('-D', _('eslint @types/node -D', 'npm uninstall eslint @types/node -D')) it('global', _('eslint -g', 'npm uninstall -g eslint')) ================================================ FILE: test/nun/pnpm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNun, serializeCommand } from '../../src/commands' const agent = 'pnpm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNun(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single add', _('axios', 'pnpm remove axios')) it('multiple', _('eslint @types/node', 'pnpm remove eslint @types/node')) it('-D', _('-D eslint @types/node', 'pnpm remove -D eslint @types/node')) it('global', _('eslint -g', 'pnpm remove --global eslint')) ================================================ FILE: test/nun/yarn.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNun, serializeCommand } from '../../src/commands' const agent = 'yarn' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNun(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single uninstall', _('axios', 'yarn remove axios')) it('multiple', _('eslint @types/node', 'yarn remove eslint @types/node')) it('-D', _('eslint @types/node -D', 'yarn remove eslint @types/node -D')) it('global', _('eslint ni -g', 'yarn global remove eslint ni')) ================================================ FILE: test/nun/yarn@berry.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNun, serializeCommand } from '../../src/commands' const agent = 'yarn@berry' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNun(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('single add', _('axios', 'yarn remove axios')) it('multiple', _('eslint @types/node', 'yarn remove eslint @types/node')) it('-D', _('eslint @types/node -D', 'yarn remove eslint @types/node -D')) it('global', _('eslint ni -g', 'npm uninstall -g eslint ni')) ================================================ FILE: test/nup/bun.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNup, serializeCommand } from '../../src/commands' const agent = 'bun' function _(arg: string, expected: string | null) { return async () => { expect( serializeCommand(await parseNup(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'bun update')) it('interactive', _('-i', 'bun update -i')) it('interactive latest', _('-i --latest', 'bun update -i --latest')) ================================================ FILE: test/nup/deno.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNup, serializeCommand } from '../../src/commands' const agent = 'deno' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNup(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'deno outdated --update')) it('interactive', _('-i', 'deno outdated --update')) it('interactive latest', _('-i --latest', 'deno outdated --update --latest')) ================================================ FILE: test/nup/npm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNup, serializeCommand } from '../../src/commands' const agent = 'npm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNup(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'npm update')) ================================================ FILE: test/nup/pnpm.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNup, serializeCommand } from '../../src/commands' const agent = 'pnpm' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNup(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'pnpm update')) it('interactive', _('-i', 'pnpm update -i')) it('interactive latest', _('-i --latest', 'pnpm update -i --latest')) ================================================ FILE: test/nup/yarn.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNup, serializeCommand } from '../../src/commands' const agent = 'yarn' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNup(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'yarn upgrade')) it('interactive', _('-i', 'yarn upgrade-interactive')) it('interactive latest', _('-i --latest', 'yarn upgrade-interactive --latest')) ================================================ FILE: test/nup/yarn@berry.spec.ts ================================================ import { expect, it } from 'vitest' import { parseNup, serializeCommand } from '../../src/commands' const agent = 'yarn@berry' function _(arg: string, expected: string) { return async () => { expect( serializeCommand(await parseNup(agent, arg.split(' ').filter(Boolean))), ).toBe( expected, ) } } it('empty', _('', 'yarn up')) it('interactive', _('-i', 'yarn up -i')) ================================================ FILE: test/programmatic/__snapshots__/detect.spec.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`lockfile > bun 1`] = `"bun"`; exports[`lockfile > deno 1`] = `"deno"`; exports[`lockfile > npm 1`] = `"npm"`; exports[`lockfile > pnpm 1`] = `"pnpm"`; exports[`lockfile > pnpm@6 1`] = `"pnpm"`; exports[`lockfile > unknown 1`] = `undefined`; exports[`lockfile > yarn 1`] = `"yarn"`; exports[`lockfile > yarn@berry 1`] = `"yarn"`; exports[`packager > bun 1`] = `"bun"`; exports[`packager > deno 1`] = `"deno"`; exports[`packager > npm 1`] = `"npm"`; exports[`packager > pnpm 1`] = `"pnpm"`; exports[`packager > pnpm@6 1`] = `"pnpm@6"`; exports[`packager > unknown 1`] = `undefined`; exports[`packager > yarn 1`] = `"yarn"`; exports[`packager > yarn@berry 1`] = `"yarn@berry"`; ================================================ FILE: test/programmatic/__snapshots__/runCli.spec.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`debug mode > should return command results in plain text format 1`] = `"npm i @antfu/ni"`; exports[`lockfile > bun > na 1`] = `"bun"`; exports[`lockfile > bun > na run foo 1`] = `"bun run foo"`; exports[`lockfile > bun > ni --frozen 1`] = `"bun install --frozen-lockfile"`; exports[`lockfile > bun > ni -g foo 1`] = `"npm i -g foo"`; exports[`lockfile > bun > ni 1`] = `"bun install"`; exports[`lockfile > bun > ni foo -D 1`] = `"bun add foo -d"`; exports[`lockfile > bun > ni foo 1`] = `"bun add foo"`; exports[`lockfile > bun > nlx 1`] = `"bun x foo"`; exports[`lockfile > bun > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`lockfile > bun > nun foo 1`] = `"bun remove foo"`; exports[`lockfile > bun > nup -i 1`] = `"bun update -i"`; exports[`lockfile > bun > nup 1`] = `"bun update"`; exports[`lockfile > deno > na 1`] = `"deno"`; exports[`lockfile > deno > na run foo 1`] = `"deno run foo"`; exports[`lockfile > deno > ni --frozen 1`] = `"deno install --frozen"`; exports[`lockfile > deno > ni -g foo 1`] = `"npm i -g foo"`; exports[`lockfile > deno > ni 1`] = `"deno install"`; exports[`lockfile > deno > ni foo -D 1`] = `"deno add foo -D"`; exports[`lockfile > deno > ni foo 1`] = `"deno add foo"`; exports[`lockfile > deno > nlx 1`] = `"deno run npm:foo"`; exports[`lockfile > deno > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`lockfile > deno > nun foo 1`] = `"deno remove foo"`; exports[`lockfile > deno > nup -i 1`] = `"deno outdated --update"`; exports[`lockfile > deno > nup 1`] = `"deno outdated --update"`; exports[`lockfile > npm > na 1`] = `"npm"`; exports[`lockfile > npm > na run foo 1`] = `"npm run foo"`; exports[`lockfile > npm > ni --frozen 1`] = `"npm ci"`; exports[`lockfile > npm > ni -g foo 1`] = `"npm i -g foo"`; exports[`lockfile > npm > ni 1`] = `"npm i"`; exports[`lockfile > npm > ni foo -D 1`] = `"npm i foo -D"`; exports[`lockfile > npm > ni foo 1`] = `"npm i foo"`; exports[`lockfile > npm > nlx 1`] = `"npx foo"`; exports[`lockfile > npm > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`lockfile > npm > nun foo 1`] = `"npm uninstall foo"`; exports[`lockfile > npm > nup -i 1`] = `"Command "upgrade-interactive" is not support by agent "npm""`; exports[`lockfile > npm > nup 1`] = `"npm update"`; exports[`lockfile > pnpm > na 1`] = `"pnpm"`; exports[`lockfile > pnpm > na run foo 1`] = `"pnpm run foo"`; exports[`lockfile > pnpm > ni --frozen 1`] = `"pnpm i --frozen-lockfile"`; exports[`lockfile > pnpm > ni -g foo 1`] = `"npm i -g foo"`; exports[`lockfile > pnpm > ni 1`] = `"pnpm i"`; exports[`lockfile > pnpm > ni foo -D 1`] = `"pnpm add foo -D"`; exports[`lockfile > pnpm > ni foo 1`] = `"pnpm add foo"`; exports[`lockfile > pnpm > nlx 1`] = `"pnpm dlx foo"`; exports[`lockfile > pnpm > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`lockfile > pnpm > nun foo 1`] = `"pnpm remove foo"`; exports[`lockfile > pnpm > nup -i 1`] = `"pnpm update -i"`; exports[`lockfile > pnpm > nup 1`] = `"pnpm update"`; exports[`lockfile > pnpm@6 > na 1`] = `"pnpm"`; exports[`lockfile > pnpm@6 > na run foo 1`] = `"pnpm run foo"`; exports[`lockfile > pnpm@6 > ni --frozen 1`] = `"pnpm i --frozen-lockfile"`; exports[`lockfile > pnpm@6 > ni -g foo 1`] = `"npm i -g foo"`; exports[`lockfile > pnpm@6 > ni 1`] = `"pnpm i"`; exports[`lockfile > pnpm@6 > ni foo -D 1`] = `"pnpm add foo -D"`; exports[`lockfile > pnpm@6 > ni foo 1`] = `"pnpm add foo"`; exports[`lockfile > pnpm@6 > nlx 1`] = `"pnpm dlx foo"`; exports[`lockfile > pnpm@6 > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`lockfile > pnpm@6 > nun foo 1`] = `"pnpm remove foo"`; exports[`lockfile > pnpm@6 > nup -i 1`] = `"pnpm update -i"`; exports[`lockfile > pnpm@6 > nup 1`] = `"pnpm update"`; exports[`lockfile > unknown > na 1`] = `"pnpm"`; exports[`lockfile > unknown > na run foo 1`] = `"pnpm run foo"`; exports[`lockfile > unknown > ni --frozen 1`] = `"pnpm i --frozen-lockfile"`; exports[`lockfile > unknown > ni -g foo 1`] = `"npm i -g foo"`; exports[`lockfile > unknown > ni 1`] = `"pnpm i"`; exports[`lockfile > unknown > ni foo -D 1`] = `"pnpm add foo -D"`; exports[`lockfile > unknown > ni foo 1`] = `"pnpm add foo"`; exports[`lockfile > unknown > nlx 1`] = `"pnpm dlx foo"`; exports[`lockfile > unknown > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`lockfile > unknown > nun foo 1`] = `"pnpm remove foo"`; exports[`lockfile > unknown > nup -i 1`] = `"pnpm update -i"`; exports[`lockfile > unknown > nup 1`] = `"pnpm update"`; exports[`lockfile > yarn > na 1`] = `"yarn"`; exports[`lockfile > yarn > na run foo 1`] = `"yarn run foo"`; exports[`lockfile > yarn > ni --frozen 1`] = `"yarn install --frozen-lockfile"`; exports[`lockfile > yarn > ni -g foo 1`] = `"npm i -g foo"`; exports[`lockfile > yarn > ni 1`] = `"yarn install"`; exports[`lockfile > yarn > ni foo -D 1`] = `"yarn add foo -D"`; exports[`lockfile > yarn > ni foo 1`] = `"yarn add foo"`; exports[`lockfile > yarn > nlx 1`] = `"npx foo"`; exports[`lockfile > yarn > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`lockfile > yarn > nun foo 1`] = `"yarn remove foo"`; exports[`lockfile > yarn > nup -i 1`] = `"yarn upgrade-interactive"`; exports[`lockfile > yarn > nup 1`] = `"yarn upgrade"`; exports[`lockfile > yarn@berry > na 1`] = `"yarn"`; exports[`lockfile > yarn@berry > na run foo 1`] = `"yarn run foo"`; exports[`lockfile > yarn@berry > ni --frozen 1`] = `"yarn install --frozen-lockfile"`; exports[`lockfile > yarn@berry > ni -g foo 1`] = `"npm i -g foo"`; exports[`lockfile > yarn@berry > ni 1`] = `"yarn install"`; exports[`lockfile > yarn@berry > ni foo -D 1`] = `"yarn add foo -D"`; exports[`lockfile > yarn@berry > ni foo 1`] = `"yarn add foo"`; exports[`lockfile > yarn@berry > nlx 1`] = `"npx foo"`; exports[`lockfile > yarn@berry > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`lockfile > yarn@berry > nun foo 1`] = `"yarn remove foo"`; exports[`lockfile > yarn@berry > nup -i 1`] = `"yarn upgrade-interactive"`; exports[`lockfile > yarn@berry > nup 1`] = `"yarn upgrade"`; exports[`packager > bun > na 1`] = `"bun"`; exports[`packager > bun > na run foo 1`] = `"bun run foo"`; exports[`packager > bun > ni --frozen 1`] = `"bun install --frozen-lockfile"`; exports[`packager > bun > ni -g foo 1`] = `"npm i -g foo"`; exports[`packager > bun > ni 1`] = `"bun install"`; exports[`packager > bun > ni foo -D 1`] = `"bun add foo -d"`; exports[`packager > bun > ni foo 1`] = `"bun add foo"`; exports[`packager > bun > nlx 1`] = `"bun x foo"`; exports[`packager > bun > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`packager > bun > nun foo 1`] = `"bun remove foo"`; exports[`packager > bun > nup -i 1`] = `"bun update -i"`; exports[`packager > bun > nup 1`] = `"bun update"`; exports[`packager > deno > na 1`] = `"deno"`; exports[`packager > deno > na run foo 1`] = `"deno run foo"`; exports[`packager > deno > ni --frozen 1`] = `"deno install --frozen"`; exports[`packager > deno > ni -g foo 1`] = `"npm i -g foo"`; exports[`packager > deno > ni 1`] = `"deno install"`; exports[`packager > deno > ni foo -D 1`] = `"deno add foo -D"`; exports[`packager > deno > ni foo 1`] = `"deno add foo"`; exports[`packager > deno > nlx 1`] = `"deno run npm:foo"`; exports[`packager > deno > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`packager > deno > nun foo 1`] = `"deno remove foo"`; exports[`packager > deno > nup -i 1`] = `"deno outdated --update"`; exports[`packager > deno > nup 1`] = `"deno outdated --update"`; exports[`packager > npm > na 1`] = `"npm"`; exports[`packager > npm > na run foo 1`] = `"npm run foo"`; exports[`packager > npm > ni --frozen 1`] = `"npm ci"`; exports[`packager > npm > ni -g foo 1`] = `"npm i -g foo"`; exports[`packager > npm > ni 1`] = `"npm i"`; exports[`packager > npm > ni foo -D 1`] = `"npm i foo -D"`; exports[`packager > npm > ni foo 1`] = `"npm i foo"`; exports[`packager > npm > nlx 1`] = `"npx foo"`; exports[`packager > npm > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`packager > npm > nun foo 1`] = `"npm uninstall foo"`; exports[`packager > npm > nup -i 1`] = `"Command "upgrade-interactive" is not support by agent "npm""`; exports[`packager > npm > nup 1`] = `"npm update"`; exports[`packager > pnpm > na 1`] = `"pnpm"`; exports[`packager > pnpm > na run foo 1`] = `"pnpm run foo"`; exports[`packager > pnpm > ni --frozen 1`] = `"pnpm i --frozen-lockfile"`; exports[`packager > pnpm > ni -g foo 1`] = `"npm i -g foo"`; exports[`packager > pnpm > ni 1`] = `"pnpm i"`; exports[`packager > pnpm > ni foo -D 1`] = `"pnpm add foo -D"`; exports[`packager > pnpm > ni foo 1`] = `"pnpm add foo"`; exports[`packager > pnpm > nlx 1`] = `"pnpm dlx foo"`; exports[`packager > pnpm > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`packager > pnpm > nun foo 1`] = `"pnpm remove foo"`; exports[`packager > pnpm > nup -i 1`] = `"pnpm update -i"`; exports[`packager > pnpm > nup 1`] = `"pnpm update"`; exports[`packager > pnpm@6 > na 1`] = `"pnpm"`; exports[`packager > pnpm@6 > na run foo 1`] = `"pnpm run foo"`; exports[`packager > pnpm@6 > ni --frozen 1`] = `"pnpm i --frozen-lockfile"`; exports[`packager > pnpm@6 > ni -g foo 1`] = `"npm i -g foo"`; exports[`packager > pnpm@6 > ni 1`] = `"pnpm i"`; exports[`packager > pnpm@6 > ni foo -D 1`] = `"pnpm add foo -D"`; exports[`packager > pnpm@6 > ni foo 1`] = `"pnpm add foo"`; exports[`packager > pnpm@6 > nlx 1`] = `"pnpm dlx foo"`; exports[`packager > pnpm@6 > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`packager > pnpm@6 > nun foo 1`] = `"pnpm remove foo"`; exports[`packager > pnpm@6 > nup -i 1`] = `"pnpm update -i"`; exports[`packager > pnpm@6 > nup 1`] = `"pnpm update"`; exports[`packager > unknown > na 1`] = `"pnpm"`; exports[`packager > unknown > na run foo 1`] = `"pnpm run foo"`; exports[`packager > unknown > ni --frozen 1`] = `"pnpm i --frozen-lockfile"`; exports[`packager > unknown > ni -g foo 1`] = `"npm i -g foo"`; exports[`packager > unknown > ni 1`] = `"pnpm i"`; exports[`packager > unknown > ni foo -D 1`] = `"pnpm add foo -D"`; exports[`packager > unknown > ni foo 1`] = `"pnpm add foo"`; exports[`packager > unknown > nlx 1`] = `"pnpm dlx foo"`; exports[`packager > unknown > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`packager > unknown > nun foo 1`] = `"pnpm remove foo"`; exports[`packager > unknown > nup -i 1`] = `"pnpm update -i"`; exports[`packager > unknown > nup 1`] = `"pnpm update"`; exports[`packager > yarn > na 1`] = `"yarn"`; exports[`packager > yarn > na run foo 1`] = `"yarn run foo"`; exports[`packager > yarn > ni --frozen 1`] = `"yarn install --frozen-lockfile"`; exports[`packager > yarn > ni -g foo 1`] = `"npm i -g foo"`; exports[`packager > yarn > ni 1`] = `"yarn install"`; exports[`packager > yarn > ni foo -D 1`] = `"yarn add foo -D"`; exports[`packager > yarn > ni foo 1`] = `"yarn add foo"`; exports[`packager > yarn > nlx 1`] = `"npx foo"`; exports[`packager > yarn > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`packager > yarn > nun foo 1`] = `"yarn remove foo"`; exports[`packager > yarn > nup -i 1`] = `"yarn upgrade-interactive"`; exports[`packager > yarn > nup 1`] = `"yarn upgrade"`; exports[`packager > yarn@berry > na 1`] = `"yarn"`; exports[`packager > yarn@berry > na run foo 1`] = `"yarn run foo"`; exports[`packager > yarn@berry > ni --frozen 1`] = `"yarn install --immutable"`; exports[`packager > yarn@berry > ni -g foo 1`] = `"npm i -g foo"`; exports[`packager > yarn@berry > ni 1`] = `"yarn install"`; exports[`packager > yarn@berry > ni foo -D 1`] = `"yarn add foo -D"`; exports[`packager > yarn@berry > ni foo 1`] = `"yarn add foo"`; exports[`packager > yarn@berry > nlx 1`] = `"yarn dlx foo"`; exports[`packager > yarn@berry > nun -g foo 1`] = `"npm uninstall -g foo"`; exports[`packager > yarn@berry > nun foo 1`] = `"yarn remove foo"`; exports[`packager > yarn@berry > nup -i 1`] = `"yarn up -i"`; exports[`packager > yarn@berry > nup 1`] = `"yarn up"`; ================================================ FILE: test/programmatic/catalog.spec.ts ================================================ import fs from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { beforeEach, describe, expect, it, vi } from 'vitest' const __dirname = path.dirname(fileURLToPath(import.meta.url)) vi.mock('../../src/detect', () => ({ detect: vi.fn(() => 'pnpm'), })) vi.mock('../../src/config', async (importOriginal) => { const original = await importOriginal() return { ...original, getConfig: vi.fn(async () => ({ defaultAgent: 'pnpm', globalAgent: 'npm', runAgent: undefined, useSfw: false, catalog: true, })), getDefaultAgent: vi.fn(async () => 'pnpm'), getGlobalAgent: vi.fn(async () => 'npm'), getRunAgent: vi.fn(async () => undefined), getUseSfw: vi.fn(async () => false), getCatalog: vi.fn(async () => true), } }) vi.mock('fast-npm-meta', () => ({ getLatestVersion: vi.fn(async (name: string) => ({ name, version: '1.0.0', })), })) vi.mock('@posva/prompts', () => ({ default: vi.fn(async () => ({})), })) async function createTempDir(fixture: string): Promise { const tmp = await fs.promises.mkdtemp(path.join(tmpdir(), 'ni-catalog-')) const fixtureDir = path.join(__dirname, '..', 'fixtures', 'catalog', fixture) await fs.promises.cp(fixtureDir, tmp, { recursive: true }) return tmp } function readJson(filePath: string) { return JSON.parse(fs.readFileSync(filePath, 'utf-8')) } beforeEach(() => { vi.restoreAllMocks() }) describe('catalog handler - named catalogs', () => { it('package found in catalog → updates package.json, returns pnpm install', async () => { const cwd = await createTempDir('pnpm') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['react'], { cwd, programmatic: true }) expect(result).toBeDefined() expect(result!.command).toBe('pnpm') expect(result!.args).toEqual(['i']) const pkg = readJson(path.join(cwd, 'package.json')) expect(pkg.dependencies.react).toBe('catalog:prod') }) it('multiple packages in different catalogs', async () => { const cwd = await createTempDir('pnpm') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['react', 'typescript'], { cwd, programmatic: true }) expect(result).toBeDefined() expect(result!.command).toBe('pnpm') expect(result!.args).toEqual(['i']) const pkg = readJson(path.join(cwd, 'package.json')) expect(pkg.dependencies.react).toBe('catalog:prod') expect(pkg.dependencies.typescript).toBe('catalog:dev') }) it('-D flag → writes to devDependencies', async () => { const cwd = await createTempDir('pnpm') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['react', '-D'], { cwd, programmatic: true }) expect(result).toBeDefined() const pkg = readJson(path.join(cwd, 'package.json')) expect(pkg.devDependencies.react).toBe('catalog:prod') expect(pkg.dependencies?.react).toBeUndefined() }) it('unknown package in programmatic mode → skips catalog', async () => { const cwd = await createTempDir('pnpm') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['unknown-pkg'], { cwd, programmatic: true }) // In programmatic mode, unknown packages are skipped → falls through expect(result).toBeUndefined() }) it('mixed known/unknown packages in programmatic mode', async () => { const cwd = await createTempDir('pnpm') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['react', 'unknown-pkg'], { cwd, programmatic: true }) // react is cataloged, unknown-pkg is skipped → add command for skipped ones expect(result).toBeDefined() expect(result!.command).toBe('pnpm') expect(result!.args).toContain('unknown-pkg') const pkg = readJson(path.join(cwd, 'package.json')) expect(pkg.dependencies.react).toBe('catalog:prod') }) }) describe('catalog handler - default catalog only', () => { it('package found → uses catalog: ref (no name)', async () => { const cwd = await createTempDir('pnpm-default-only') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['react'], { cwd, programmatic: true }) expect(result).toBeDefined() expect(result!.args).toEqual(['i']) const pkg = readJson(path.join(cwd, 'package.json')) expect(pkg.dependencies.react).toBe('catalog:') }) it('new package → adds to default catalog without prompt', async () => { const cwd = await createTempDir('pnpm-default-only') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['lodash'], { cwd, programmatic: true }) expect(result).toBeDefined() expect(result!.args).toEqual(['i']) // Check workspace yaml was updated const yamlContent = fs.readFileSync(path.join(cwd, 'pnpm-workspace.yaml'), 'utf-8') expect(yamlContent).toContain('lodash') // Check package.json uses catalog: const pkg = readJson(path.join(cwd, 'package.json')) expect(pkg.dependencies.lodash).toBe('catalog:') }) }) describe('catalog handler - skip conditions', () => { it('returns undefined for non-pnpm agent', async () => { const cwd = await createTempDir('pnpm') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('npm', ['react'], { cwd, programmatic: true }) expect(result).toBeUndefined() }) it('returns undefined when no packages in args (bare install)', async () => { const cwd = await createTempDir('pnpm') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', [], { cwd, programmatic: true }) expect(result).toBeUndefined() }) it('returns undefined when only flags', async () => { const cwd = await createTempDir('pnpm') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['--frozen'], { cwd, programmatic: true }) expect(result).toBeUndefined() }) it('returns undefined when catalog config disabled', async () => { const { getCatalog } = await import('../../src/config') vi.mocked(getCatalog).mockResolvedValueOnce(false) const cwd = await createTempDir('pnpm') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['react'], { cwd, programmatic: true }) expect(result).toBeUndefined() }) }) describe('catalog handler - subdirectory', () => { it('finds closest package.json from subdirectory', async () => { const cwd = await createTempDir('pnpm') const subDir = path.join(cwd, 'packages', 'app') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['react'], { cwd: subDir, programmatic: true }) expect(result).toBeDefined() // Should write to the subdirectory's package.json (closest) const pkg = readJson(path.join(subDir, 'package.json')) expect(pkg.dependencies.react).toBe('catalog:prod') }) it('-w flag targets workspace root package.json', async () => { const cwd = await createTempDir('pnpm') const subDir = path.join(cwd, 'packages', 'app') const { handleCatalogInstall } = await import('../../src/catalog/handler') const result = await handleCatalogInstall('pnpm', ['react', '-w'], { cwd: subDir, programmatic: true }) expect(result).toBeDefined() // Should write to root package.json, not subdirectory const rootPkg = readJson(path.join(cwd, 'package.json')) expect(rootPkg.dependencies.react).toBe('catalog:prod') // Subdirectory package.json should be unchanged const subPkg = readJson(path.join(subDir, 'package.json')) expect(subPkg.dependencies.react).toBeUndefined() }) }) ================================================ FILE: test/programmatic/detect.spec.ts ================================================ import type { MockInstance } from 'vitest' import fs from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { AGENTS, detect } from '../../src' let basicLog: MockInstance, errorLog: MockInstance, warnLog: MockInstance, infoLog: MockInstance function detectTest(fixture: string, agent: string) { return async () => { const cwd = await fs.mkdtemp(path.join(tmpdir(), 'ni-')) const dir = path.join(__dirname, '..', 'fixtures', fixture, agent) await fs.cp(dir, cwd, { recursive: true }) expect(await detect({ programmatic: true, cwd })).toMatchSnapshot() } } beforeAll(() => { basicLog = vi.spyOn(console, 'log') warnLog = vi.spyOn(console, 'warn') errorLog = vi.spyOn(console, 'error') infoLog = vi.spyOn(console, 'info') }) afterAll(() => { vi.resetAllMocks() }) const agents = [...AGENTS, 'unknown'] const fixtures = ['lockfile', 'packager'] const skippedAgents: string[] = [] // matrix testing of: fixtures x agents fixtures.forEach(fixture => describe(fixture, () => agents.forEach((agent) => { if (skippedAgents.includes(agent)) return it.skip(`skipped for ${agent}`, () => {}) it(agent, detectTest(fixture, agent)) it('no logs', () => { expect(basicLog).not.toHaveBeenCalled() expect(warnLog).not.toHaveBeenCalled() expect(errorLog).not.toHaveBeenCalled() expect(infoLog).not.toHaveBeenCalled() }) }))) ================================================ FILE: test/programmatic/runCli.spec.ts ================================================ import type { MockInstance } from 'vitest' import type { Runner } from '../../src' import fs from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { AGENTS, parseNa, parseNi, parseNlx, parseNun, parseNup, runCli } from '../../src' let basicLog: MockInstance, errorLog: MockInstance, warnLog: MockInstance, infoLog: MockInstance function runCliTest(fixtureName: string, agent: string, runner: Runner, args: string[]) { return async () => { const cwd = await fs.mkdtemp(path.join(tmpdir(), 'ni-')) const fixture = path.join(__dirname, '..', 'fixtures', fixtureName, agent) await fs.cp(fixture, cwd, { recursive: true }) await runCli( async (agent, _, ctx) => { // we override the args to be test specific return runner(agent, args, ctx) }, { programmatic: true, cwd, args, }, ).catch((e) => { // it will always throw if ezspawn is mocked if (e.command) expect(e.command).toMatchSnapshot() else expect(e.message).toMatchSnapshot() }) } } beforeAll(() => { basicLog = vi.spyOn(console, 'log') warnLog = vi.spyOn(console, 'warn') errorLog = vi.spyOn(console, 'error') infoLog = vi.spyOn(console, 'info') vi.mock('tinyexec', async (importOriginal) => { const mod = await importOriginal() as any return { ...mod, x: (cmd: string, args?: string[]) => { // break execution flow for easier snapshotting // eslint-disable-next-line no-throw-literal throw { command: [cmd, ...(args ?? [])].join(' ') } }, } }) }) afterAll(() => { vi.resetAllMocks() }) const agents = [...AGENTS, 'unknown'] const fixtures = ['lockfile', 'packager'] const skippedAgents: string[] = [] // matrix testing of: fixtures x agents x commands fixtures.forEach(fixture => describe(fixture, () => agents.forEach(agent => describe(agent, () => { if (skippedAgents.includes(agent)) return it.skip(`skipped for ${agent}`, () => {}) /** na */ it('na', runCliTest(fixture, agent, parseNa, [])) it('na run foo', runCliTest(fixture, agent, parseNa, ['run', 'foo'])) /** ni */ it('ni', runCliTest(fixture, agent, parseNi, [])) it('ni foo', runCliTest(fixture, agent, parseNi, ['foo'])) it('ni foo -D', runCliTest(fixture, agent, parseNi, ['foo', '-D'])) it('ni --frozen', runCliTest(fixture, agent, parseNi, ['--frozen'])) it('ni -g foo', runCliTest(fixture, agent, parseNi, ['-g', 'foo'])) /** nlx */ it('nlx', runCliTest(fixture, agent, parseNlx, ['foo'])) /** nup */ it('nup', runCliTest(fixture, agent, parseNup, [])) it('nup -i', runCliTest(fixture, agent, parseNup, ['-i'])) /** nun */ it('nun foo', runCliTest(fixture, agent, parseNun, ['foo'])) it('nun -g foo', runCliTest(fixture, agent, parseNun, ['-g', 'foo'])) it('no logs', () => { expect(basicLog).not.toHaveBeenCalled() expect(warnLog).not.toHaveBeenCalled() expect(errorLog).not.toHaveBeenCalled() expect(infoLog).not.toHaveBeenCalled() }) })))) // https://github.com/antfu-collective/ni/issues/266 describe('debug mode', () => { beforeAll(() => basicLog.mockClear()) it('ni', runCliTest('lockfile', 'npm', parseNi, ['@antfu/ni', '?'])) it('should return command results in plain text format', () => { expect(basicLog).toHaveBeenCalled() expect(basicLog.mock.calls[0][0]).toMatchSnapshot() }) }) ================================================ FILE: test/runner/runCli.test.ts ================================================ import type { Runner } from '../../src' import { afterEach, describe, expect, it, vi } from 'vitest' import { runCli } from '../../src' // Mock detect to see what options are passed to it const mocks = vi.hoisted(() => ({ detectSpy: vi.fn(() => Promise.resolve('npm')), baseRunFnSpy: vi.fn(() => Promise.resolve(undefined)), })) vi.mock('../../src/detect', () => ({ detect: mocks.detectSpy, })) describe('runCli', () => { afterEach(() => { vi.clearAllMocks() vi.unstubAllEnvs() }) it('run without errors', async () => { const result = await runCli(mocks.baseRunFnSpy, {}) expect(result).toBe(undefined) }) it('handle errors in programmatic mode', async () => { await expect( runCli(() => { throw new Error('test error') }, { programmatic: true }), ).rejects.toThrow('test error') }) it('calls detect with the correct options', async () => { await runCli(mocks.baseRunFnSpy) expect(mocks.detectSpy).toHaveBeenCalledWith(({ autoInstall: false, programmatic: false, cwd: expect.any(String) })) }) it('detects environment options', async () => { vi.stubEnv('NI_AUTO_INSTALL', 'true') await runCli(mocks.baseRunFnSpy) expect(mocks.detectSpy).toHaveBeenCalledWith({ autoInstall: true, programmatic: false, cwd: expect.any(String) }) }) it('accepts options as input', async () => { await runCli(mocks.baseRunFnSpy, { autoInstall: true, programmatic: true }) expect(mocks.detectSpy).toHaveBeenCalledWith({ autoInstall: true, programmatic: true, cwd: expect.any(String) }) }) it('merges inputs and environment prioritizing inputs', async () => { vi.stubEnv('NI_AUTO_INSTALL', 'true') await runCli(mocks.baseRunFnSpy, { autoInstall: false, programmatic: true }) expect(mocks.detectSpy).toHaveBeenCalledWith({ autoInstall: false, programmatic: true, cwd: expect.any(String) }) }) it('parses --programmatic flag from args', async () => { await runCli(mocks.baseRunFnSpy, { args: ['--programmatic'] }) expect(mocks.detectSpy).toHaveBeenCalledWith(expect.objectContaining({ autoInstall: false, programmatic: true, cwd: expect.any(String) })) }) it('removes --programmatic from args before passing to runner', async () => { await runCli(mocks.baseRunFnSpy, { args: ['--programmatic', 'foo'] }) expect(mocks.baseRunFnSpy).toHaveBeenCalledWith('npm', ['foo'], { programmatic: true, hasLock: true, cwd: expect.any(String) }) }) describe('onBeforeCommand', () => { it('skips running the command when exit() is called', async () => { await runCli(mocks.baseRunFnSpy, { onBeforeCommand: (_args, ctx) => ctx.exit() }) expect(mocks.baseRunFnSpy).not.toHaveBeenCalled() // https://github.com/antfu-collective/ni/issues/308 expect(mocks.detectSpy).not.toHaveBeenCalled() }) it('continues to run the command when exit() is not called', async () => { await runCli(mocks.baseRunFnSpy, { onBeforeCommand: () => Promise.resolve() }) expect(mocks.baseRunFnSpy).toHaveBeenCalledOnce() }) }) }) ================================================ FILE: test/sfw/sfw.spec.ts ================================================ import { afterEach, describe, expect, it, vi } from 'vitest' import { parseNi, runCli } from '../../src' const mocks = vi.hoisted(() => ({ cmdExistsSpy: vi.fn(), execSpy: vi.fn(), })) vi.mock('../../src/utils', async (importOriginal) => { const mod = await importOriginal() as any return { ...mod, cmdExists: mocks.cmdExistsSpy, } }) vi.mock('tinyexec', () => ({ x: mocks.execSpy, })) describe('sfw', () => { afterEach(() => { vi.clearAllMocks() vi.unstubAllEnvs() vi.resetModules() }) it('wraps command with sfw when enabled and installed', async () => { vi.stubEnv('NI_USE_SFW', 'true') mocks.cmdExistsSpy.mockImplementation(() => true) mocks.execSpy.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) await runCli(parseNi, { programmatic: true, args: ['axios'], detectVolta: false, }) expect(mocks.execSpy).toHaveBeenCalledWith( 'sfw', ['pnpm', 'add', 'axios'], expect.objectContaining({ nodeOptions: expect.objectContaining({ stdio: 'inherit', }), }), ) }) it('throws error when sfw is not installed', async () => { vi.stubEnv('NI_USE_SFW', 'true') mocks.cmdExistsSpy.mockImplementation(() => false) await expect( runCli(parseNi, { programmatic: true, args: ['axios'], detectVolta: false, }), ).rejects.toThrow(/sfw is enabled but not installed/) expect(mocks.execSpy).not.toHaveBeenCalled() }) it('wraps command with sfw and volta', async () => { vi.stubEnv('NI_USE_SFW', 'true') mocks.cmdExistsSpy.mockImplementation(() => true) mocks.execSpy.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) await runCli(parseNi, { programmatic: true, args: ['axios'], detectVolta: true, }) expect(mocks.execSpy).toHaveBeenCalledWith( 'volta', ['run', 'sfw', 'pnpm', 'add', 'axios'], expect.objectContaining({ nodeOptions: expect.objectContaining({ stdio: 'inherit', }), }), ) }) }) ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2017", "lib": ["esnext"], "module": "esnext", "moduleResolution": "Bundler", "resolveJsonModule": true, "strict": true, "strictNullChecks": true, "noEmit": true, "esModuleInterop": true, "skipLibCheck": true } } ================================================ FILE: tsdown.config.ts ================================================ import { defineConfig } from 'tsdown' export default defineConfig({ entry: ['src/commands/*.ts'], clean: true, dts: true, exports: true, deps: { onlyBundle: [ 'which', 'ini', '@posva/prompts', 'pnpm-workspace-yaml', 'yaml', 'fast-npm-meta', 'isexe', 'kleur', 'sisteransi', ], }, }) ================================================ FILE: vitest.config.ts ================================================ import process from 'node:process' import { defineConfig } from 'vitest/config' // Disable global ni config in test to make the results more predictable process.env.NI_CONFIG_FILE = 'false' export default defineConfig({ test: { }, })