diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e839a52..f79d146 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,16 +1,15 @@ -# https://github.com/orgs/community/discussions/26276#discussioncomment-3251141 name: CI Lint on: push: - branches: - - main + branches: [main] pull_request: + jobs: lint: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 - run: npm install - run: npm run lint diff --git a/.github/workflows/test-push.yml b/.github/workflows/test-push.yml deleted file mode 100644 index 7b97d7c..0000000 --- a/.github/workflows/test-push.yml +++ /dev/null @@ -1,21 +0,0 @@ -# https://github.com/orgs/community/discussions/26276#discussioncomment-3251141 -name: CI Test -on: - push: - branches: - - main -jobs: - test: - name: Node.js ${{ matrix.node-version }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node-version: [16, 18] - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm test diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test.yml similarity index 58% rename from .github/workflows/test-pr.yml rename to .github/workflows/test.yml index ce8a4f4..d4fd192 100644 --- a/.github/workflows/test-pr.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,9 @@ -# https://github.com/orgs/community/discussions/26276#discussioncomment-3251141 name: CI Test on: - - pull_request + push: + branches: [main] + pull_request: + jobs: test: name: Node.js ${{ matrix.node-version }} @@ -9,10 +11,10 @@ jobs: strategy: fail-fast: false matrix: - node-version: [16, 18] + node-version: ["22.22", "24.15", "26"] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} - run: npm install diff --git a/.gitignore b/.gitignore index 02860b1..e1488d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -node_modules -coverage -dist +node_modules/ +coverage/ +dist/ package-lock.json yarn.lock +pnpm-*.yaml diff --git a/.npmrc b/.npmrc index 43c97e7..247f7dc 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ package-lock=false +allow-scripts=dprint,unrs-resolver diff --git a/.xo-config.json b/.xo-config.json deleted file mode 100644 index 4b4f6ba..0000000 --- a/.xo-config.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "rules": { - "arrow-parens": "off", - "quotes": ["error", "double"], - "object-curly-spacing": ["error", "always"], - "object-shorthand": ["error", "always", { "avoidExplicitReturnArrows": false }], - "no-bitwise": "off" - }, - "overrides": [ - { - "files": "**/*.{ts,cts,mts,tsx}", - "rules": { - "quotes": "off", - "object-curly-spacing": "off", - "@typescript-eslint/quotes": ["error", "double"], - "@typescript-eslint/object-curly-spacing": ["error", "always"], - "@typescript-eslint/no-confusing-void-expression": ["error", { "ignoreArrowShorthand": true }], - "n/file-extension-in-import": ["error", "always", { ".ts": "always" }], - "@typescript-eslint/prefer-regexp-exec": "off" - } - } - ] -} - diff --git a/dprint.json b/dprint.json new file mode 100644 index 0000000..575e807 --- /dev/null +++ b/dprint.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://dprint.dev/schemas/v0.json", + "includes": ["**/*.{js,ts,json,jsonc,yml,md}"], + "extends": ["node_modules/@tommy-mitchell/dprint-config/index.json"], + "plugins": [ + "npm:@dprint/json@0.25.0", + "npm:@dprint/markdown@0.24.0", + "npm:@dprint/typescript@0.96.1", + "npm:dprint-plugin-malva@0.16.0", + "npm:dprint-plugin-markup@0.27.5", + "npm:dprint-plugin-yaml@0.6.0" + ], + "json": { + "trailingCommas": "maintain" + } +} diff --git a/package.json b/package.json index 426730a..82803b8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,30 @@ { "name": "execify-cli", "version": "0.2.1-0", - "description": "Easily make a Node.js CLI executable.", + "description": "Cross-platform `chmod +x`. Easily make a Node.js CLI executable.", + "license": "MIT", + "repository": "tommy-mitchell/execify-cli", + "author": "Tommy Mitchell (https://tommymitchell.io)", + "type": "module", + "imports": { + "#src/*.ts": "./src/*.ts" + }, + "bin": { + "execify": "./dist/cli.js" + }, + "engines": { + "node": "^22.22 || ^24.15 || >=26" + }, + "scripts": { + "prepare": "npm run build", + "build": "tsc -p tsconfig.build.json && node dist/cli.js --pkg", + "lint": "dprint check && xo", + "format": "dprint fmt && xo --fix", + "test": "c8 ava" + }, + "files": [ + "dist" + ], "keywords": [ "cli", "cli-app", @@ -11,75 +34,39 @@ "chmod+x", "shebang", "hashbang", - "typescript", "binary", - "bin", - "executable" - ], - "license": "MIT", - "repository": "tommy-mitchell/execify-cli", - "author": { - "name": "Tommy Mitchell", - "url": "https://tommymitchell.io" - }, - "type": "module", - "engines": { - "node": ">=16.17" - }, - "bin": { - "execify": "dist/cli.js" - }, - "files": [ - "dist" + "bin" ], - "scripts": { - "prepare": "npm run build", - "build": "tsc -p tsconfig.build.json && node dist/cli.js --all", - "lint": "xo", - "test": "tsc --noEmit && c8 ava" - }, - "ava": { - "files": [ - "test/**/*.ts", - "!test/_test-helpers", - "!test/fixtures" - ], - "extensions": { - "ts": "module" - }, - "nodeArguments": [ - "--loader=ts-node/esm", - "--loader=esmock", - "--no-warnings=ExperimentalWarning" - ] - }, "dependencies": { - "globby": "^13.2.2", - "meow": "^12.1.0", - "read-pkg-up": "^10.0.0", - "ts-pattern": "^5.0.5" + "log-symbols": "^7.0.1", + "meow": "^14.1.0", + "read-package-up": "^12.0.0" }, "devDependencies": { - "@tommy-mitchell/tsconfig": "^1.1.0", - "@types/node": "^16.17", - "ava": "^5.3.1", - "c8": "^8.0.1", - "cp-file": "^10.0.0", - "cross-env": "^7.0.3", - "dot-prop": "^8.0.2", - "esmock": "^2.3.6", - "execa": "^7.2.0", - "get-bin-path": "^10.0.0", + "@tommy-mitchell/dprint-config": "^0.8.0", + "@tommy-mitchell/eslint-config-xo": "^2.0.0-beta.11", + "@tommy-mitchell/tsconfig": "^4.0.0", + "@types/node": "^22.20", + "async-sema": "^3.1.1", + "ava": "^8.0.1", + "c8": "^12.0.0", + "dedent": "^1.7.2", + "dprint": "^0.58.0", + "execa": "^10.0.1", + "get-executable-bin-path": "^1.0.0", "is-executable": "^2.0.1", - "make-executable": "^2.0.2", - "map-obj": "^5.0.2", - "p-map": "^6.0.0", - "proper-tags": "^2.0.1", - "tempy": "^3.1.0", - "testtriple": "^2.2.3", - "ts-node": "^10.9.1", - "type-fest": "^4.2.0", - "typescript": "~5.1.6", - "xo": "^0.56.0" + "test-quadruple": "^1.0.0", + "typescript": "~6.0.3", + "xo": "^5.0.1" + }, + "ava": { + "extensions": [ + "ts" + ], + "environmentVariables": { + "concurrency": "10", + "//": "Suppress loader warnings from esmock", + "NODE_OPTIONS": "--disable-warning=DeprecationWarning" + } } } diff --git a/readme.md b/readme.md index acdc6de..3bb5630 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,17 @@ # execify-cli -Easily make a Node.js CLI executable. Sets permissions (`chmod +x`) and handles common TypeScript conversions. See [Roadmap](#roadmap) for more details. +Cross-platform `chmod +x`. Easily make a Node.js CLI executable. + +Sets executable permissions on given binaries, or optionally on every binary in the nearest `package.json`. + +Works well in build scripts: + +```jsonc +// package.json +"scripts": { + "build": "tsc && execify --pkg" +} +``` ## Install @@ -10,43 +21,41 @@ npm install --save-dev execify-cli
Other Package Managers +

```sh yarn add --dev execify-cli ``` + +```sh +pnpm add --save-dev execify-cli +``` + +

## Usage -``` +```txt Usage - $ execify [globs…] + $ execify [paths…] Options --package, --pkg, -p Set every binary in package.json as executable - --fix-shebang Convert shebangs to "#!/usr/bin/env node" - --all Set all flags Examples - $ execify cli.js - - $ execify --pkg test/fixtures/**/cli.js - - $ execify --fix-shebang dist/ts-cli.js + $ execify foo.js bar.ts baz/xyz.sh + ✔ Execified "foo.js" + ⚠ File "foo.js" is missing a shebang! + ℹ File "bar.ts" is already executable + ✖ Failed to execify "baz/xyz.sh", file not found + + $ execify --pkg + ✔ Execified "./dist/foo.js" (foo-cli) + ✔ Execified "./dist/bar.js" (bar-cli) ``` -## Roadmap - -v0.1.0 is a minimal release. The following features were cut and will be added in the next minor version: - -- TypeScript import path mapping from `tsconfig.json` `paths` option -- A flag to fix all TypeScript-related conversions -- Logging of completed/failed files, improved error handling - ## Related -- [chmodx](https://github.com/johnowennixon/chmodx) -- [ts-fix-shebang](https://github.com/johnowennixon/ts-fix-shebang) -- [make-executable](https://github.com/bconnorwhite/make-executable) -- [replace-in-files-cli](https://github.com/sindresorhus/replace-in-files-cli) -- [tsconfig-replace-paths](https://github.com/jonkwheeler/tsconfig-replace-paths) +- [chmodx](https://github.com/johnowennixon/chmodx) - A cross platform command line utility for setting the executable bits on files. +- [make-executable](https://github.com/bconnorwhite/make-executable) - Set or remove the executable bits on a file. diff --git a/src/cli.ts b/src/cli.ts index b4a79c4..b381fb2 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,57 +1,66 @@ -#!/usr/bin/env ts-node-esm -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +#!/usr/bin/env node import meow from "meow"; -import { getFiles, setExecutableBits, fixShebangs } from "./helpers.js"; +import { execify, getBinaries } from "./helpers.ts"; +import { log } from "./utils.ts"; +// dprint-ignore const cli = meow(` Usage - $ execify [globs…] + $ execify [paths…] Options --package, --pkg, -p Set every binary in package.json as executable - --fix-shebang Convert shebangs to "#!/usr/bin/env node" - --all Set all flags Examples - $ execify cli.js + $ execify foo.js bar.ts baz/xyz.sh + ✔ Execified "foo.js" + ⚠ File "foo.js" is missing a shebang! + ℹ File "bar.ts" is already executable + ✖ Failed to execify "baz/xyz.sh", file not found - $ execify --pkg test/fixtures/**/cli.js - - $ execify --fix-shebang dist/ts-cli.js + $ execify --pkg + ✔ Execified "./dist/foo.js" (foo-cli) + ✔ Execified "./dist/bar.js" (bar-cli) `, { - importMeta: import.meta, description: false, flags: { help: { - type: "boolean", shortFlag: "h", + type: "boolean", }, package: { - type: "boolean", - shortFlag: "p", aliases: ["pkg"], - }, - fixShebang: { - type: "boolean", - }, - all: { + shortFlag: "p", type: "boolean", }, }, + importMeta: import.meta, }); -const globs = cli.input; -const { help: helpShortFlag, package: usePackageFlag, fixShebang, all: allFlags } = cli.flags; -const usePackage = usePackageFlag || allFlags; +const { flags: { package: usePackage }, input } = cli; -if ((globs.length === 0 && !usePackage) || helpShortFlag) { +if (!usePackage && input.length === 0) { cli.showHelp(0); } -const filePaths = await getFiles({ globs, usePackage }); +const binaries = await getBinaries({ input, usePackage }); +const results = await execify(binaries); + +for (const { didExecify, error, hasShebang, name, path } of results) { + const nameSuffix = name ? `(${name})` : ""; + + if (error) { + log.error(`Failed to execify "${path}", ${error}`, nameSuffix); + continue; + } -await setExecutableBits(filePaths); + if (didExecify) { + log.success(`Execified "${path}"`, nameSuffix); + } else { + log.info(`File "${path}" is already executable`, nameSuffix); + } -if (fixShebang || allFlags) { - await fixShebangs(filePaths); + if (!hasShebang) { + log.warn(`File "${path}" is missing a shebang!`, nameSuffix); + } } diff --git a/src/helpers.ts b/src/helpers.ts index 25b416e..d40d3f2 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,64 +1,74 @@ -/* eslint-disable unicorn/no-array-callback-reference */ -import { promises as fs, constants as fsConstants } from "node:fs"; -import { globby } from "globby"; -import { readPackageUp } from "read-pkg-up"; -import { match, P } from "ts-pattern"; +import nodePath from "node:path"; +import process from "node:process"; +import type { OneOf } from "./types.ts"; +import { type Binary, hasShebang, log, makeExecutable, readPackageJson } from "./utils.ts"; -type GetFilesArguments = { - globs: string[]; +type Input = { + input: string[]; usePackage?: boolean; }; -export const getFiles = async ({ globs, usePackage }: GetFilesArguments): Promise => { - const filePaths = globs.length === 0 - ? [] - : await globby(globs, { expandDirectories: false, onlyFiles: true }); +/** Collates input file paths and binaries from `package.json`, deduplicating any input paths in `package.json`. */ +export const getBinaries = async ({ input, usePackage }: Input): Promise => { + const binaries: Binary[] = []; if (usePackage) { - const maybePackageJson = await readPackageUp(); + const packageBinaries = await readPackageJson(); - if (!maybePackageJson) { - throw new Error("No package.json found."); + if (!packageBinaries) { + log.error("No package.json found."); + process.exit(1); } - const { packageJson } = maybePackageJson; - const bins: string[] = match(packageJson.bin) - .with(P.string, bin => [bin]) - .with(P.nullish, () => []) - .otherwise(bin => Object.values(bin)); - - filePaths.push(...bins); + binaries.push(...packageBinaries); } - return filePaths; -}; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const EXECUTABLE_MASK = fsConstants.S_IXUSR | fsConstants.S_IXGRP | fsConstants.S_IXOTH; + for (const path of input) { + const absolutePath = nodePath.resolve(process.cwd(), path); -const setExecutableBit = async (filePath: string) => { - const stats = await fs.stat(filePath); - - // Same as 'chmod +x' - if ((stats.mode & EXECUTABLE_MASK) !== EXECUTABLE_MASK) { - await fs.chmod(filePath, stats.mode | EXECUTABLE_MASK); + if (binaries.every(binary => binary.absolutePath !== absolutePath)) { + binaries.push({ absolutePath, path }); + } } + + return binaries; }; -export const setExecutableBits = async (filePaths: string[]) => ( - Promise.all(filePaths.map(setExecutableBit)) -); +type ExecifyResult = OneOf<{ + didExecify: boolean; + hasShebang: boolean; +}, { + error: string; +}>; -const fixShebang = async (filePath: string) => { - const file = await fs.readFile(filePath, "utf8"); - const lines = file.split(/\r?\n/); +const execifySingle = async (path: string): Promise => { + try { + return { + didExecify: await makeExecutable(path), + hasShebang: await hasShebang(path), + }; + } catch (error) { + let message = String(error); - if (lines.at(0)?.startsWith("#!/")) { - lines[0] = "#!/usr/bin/env node"; - await fs.writeFile(filePath, lines.join("\n"), "utf8"); + if (error instanceof Error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + message = `file not found`; + } else if ((error as NodeJS.ErrnoException).code === "EACCES") { + message = `permission denied`; + } else { + message = error.message; + } + } + + return { error: message }; } }; -export const fixShebangs = async (filePaths: string[]) => ( - Promise.all(filePaths.map(fixShebang)) +type ExecifyOutput = Binary & ExecifyResult; + +export const execify = async (binaries: Binary[]): Promise => ( + Promise.all(binaries.map(async (binary) => { + const result = await execifySingle(binary.absolutePath ?? binary.path); + return { ...binary, ...result }; + })) ); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0feb7bb --- /dev/null +++ b/src/types.ts @@ -0,0 +1,5 @@ +// dprint-ignore +export type OneOf = ( + | (T & { [K in keyof U]?: never }) + | (U & { [K in keyof T]?: never }) +); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..438a2d4 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,65 @@ +import { constants as fsConstants, promises as fs } from "node:fs"; +import nodePath from "node:path"; +import logSymbols from "log-symbols"; +import { readPackageUp } from "read-package-up"; + +export const log = { + error: (...messages: string[]) => console.log(logSymbols.error, ...messages), + info: (...messages: string[]) => console.log(logSymbols.info, ...messages), + success: (...messages: string[]) => console.log(logSymbols.success, ...messages), + warn: (...messages: string[]) => console.log(logSymbols.warning, ...messages), +}; + +export type Binary = { + absolutePath?: string; + name?: string; + path: string; +}; + +type PackageBinary = [name: string, path: string]; + +const resolveBinaryFrom = ([name, path]: PackageBinary, packageJsonPath: string): Binary => ({ + absolutePath: nodePath.resolve(nodePath.dirname(packageJsonPath), path), + name, + path, +}); + +/** Parses all binaries from the nearest `package.json`, returning `undefined` if none exist. */ +export const readPackageJson = async (): Promise => { + const maybePackageJson = await readPackageUp(); + + if (!maybePackageJson) { + return; + } + + const { packageJson, path } = maybePackageJson; + const { bin, name } = packageJson; + + if (!bin) { + return []; + } + + return typeof bin === "string" + ? [resolveBinaryFrom([name, bin], path)] + : Object.entries(bin).map(binary => resolveBinaryFrom(binary, path)); +}; + +const EXECUTABLE_MASK = fsConstants.S_IXUSR | fsConstants.S_IXGRP | fsConstants.S_IXOTH; + +/** Sets the executable bit on a file, if not already set. Returns `true` if the bit was changed, `false` otherwise. */ +// eslint-disable-next-line unicorn/consistent-boolean-name +export const makeExecutable = async (path: string): Promise => { + const stats = await fs.stat(path); + + if ((stats.mode & EXECUTABLE_MASK) !== EXECUTABLE_MASK) { + await fs.chmod(path, stats.mode | EXECUTABLE_MASK); // Same as 'chmod +x' + return true; + } + + return false; +}; + +export const hasShebang = async (path: string): Promise => { + const content = await fs.readFile(path, "utf8"); + return content.startsWith("#!"); +}; diff --git a/test/_test-helpers/index.ts b/test/_test-helpers/index.ts deleted file mode 100644 index 4cd4048..0000000 --- a/test/_test-helpers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./util.js"; -export * from "./verify-cli.js"; diff --git a/test/_test-helpers/util.ts b/test/_test-helpers/util.ts deleted file mode 100644 index 94998c1..0000000 --- a/test/_test-helpers/util.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { fileURLToPath } from "node:url"; -import path from "node:path"; -import { type Options as ExecaOptions } from "execa"; -import { createTag, stripIndentTransformer, trimResultTransformer } from "proper-tags"; - -export const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -export const atFixture = (name: string) => path.join(__dirname, "..", "fixtures", name); - -export const atFixtureCwd = (name: string): ExecaOptions => ({ cwd: atFixture(name) }); - -type Tag = { - (string_: string): ReturnType; - (literals: TemplateStringsArray, ...placeholders: any[]): ReturnType; -}; - -export const trimStdout = createTag( - stripIndentTransformer(), - trimResultTransformer("smart" as ""), -) as unknown as Tag; - -export const splitStdout = createTag( - trimStdout, - { onEndResult: (result: string) => result.split("\n") }, -) as unknown as Tag; diff --git a/test/_test-helpers/verify-cli.ts b/test/_test-helpers/verify-cli.ts deleted file mode 100644 index 55bd043..0000000 --- a/test/_test-helpers/verify-cli.ts +++ /dev/null @@ -1,200 +0,0 @@ -import process from "node:process"; -import type { AsyncReturnType, RequireOneOrNone as OneOrNoneOf, IfNever } from "type-fest"; -import test, { type ExecutionContext } from "ava"; -import esmock from "esmock"; -import * as tt from "testtriple"; -import { P, match } from "ts-pattern"; -import { getProperty } from "dot-prop"; -import mapObject from "map-obj"; -import * as helpers from "../../src/helpers.js"; -import { splitStdout } from "./util.js"; - -declare module "testtriple" { - type Spy any>> = ( - IfNever void, U> - ); - - export function spy(...functions: Array any>>): Spy; -} - -type HelperCalls = number | { - [helper in keyof typeof helpers]?: number | ( - | { - callCount: number; - args: Parameters; - } - | { - resolves?: AsyncReturnType; - } - ) -}; - -type Helpers = typeof helpers; - -type HelperStubs = { - [Helper in keyof Helpers]: ReturnType>; -}; - -type MockCliArguments = { - helperCalls: HelperCalls; - args?: string; - logs: string[]; -}; - -const mockCli = async ({ helperCalls, args, logs }: MockCliArguments) => { - const helperStubs = mapObject(helpers, helper => { - // @ts-expect-error: helperCalls might not be indexable -> undefined - const resolved = helperCalls[helper]?.resolves as AsyncReturnType; - const spy = resolved === undefined - ? tt.spy() - // @ts-expect-error: weird typing of tt.resolves - : tt.spy(tt.resolves(resolved)); - - return [helper, spy]; - }) as HelperStubs; - - /* eslint-disable @typescript-eslint/naming-convention */ - await esmock("../../src/cli.ts", import.meta.url, { - "../../src/helpers.ts": helperStubs, - }, { - "node:process": { - argv: [...process.argv, ...(args?.split(" ") ?? [])], - exit: (code: number) => { - class HelperStubError extends Error { - helperStubs: HelperStubs; - - constructor(message: string) { - super(message); - - this.name = "HelperStubError"; - this.helperStubs = helperStubs; - } - } - - throw new HelperStubError(`process.exit, exit code: ${code}`); - }, - }, - import: { - console: { - log: (...args: string[]) => args.map(log => ( - logs.push(...splitStdout(log)) - )), - }, - }, - }) as typeof import("../../src/cli.js"); // eslint-disable-line @typescript-eslint/consistent-type-imports - /* eslint-enable @typescript-eslint/naming-convention */ - - return { helperStubs }; -}; - -type HelperStubError = Error & { - name: "HelperStubError"; - helperStubs: HelperStubs; -}; - -type VerifyCliSucceedsArguments = MockCliArguments & { - t: ExecutionContext; - expected: string | string[]; -}; - -const verifyCliSucceeds = async ({ t, expected, helperCalls, args, logs }: VerifyCliSucceedsArguments): Promise => { - let helperStubs: HelperStubs; - - try { - ({ helperStubs } = await mockCli({ helperCalls, args, logs })); - } catch (maybeError: unknown) { - const error = maybeError as HelperStubError | undefined; - - if (error?.name === "HelperStubError") { - t.is(error.message, "process.exit, exit code: 0"); - helperStubs = error.helperStubs; - } else { - throw error; // eslint-disable-line @typescript-eslint/no-throw-literal - } - } - - const expectedLogs = (expected === "" - ? [] - : (Array.isArray(expected) - ? expected - : splitStdout(expected) - ) - ); - - t.deepEqual(logs, expectedLogs); - - return helperStubs; -}; - -type VerifyCliFailsArguments = MockCliArguments & { - t: ExecutionContext; - message: string; -}; - -const verifyCliFails = async ({ t, message, ...mockCliArgs }: VerifyCliFailsArguments): Promise => { - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - const { helperStubs } = await t.throwsAsync( - mockCli(mockCliArgs), - { message }, - ) as HelperStubError; - - return helperStubs; -}; - -type VerifyHelperCallsArguments = { - t: ExecutionContext; - helperCalls: HelperCalls; - helperStubs: HelperStubs; -}; - -const verifyHelperCalls = ({ t, helperCalls, helperStubs }: VerifyHelperCallsArguments) => { - for (const [helper, stub] of Object.entries(helperStubs)) { - if (helper === "default") { - continue; - } - - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - const expectedArgs = getProperty(helperCalls, `${helper}.args`) ?? []; - const expectedCalls: number | "skip assertion" = match(helperCalls) - .with(P.number, callCount => callCount) - .otherwise(_helperCalls => ( - match(_helperCalls[helper as keyof typeof helpers]) - .with(P.number, callCount => callCount) - .with(P.nullish, _ => "skip assertion" as const) - .with({ callCount: P.number }, call => call.callCount) - .otherwise(_ => "skip assertion" as const) - )); - - const actualCalls = tt.callsOf(stub); - - if (expectedCalls !== "skip assertion") { - t.is(actualCalls.length, expectedCalls); - } - - if (expectedArgs.length > 0) { - // Access first call -> helpers only called once currently, can change later - t.deepEqual(actualCalls[0], expectedArgs); - } - } -}; - -type VerifyCliArguments = { - args?: string; - helperCalls?: HelperCalls; -} & OneOrNoneOf<{ - output: string | string[]; - errorMessage: string; -}>; - -export const verifyCli = test.macro(async (t, { args, output: expected = "", errorMessage, helperCalls = 0 }: VerifyCliArguments) => { - const logs: string[] = []; - const mockCliArgs: MockCliArguments = { helperCalls, args, logs }; - - // Stub helpers to remove side-effects and spy on call counts - const helperStubs = await (errorMessage === undefined - ? verifyCliSucceeds({ t, expected, ...mockCliArgs }) - : verifyCliFails({ t, message: errorMessage, ...mockCliArgs }) - ); - - verifyHelperCalls({ t, helperCalls, helperStubs }); -}); diff --git a/test/cli.ts b/test/cli.ts index 9323ed0..7e8b108 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -1,96 +1,63 @@ -import test from "ava"; -import { getBinPath } from "get-bin-path"; -import { $ } from "execa"; -import { isExecutable } from "is-executable"; -import { splitStdout, verifyCli } from "./_test-helpers/index.js"; +import dedent from "dedent"; +import { $, test, verifyCli } from "./helpers/cli.ts"; -const helpText = splitStdout` - Usage - $ execify [globs…] - - Options - --package, --pkg, -p Set every binary in package.json as executable - --fix-shebang Convert shebangs to "#!/usr/bin/env node" - --all Set all flags - - Examples - $ execify cli.js - - $ execify --pkg test/fixtures/**/cli.js - - $ execify --fix-shebang dist/ts-cli.js -`; - -test.serial("main", async t => { - let binPath = await getBinPath(); - t.truthy(binPath, "No bin path found!"); - - binPath = binPath!.replace("dist", "src").replace(".js", ".ts"); - t.true(await isExecutable(binPath), "Source binary not executable!"); - - const { exitCode } = await $`${binPath}`; - t.is(exitCode, 0); +test("shows help if no input", async t => { + const { all: helpText } = await $`${t.context.binPath}`; + t.snapshot(helpText); }); -test("help - no arguments", verifyCli, { - output: helpText, -}); - -test("help - --help flag", verifyCli, { - args: "--help", - output: helpText, -}); - -test("help - -h short flag", verifyCli, { - args: "-h", - output: helpText, -}); +for (const flag of ["--help", "-h"]) { + test(`shows help (${flag})`, async t => { + const { all: helpText } = await $`${t.context.binPath} ${flag}`; + t.snapshot(helpText); + }); +} -test("resolves globs and sets executable", verifyCli, { - args: "globs/**/*.ts", - helperCalls: { - getFiles: { - callCount: 1, - args: [{ globs: ["globs/**/*.ts"], usePackage: false }], - }, - setExecutableBits: 1, - }, +const fixtures = "executable.ts non-executable.js nested/executable.sh nested/non-executable.sh non-existent.py"; + +test("reports results", verifyCli, { + args: fixtures, + expected: dedent` + ℹ File "executable.ts" is already executable + ⚠ File "executable.ts" is missing a shebang! + ✔ Execified "non-executable.js" + ℹ File "nested/executable.sh" is already executable + ✔ Execified "nested/non-executable.sh" + ⚠ File "nested/non-executable.sh" is missing a shebang! + ✖ Failed to execify "non-existent.py", file not found + `, + fixture: "binaries", }); -for (const flag of ["--package", "--pkg", "-p", "--all"]) { - test(`usePackage - ${flag} flag`, verifyCli, { - args: flag, - helperCalls: { - getFiles: { - callCount: 1, - args: [{ globs: [], usePackage: true }], - }, - }, +for (const flag of ["--package", "--pkg", "-p"]) { + test(`includes binaries from \`package.json\` and deduplicates (${flag})`, verifyCli, { + args: `${fixtures} ${flag}`, + expected: dedent` + ℹ File "./executable.ts" is already executable (bar-cli) + ⚠ File "./executable.ts" is missing a shebang! (bar-cli) + ✔ Execified "./nested/non-executable.sh" (baz-cli) + ⚠ File "./nested/non-executable.sh" is missing a shebang! (baz-cli) + ✔ Execified "non-executable.js" + ℹ File "nested/executable.sh" is already executable + ✖ Failed to execify "non-existent.py", file not found + `, + fixture: "binaries", }); - test(`globs with usePackage - ${flag} flag`, verifyCli, { - args: `${flag} globs/**/*.ts`, - helperCalls: { - getFiles: { - callCount: 1, - args: [{ globs: ["globs/**/*.ts"], usePackage: true }], - }, - }, + test(`resolves paths if \`package.json\` not in cwd (${flag})`, verifyCli, { + args: flag, + cwd: "foo/bar", + expected: dedent` + ✔ Execified "./bar/cli.js" (bar-cli) + `, + fixture: "package-nested", }); -} -for (const flag of ["--fix-shebang", "--all"]) { - test(`fixShebangs uses getFiles output - ${flag} flag`, verifyCli, { - args: `${flag} shebang-*/fixture.ts`, - helperCalls: { - getFiles: { - resolves: ["shebang-tsx/fixture.ts", "shebang-ts-node/fixture.ts"], - }, - fixShebangs: { - callCount: 1, - args: [["shebang-tsx/fixture.ts", "shebang-ts-node/fixture.ts"]], - }, - }, + test(`errors if no \`package.json\` found (${flag})`, verifyCli, { + args: flag, + error: dedent` + ✖ No package.json found. + `, + fixture: "package-none", }); } - diff --git a/test/fix-shebangs.ts b/test/fix-shebangs.ts deleted file mode 100644 index 869ba11..0000000 --- a/test/fix-shebangs.ts +++ /dev/null @@ -1,73 +0,0 @@ -import path from "node:path"; -import { promises as fs } from "node:fs"; -import test from "ava"; -import { temporaryDirectory } from "tempy"; -import { copyFile } from "cp-file"; -import pMap from "p-map"; -import esmock from "esmock"; -import * as tt from "testtriple"; -import { atFixture } from "./_test-helpers/util.js"; - -type VerifyShebangsArguments = { - fixtures: string[]; - writeCount?: number; -}; - -const verifyShebangs = test.macro(async (t, { fixtures, writeCount = fixtures.length }: VerifyShebangsArguments) => { - const temporaryDir = temporaryDirectory(); - - // Map and copy fixtures to temporary files - const fixturePaths = fixtures.map(fixture => ( - path.join(temporaryDir, `${fixture}-fixture.ts`) - )); - - await pMap(fixtures, async fixture => copyFile( - path.join(atFixture(fixture), "fixture.ts"), - path.join(temporaryDir, `${fixture}-fixture.ts`), - )); - - const spy = tt.spy(fs.writeFile); - - const { fixShebangs } = await esmock("../src/helpers.ts", { - "node:fs": { promises: { ...fs, writeFile: spy } }, // eslint-disable-line @typescript-eslint/naming-convention - }) as typeof import("../src/helpers.js"); // eslint-disable-line @typescript-eslint/consistent-type-imports - - // Fix and verify temporary files - await fixShebangs(fixturePaths); - - await pMap(fixturePaths, async fixture => { - const output = await fs.readFile(fixture, "utf8"); - const firstLine = output.split("\n")[0]!; - - if (fixture.includes("no-shebang")) { - t.notRegex(firstLine, /#!.*/, "Included shebang!"); - } else { - t.is(firstLine, "#!/usr/bin/env node"); - } - }); - - const spyCallCount = tt.callsOf(spy).length; - t.is(spyCallCount, writeCount); -}); - -test("one file", verifyShebangs, { - fixtures: ["shebang-tsx"], -}); - -test("multiple files", verifyShebangs, { - fixtures: ["shebang-tsx"], -}); - -test("ts-node shebang", verifyShebangs, { - fixtures: ["shebang-ts-node"], -}); - -test("no shebang - does not write to file", verifyShebangs, { - fixtures: ["no-shebang"], - writeCount: 0, -}); - -test("some shebang, some not", verifyShebangs, { - fixtures: ["shebang-tsx", "shebang-ts-node", "no-shebang"], - writeCount: 2, -}); diff --git a/test/fixtures/binaries/executable.ts b/test/fixtures/binaries/executable.ts new file mode 100755 index 0000000..f6e89da --- /dev/null +++ b/test/fixtures/binaries/executable.ts @@ -0,0 +1 @@ +console.log("executable.ts"); diff --git a/test/fixtures/binaries/nested/executable.sh b/test/fixtures/binaries/nested/executable.sh new file mode 100755 index 0000000..6bc0e72 --- /dev/null +++ b/test/fixtures/binaries/nested/executable.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "nested/executable.js" diff --git a/test/fixtures/binaries/nested/non-executable.sh b/test/fixtures/binaries/nested/non-executable.sh new file mode 100644 index 0000000..b89ef33 --- /dev/null +++ b/test/fixtures/binaries/nested/non-executable.sh @@ -0,0 +1 @@ +echo "nested/non-executable.ts" diff --git a/test/fixtures/binaries/non-executable.js b/test/fixtures/binaries/non-executable.js new file mode 100644 index 0000000..c7e396d --- /dev/null +++ b/test/fixtures/binaries/non-executable.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log("non-executable.js"); diff --git a/test/fixtures/binaries/package.json b/test/fixtures/binaries/package.json new file mode 100644 index 0000000..c8aa3ca --- /dev/null +++ b/test/fixtures/binaries/package.json @@ -0,0 +1,7 @@ +{ + "name": "foo", + "bin": { + "bar-cli": "./executable.ts", + "baz-cli": "./nested/non-executable.sh" + } +} diff --git a/test/fixtures/executable.js b/test/fixtures/executable.js new file mode 100755 index 0000000..9d0fc0c --- /dev/null +++ b/test/fixtures/executable.js @@ -0,0 +1 @@ +console.log("executable.js"); diff --git a/test/fixtures/globs/cli.js b/test/fixtures/globs/cli.js deleted file mode 100644 index 89f8a68..0000000 --- a/test/fixtures/globs/cli.js +++ /dev/null @@ -1 +0,0 @@ -console.log("cli.js"); diff --git a/test/fixtures/globs/cli.ts b/test/fixtures/globs/cli.ts deleted file mode 100644 index 25d7a63..0000000 --- a/test/fixtures/globs/cli.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("cli.ts"); diff --git a/test/fixtures/globs/src/cli.js b/test/fixtures/globs/src/cli.js deleted file mode 100644 index 1226fff..0000000 --- a/test/fixtures/globs/src/cli.js +++ /dev/null @@ -1 +0,0 @@ -console.log("src/cli.js"); diff --git a/test/fixtures/globs/src/cli.ts b/test/fixtures/globs/src/cli.ts deleted file mode 100644 index 69662c3..0000000 --- a/test/fixtures/globs/src/cli.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("src/cli.ts"); diff --git a/test/fixtures/no-shebang/fixture.ts b/test/fixtures/no-shebang/fixture.ts deleted file mode 100644 index a8141d3..0000000 --- a/test/fixtures/no-shebang/fixture.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello, world!"); diff --git a/test/fixtures/non-executable.js b/test/fixtures/non-executable.js new file mode 100644 index 0000000..537d37b --- /dev/null +++ b/test/fixtures/non-executable.js @@ -0,0 +1 @@ +console.log("non-executable.js"); diff --git a/test/fixtures/package-bin-empty/package.json b/test/fixtures/package-bin-empty/package.json index de6dc1d..a57c5ed 100644 --- a/test/fixtures/package-bin-empty/package.json +++ b/test/fixtures/package-bin-empty/package.json @@ -1,3 +1,3 @@ { - "name": "foo" + "name": "foo-cli" } diff --git a/test/fixtures/package-bin-object-multiple/package.json b/test/fixtures/package-bin-object-multiple/package.json new file mode 100644 index 0000000..d206661 --- /dev/null +++ b/test/fixtures/package-bin-object-multiple/package.json @@ -0,0 +1,7 @@ +{ + "name": "foo", + "bin": { + "foo": "foo.js", + "bar": "bar.js" + } +} diff --git a/test/fixtures/package-bin-object/package.json b/test/fixtures/package-bin-object/package.json index d206661..358f853 100644 --- a/test/fixtures/package-bin-object/package.json +++ b/test/fixtures/package-bin-object/package.json @@ -1,7 +1,6 @@ { - "name": "foo", + "name": "foo-cli", "bin": { - "foo": "foo.js", - "bar": "bar.js" + "foo": "foo.js" } } diff --git a/test/fixtures/package-bin-string/package.json b/test/fixtures/package-bin-string/package.json index 4756945..d780338 100644 --- a/test/fixtures/package-bin-string/package.json +++ b/test/fixtures/package-bin-string/package.json @@ -1,4 +1,4 @@ { - "name": "foo", + "name": "foo-cli", "bin": "foo.js" } diff --git a/test/fixtures/package-nested/foo/bar/cli.js b/test/fixtures/package-nested/foo/bar/cli.js new file mode 100644 index 0000000..ac103d9 --- /dev/null +++ b/test/fixtures/package-nested/foo/bar/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log("foo/bar/cli.js"); diff --git a/test/fixtures/package-nested/foo/package.json b/test/fixtures/package-nested/foo/package.json new file mode 100644 index 0000000..435feac --- /dev/null +++ b/test/fixtures/package-nested/foo/package.json @@ -0,0 +1,4 @@ +{ + "name": "bar-cli", + "bin": "./bar/cli.js" +} diff --git a/test/fixtures/package-nested/package.json b/test/fixtures/package-nested/package.json new file mode 100644 index 0000000..d780338 --- /dev/null +++ b/test/fixtures/package-nested/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo-cli", + "bin": "foo.js" +} diff --git a/test/fixtures/package-none/.gitkeep b/test/fixtures/package-none/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/shebang-ts-node/fixture.ts b/test/fixtures/shebang-ts-node/fixture.ts deleted file mode 100644 index 2ffbe40..0000000 --- a/test/fixtures/shebang-ts-node/fixture.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env ts-node - -console.log("Hello, world!"); diff --git a/test/fixtures/shebang-tsx/fixture.ts b/test/fixtures/shebang-tsx/fixture.ts deleted file mode 100644 index bf70ab4..0000000 --- a/test/fixtures/shebang-tsx/fixture.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env tsx - -console.log("Hello, world!"); diff --git a/test/get-files.ts b/test/get-files.ts deleted file mode 100644 index db2ce0b..0000000 --- a/test/get-files.ts +++ /dev/null @@ -1,84 +0,0 @@ -import path from "node:path"; -import type { RequireAllOrNone } from "type-fest"; -import test from "ava"; -import esmock from "esmock"; -import * as tt from "testtriple"; -import { readPackageUp } from "read-pkg-up"; -import { getFiles as getFilesOriginal } from "../src/helpers.js"; -import { atFixture, atFixtureCwd } from "./_test-helpers/util.js"; - -const stubReadPackageUp = async (stub: () => Promise) => ( - esmock("../src/helpers.ts", { - "read-pkg-up": { - readPackageUp: stub, - }, - }) as typeof import("../src/helpers.js") // eslint-disable-line @typescript-eslint/consistent-type-imports -); - -type GetFilesArguments = RequireAllOrNone<{ - /** Resolves relative to `test/fixtures` */ - globs?: string[]; - usePackage: boolean; - /** Resolves relative to `test/fixtures` if prefixed with a directory */ - fixture: string; -}, "usePackage" | "fixture">; - -type ExpectedFiles = { - files: string[]; -}; - -const verifyFiles = test.macro(async (t, args: GetFilesArguments, { files }: ExpectedFiles) => { - let getFiles = getFilesOriginal; - - if (args.fixture) { - ({ getFiles } = await stubReadPackageUp(tt.resolves(readPackageUp(atFixtureCwd(args.fixture))))); - } - - // Map globs and files with a directory component to an absolute fixture path - const globs = args.globs?.map(glob => atFixture(glob)) ?? []; - files = files.map(file => path.dirname(file) === "." ? file : atFixture(file)); - - const getFileArgs = { globs, usePackage: args.usePackage }; - t.deepEqual(await getFiles(getFileArgs), files, "Files different from expectations!"); -}); - -const expectedGlobFiles = ["globs/cli.js", "globs/cli.ts", "globs/src/cli.js", "globs/src/cli.ts"]; - -test("resolves globs to respective files", verifyFiles, - { globs: ["globs/**/cli.*"] }, - { files: expectedGlobFiles }, -); - -test("no globs returns empty array", verifyFiles, - { globs: [] }, - { files: [] }, -); - -test("usePackage - handles bin as string", verifyFiles, - { usePackage: true, fixture: "package-bin-string" }, - { files: ["foo.js"] }, -); - -test("usePackage - handles bin as object", verifyFiles, - { usePackage: true, fixture: "package-bin-object" }, - { files: ["foo.js", "bar.js"] }, -); - -test("usePackage - handles empty bin", verifyFiles, - { usePackage: true, fixture: "package-bin-empty" }, - { files: [] }, -); - -test("usePackage - errors if no package.json found", async t => { - const { getFiles } = await stubReadPackageUp(tt.resolves(undefined)); - - await t.throwsAsync( - getFiles({ globs: [], usePackage: true }), - { message: "No package.json found." }, - ); -}); - -test("combines resolved globs and package bin", verifyFiles, - { globs: ["globs/**/cli.*"], usePackage: true, fixture: "package-bin-object" }, - { files: [...expectedGlobFiles, "foo.js", "bar.js"] }, -); diff --git a/test/helpers/cli.ts b/test/helpers/cli.ts new file mode 100644 index 0000000..f60960b --- /dev/null +++ b/test/helpers/cli.ts @@ -0,0 +1,66 @@ +/* eslint-disable ava/no-ignored-test-files, unicorn/no-top-level-side-effects -- invalid */ +import path from "node:path"; +import process from "node:process"; +import anyTest, { type TestFn } from "ava"; +import { Sema } from "async-sema"; +import { execa, type ExecaError, parseCommandString } from "execa"; +import { getExecutableBinPath } from "get-executable-bin-path"; +import type { OneOf } from "#src/types.ts"; +import { trimLines, withFixture } from "./util.ts"; + +export const test = anyTest as TestFn<{ + binPath: string; + semaphore: Sema; +}>; + +test.before("setup context", async t => { + t.context.binPath = await getExecutableBinPath({ + map: binPath => binPath.replace("dist", "src").replace(".js", ".ts"), + }); + + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + const concurrency = Number(process.env["concurrency"]) || 5; + t.log("CLI concurrency:", concurrency); + + t.context.semaphore = new Sema(concurrency); +}); + +test.beforeEach("setup concurrency", async t => { + await t.context.semaphore.acquire(); +}); + +test.afterEach.always(t => { + t.context.semaphore.release(); +}); + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const $ = execa({ all: true, env: { NO_COLOR: "1" }, reject: false }); + +type VerifyCliMacroArgs = [ + OneOf< + { error: string; }, + { expected: string; } + > & { + args?: string; + cwd?: string; + fixture?: string; + }, +]; + +export const verifyCli = test.macro(async ( + t, + { args = "", cwd: optCwd = "", error, expected, fixture }, +) => { + const cwd = fixture ? path.join(await withFixture(t, fixture), optCwd) : undefined; + const resultOrError = await $(t.context.binPath, parseCommandString(args), { cwd }); + + if (resultOrError.failed && resultOrError.exitCode === undefined) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- types are wrong, class is an actual Error instance + throw resultOrError as ExecaError; + } + + const { all: output, exitCode } = resultOrError; + + t.is(trimLines(output), trimLines(expected ?? error)); + t.is(exitCode, expected ? 0 : 1, "Process exited with incorrect exit code!"); +}); diff --git a/test/helpers/util.ts b/test/helpers/util.ts new file mode 100644 index 0000000..1784383 --- /dev/null +++ b/test/helpers/util.ts @@ -0,0 +1,37 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { ExecutionContext } from "ava"; + +const atFixture = (name: string) => path.join(import.meta.dirname, "..", "fixtures", name); + +/** Makes a temporary directory and registers a teardown to remove it. */ +export const withTemporaryDirectory = async (t: ExecutionContext) => { + // eslint-disable-next-line unicorn/max-nested-calls + const temporaryDir = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "execify-cli-")); + + t.teardown(async () => { + await fs.rm(temporaryDir, { force: true, recursive: true }); + }); + + return temporaryDir; +}; + +/** Copies a fixture to a temporary directory and returns path to copied fixture. */ +export const withFixture = async (t: ExecutionContext, name: string) => { + const temporaryDir = await withTemporaryDirectory(t); + + const fixture = atFixture(name); + const isDirectory = !fixture.includes("."); // None of the fixtures have dots ¯\_(ツ)_/¯ + + if (isDirectory) { + await fs.cp(fixture, temporaryDir, { recursive: true }); + return temporaryDir; + } + + const copiedFixture = path.join(temporaryDir, name); + await fs.copyFile(fixture, copiedFixture); + return copiedFixture; +}; + +export const trimLines = (input: string) => input.split("\n").map(line => line.trim()).join("\n"); diff --git a/test/make-executable.ts b/test/make-executable.ts new file mode 100644 index 0000000..7ceb7cb --- /dev/null +++ b/test/make-executable.ts @@ -0,0 +1,29 @@ +/* eslint-disable unicorn/consistent-boolean-name -- test file */ +import test from "ava"; +import { isExecutable } from "is-executable"; +import { makeExecutable } from "#src/utils.ts"; // TODO[engine:node@>=24.14]: use #/ +import { withFixture } from "./helpers/util.ts"; + +test("sets bitmask", async t => { + const fixture = await withFixture(t, "non-executable.js"); + t.false(await isExecutable(fixture), "Fixture should not be executable!"); + + const result = await makeExecutable(fixture); + t.true(result, "Should have made fixture executable!"); + t.true(await isExecutable(fixture), "Fixture should be executable!"); +}); + +test("does nothing if already executable", async t => { + const fixture = await withFixture(t, "executable.js"); + t.true(await isExecutable(fixture), "Fixture should be executable!"); + + const result = await makeExecutable(fixture); + t.false(result, "Should not have changed fixture executable status!"); +}); + +test("does not handle file system errors", async t => { + await t.throwsAsync( + async () => makeExecutable("NON-EXISTENT-FILE"), + { code: "ENOENT", instanceOf: Error }, + ); +}); diff --git a/test/read-package-json.ts b/test/read-package-json.ts new file mode 100644 index 0000000..0b7f6e1 --- /dev/null +++ b/test/read-package-json.ts @@ -0,0 +1,68 @@ +import nodePath from "node:path"; +import test from "ava"; +import * as tq from "test-quadruple"; +import type { Binary } from "#src/utils.ts"; +import { withFixture } from "./helpers/util.ts"; + +const withCwd = async (cwd: string) => ( + tq.replace({ + globalMocks: { + "node:process": { cwd: () => cwd }, + }, + importMeta: import.meta, + modulePath: "../src/utils.js", + }) +); + +type MacroArgs = [{ + cwd?: string; + expected: Binary[] | undefined; + fixture: string; +}]; + +const verify = test.macro(async (t, { cwd, expected, fixture: fixtureName }) => { + const fixture = await withFixture(t, fixtureName); + const { readPackageJson } = await withCwd(cwd ? nodePath.join(fixture, cwd) : fixture); + + const _binaries = await readPackageJson(); + const binaries = _binaries?.map(({ name, path }) => ({ name, path })); + + t.deepEqual(binaries, expected); +}); + +test("returns nothing from empty `bin` field", verify, { + expected: [], + fixture: "package-bin-empty", +}); + +test("returns binary with package name from string `bin` field", verify, { + expected: [{ name: "foo-cli", path: "foo.js" }], + fixture: "package-bin-string", +}); + +test("returns binary from object `bin` field", verify, { + expected: [{ name: "foo", path: "foo.js" }], + fixture: "package-bin-object", +}); + +test("returns binaries from object `bin` field", verify, { + expected: [{ + name: "foo", + path: "foo.js", + }, { + name: "bar", + path: "bar.js", + }], + fixture: "package-bin-object-multiple", +}); + +test("searches for nearest `package.json`", verify, { + cwd: "foo/bar", + expected: [{ name: "bar-cli", path: "./bar/cli.js" }], + fixture: "package-nested", +}); + +test("returns `undefined` when no `package.json` is found", verify, { + expected: undefined, + fixture: "package-none", +}); diff --git a/test/set-executable-bits.ts b/test/set-executable-bits.ts deleted file mode 100644 index 3c4306c..0000000 --- a/test/set-executable-bits.ts +++ /dev/null @@ -1,76 +0,0 @@ -import path from "node:path"; -import { promises as fs } from "node:fs"; -import test from "ava"; -import { temporaryDirectory } from "tempy"; -import { isExecutable } from "is-executable"; -import esmock from "esmock"; -import * as tt from "testtriple"; -import { $ } from "execa"; -import { setExecutableBits } from "../src/helpers.js"; - -test("one file", async t => { - const temporaryDir = temporaryDirectory(); - const fixture = path.join(temporaryDir, "cli.js"); - - await fs.writeFile(fixture, "#!/usr/bin/env node", "utf8"); - t.false(await isExecutable(fixture)); - - await setExecutableBits([fixture]); - t.true(await isExecutable(fixture)); -}); - -test("multiple files", async t => { - const temporaryDir = temporaryDirectory(); - const fixtures = [ - path.join(temporaryDir, "cli-1.js"), - path.join(temporaryDir, "cli-2.js"), - path.join(temporaryDir, "cli-3.js"), - ]; - - await Promise.all(fixtures.map(async (fixture) => { - await fs.writeFile(fixture, "#!/usr/bin/env node", "utf8"); - t.false(await isExecutable(fixture)); - })); - - await setExecutableBits(fixtures); - - await Promise.all(fixtures.map(async (fixture) => { - t.true(await isExecutable(fixture)); - })); -}); - -test("already executable", async t => { - const temporaryDir = temporaryDirectory(); - const $$ = $({ cwd: temporaryDir }); - - const fixtures = [ - path.join(temporaryDir, "cli-1.js"), - path.join(temporaryDir, "cli-2.js"), - path.join(temporaryDir, "cli-3.js"), - ]; - - await Promise.all(fixtures.map(async (fixture) => { - await fs.writeFile(fixture, "#!/usr/bin/env node", "utf8"); - await $$`chmod +x ${fixture}`; - - t.true(await isExecutable(fixture)); - })); - - /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports */ - const spy = tt.spy(); - - const { setExecutableBits } = await esmock("../src/helpers.ts", import.meta.url, { - "node:fs": { promises: { ...fs, chmod: spy } }, - }) as typeof import("../src/helpers.js"); - /* eslint-enable */ - - await setExecutableBits(fixtures); - - // Verify fs/promises.chmod was not called -> fixture was already executable - await Promise.all(fixtures.map(async (fixture) => { - t.true(await isExecutable(fixture)); - - const spyCalled = tt.callsOf(spy).length > 0; - t.false(spyCalled, "fs/promises.chmod was called!"); - })); -}); diff --git a/test/snapshots/cli.ts.md b/test/snapshots/cli.ts.md new file mode 100644 index 0000000..fee2624 --- /dev/null +++ b/test/snapshots/cli.ts.md @@ -0,0 +1,74 @@ +# Snapshot report for `test/cli.ts` + +The actual snapshot is saved in `cli.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## shows help if no input + +> Snapshot 1 + + `␊ + Usage␊ + $ execify [paths…]␊ + ␊ + Options␊ + --package, --pkg, -p Set every binary in package.json as executable␊ + ␊ + Examples␊ + $ execify foo.js bar.ts baz/xyz.sh␊ + ✔ Execified "foo.js"␊ + ⚠ File "foo.js" is missing a shebang!␊ + ℹ File "bar.ts" is already executable␊ + ✖ Failed to execify "baz/xyz.sh", file not found␊ + ␊ + $ execify --pkg␊ + ✔ Execified "./dist/foo.js" (foo-cli)␊ + ✔ Execified "./dist/bar.js" (bar-cli)␊ + ` + +## shows help (--help) + +> Snapshot 1 + + `␊ + Usage␊ + $ execify [paths…]␊ + ␊ + Options␊ + --package, --pkg, -p Set every binary in package.json as executable␊ + ␊ + Examples␊ + $ execify foo.js bar.ts baz/xyz.sh␊ + ✔ Execified "foo.js"␊ + ⚠ File "foo.js" is missing a shebang!␊ + ℹ File "bar.ts" is already executable␊ + ✖ Failed to execify "baz/xyz.sh", file not found␊ + ␊ + $ execify --pkg␊ + ✔ Execified "./dist/foo.js" (foo-cli)␊ + ✔ Execified "./dist/bar.js" (bar-cli)␊ + ` + +## shows help (-h) + +> Snapshot 1 + + `␊ + Usage␊ + $ execify [paths…]␊ + ␊ + Options␊ + --package, --pkg, -p Set every binary in package.json as executable␊ + ␊ + Examples␊ + $ execify foo.js bar.ts baz/xyz.sh␊ + ✔ Execified "foo.js"␊ + ⚠ File "foo.js" is missing a shebang!␊ + ℹ File "bar.ts" is already executable␊ + ✖ Failed to execify "baz/xyz.sh", file not found␊ + ␊ + $ execify --pkg␊ + ✔ Execified "./dist/foo.js" (foo-cli)␊ + ✔ Execified "./dist/bar.js" (bar-cli)␊ + ` diff --git a/test/snapshots/cli.ts.snap b/test/snapshots/cli.ts.snap new file mode 100644 index 0000000..83b8461 Binary files /dev/null and b/test/snapshots/cli.ts.snap differ diff --git a/tsconfig.build.json b/tsconfig.build.json index e660bf0..5059108 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,9 +2,10 @@ "extends": "./tsconfig.json", "include": ["src"], "compilerOptions": { - "outDir": "dist", - "noEmit": false, "declaration": false, + "noEmit": false, + "outDir": "dist", "removeComments": true, - }, + "rootDir": "./src" + } } diff --git a/tsconfig.json b/tsconfig.json index 0cc6798..0d95292 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,12 @@ { "extends": "@tommy-mitchell/tsconfig", - "exclude": ["node_modules", "coverage", "dist"], "include": ["src", "test"], "compilerOptions": { + "erasableSyntaxOnly": true, + "moduleResolution": "nodenext", + "rewriteRelativeImportExtensions": true, "noEmit": true, + "types": ["node"], + "verbatimModuleSyntax": true, }, - "ts-node": { - "transpileOnly": true, - } } diff --git a/xo.config.js b/xo.config.js new file mode 100644 index 0000000..24d3639 --- /dev/null +++ b/xo.config.js @@ -0,0 +1,9 @@ +import * as configs from "@tommy-mitchell/eslint-config-xo"; + +/** @type {import('xo').FlatXoConfig} */ +export default [...configs.xo, ...configs.dprint, { ignores: ["test/fixtures"] }, { + rules: { + "no-bitwise": "off", + "unicorn/no-process-exit": "off", + }, +}];