From 3ada9067356e43312aff31aa9c79d0c969e9422a Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Fri, 12 Jul 2024 19:07:44 -0500 Subject: [PATCH 01/17] updates --- .github/workflows/lint.yml | 4 +- .github/workflows/test-pr.yml | 19 ------ .github/workflows/{test-push.yml => test.yml} | 9 +-- .gitignore | 1 + .xo-config.json | 23 +------ dprint.json | 8 +++ package.json | 57 +++++++++-------- readme.md | 3 + src/cli.ts | 13 ++-- src/helpers.ts | 12 ++-- test/cli.ts | 38 +++++------ test/fix-shebangs.ts | 11 ++-- test/get-files.ts | 17 +++-- test/{_test-helpers => helpers}/index.ts | 0 test/{_test-helpers => helpers}/util.ts | 0 test/{_test-helpers => helpers}/verify-cli.ts | 64 ++++++++++--------- test/set-executable-bits.ts | 7 +- tsconfig.build.json | 1 - tsconfig.json | 3 - 19 files changed, 136 insertions(+), 154 deletions(-) delete mode 100644 .github/workflows/test-pr.yml rename .github/workflows/{test-push.yml => test.yml} (63%) create mode 100644 dprint.json rename test/{_test-helpers => helpers}/index.ts (100%) rename test/{_test-helpers => helpers}/util.ts (100%) rename test/{_test-helpers => helpers}/verify-cli.ts (80%) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e839a52..9578aeb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 - run: npm install - run: npm run lint diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml deleted file mode 100644 index ce8a4f4..0000000 --- a/.github/workflows/test-pr.yml +++ /dev/null @@ -1,19 +0,0 @@ -# https://github.com/orgs/community/discussions/26276#discussioncomment-3251141 -name: CI Test -on: - - pull_request -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-push.yml b/.github/workflows/test.yml similarity index 63% rename from .github/workflows/test-push.yml rename to .github/workflows/test.yml index 7b97d7c..af02d62 100644 --- a/.github/workflows/test-push.yml +++ b/.github/workflows/test.yml @@ -1,9 +1,9 @@ -# https://github.com/orgs/community/discussions/26276#discussioncomment-3251141 name: CI Test on: push: branches: - main + pull_request: jobs: test: name: Node.js ${{ matrix.node-version }} @@ -11,11 +11,12 @@ jobs: strategy: fail-fast: false matrix: - node-version: [16, 18] + node-version: [18, 20, 21] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm install + - run: npx tsimp --start # Preload the transpiler - run: npm test diff --git a/.gitignore b/.gitignore index 02860b1..21baf6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules coverage dist +.tsimp package-lock.json yarn.lock diff --git a/.xo-config.json b/.xo-config.json index 4b4f6ba..9051012 100644 --- a/.xo-config.json +++ b/.xo-config.json @@ -1,24 +1,7 @@ { + "$schema": "https://json.schemastore.org/eslintrc.json", + "extends": ["@tommy-mitchell/xo"], "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..2f6b2d2 --- /dev/null +++ b/dprint.json @@ -0,0 +1,8 @@ +{ + "includes": ["**/*.{js,ts,json,jsonc}"], + "extends": ["node_modules/@tommy-mitchell/dprint-config/index.json"], + "plugins": [ + "https://plugins.dprint.dev/json-0.19.3.wasm", + "https://plugins.dprint.dev/typescript-0.91.3.wasm" + ] +} diff --git a/package.json b/package.json index 426730a..b054b35 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "type": "module", "engines": { - "node": ">=16.17" + "node": "^18.18 || ^20.8 || ^21" }, "bin": { "execify": "dist/cli.js" @@ -34,52 +34,53 @@ ], "scripts": { "prepare": "npm run build", - "build": "tsc -p tsconfig.build.json && node dist/cli.js --all", + "build": "tsc -p tsconfig.build.json && node dist/cli.js --all && dprint fmt --excludes '!dist'", "lint": "xo", - "test": "tsc --noEmit && c8 ava" + "format": "dprint fmt && xo --fix", + "test": "c8 ava" }, "ava": { "files": [ - "test/**/*.ts", - "!test/_test-helpers", - "!test/fixtures" + "test/*.ts" ], "extensions": { "ts": "module" }, "nodeArguments": [ - "--loader=ts-node/esm", - "--loader=esmock", - "--no-warnings=ExperimentalWarning" + "--import=tsimp", + "--import=esmock" ] }, "dependencies": { - "globby": "^13.2.2", - "meow": "^12.1.0", - "read-pkg-up": "^10.0.0", - "ts-pattern": "^5.0.5" + "globby": "^14.0.2", + "meow": "^13.2.0", + "read-package-up": "^11.0.0", + "ts-pattern": "^5.2.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", + "@tommy-mitchell/dprint-config": "^0.2.0", + "@tommy-mitchell/eslint-config-xo": "^0.3.1", + "@tommy-mitchell/tsconfig": "^2.1.0", + "@types/node": "^18.18", + "ava": "^6.1.3", + "c8": "^10.1.2", + "copy-file": "^11.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", + "dot-prop": "^9.0.0", + "dprint": "^0.47.2", + "esmock": "^2.6.6", + "execa": "^9.3.0", + "get-executable-bin-path": "^0.1.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", + "p-map": "^7.0.2", + "proper-tags": "^2.0.2", "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" + "tsimp": "^2.0.11", + "type-fest": "^4.21.0", + "typescript": "~5.5.3", + "xo": "^0.58.0" } } diff --git a/readme.md b/readme.md index acdc6de..9ab387f 100644 --- a/readme.md +++ b/readme.md @@ -10,10 +10,13 @@ npm install --save-dev execify-cli
Other Package Managers +

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

## Usage diff --git a/src/cli.ts b/src/cli.ts index b4a79c4..90394cc 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,9 @@ -#!/usr/bin/env ts-node-esm +#!/usr/bin/env tsimp /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import meow from "meow"; -import { getFiles, setExecutableBits, fixShebangs } from "./helpers.js"; +import { fixShebangs, getFiles, setExecutableBits } from "./helpers.js"; +// dprint-ignore const cli = meow(` Usage $ execify [globs…] @@ -41,10 +42,10 @@ const cli = meow(` }); const globs = cli.input; -const { help: helpShortFlag, package: usePackageFlag, fixShebang, all: allFlags } = cli.flags; -const usePackage = usePackageFlag || allFlags; +const usePackage = cli.flags.package || cli.flags.all; +const fixShebang = cli.flags.fixShebang || cli.flags.all; -if ((globs.length === 0 && !usePackage) || helpShortFlag) { +if (globs.length === 0 && !usePackage) { cli.showHelp(0); } @@ -52,6 +53,6 @@ const filePaths = await getFiles({ globs, usePackage }); await setExecutableBits(filePaths); -if (fixShebang || allFlags) { +if (fixShebang) { await fixShebangs(filePaths); } diff --git a/src/helpers.ts b/src/helpers.ts index 25b416e..e6a927b 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,7 +1,7 @@ /* eslint-disable unicorn/no-array-callback-reference */ -import { promises as fs, constants as fsConstants } from "node:fs"; +import { constants as fsConstants, promises as fs } from "node:fs"; import { globby } from "globby"; -import { readPackageUp } from "read-pkg-up"; +import { readPackageUp } from "read-package-up"; import { match, P } from "ts-pattern"; type GetFilesArguments = { @@ -22,12 +22,12 @@ export const getFiles = async ({ globs, usePackage }: GetFilesArguments): Promis } const { packageJson } = maybePackageJson; - const bins: string[] = match(packageJson.bin) - .with(P.string, bin => [bin]) + const binaries: string[] = match(packageJson.bin) + .with(P.string, binary => [binary]) .with(P.nullish, () => []) - .otherwise(bin => Object.values(bin)); + .otherwise(binary => Object.values(binary)); - filePaths.push(...bins); + filePaths.push(...binaries); } return filePaths; diff --git a/test/cli.ts b/test/cli.ts index 9323ed0..3ee4cf2 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -1,8 +1,7 @@ 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 { execa } from "execa"; +import { getExecutableBinPath } from "get-executable-bin-path"; +import { splitStdout, verifyCli } from "./helpers/index.js"; const helpText = splitStdout` Usage @@ -22,13 +21,10 @@ const helpText = splitStdout` `; test.serial("main", async t => { - let binPath = await getBinPath(); - t.truthy(binPath, "No bin path found!"); + // eslint-disable-next-line unicorn/prevent-abbreviations + const binPath = await getExecutableBinPath(); - binPath = binPath!.replace("dist", "src").replace(".js", ".ts"); - t.true(await isExecutable(binPath), "Source binary not executable!"); - - const { exitCode } = await $`${binPath}`; + const { exitCode } = await execa(binPath); t.is(exitCode, 0); }); @@ -36,15 +32,12 @@ 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(`help (${flag})`, verifyCli, { + args: flag, + output: helpText, + }); +} test("resolves globs and sets executable", verifyCli, { args: "globs/**/*.ts", @@ -58,7 +51,7 @@ test("resolves globs and sets executable", verifyCli, { }); for (const flag of ["--package", "--pkg", "-p", "--all"]) { - test(`usePackage - ${flag} flag`, verifyCli, { + test(`usePackage (${flag})`, verifyCli, { args: flag, helperCalls: { getFiles: { @@ -68,7 +61,7 @@ for (const flag of ["--package", "--pkg", "-p", "--all"]) { }, }); - test(`globs with usePackage - ${flag} flag`, verifyCli, { + test(`globs with usePackage (${flag})`, verifyCli, { args: `${flag} globs/**/*.ts`, helperCalls: { getFiles: { @@ -80,7 +73,7 @@ for (const flag of ["--package", "--pkg", "-p", "--all"]) { } for (const flag of ["--fix-shebang", "--all"]) { - test(`fixShebangs uses getFiles output - ${flag} flag`, verifyCli, { + test(`fixShebangs uses getFiles output (${flag})`, verifyCli, { args: `${flag} shebang-*/fixture.ts`, helperCalls: { getFiles: { @@ -93,4 +86,3 @@ for (const flag of ["--fix-shebang", "--all"]) { }, }); } - diff --git a/test/fix-shebangs.ts b/test/fix-shebangs.ts index 869ba11..8f20b47 100644 --- a/test/fix-shebangs.ts +++ b/test/fix-shebangs.ts @@ -2,11 +2,11 @@ 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 { copyFile } from "copy-file"; import pMap from "p-map"; import esmock from "esmock"; import * as tt from "testtriple"; -import { atFixture } from "./_test-helpers/util.js"; +import { atFixture } from "./helpers/util.js"; type VerifyShebangsArguments = { fixtures: string[]; @@ -14,6 +14,7 @@ type VerifyShebangsArguments = { }; const verifyShebangs = test.macro(async (t, { fixtures, writeCount = fixtures.length }: VerifyShebangsArguments) => { + // eslint-disable-next-line unicorn/prevent-abbreviations const temporaryDir = temporaryDirectory(); // Map and copy fixtures to temporary files @@ -21,14 +22,14 @@ const verifyShebangs = test.macro(async (t, { fixtures, writeCount = fixtures.le path.join(temporaryDir, `${fixture}-fixture.ts`) )); - await pMap(fixtures, async fixture => copyFile( + 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", { + const { fixShebangs } = await esmock("../src/helpers.js", { // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion "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 diff --git a/test/get-files.ts b/test/get-files.ts index db2ce0b..dccf3db 100644 --- a/test/get-files.ts +++ b/test/get-files.ts @@ -3,16 +3,17 @@ 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 { readPackageUp } from "read-package-up"; import { getFiles as getFilesOriginal } from "../src/helpers.js"; -import { atFixture, atFixtureCwd } from "./_test-helpers/util.js"; +import { atFixture, atFixtureCwd } from "./helpers/util.js"; const stubReadPackageUp = async (stub: () => Promise) => ( - esmock("../src/helpers.ts", { - "read-pkg-up": { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + esmock("../src/helpers.js", { + "read-package-up": { readPackageUp: stub, }, - }) as typeof import("../src/helpers.js") // eslint-disable-line @typescript-eslint/consistent-type-imports + }) ); type GetFilesArguments = RequireAllOrNone<{ @@ -44,26 +45,31 @@ const verifyFiles = test.macro(async (t, args: GetFilesArguments, { files }: Exp const expectedGlobFiles = ["globs/cli.js", "globs/cli.ts", "globs/src/cli.js", "globs/src/cli.ts"]; +// dprint-ignore test("resolves globs to respective files", verifyFiles, { globs: ["globs/**/cli.*"] }, { files: expectedGlobFiles }, ); +// dprint-ignore test("no globs returns empty array", verifyFiles, { globs: [] }, { files: [] }, ); +// dprint-ignore test("usePackage - handles bin as string", verifyFiles, { usePackage: true, fixture: "package-bin-string" }, { files: ["foo.js"] }, ); +// dprint-ignore test("usePackage - handles bin as object", verifyFiles, { usePackage: true, fixture: "package-bin-object" }, { files: ["foo.js", "bar.js"] }, ); +// dprint-ignore test("usePackage - handles empty bin", verifyFiles, { usePackage: true, fixture: "package-bin-empty" }, { files: [] }, @@ -78,6 +84,7 @@ test("usePackage - errors if no package.json found", async t => { ); }); +// dprint-ignore 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/_test-helpers/index.ts b/test/helpers/index.ts similarity index 100% rename from test/_test-helpers/index.ts rename to test/helpers/index.ts diff --git a/test/_test-helpers/util.ts b/test/helpers/util.ts similarity index 100% rename from test/_test-helpers/util.ts rename to test/helpers/util.ts diff --git a/test/_test-helpers/verify-cli.ts b/test/helpers/verify-cli.ts similarity index 80% rename from test/_test-helpers/verify-cli.ts rename to test/helpers/verify-cli.ts index 55bd043..fa68a60 100644 --- a/test/_test-helpers/verify-cli.ts +++ b/test/helpers/verify-cli.ts @@ -1,36 +1,35 @@ import process from "node:process"; -import type { AsyncReturnType, RequireOneOrNone as OneOrNoneOf, IfNever } from "type-fest"; +import type { AsyncReturnType, IfNever, RequireOneOrNone as OneOrNoneOf } 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 { match, P } 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> - ); + type Spy any>> = IfNever void, U>; export function spy(...functions: Array any>>): Spy; } +type Helpers = typeof helpers; + +// dprint-ignore type HelperCalls = number | { - [helper in keyof typeof helpers]?: number | ( + [helper in keyof Helpers]?: number | ( | { callCount: number; - args: Parameters; + args: Parameters; } | { - resolves?: AsyncReturnType; + resolves?: AsyncReturnType; } ) }; -type Helpers = typeof helpers; - type HelperStubs = { [Helper in keyof Helpers]: ReturnType>; }; @@ -54,8 +53,8 @@ const mockCli = async ({ helperCalls, args, logs }: MockCliArguments) => { }) as HelperStubs; /* eslint-disable @typescript-eslint/naming-convention */ - await esmock("../../src/cli.ts", import.meta.url, { - "../../src/helpers.ts": helperStubs, + await esmock("../../src/cli.js", import.meta.url, { // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion + "../../src/helpers.js": helperStubs, }, { "node:process": { argv: [...process.argv, ...(args?.split(" ") ?? [])], @@ -74,11 +73,12 @@ const mockCli = async ({ helperCalls, args, logs }: MockCliArguments) => { throw new HelperStubError(`process.exit, exit code: ${code}`); }, }, - import: { + "import": { console: { - log: (...args: string[]) => args.map(log => ( - logs.push(...splitStdout(log)) - )), + log: (...args: string[]) => + args.map(log => ( + logs.push(...splitStdout(log)) + )), }, }, }) as typeof import("../../src/cli.js"); // eslint-disable-line @typescript-eslint/consistent-type-imports @@ -97,7 +97,13 @@ type VerifyCliSucceedsArguments = MockCliArguments & { expected: string | string[]; }; -const verifyCliSucceeds = async ({ t, expected, helperCalls, args, logs }: VerifyCliSucceedsArguments): Promise => { +const verifyCliSucceeds = async ({ + t, + expected, + helperCalls, + args, + logs, +}: VerifyCliSucceedsArguments): Promise => { let helperStubs: HelperStubs; try { @@ -113,13 +119,11 @@ const verifyCliSucceeds = async ({ t, expected, helperCalls, args, logs }: Verif } } - const expectedLogs = (expected === "" + const expectedLogs = expected === "" ? [] : (Array.isArray(expected) ? expected - : splitStdout(expected) - ) - ); + : splitStdout(expected)); t.deepEqual(logs, expectedLogs); @@ -132,12 +136,7 @@ type VerifyCliFailsArguments = MockCliArguments & { }; 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; - + const { helperStubs } = await t.throwsAsync(mockCli(mockCliArgs), { message }); return helperStubs; }; @@ -178,6 +177,7 @@ const verifyHelperCalls = ({ t, helperCalls, helperStubs }: VerifyHelperCallsArg } }; +// dprint-ignore type VerifyCliArguments = { args?: string; helperCalls?: HelperCalls; @@ -186,15 +186,19 @@ type VerifyCliArguments = { errorMessage: string; }>; -export const verifyCli = test.macro(async (t, { args, output: expected = "", errorMessage, helperCalls = 0 }: VerifyCliArguments) => { +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 }) - ); + : verifyCliFails({ t, message: errorMessage, ...mockCliArgs })); verifyHelperCalls({ t, helperCalls, helperStubs }); }); diff --git a/test/set-executable-bits.ts b/test/set-executable-bits.ts index 3c4306c..6c1eba0 100644 --- a/test/set-executable-bits.ts +++ b/test/set-executable-bits.ts @@ -9,6 +9,7 @@ import { $ } from "execa"; import { setExecutableBits } from "../src/helpers.js"; test("one file", async t => { + // eslint-disable-next-line unicorn/prevent-abbreviations const temporaryDir = temporaryDirectory(); const fixture = path.join(temporaryDir, "cli.js"); @@ -20,6 +21,7 @@ test("one file", async t => { }); test("multiple files", async t => { + // eslint-disable-next-line unicorn/prevent-abbreviations const temporaryDir = temporaryDirectory(); const fixtures = [ path.join(temporaryDir, "cli-1.js"), @@ -40,6 +42,7 @@ test("multiple files", async t => { }); test("already executable", async t => { + // eslint-disable-next-line unicorn/prevent-abbreviations const temporaryDir = temporaryDirectory(); const $$ = $({ cwd: temporaryDir }); @@ -56,10 +59,10 @@ test("already executable", async t => { t.true(await isExecutable(fixture)); })); - /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports */ + /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports, @typescript-eslint/no-unnecessary-type-assertion */ const spy = tt.spy(); - const { setExecutableBits } = await esmock("../src/helpers.ts", import.meta.url, { + const { setExecutableBits } = await esmock("../src/helpers.js", import.meta.url, { "node:fs": { promises: { ...fs, chmod: spy } }, }) as typeof import("../src/helpers.js"); /* eslint-enable */ diff --git a/tsconfig.build.json b/tsconfig.build.json index e660bf0..9aca565 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,6 +5,5 @@ "outDir": "dist", "noEmit": false, "declaration": false, - "removeComments": true, }, } diff --git a/tsconfig.json b/tsconfig.json index 0cc6798..0713e34 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,4 @@ "compilerOptions": { "noEmit": true, }, - "ts-node": { - "transpileOnly": true, - } } From 008916ca387ac27ccf3b469f70d618de20d3c1ec Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Fri, 12 Jul 2024 19:12:46 -0500 Subject: [PATCH 02/17] update `testtriple` --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b054b35..7229c26 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "p-map": "^7.0.2", "proper-tags": "^2.0.2", "tempy": "^3.1.0", - "testtriple": "^2.2.3", + "testtriple": "^3.0.1", "tsimp": "^2.0.11", "type-fest": "^4.21.0", "typescript": "~5.5.3", From 5295765e5cdff977ad49863af3c2fa131b486a1e Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Fri, 12 Jul 2024 20:17:29 -0500 Subject: [PATCH 03/17] use `shebang-regex` --- package.json | 2 +- src/cli.ts | 3 ++- src/constants.ts | 2 ++ src/helpers.ts | 8 ++++---- test/fix-shebangs.ts | 15 ++++++++------- test/set-executable-bits.ts | 7 ++++--- 6 files changed, 21 insertions(+), 16 deletions(-) create mode 100644 src/constants.ts diff --git a/package.json b/package.json index 7229c26..48d41d9 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "globby": "^14.0.2", "meow": "^13.2.0", "read-package-up": "^11.0.0", + "shebang-regex": "^4.0.0", "ts-pattern": "^5.2.0" }, "devDependencies": { @@ -74,7 +75,6 @@ "is-executable": "^2.0.1", "make-executable": "^2.0.2", "map-obj": "^5.0.2", - "p-map": "^7.0.2", "proper-tags": "^2.0.2", "tempy": "^3.1.0", "testtriple": "^3.0.1", diff --git a/src/cli.ts b/src/cli.ts index 90394cc..d344e48 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env tsimp /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import meow from "meow"; +import { NODE_SHEBANG } from "./constants.js"; import { fixShebangs, getFiles, setExecutableBits } from "./helpers.js"; // dprint-ignore @@ -10,7 +11,7 @@ const cli = meow(` Options --package, --pkg, -p Set every binary in package.json as executable - --fix-shebang Convert shebangs to "#!/usr/bin/env node" + --fix-shebang Convert shebangs to "${NODE_SHEBANG}" --all Set all flags Examples diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..4156793 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const NODE_SHEBANG = "#!/usr/bin/env node"; diff --git a/src/helpers.ts b/src/helpers.ts index e6a927b..7c25bef 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -2,7 +2,9 @@ import { constants as fsConstants, promises as fs } from "node:fs"; import { globby } from "globby"; import { readPackageUp } from "read-package-up"; +import shebangRegex from "shebang-regex"; import { match, P } from "ts-pattern"; +import { NODE_SHEBANG } from "./constants.js"; type GetFilesArguments = { globs: string[]; @@ -51,11 +53,9 @@ export const setExecutableBits = async (filePaths: string[]) => ( const fixShebang = async (filePath: string) => { const file = await fs.readFile(filePath, "utf8"); - const lines = file.split(/\r?\n/); - if (lines.at(0)?.startsWith("#!/")) { - lines[0] = "#!/usr/bin/env node"; - await fs.writeFile(filePath, lines.join("\n"), "utf8"); + if (shebangRegex.test(file)) { + await fs.writeFile(filePath, file.replace(shebangRegex, NODE_SHEBANG), "utf8"); } }; diff --git a/test/fix-shebangs.ts b/test/fix-shebangs.ts index 8f20b47..950e275 100644 --- a/test/fix-shebangs.ts +++ b/test/fix-shebangs.ts @@ -3,9 +3,10 @@ import { promises as fs } from "node:fs"; import test from "ava"; import { temporaryDirectory } from "tempy"; import { copyFile } from "copy-file"; -import pMap from "p-map"; import esmock from "esmock"; +import shebangRegex from "shebang-regex"; import * as tt from "testtriple"; +import { NODE_SHEBANG } from "../src/constants.js"; import { atFixture } from "./helpers/util.js"; type VerifyShebangsArguments = { @@ -22,10 +23,10 @@ const verifyShebangs = test.macro(async (t, { fixtures, writeCount = fixtures.le path.join(temporaryDir, `${fixture}-fixture.ts`) )); - await pMap(fixtures, async fixture => (copyFile( + await Promise.all(fixtures.map(async fixture => (copyFile( path.join(atFixture(fixture), "fixture.ts"), path.join(temporaryDir, `${fixture}-fixture.ts`), - ))); + )))); const spy = tt.spy(fs.writeFile); @@ -36,16 +37,16 @@ const verifyShebangs = test.macro(async (t, { fixtures, writeCount = fixtures.le // Fix and verify temporary files await fixShebangs(fixturePaths); - await pMap(fixturePaths, async fixture => { + await Promise.all(fixturePaths.map(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!"); + t.notRegex(firstLine, shebangRegex, "Included shebang!"); } else { - t.is(firstLine, "#!/usr/bin/env node"); + t.is(firstLine, NODE_SHEBANG); } - }); + })); const spyCallCount = tt.callsOf(spy).length; t.is(spyCallCount, writeCount); diff --git a/test/set-executable-bits.ts b/test/set-executable-bits.ts index 6c1eba0..c3e43c2 100644 --- a/test/set-executable-bits.ts +++ b/test/set-executable-bits.ts @@ -6,6 +6,7 @@ import { isExecutable } from "is-executable"; import esmock from "esmock"; import * as tt from "testtriple"; import { $ } from "execa"; +import { NODE_SHEBANG } from "../src/constants.js"; import { setExecutableBits } from "../src/helpers.js"; test("one file", async t => { @@ -13,7 +14,7 @@ test("one file", async t => { const temporaryDir = temporaryDirectory(); const fixture = path.join(temporaryDir, "cli.js"); - await fs.writeFile(fixture, "#!/usr/bin/env node", "utf8"); + await fs.writeFile(fixture, NODE_SHEBANG, "utf8"); t.false(await isExecutable(fixture)); await setExecutableBits([fixture]); @@ -30,7 +31,7 @@ test("multiple files", async t => { ]; await Promise.all(fixtures.map(async (fixture) => { - await fs.writeFile(fixture, "#!/usr/bin/env node", "utf8"); + await fs.writeFile(fixture, NODE_SHEBANG, "utf8"); t.false(await isExecutable(fixture)); })); @@ -53,7 +54,7 @@ test("already executable", async t => { ]; await Promise.all(fixtures.map(async (fixture) => { - await fs.writeFile(fixture, "#!/usr/bin/env node", "utf8"); + await fs.writeFile(fixture, NODE_SHEBANG, "utf8"); await $$`chmod +x ${fixture}`; t.true(await isExecutable(fixture)); From b64055e4591661d5afe4c27afa7c87ae168f31d2 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 29 Sep 2026 16:31:00 -0500 Subject: [PATCH 04/17] change test name? --- test/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cli.ts b/test/cli.ts index 3ee4cf2..de033ad 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -20,7 +20,7 @@ const helpText = splitStdout` $ execify --fix-shebang dist/ts-cli.js `; -test.serial("main", async t => { +test.serial("build", async t => { // eslint-disable-next-line unicorn/prevent-abbreviations const binPath = await getExecutableBinPath(); From 0960b1b9e6ab0516bbcb443697058bff0c2e462b Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Wed, 30 Sep 2026 21:32:08 -0500 Subject: [PATCH 05/17] bump deps, node, remove options --- .github/workflows/lint.yml | 9 ++-- .github/workflows/test.yml | 11 +++-- .gitignore | 8 ++-- .xo-config.json | 7 --- dprint.json | 10 +++-- package.json | 55 ++++++++++------------- readme.md | 42 ++++++++---------- src/cli.ts | 50 ++++++++------------- src/constants.ts | 2 - src/helpers.ts | 90 ++++++++++++++++++++++++++------------ tsconfig.build.json | 4 +- tsconfig.json | 4 ++ xo.config.js | 18 ++++++++ 13 files changed, 167 insertions(+), 143 deletions(-) delete mode 100644 .xo-config.json delete mode 100644 src/constants.ts create mode 100644 xo.config.js diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9578aeb..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@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 - run: npm install - run: npm run lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af02d62..d4fd192 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,9 +1,9 @@ name: CI Test on: push: - branches: - - main + branches: [main] pull_request: + jobs: test: name: Node.js ${{ matrix.node-version }} @@ -11,12 +11,11 @@ jobs: strategy: fail-fast: false matrix: - node-version: [18, 20, 21] + node-version: ["22.22", "24.15", "26"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} - run: npm install - - run: npx tsimp --start # Preload the transpiler - run: npm test diff --git a/.gitignore b/.gitignore index 21baf6d..e1488d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -node_modules -coverage -dist -.tsimp +node_modules/ +coverage/ +dist/ package-lock.json yarn.lock +pnpm-*.yaml diff --git a/.xo-config.json b/.xo-config.json deleted file mode 100644 index 9051012..0000000 --- a/.xo-config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/eslintrc.json", - "extends": ["@tommy-mitchell/xo"], - "rules": { - "no-bitwise": "off" - } -} diff --git a/dprint.json b/dprint.json index 2f6b2d2..cc1c520 100644 --- a/dprint.json +++ b/dprint.json @@ -1,8 +1,12 @@ { - "includes": ["**/*.{js,ts,json,jsonc}"], + "includes": ["**/*.{js,ts,json,jsonc,yml,markdown}"], "extends": ["node_modules/@tommy-mitchell/dprint-config/index.json"], "plugins": [ - "https://plugins.dprint.dev/json-0.19.3.wasm", - "https://plugins.dprint.dev/typescript-0.91.3.wasm" + "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" ] } diff --git a/package.json b/package.json index 48d41d9..9b95ad7 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,6 @@ "executable", "chmod", "chmod+x", - "shebang", - "hashbang", - "typescript", "binary", "bin", "executable" @@ -24,7 +21,7 @@ }, "type": "module", "engines": { - "node": "^18.18 || ^20.8 || ^21" + "node": "^22.22 || ^24.15 || >=26" }, "bin": { "execify": "dist/cli.js" @@ -34,7 +31,7 @@ ], "scripts": { "prepare": "npm run build", - "build": "tsc -p tsconfig.build.json && node dist/cli.js --all && dprint fmt --excludes '!dist'", + "build": "tsc -p tsconfig.build.json && node dist/cli.js --pkg", "lint": "xo", "format": "dprint fmt && xo --fix", "test": "c8 ava" @@ -43,44 +40,36 @@ "files": [ "test/*.ts" ], - "extensions": { - "ts": "module" - }, - "nodeArguments": [ - "--import=tsimp", - "--import=esmock" + "extensions": [ + "ts" ] }, "dependencies": { - "globby": "^14.0.2", - "meow": "^13.2.0", - "read-package-up": "^11.0.0", - "shebang-regex": "^4.0.0", - "ts-pattern": "^5.2.0" + "log-symbols": "^7.0.1", + "meow": "^14.1.0", + "read-package-up": "^12.0.0" }, "devDependencies": { - "@tommy-mitchell/dprint-config": "^0.2.0", - "@tommy-mitchell/eslint-config-xo": "^0.3.1", - "@tommy-mitchell/tsconfig": "^2.1.0", - "@types/node": "^18.18", - "ava": "^6.1.3", - "c8": "^10.1.2", + "@tommy-mitchell/dprint-config": "^0.8.0", + "@tommy-mitchell/eslint-config-xo": "^2.0.0-beta.10", + "@tommy-mitchell/tsconfig": "^4.0.0", + "@types/node": "^22.20", + "ava": "^8.0.1", + "c8": "^12.0.0", "copy-file": "^11.0.0", "cross-env": "^7.0.3", "dot-prop": "^9.0.0", - "dprint": "^0.47.2", - "esmock": "^2.6.6", - "execa": "^9.3.0", - "get-executable-bin-path": "^0.1.0", + "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", + "map-obj": "^6.0.0", "proper-tags": "^2.0.2", - "tempy": "^3.1.0", - "testtriple": "^3.0.1", - "tsimp": "^2.0.11", - "type-fest": "^4.21.0", - "typescript": "~5.5.3", - "xo": "^0.58.0" + "test-quadruple": "^1.0.0", + "ts-pattern": "^5.9.0", + "type-fest": "^5.10.0", + "typescript": "~6.0.3", + "xo": "^5.0.1" } } diff --git a/readme.md b/readme.md index 9ab387f..dde9de6 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,8 @@ # execify-cli -Easily make a Node.js CLI executable. Sets permissions (`chmod +x`) and handles common TypeScript conversions. See [Roadmap](#roadmap) for more details. +Easily make a Node.js CLI executable. + +Sets permissions (`chmod +x`) on given binaries, or optionally on every binary in `package.json`. Cross-platform. ## Install @@ -16,40 +18,34 @@ npm install --save-dev execify-cli 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 dist/cli.js dist/bin.ts dist/foo.cjs + ✔ Execified "dist/cli.js" + ℹ "dist/cli.ts" already executable + ✖ Failed to execify "dist/foo.cjs", 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 d344e48..3e35fce 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,59 +1,45 @@ -#!/usr/bin/env tsimp -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +#!/usr/bin/env node import meow from "meow"; -import { NODE_SHEBANG } from "./constants.js"; -import { fixShebangs, getFiles, setExecutableBits } from "./helpers.js"; +import { execify, getBinaries } from "./helpers.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 "${NODE_SHEBANG}" - --all Set all flags Examples - $ execify cli.js - - $ execify --pkg test/fixtures/**/cli.js - - $ execify --fix-shebang dist/ts-cli.js + $ execify dist/cli.js dist/bin.ts dist/foo.cjs + ✔ Execified "dist/cli.js" + ℹ "dist/cli.ts" already executable + ✖ Failed to execify "dist/foo.cjs", file not found + + $ 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 usePackage = cli.flags.package || cli.flags.all; -const fixShebang = cli.flags.fixShebang || cli.flags.all; +const { flags: { package: usePackage }, input } = cli; -if (globs.length === 0 && !usePackage) { +if (!usePackage && input.length === 0) { cli.showHelp(0); } -const filePaths = await getFiles({ globs, usePackage }); - -await setExecutableBits(filePaths); - -if (fixShebang) { - await fixShebangs(filePaths); -} +const binaries = await getBinaries({ input, usePackage }); +await execify(binaries); diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 4156793..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/naming-convention -export const NODE_SHEBANG = "#!/usr/bin/env node"; diff --git a/src/helpers.ts b/src/helpers.ts index 7c25bef..7275b3b 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,20 +1,20 @@ -/* eslint-disable unicorn/no-array-callback-reference */ import { constants as fsConstants, promises as fs } from "node:fs"; -import { globby } from "globby"; +import logSymbols from "log-symbols"; import { readPackageUp } from "read-package-up"; -import shebangRegex from "shebang-regex"; -import { match, P } from "ts-pattern"; -import { NODE_SHEBANG } from "./constants.js"; type GetFilesArguments = { - globs: string[]; + input: string[]; usePackage?: boolean; }; -export const getFiles = async ({ globs, usePackage }: GetFilesArguments): Promise => { - const filePaths = globs.length === 0 - ? [] - : await globby(globs, { expandDirectories: false, onlyFiles: true }); +type Binary = { + name?: string; + path: string; +}; + +/** Collates input file paths and binaries from `package.json`, deduplicating any input paths in `package.json`. */ +export const getBinaries = async ({ input, usePackage }: GetFilesArguments): Promise => { + const binaries: Binary[] = []; if (usePackage) { const maybePackageJson = await readPackageUp(); @@ -24,41 +24,77 @@ export const getFiles = async ({ globs, usePackage }: GetFilesArguments): Promis } const { packageJson } = maybePackageJson; - const binaries: string[] = match(packageJson.bin) - .with(P.string, binary => [binary]) - .with(P.nullish, () => []) - .otherwise(binary => Object.values(binary)); - filePaths.push(...binaries); + if (!packageJson.bin) { + return binaries; + } + + if (typeof packageJson.bin === "string") { + binaries.push({ name: packageJson.name, path: packageJson.bin }); + input = input.filter(p => p !== packageJson.bin); + } else { + for (const [name, path] of Object.entries(packageJson.bin)) { + binaries.push({ name, path }); + input = input.filter(p => p !== path); + } + } } - return filePaths; + return [...binaries, ...input.map(path => ({ path }))]; }; -// eslint-disable-next-line @typescript-eslint/naming-convention const EXECUTABLE_MASK = fsConstants.S_IXUSR | fsConstants.S_IXGRP | fsConstants.S_IXOTH; -const setExecutableBit = async (filePath: string) => { +// eslint-disable-next-line unicorn/consistent-boolean-name +const setExecutableBit = async (filePath: string): Promise => { 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); + return true; } + + return false; }; -export const setExecutableBits = async (filePaths: string[]) => ( - Promise.all(filePaths.map(setExecutableBit)) -); +type ExecifyResult = { + didExecify: boolean; + error?: string; +}; + +const execifySingle = async (filePath: string): Promise => { + try { + const didExecify = await setExecutableBit(filePath); + return { didExecify }; + } catch (error) { + let message = String(error); -const fixShebang = async (filePath: string) => { - const file = await fs.readFile(filePath, "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; + } + } - if (shebangRegex.test(file)) { - await fs.writeFile(filePath, file.replace(shebangRegex, NODE_SHEBANG), "utf8"); + return { didExecify: false, error: message }; } }; -export const fixShebangs = async (filePaths: string[]) => ( - Promise.all(filePaths.map(fixShebang)) +export const execify = async (binaries: Binary[]): Promise => ( + Promise.all(binaries.map(async ({ name, path }) => { + const { didExecify, error } = await execifySingle(path); + const nameSuffix = name ? ` (${name})` : ""; // eslint-disable-line @typescript-eslint/strict-boolean-expressions + + if (didExecify) { + console.log(`${logSymbols.success} Execified "${path}"${nameSuffix}`); + } else if (error) { // eslint-disable-line @typescript-eslint/strict-boolean-expressions + console.error(`${logSymbols.error} Failed to execify "${path}", ${error}${nameSuffix}`); + } else { + console.log(`${logSymbols.info} "${path}" already executable${nameSuffix}`); + } + })) ); diff --git a/tsconfig.build.json b/tsconfig.build.json index 9aca565..607528d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,7 +3,9 @@ "include": ["src"], "compilerOptions": { "outDir": "dist", + "rootDir": "./src", "noEmit": false, "declaration": false, - }, + "removeComments": true + } } diff --git a/tsconfig.json b/tsconfig.json index 0713e34..04fbdef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,5 +4,9 @@ "include": ["src", "test"], "compilerOptions": { "noEmit": true, + "types": ["node"], + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, }, } diff --git a/xo.config.js b/xo.config.js new file mode 100644 index 0000000..01c63ff --- /dev/null +++ b/xo.config.js @@ -0,0 +1,18 @@ +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", + }, +}, { + files: "package.json", + rules: { + "package-json/dependency-version-range": ["error", { + exceptions: ["typescript"], + }], + }, +}]; From c69b7e2d1d3ba7dd52c97074ae7bb755dedd6ab4 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Wed, 30 Sep 2026 21:47:00 -0500 Subject: [PATCH 06/17] separate helpers/utils --- src/helpers.ts | 53 ++++++++++---------------------------------------- src/utils.ts | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 43 deletions(-) create mode 100644 src/utils.ts diff --git a/src/helpers.ts b/src/helpers.ts index 7275b3b..15a58d1 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,63 +1,31 @@ -import { constants as fsConstants, promises as fs } from "node:fs"; +import process from "node:process"; import logSymbols from "log-symbols"; -import { readPackageUp } from "read-package-up"; +import { type Binary, readPackageJson, setExecutableBit } from "./utils.ts"; type GetFilesArguments = { input: string[]; usePackage?: boolean; }; -type Binary = { - name?: string; - path: string; -}; - /** Collates input file paths and binaries from `package.json`, deduplicating any input paths in `package.json`. */ export const getBinaries = async ({ input, usePackage }: GetFilesArguments): 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) { + console.error(`${logSymbols.error} No package.json found.`); + process.exit(1); } - const { packageJson } = maybePackageJson; - - if (!packageJson.bin) { - return binaries; - } - - if (typeof packageJson.bin === "string") { - binaries.push({ name: packageJson.name, path: packageJson.bin }); - input = input.filter(p => p !== packageJson.bin); - } else { - for (const [name, path] of Object.entries(packageJson.bin)) { - binaries.push({ name, path }); - input = input.filter(p => p !== path); - } - } + binaries.push(...packageBinaries); + input = input.filter(path => packageBinaries.every(binary => binary.path !== path)); } return [...binaries, ...input.map(path => ({ path }))]; }; -const EXECUTABLE_MASK = fsConstants.S_IXUSR | fsConstants.S_IXGRP | fsConstants.S_IXOTH; - -// eslint-disable-next-line unicorn/consistent-boolean-name -const setExecutableBit = async (filePath: string): Promise => { - 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); - return true; - } - - return false; -}; - type ExecifyResult = { didExecify: boolean; error?: string; @@ -65,8 +33,7 @@ type ExecifyResult = { const execifySingle = async (filePath: string): Promise => { try { - const didExecify = await setExecutableBit(filePath); - return { didExecify }; + return { didExecify: await setExecutableBit(filePath) }; } catch (error) { let message = String(error); @@ -92,7 +59,7 @@ export const execify = async (binaries: Binary[]): Promise => ( if (didExecify) { console.log(`${logSymbols.success} Execified "${path}"${nameSuffix}`); } else if (error) { // eslint-disable-line @typescript-eslint/strict-boolean-expressions - console.error(`${logSymbols.error} Failed to execify "${path}", ${error}${nameSuffix}`); + console.log(`${logSymbols.error} Failed to execify "${path}", ${error}${nameSuffix}`); } else { console.log(`${logSymbols.info} "${path}" already executable${nameSuffix}`); } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..54fcd1e --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,42 @@ +import { constants as fsConstants, promises as fs } from "node:fs"; +import { readPackageUp } from "read-package-up"; + +export type Binary = { + name?: string; + path: string; +}; + +/** Parses all binaries from the nearest `package.json`, returning `undefined` if none exists. */ +export const readPackageJson = async (): Promise => { + const maybePackageJson = await readPackageUp(); + + if (!maybePackageJson) { + return; + } + + const { packageJson } = maybePackageJson; + + if (!packageJson.bin) { + return []; + } + + return typeof packageJson.bin === "string" + ? [{ name: packageJson.name, path: packageJson.bin }] + : Object.entries(packageJson.bin).map(([name, path]) => ({ name, 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 setExecutableBit = async (path: string): Promise => { + const stats = await fs.stat(path); + + // Same as 'chmod +x' + if ((stats.mode & EXECUTABLE_MASK) !== EXECUTABLE_MASK) { + await fs.chmod(path, stats.mode | EXECUTABLE_MASK); + return true; + } + + return false; +}; From 2f74464163be38d936c22b838d251fe4f901e082 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 06:29:38 -0500 Subject: [PATCH 07/17] clean up util tests --- package.json | 1 - test/fixtures/executable.js | 1 + test/fixtures/non-executable.js | 1 + test/fixtures/package-bin-empty/package.json | 2 +- .../package-bin-object-multiple/package.json | 7 ++ test/fixtures/package-bin-object/package.json | 5 +- test/fixtures/package-bin-string/package.json | 2 +- test/fixtures/package-nested/foo/bar/.gitkeep | 0 test/fixtures/package-nested/package.json | 4 + test/fixtures/package-none/.gitkeep | 0 test/helpers/util.ts | 39 ++++++++- test/read-package-json.ts | 66 +++++++++++++++ test/set-executable-bit.ts | 31 +++++++ test/set-executable-bits.ts | 80 ------------------- xo.config.js | 19 +++++ 15 files changed, 169 insertions(+), 89 deletions(-) create mode 100644 test/fixtures/executable.js create mode 100644 test/fixtures/non-executable.js create mode 100644 test/fixtures/package-bin-object-multiple/package.json create mode 100644 test/fixtures/package-nested/foo/bar/.gitkeep create mode 100644 test/fixtures/package-nested/package.json create mode 100644 test/fixtures/package-none/.gitkeep create mode 100644 test/read-package-json.ts create mode 100644 test/set-executable-bit.ts delete mode 100644 test/set-executable-bits.ts diff --git a/package.json b/package.json index 9b95ad7..324493a 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "@types/node": "^22.20", "ava": "^8.0.1", "c8": "^12.0.0", - "copy-file": "^11.0.0", "cross-env": "^7.0.3", "dot-prop": "^9.0.0", "dprint": "^0.58.0", diff --git a/test/fixtures/executable.js b/test/fixtures/executable.js new file mode 100644 index 0000000..9d0fc0c --- /dev/null +++ b/test/fixtures/executable.js @@ -0,0 +1 @@ +console.log("executable.js"); 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/.gitkeep b/test/fixtures/package-nested/foo/bar/.gitkeep new file mode 100644 index 0000000..e69de29 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/helpers/util.ts b/test/helpers/util.ts index 94998c1..260d4b6 100644 --- a/test/helpers/util.ts +++ b/test/helpers/util.ts @@ -1,6 +1,9 @@ -import { fileURLToPath } from "node:url"; +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { type Options as ExecaOptions } from "execa"; +import { fileURLToPath } from "node:url"; +import type { ExecutionContext } from "ava"; +import { $, type Options as ExecaOptions } from "execa"; import { createTag, stripIndentTransformer, trimResultTransformer } from "proper-tags"; export const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -9,6 +12,36 @@ export const atFixture = (name: string) => path.join(__dirname, "..", "fixtures" export const atFixtureCwd = (name: string): ExecaOptions => ({ cwd: atFixture(name) }); +/** Makes a temporary directory and registers a teardown to remove it. */ +export const withTemporaryDirectory = async (t: ExecutionContext) => { + // TODO [engine:node@>=24]: use mkdtempDisposable + // 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. Returns path to copied fixture and an `execa` `$` with a `cwd` at the fixture's directory. */ +export const withFixture = async (t: ExecutionContext, name: string) => { + const temporaryDir = await withTemporaryDirectory(t); + const fixture = path.join(temporaryDir, name); + + const originalFixture = atFixture(name); + const isDirectory = !originalFixture.includes("."); + + if (isDirectory) { + await fs.cp(originalFixture, temporaryDir, { recursive: true }); + } else { + await fs.copyFile(originalFixture, fixture); + } + + return { $: $({ cwd: path.dirname(fixture) }), fixture }; +}; + type Tag = { (string_: string): ReturnType; (literals: TemplateStringsArray, ...placeholders: any[]): ReturnType; @@ -16,7 +49,7 @@ type Tag = { export const trimStdout = createTag( stripIndentTransformer(), - trimResultTransformer("smart" as ""), + trimResultTransformer("smart"), ) as unknown as Tag; export const splitStdout = createTag( diff --git a/test/read-package-json.ts b/test/read-package-json.ts new file mode 100644 index 0000000..95e499e --- /dev/null +++ b/test/read-package-json.ts @@ -0,0 +1,66 @@ +import path 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: new URL("../src/utils.js", import.meta.url), + }) +); + +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 ? path.join(fixture, cwd) : fixture); + + const binaries = await readPackageJson(); + 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 up for nearest `package.json`", verify, { + cwd: "foo/bar", + expected: [{ name: "foo-cli", path: "foo.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-bit.ts b/test/set-executable-bit.ts new file mode 100644 index 0000000..33a3280 --- /dev/null +++ b/test/set-executable-bit.ts @@ -0,0 +1,31 @@ +/* eslint-disable unicorn/consistent-boolean-name -- test file */ +import test from "ava"; +import { isExecutable } from "is-executable"; +import { setExecutableBit } from "../src/utils.ts"; +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 setExecutableBit(fixture); + t.true(result, "setExecutableBit should return true!"); + t.true(await isExecutable(fixture), "Fixture should be executable!"); +}); + +test("does nothing if already executable", async t => { + const { $, fixture } = await withFixture(t, "executable.js"); + + await $`chmod +x ${fixture}`; + t.true(await isExecutable(fixture), "Fixture should be executable!"); + + const result = await setExecutableBit(fixture); + t.false(result, "setExecutableBit should return false!"); +}); + +test("does not handle file system errors", async t => { + await t.throwsAsync( + async () => setExecutableBit("NON-EXISTENT-FILE"), + { code: "ENOENT", instanceOf: Error }, + ); +}); diff --git a/test/set-executable-bits.ts b/test/set-executable-bits.ts deleted file mode 100644 index c3e43c2..0000000 --- a/test/set-executable-bits.ts +++ /dev/null @@ -1,80 +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 { NODE_SHEBANG } from "../src/constants.js"; -import { setExecutableBits } from "../src/helpers.js"; - -test("one file", async t => { - // eslint-disable-next-line unicorn/prevent-abbreviations - const temporaryDir = temporaryDirectory(); - const fixture = path.join(temporaryDir, "cli.js"); - - await fs.writeFile(fixture, NODE_SHEBANG, "utf8"); - t.false(await isExecutable(fixture)); - - await setExecutableBits([fixture]); - t.true(await isExecutable(fixture)); -}); - -test("multiple files", async t => { - // eslint-disable-next-line unicorn/prevent-abbreviations - 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, NODE_SHEBANG, "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 => { - // eslint-disable-next-line unicorn/prevent-abbreviations - 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, NODE_SHEBANG, "utf8"); - await $$`chmod +x ${fixture}`; - - t.true(await isExecutable(fixture)); - })); - - /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports, @typescript-eslint/no-unnecessary-type-assertion */ - const spy = tt.spy(); - - const { setExecutableBits } = await esmock("../src/helpers.js", 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/xo.config.js b/xo.config.js index 01c63ff..e7d711b 100644 --- a/xo.config.js +++ b/xo.config.js @@ -15,4 +15,23 @@ export default [...configs.xo, ...configs.dprint, { exceptions: ["typescript"], }], }, +}, { + // TODO: move to main config + rules: { + "@typescript-eslint/consistent-type-imports": ["error", { + disallowTypeAnnotations: false, + fixStyle: "inline-type-imports", + }], + "@typescript-eslint/strict-boolean-expressions": ["error", { + allowNullableBoolean: true, + allowNullableObject: true, + allowNullableString: true, // diff from xo + allowNumber: false, + allowString: true, // diff from xo + }], + "no-warning-comments": "off", + "unicorn/expiring-todo-comments": ["error", { + allowWarningComments: false, + }], + }, }]; From 1d6078df813a45325f11d3857f5f9d3ea35286df Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 07:28:22 -0500 Subject: [PATCH 08/17] clean up cli tests (todos), dev deps --- package.json | 60 +++--- src/helpers.ts | 4 +- test/cli.ts | 89 +------- test/fix-shebangs.ts | 75 ------- test/fixtures/binaries/executable.ts | 1 + test/fixtures/binaries/nested/executable.js | 1 + .../binaries/nested/non-executable.ts | 1 + test/fixtures/binaries/non-executable.js | 1 + test/fixtures/binaries/package.json | 7 + test/fixtures/executable.js | 0 test/fixtures/globs/cli.js | 1 - test/fixtures/globs/cli.ts | 1 - test/fixtures/globs/src/cli.js | 1 - test/fixtures/globs/src/cli.ts | 1 - test/fixtures/no-shebang/fixture.ts | 1 - test/fixtures/shebang-ts-node/fixture.ts | 3 - test/fixtures/shebang-tsx/fixture.ts | 3 - test/get-files.ts | 91 -------- test/helpers/cli.ts | 54 +++++ test/helpers/index.ts | 2 - test/helpers/util.ts | 27 +-- test/helpers/verify-cli.ts | 204 ------------------ test/read-package-json.ts | 4 +- test/set-executable-bit.ts | 6 +- test/snapshots/cli.ts.md | 49 +++++ test/snapshots/cli.ts.snap | Bin 0 -> 368 bytes 26 files changed, 156 insertions(+), 531 deletions(-) delete mode 100644 test/fix-shebangs.ts create mode 100755 test/fixtures/binaries/executable.ts create mode 100755 test/fixtures/binaries/nested/executable.js create mode 100644 test/fixtures/binaries/nested/non-executable.ts create mode 100644 test/fixtures/binaries/non-executable.js create mode 100644 test/fixtures/binaries/package.json mode change 100644 => 100755 test/fixtures/executable.js delete mode 100644 test/fixtures/globs/cli.js delete mode 100644 test/fixtures/globs/cli.ts delete mode 100644 test/fixtures/globs/src/cli.js delete mode 100644 test/fixtures/globs/src/cli.ts delete mode 100644 test/fixtures/no-shebang/fixture.ts delete mode 100644 test/fixtures/shebang-ts-node/fixture.ts delete mode 100644 test/fixtures/shebang-tsx/fixture.ts delete mode 100644 test/get-files.ts create mode 100644 test/helpers/cli.ts delete mode 100644 test/helpers/index.ts delete mode 100644 test/helpers/verify-cli.ts create mode 100644 test/snapshots/cli.ts.md create mode 100644 test/snapshots/cli.ts.snap diff --git a/package.json b/package.json index 324493a..e2624bf 100644 --- a/package.json +++ b/package.json @@ -2,48 +2,36 @@ "name": "execify-cli", "version": "0.2.1-0", "description": "Easily make a Node.js CLI executable.", - "keywords": [ - "cli", - "cli-app", - "command-line", - "executable", - "chmod", - "chmod+x", - "binary", - "bin", - "executable" - ], "license": "MIT", "repository": "tommy-mitchell/execify-cli", - "author": { - "name": "Tommy Mitchell", - "url": "https://tommymitchell.io" - }, + "author": "Tommy Mitchell (https://tommymitchell.io)", "type": "module", + "bin": { + "execify": "./dist/cli.js" + }, "engines": { "node": "^22.22 || ^24.15 || >=26" }, - "bin": { - "execify": "dist/cli.js" - }, - "files": [ - "dist" - ], "scripts": { "prepare": "npm run build", "build": "tsc -p tsconfig.build.json && node dist/cli.js --pkg", "lint": "xo", "format": "dprint fmt && xo --fix", - "test": "c8 ava" - }, - "ava": { - "files": [ - "test/*.ts" - ], - "extensions": [ - "ts" - ] + "test": "NODE_OPTIONS=\"--disable-warning=DeprecationWarning\" c8 ava" }, + "files": [ + "dist" + ], + "keywords": [ + "cli", + "cli-app", + "command-line", + "executable", + "chmod", + "chmod+x", + "binary", + "bin" + ], "dependencies": { "log-symbols": "^7.0.1", "meow": "^14.1.0", @@ -54,21 +42,21 @@ "@tommy-mitchell/eslint-config-xo": "^2.0.0-beta.10", "@tommy-mitchell/tsconfig": "^4.0.0", "@types/node": "^22.20", + "async-sema": "^3.1.1", "ava": "^8.0.1", "c8": "^12.0.0", - "cross-env": "^7.0.3", - "dot-prop": "^9.0.0", "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": "^6.0.0", - "proper-tags": "^2.0.2", "test-quadruple": "^1.0.0", - "ts-pattern": "^5.9.0", "type-fest": "^5.10.0", "typescript": "~6.0.3", "xo": "^5.0.1" + }, + "ava": { + "extensions": [ + "ts" + ] } } diff --git a/src/helpers.ts b/src/helpers.ts index 15a58d1..6c834d4 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -54,11 +54,11 @@ const execifySingle = async (filePath: string): Promise => { export const execify = async (binaries: Binary[]): Promise => ( Promise.all(binaries.map(async ({ name, path }) => { const { didExecify, error } = await execifySingle(path); - const nameSuffix = name ? ` (${name})` : ""; // eslint-disable-line @typescript-eslint/strict-boolean-expressions + const nameSuffix = name ? ` (${name})` : ""; if (didExecify) { console.log(`${logSymbols.success} Execified "${path}"${nameSuffix}`); - } else if (error) { // eslint-disable-line @typescript-eslint/strict-boolean-expressions + } else if (error) { console.log(`${logSymbols.error} Failed to execify "${path}", ${error}${nameSuffix}`); } else { console.log(`${logSymbols.info} "${path}" already executable${nameSuffix}`); diff --git a/test/cli.ts b/test/cli.ts index de033ad..1c1c9cb 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -1,88 +1,15 @@ -import test from "ava"; -import { execa } from "execa"; -import { getExecutableBinPath } from "get-executable-bin-path"; -import { splitStdout, verifyCli } from "./helpers/index.js"; - -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("build", async t => { - // eslint-disable-next-line unicorn/prevent-abbreviations - const binPath = await getExecutableBinPath(); - - const { exitCode } = await execa(binPath); - t.is(exitCode, 0); -}); - -test("help - no arguments", verifyCli, { - output: helpText, -}); +import { $, test, verifyCli } from "./helpers/cli.ts"; for (const flag of ["--help", "-h"]) { - test(`help (${flag})`, verifyCli, { - args: flag, - output: helpText, + 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, - }, -}); - -for (const flag of ["--package", "--pkg", "-p", "--all"]) { - test(`usePackage (${flag})`, verifyCli, { - args: flag, - helperCalls: { - getFiles: { - callCount: 1, - args: [{ globs: [], usePackage: true }], - }, - }, - }); +test.todo("reports results"); - test(`globs with usePackage (${flag})`, verifyCli, { - args: `${flag} globs/**/*.ts`, - helperCalls: { - getFiles: { - callCount: 1, - args: [{ globs: ["globs/**/*.ts"], usePackage: true }], - }, - }, - }); -} - -for (const flag of ["--fix-shebang", "--all"]) { - test(`fixShebangs uses getFiles output (${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"]], - }, - }, - }); +for (const flag of ["--package", "--pkg", "-p"]) { + test.todo(`includes binaries from \`package.json\` and deduplicates (${flag})`); + test.todo(`errors if no \`package.json\` found (${flag})`); } diff --git a/test/fix-shebangs.ts b/test/fix-shebangs.ts deleted file mode 100644 index 950e275..0000000 --- a/test/fix-shebangs.ts +++ /dev/null @@ -1,75 +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 "copy-file"; -import esmock from "esmock"; -import shebangRegex from "shebang-regex"; -import * as tt from "testtriple"; -import { NODE_SHEBANG } from "../src/constants.js"; -import { atFixture } from "./helpers/util.js"; - -type VerifyShebangsArguments = { - fixtures: string[]; - writeCount?: number; -}; - -const verifyShebangs = test.macro(async (t, { fixtures, writeCount = fixtures.length }: VerifyShebangsArguments) => { - // eslint-disable-next-line unicorn/prevent-abbreviations - const temporaryDir = temporaryDirectory(); - - // Map and copy fixtures to temporary files - const fixturePaths = fixtures.map(fixture => ( - path.join(temporaryDir, `${fixture}-fixture.ts`) - )); - - await Promise.all(fixtures.map(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.js", { // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion - "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 Promise.all(fixturePaths.map(async fixture => { - const output = await fs.readFile(fixture, "utf8"); - const firstLine = output.split("\n")[0]!; - - if (fixture.includes("no-shebang")) { - t.notRegex(firstLine, shebangRegex, "Included shebang!"); - } else { - t.is(firstLine, NODE_SHEBANG); - } - })); - - 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.js b/test/fixtures/binaries/nested/executable.js new file mode 100755 index 0000000..25f444e --- /dev/null +++ b/test/fixtures/binaries/nested/executable.js @@ -0,0 +1 @@ +console.log("nested/executable.js"); diff --git a/test/fixtures/binaries/nested/non-executable.ts b/test/fixtures/binaries/nested/non-executable.ts new file mode 100644 index 0000000..ccce0cf --- /dev/null +++ b/test/fixtures/binaries/nested/non-executable.ts @@ -0,0 +1 @@ +console.log("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..537d37b --- /dev/null +++ b/test/fixtures/binaries/non-executable.js @@ -0,0 +1 @@ +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..b80b92f --- /dev/null +++ b/test/fixtures/binaries/package.json @@ -0,0 +1,7 @@ +{ + "name": "foo", + "bin": { + "bar": "./executable.ts", + "baz": "./nested/non-executable.ts" + } +} diff --git a/test/fixtures/executable.js b/test/fixtures/executable.js old mode 100644 new mode 100755 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/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 dccf3db..0000000 --- a/test/get-files.ts +++ /dev/null @@ -1,91 +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-package-up"; -import { getFiles as getFilesOriginal } from "../src/helpers.js"; -import { atFixture, atFixtureCwd } from "./helpers/util.js"; - -const stubReadPackageUp = async (stub: () => Promise) => ( - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - esmock("../src/helpers.js", { - "read-package-up": { - readPackageUp: stub, - }, - }) -); - -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"]; - -// dprint-ignore -test("resolves globs to respective files", verifyFiles, - { globs: ["globs/**/cli.*"] }, - { files: expectedGlobFiles }, -); - -// dprint-ignore -test("no globs returns empty array", verifyFiles, - { globs: [] }, - { files: [] }, -); - -// dprint-ignore -test("usePackage - handles bin as string", verifyFiles, - { usePackage: true, fixture: "package-bin-string" }, - { files: ["foo.js"] }, -); - -// dprint-ignore -test("usePackage - handles bin as object", verifyFiles, - { usePackage: true, fixture: "package-bin-object" }, - { files: ["foo.js", "bar.js"] }, -); - -// dprint-ignore -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." }, - ); -}); - -// dprint-ignore -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..a9da3aa --- /dev/null +++ b/test/helpers/cli.ts @@ -0,0 +1,54 @@ +/* eslint-disable ava/no-ignored-test-files, unicorn/no-top-level-side-effects -- invalid */ +import process from "node:process"; +import anyTest, { type TestFn } from "ava"; +import { Sema } from "async-sema"; +import { execa, parseCommandString } from "execa"; +import { getExecutableBinPath } from "get-executable-bin-path"; +import type { RequireExactlyOne as OneOf } from "type-fest"; +import { 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; + fixture?: string; + }, +]; + +export const verifyCli = test.macro(async (t, { args = "", error, expected, fixture }) => { + const cwd = fixture ? await withFixture(t, fixture) : undefined; + const { all: output, exitCode } = await $(t.context.binPath, parseCommandString(args), { cwd }); + + t.is(output, expected ?? error); + t.is(exitCode, expected ? 0 : 1, "Process exited with incorrect exit code!"); +}); diff --git a/test/helpers/index.ts b/test/helpers/index.ts deleted file mode 100644 index 4cd4048..0000000 --- a/test/helpers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./util.js"; -export * from "./verify-cli.js"; diff --git a/test/helpers/util.ts b/test/helpers/util.ts index 260d4b6..25f9560 100644 --- a/test/helpers/util.ts +++ b/test/helpers/util.ts @@ -3,14 +3,10 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ExecutionContext } from "ava"; -import { $, type Options as ExecaOptions } from "execa"; -import { createTag, stripIndentTransformer, trimResultTransformer } from "proper-tags"; -export const __dirname = path.dirname(fileURLToPath(import.meta.url)); +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) }); +const atFixture = (name: string) => path.join(__dirname, "..", "fixtures", name); /** Makes a temporary directory and registers a teardown to remove it. */ export const withTemporaryDirectory = async (t: ExecutionContext) => { @@ -25,7 +21,7 @@ export const withTemporaryDirectory = async (t: ExecutionContext) => { return temporaryDir; }; -/** Copies a fixture to a temporary directory. Returns path to copied fixture and an `execa` `$` with a `cwd` at the fixture's directory. */ +/** 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 = path.join(temporaryDir, name); @@ -39,20 +35,5 @@ export const withFixture = async (t: ExecutionContext, name: string) => { await fs.copyFile(originalFixture, fixture); } - return { $: $({ cwd: path.dirname(fixture) }), fixture }; -}; - -type Tag = { - (string_: string): ReturnType; - (literals: TemplateStringsArray, ...placeholders: any[]): ReturnType; + return fixture; }; - -export const trimStdout = createTag( - stripIndentTransformer(), - trimResultTransformer("smart"), -) as unknown as Tag; - -export const splitStdout = createTag( - trimStdout, - { onEndResult: (result: string) => result.split("\n") }, -) as unknown as Tag; diff --git a/test/helpers/verify-cli.ts b/test/helpers/verify-cli.ts deleted file mode 100644 index fa68a60..0000000 --- a/test/helpers/verify-cli.ts +++ /dev/null @@ -1,204 +0,0 @@ -import process from "node:process"; -import type { AsyncReturnType, IfNever, RequireOneOrNone as OneOrNoneOf } from "type-fest"; -import test, { type ExecutionContext } from "ava"; -import esmock from "esmock"; -import * as tt from "testtriple"; -import { match, P } 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 Helpers = typeof helpers; - -// dprint-ignore -type HelperCalls = number | { - [helper in keyof Helpers]?: number | ( - | { - callCount: number; - args: Parameters; - } - | { - resolves?: AsyncReturnType; - } - ) -}; - -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.js", import.meta.url, { // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion - "../../src/helpers.js": 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 => { - const { helperStubs } = await t.throwsAsync(mockCli(mockCliArgs), { message }); - 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); - } - } -}; - -// dprint-ignore -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/read-package-json.ts b/test/read-package-json.ts index 95e499e..c7bea00 100644 --- a/test/read-package-json.ts +++ b/test/read-package-json.ts @@ -10,7 +10,7 @@ const withCwd = async (cwd: string) => ( "node:process": { cwd: () => cwd }, }, importMeta: import.meta, - modulePath: new URL("../src/utils.js", import.meta.url), + modulePath: "../src/utils.js", }) ); @@ -21,7 +21,7 @@ type MacroArgs = [{ }]; const verify = test.macro(async (t, { cwd, expected, fixture: fixtureName }) => { - const { fixture } = await withFixture(t, fixtureName); + const fixture = await withFixture(t, fixtureName); const { readPackageJson } = await withCwd(cwd ? path.join(fixture, cwd) : fixture); const binaries = await readPackageJson(); diff --git a/test/set-executable-bit.ts b/test/set-executable-bit.ts index 33a3280..c3af09b 100644 --- a/test/set-executable-bit.ts +++ b/test/set-executable-bit.ts @@ -5,7 +5,7 @@ import { setExecutableBit } from "../src/utils.ts"; import { withFixture } from "./helpers/util.ts"; test("sets bitmask", async t => { - const { fixture } = await withFixture(t, "non-executable.js"); + const fixture = await withFixture(t, "non-executable.js"); t.false(await isExecutable(fixture), "Fixture should not be executable!"); const result = await setExecutableBit(fixture); @@ -14,9 +14,7 @@ test("sets bitmask", async t => { }); test("does nothing if already executable", async t => { - const { $, fixture } = await withFixture(t, "executable.js"); - - await $`chmod +x ${fixture}`; + const fixture = await withFixture(t, "executable.js"); t.true(await isExecutable(fixture), "Fixture should be executable!"); const result = await setExecutableBit(fixture); diff --git a/test/snapshots/cli.ts.md b/test/snapshots/cli.ts.md new file mode 100644 index 0000000..ebba874 --- /dev/null +++ b/test/snapshots/cli.ts.md @@ -0,0 +1,49 @@ +# Snapshot report for `test/cli.ts` + +The actual snapshot is saved in `cli.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## shows help (--help) + +> Snapshot 1 + + `␊ + Usage␊ + $ execify [paths…]␊ + ␊ + Options␊ + --package, --pkg, -p Set every binary in package.json as executable␊ + ␊ + Examples␊ + $ execify dist/cli.js dist/bin.ts dist/foo.cjs␊ + ✔ Execified "dist/cli.js"␊ + ℹ "dist/cli.ts" already executable␊ + ✖ Failed to execify "dist/foo.cjs", 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 dist/cli.js dist/bin.ts dist/foo.cjs␊ + ✔ Execified "dist/cli.js"␊ + ℹ "dist/cli.ts" already executable␊ + ✖ Failed to execify "dist/foo.cjs", 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 0000000000000000000000000000000000000000..e2d2cfae628b9776c5c5b1ae4f551ce46bf7ef0f GIT binary patch literal 368 zcmV-$0gwJcRzVB)pZkNuL34%&gQLB-VE|kR%{TynWAKtwoz=K1$>*}Z|F{kU;H>o8 zS!p>@R8l&p8BZEGpG^>zz&U&HEmt)XZKp7k2MYU%jc95M(MQRrhmrMlU!eZCJs( zpB!O-VK_mi4Z}K*%q^@6woqV=3JB-J>JPosOp#rRULEFuKU$){@?81 OZ}uD0Z_BvB0{{TlP^6>) literal 0 HcmV?d00001 From 89b7fa86df858fa6df03c17225c77958424d5c8c Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 07:33:19 -0500 Subject: [PATCH 09/17] clarify package purpose --- package.json | 2 +- readme.md | 12 ++++++------ src/cli.ts | 8 ++++---- test/snapshots/cli.ts.md | 16 ++++++++-------- test/snapshots/cli.ts.snap | Bin 368 -> 368 bytes 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index e2624bf..876346f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "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)", diff --git a/readme.md b/readme.md index dde9de6..b7c7301 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,8 @@ # execify-cli -Easily make a Node.js CLI executable. +Cross-platform `chmod +x`. Easily make a Node.js CLI executable. -Sets permissions (`chmod +x`) on given binaries, or optionally on every binary in `package.json`. Cross-platform. +Sets executable permissions on given binaries, or optionally on every binary in the nearest `package.json`. ## Install @@ -35,10 +35,10 @@ Options --package, --pkg, -p Set every binary in package.json as executable Examples - $ execify dist/cli.js dist/bin.ts dist/foo.cjs - ✔ Execified "dist/cli.js" - ℹ "dist/cli.ts" already executable - ✖ Failed to execify "dist/foo.cjs", file not found + $ execify foo.js bar.ts baz/xyz.sh + ✔ Execified "foo.js" + ℹ "bar.ts" already executable + ✖ Failed to execify "baz/xyz.sh", file not found $ execify --pkg ✔ Execified "dist/foo.js" (foo-cli) diff --git a/src/cli.ts b/src/cli.ts index 3e35fce..ab3f50d 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,10 +11,10 @@ const cli = meow(` --package, --pkg, -p Set every binary in package.json as executable Examples - $ execify dist/cli.js dist/bin.ts dist/foo.cjs - ✔ Execified "dist/cli.js" - ℹ "dist/cli.ts" already executable - ✖ Failed to execify "dist/foo.cjs", file not found + $ execify foo.js bar.ts baz/xyz.sh + ✔ Execified "foo.js" + ℹ "bar.ts" already executable + ✖ Failed to execify "baz/xyz.sh", file not found $ execify --pkg ✔ Execified "dist/foo.js" (foo-cli) diff --git a/test/snapshots/cli.ts.md b/test/snapshots/cli.ts.md index ebba874..a14c771 100644 --- a/test/snapshots/cli.ts.md +++ b/test/snapshots/cli.ts.md @@ -16,10 +16,10 @@ Generated by [AVA](https://avajs.dev). --package, --pkg, -p Set every binary in package.json as executable␊ ␊ Examples␊ - $ execify dist/cli.js dist/bin.ts dist/foo.cjs␊ - ✔ Execified "dist/cli.js"␊ - ℹ "dist/cli.ts" already executable␊ - ✖ Failed to execify "dist/foo.cjs", file not found␊ + $ execify foo.js bar.ts baz/xyz.sh␊ + ✔ Execified "foo.js"␊ + ℹ "bar.ts" already executable␊ + ✖ Failed to execify "baz/xyz.sh", file not found␊ ␊ $ execify --pkg␊ ✔ Execified "dist/foo.js" (foo-cli)␊ @@ -38,10 +38,10 @@ Generated by [AVA](https://avajs.dev). --package, --pkg, -p Set every binary in package.json as executable␊ ␊ Examples␊ - $ execify dist/cli.js dist/bin.ts dist/foo.cjs␊ - ✔ Execified "dist/cli.js"␊ - ℹ "dist/cli.ts" already executable␊ - ✖ Failed to execify "dist/foo.cjs", file not found␊ + $ execify foo.js bar.ts baz/xyz.sh␊ + ✔ Execified "foo.js"␊ + ℹ "bar.ts" already executable␊ + ✖ Failed to execify "baz/xyz.sh", file not found␊ ␊ $ execify --pkg␊ ✔ Execified "dist/foo.js" (foo-cli)␊ diff --git a/test/snapshots/cli.ts.snap b/test/snapshots/cli.ts.snap index e2d2cfae628b9776c5c5b1ae4f551ce46bf7ef0f..7cd40ada54f6d17da5f3d1f6459f4cc96328aa58 100644 GIT binary patch literal 368 zcmV-$0gwJcRzV3!{1OagD zytaijOcXgvr!?k4Jx<31L=HG%55D5ELZS;QE9e3nE}n=jK+@cvc}fh|H%D{2bcSV1 zII}jM2ofq|U$@0@UM*tD8*KIRh*1M-RtQ@*Y@hDm5w=?)$dpW~`l{HjKjDb9VFhnL z6vI!EaDYr3MqxcNJ1bPM<*xbhPgax`Kiv31FS9mEjqd+S)B)pZkNuL34%&gQLB-VE|kR%{TynWAKtwoz=K1$>*}Z|F{kU;H>o8 zS!p>@R8l&p8BZEGpG^>zz&U&HEmt)XZKp7k2MYU%jc95M(MQRrhmrMlU!eZCJs( zpB!O-VK_mi4Z}K*%q^@6woqV=3JB-J>JPosOp#rRULEFuKU$){@?81 OZ}uD0Z_BvB0{{TlP^6>) From 6ccb94b64e87010d6eb043e13cfd6f67de0155e1 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 07:58:49 -0500 Subject: [PATCH 10/17] warn on missing shebang --- readme.md | 3 ++- src/cli.ts | 3 ++- src/helpers.ts | 21 ++++++++++++++------- src/utils.ts | 20 +++++++++++++++++++- test/snapshots/cli.ts.md | 6 ++++-- test/snapshots/cli.ts.snap | Bin 368 -> 393 bytes 6 files changed, 41 insertions(+), 12 deletions(-) diff --git a/readme.md b/readme.md index b7c7301..5cc8bad 100644 --- a/readme.md +++ b/readme.md @@ -37,7 +37,8 @@ Options Examples $ execify foo.js bar.ts baz/xyz.sh ✔ Execified "foo.js" - ℹ "bar.ts" already executable + ⚠ File "foo.js" is missing a shebang! + ℹ File "bar.ts" is already executable ✖ Failed to execify "baz/xyz.sh", file not found $ execify --pkg diff --git a/src/cli.ts b/src/cli.ts index ab3f50d..8cd8dd0 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,8 @@ const cli = meow(` Examples $ execify foo.js bar.ts baz/xyz.sh ✔ Execified "foo.js" - ℹ "bar.ts" already executable + ⚠ File "foo.js" is missing a shebang! + ℹ File "bar.ts" is already executable ✖ Failed to execify "baz/xyz.sh", file not found $ execify --pkg diff --git a/src/helpers.ts b/src/helpers.ts index 6c834d4..1f88516 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,6 +1,5 @@ import process from "node:process"; -import logSymbols from "log-symbols"; -import { type Binary, readPackageJson, setExecutableBit } from "./utils.ts"; +import { type Binary, hasShebang, log, readPackageJson, setExecutableBit } from "./utils.ts"; type GetFilesArguments = { input: string[]; @@ -15,7 +14,7 @@ export const getBinaries = async ({ input, usePackage }: GetFilesArguments): Pro const packageBinaries = await readPackageJson(); if (!packageBinaries) { - console.error(`${logSymbols.error} No package.json found.`); + log.error("No package.json found."); process.exit(1); } @@ -54,14 +53,22 @@ const execifySingle = async (filePath: string): Promise => { export const execify = async (binaries: Binary[]): Promise => ( Promise.all(binaries.map(async ({ name, path }) => { const { didExecify, error } = await execifySingle(path); - const nameSuffix = name ? ` (${name})` : ""; + const nameSuffix = name ? `(${name})` : ""; if (didExecify) { - console.log(`${logSymbols.success} Execified "${path}"${nameSuffix}`); + log.success(`Execified "${path}"`, nameSuffix); } else if (error) { - console.log(`${logSymbols.error} Failed to execify "${path}", ${error}${nameSuffix}`); + log.error(`Failed to execify "${path}", ${error}`, nameSuffix); } else { - console.log(`${logSymbols.info} "${path}" already executable${nameSuffix}`); + log.info(`File "${path}" is already executable`, nameSuffix); + } + + try { + if (!await hasShebang(path)) { + log.warn(`File "${path}" is missing a shebang!`, nameSuffix); + } + } catch { + // Errors here don't matter, one is already logged if the file is non-existent } })) ); diff --git a/src/utils.ts b/src/utils.ts index 54fcd1e..097d0b8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,12 +1,23 @@ import { constants as fsConstants, promises as fs } from "node:fs"; +import logSymbols from "log-symbols"; import { readPackageUp } from "read-package-up"; +export const log = { + // TODO: do i need stderr? + 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 = { name?: string; path: string; }; -/** Parses all binaries from the nearest `package.json`, returning `undefined` if none exists. */ +// TODO: resolve paths to absolutes? + +/** Parses all binaries from the nearest `package.json`, returning `undefined` if none exist. */ export const readPackageJson = async (): Promise => { const maybePackageJson = await readPackageUp(); @@ -40,3 +51,10 @@ export const setExecutableBit = async (path: string): Promise => { return false; }; + +const SHEBANG_REGEX = /^#!.+/v; + +export const hasShebang = async (path: string): Promise => { + const content = await fs.readFile(path, "utf8"); + return SHEBANG_REGEX.test(content); +}; diff --git a/test/snapshots/cli.ts.md b/test/snapshots/cli.ts.md index a14c771..a02c899 100644 --- a/test/snapshots/cli.ts.md +++ b/test/snapshots/cli.ts.md @@ -18,7 +18,8 @@ Generated by [AVA](https://avajs.dev). Examples␊ $ execify foo.js bar.ts baz/xyz.sh␊ ✔ Execified "foo.js"␊ - ℹ "bar.ts" already executable␊ + ⚠ File "foo.js" is missing a shebang!␊ + ℹ File "bar.ts" is already executable␊ ✖ Failed to execify "baz/xyz.sh", file not found␊ ␊ $ execify --pkg␊ @@ -40,7 +41,8 @@ Generated by [AVA](https://avajs.dev). Examples␊ $ execify foo.js bar.ts baz/xyz.sh␊ ✔ Execified "foo.js"␊ - ℹ "bar.ts" already executable␊ + ⚠ File "foo.js" is missing a shebang!␊ + ℹ File "bar.ts" is already executable␊ ✖ Failed to execify "baz/xyz.sh", file not found␊ ␊ $ execify --pkg␊ diff --git a/test/snapshots/cli.ts.snap b/test/snapshots/cli.ts.snap index 7cd40ada54f6d17da5f3d1f6459f4cc96328aa58..0546c9d7265a6881a9f3818b87a5deefa7fb7426 100644 GIT binary patch literal 393 zcmV;40e1dDRzVdmU+LZw@AjI&e`IESH?I^zs zO(#Gc0Wly3%D^3vxB<85Nl=nfpn?k^y!@8^>Ah#aMI3579Lx0+JLNQsr1nCPvd+-< z{c?7c7|JBIPOcVFM2=3HZ`+OCM!A|hjlHecrUx7gCF}vf0eHqk6&ETfr=xvYPMBh-9KWWG_Oyg1QR8qOag+W9Fj@) zYxd@4#nv_zOFCzYimxea%R3B6X^!CRI%VrKSgVV;)H2o~wo?;%8!xJU>8JRS64(1k n+m5aEhgx-hCCjW88B$)8tLE*u=F|Vy|90!|@C<@5`2zp|_ldaj literal 368 zcmV-$0gwJcRzV3!{1OagD zytaijOcXgvr!?k4Jx<31L=HG%55D5ELZS;QE9e3nE}n=jK+@cvc}fh|H%D{2bcSV1 zII}jM2ofq|U$@0@UM*tD8*KIRh*1M-RtQ@*Y@hDm5w=?)$dpW~`l{HjKjDb9VFhnL z6vI!EaDYr3MqxcNJ1bPM<*xbhPgax`Kiv31FS9mEjqd+S) Date: Thu, 1 Oct 2026 12:19:04 -0500 Subject: [PATCH 11/17] add cli tests, resolve package bins to absolute paths --- package.json | 3 +- src/cli.ts | 22 ++++++- src/helpers.ts | 40 ++++++------- src/utils.ts | 25 +++++--- test/cli.ts | 54 +++++++++++++++++- test/fixtures/binaries/nested/executable.js | 1 - test/fixtures/binaries/nested/executable.sh | 2 + .../binaries/nested/non-executable.sh | 1 + .../binaries/nested/non-executable.ts | 1 - test/fixtures/binaries/non-executable.js | 1 + test/fixtures/binaries/package.json | 4 +- test/fixtures/package-nested/foo/bar/.gitkeep | 0 test/fixtures/package-nested/foo/bar/cli.js | 2 + test/fixtures/package-nested/foo/package.json | 4 ++ test/helpers/cli.ts | 24 ++++++-- test/helpers/util.ts | 16 +++--- test/read-package-json.ts | 10 ++-- test/snapshots/cli.ts.md | 23 ++++++++ test/snapshots/cli.ts.snap | Bin 393 -> 409 bytes xo.config.js | 4 -- 20 files changed, 177 insertions(+), 60 deletions(-) delete mode 100755 test/fixtures/binaries/nested/executable.js create mode 100755 test/fixtures/binaries/nested/executable.sh create mode 100644 test/fixtures/binaries/nested/non-executable.sh delete mode 100644 test/fixtures/binaries/nested/non-executable.ts delete mode 100644 test/fixtures/package-nested/foo/bar/.gitkeep create mode 100644 test/fixtures/package-nested/foo/bar/cli.js create mode 100644 test/fixtures/package-nested/foo/package.json diff --git a/package.json b/package.json index 876346f..19bffce 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "prepare": "npm run build", "build": "tsc -p tsconfig.build.json && node dist/cli.js --pkg", - "lint": "xo", + "lint": "dprint check && xo", "format": "dprint fmt && xo --fix", "test": "NODE_OPTIONS=\"--disable-warning=DeprecationWarning\" c8 ava" }, @@ -45,6 +45,7 @@ "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", diff --git a/src/cli.ts b/src/cli.ts index 8cd8dd0..8e1038e 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import meow from "meow"; import { execify, getBinaries } from "./helpers.ts"; +import { log } from "./utils.ts"; // dprint-ignore const cli = meow(` @@ -43,4 +44,23 @@ if (!usePackage && input.length === 0) { } const binaries = await getBinaries({ input, usePackage }); -await execify(binaries); +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; + } + + if (didExecify) { + log.success(`Execified "${path}"`, nameSuffix); + } else { + log.info(`File "${path}" is already executable`, nameSuffix); + } + + if (!hasShebang) { + log.warn(`File "${path}" is missing a shebang!`, nameSuffix); + } +} diff --git a/src/helpers.ts b/src/helpers.ts index 1f88516..a77867d 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -19,7 +19,7 @@ export const getBinaries = async ({ input, usePackage }: GetFilesArguments): Pro } binaries.push(...packageBinaries); - input = input.filter(path => packageBinaries.every(binary => binary.path !== path)); + input = input.filter(path => packageBinaries.every(binary => binary.path !== path && binary.path !== `./${path}`)); } return [...binaries, ...input.map(path => ({ path }))]; @@ -27,12 +27,20 @@ export const getBinaries = async ({ input, usePackage }: GetFilesArguments): Pro type ExecifyResult = { didExecify: boolean; - error?: string; + error?: never; + hasShebang: boolean; +} | { + didExecify?: never; + error: string; + hasShebang?: never; }; const execifySingle = async (filePath: string): Promise => { try { - return { didExecify: await setExecutableBit(filePath) }; + return { + didExecify: await setExecutableBit(filePath), + hasShebang: await hasShebang(filePath), + }; } catch (error) { let message = String(error); @@ -46,29 +54,15 @@ const execifySingle = async (filePath: string): Promise => { } } - return { didExecify: false, error: message }; + return { error: message }; } }; -export const execify = async (binaries: Binary[]): Promise => ( - Promise.all(binaries.map(async ({ name, path }) => { - const { didExecify, error } = await execifySingle(path); - const nameSuffix = name ? `(${name})` : ""; +type ExecifyOutput = Binary & ExecifyResult; - if (didExecify) { - log.success(`Execified "${path}"`, nameSuffix); - } else if (error) { - log.error(`Failed to execify "${path}", ${error}`, nameSuffix); - } else { - log.info(`File "${path}" is already executable`, nameSuffix); - } - - try { - if (!await hasShebang(path)) { - log.warn(`File "${path}" is missing a shebang!`, nameSuffix); - } - } catch { - // Errors here don't matter, one is already logged if the file is non-existent - } +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/utils.ts b/src/utils.ts index 097d0b8..3a2d601 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,9 @@ 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 = { - // TODO: do i need stderr? error: (...messages: string[]) => console.log(logSymbols.error, ...messages), info: (...messages: string[]) => console.log(logSymbols.info, ...messages), success: (...messages: string[]) => console.log(logSymbols.success, ...messages), @@ -11,11 +11,21 @@ export const log = { }; export type Binary = { + absolutePath?: string; name?: string; path: string; }; -// TODO: resolve paths to absolutes? +type PackageBinary = [ + name: string, + path: string, +]; + +const resolveBinary = ([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 => { @@ -25,15 +35,16 @@ export const readPackageJson = async (): Promise => { return; } - const { packageJson } = maybePackageJson; + const { packageJson, path: packageJsonPath } = maybePackageJson; + const { bin, name } = packageJson; - if (!packageJson.bin) { + if (!bin) { return []; } - return typeof packageJson.bin === "string" - ? [{ name: packageJson.name, path: packageJson.bin }] - : Object.entries(packageJson.bin).map(([name, path]) => ({ name, path })); + return typeof bin === "string" + ? [resolveBinary([name, bin], packageJsonPath)] + : Object.entries(bin).map(binary => resolveBinary(binary, packageJsonPath)); }; const EXECUTABLE_MASK = fsConstants.S_IXUSR | fsConstants.S_IXGRP | fsConstants.S_IXOTH; diff --git a/test/cli.ts b/test/cli.ts index 1c1c9cb..7e8b108 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -1,5 +1,11 @@ +import dedent from "dedent"; import { $, test, verifyCli } from "./helpers/cli.ts"; +test("shows help if no input", async t => { + const { all: helpText } = await $`${t.context.binPath}`; + t.snapshot(helpText); +}); + for (const flag of ["--help", "-h"]) { test(`shows help (${flag})`, async t => { const { all: helpText } = await $`${t.context.binPath} ${flag}`; @@ -7,9 +13,51 @@ for (const flag of ["--help", "-h"]) { }); } -test.todo("reports results"); +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"]) { - test.todo(`includes binaries from \`package.json\` and deduplicates (${flag})`); - test.todo(`errors if no \`package.json\` found (${flag})`); + 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(`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", + }); + + test(`errors if no \`package.json\` found (${flag})`, verifyCli, { + args: flag, + error: dedent` + ✖ No package.json found. + `, + fixture: "package-none", + }); } diff --git a/test/fixtures/binaries/nested/executable.js b/test/fixtures/binaries/nested/executable.js deleted file mode 100755 index 25f444e..0000000 --- a/test/fixtures/binaries/nested/executable.js +++ /dev/null @@ -1 +0,0 @@ -console.log("nested/executable.js"); 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/nested/non-executable.ts b/test/fixtures/binaries/nested/non-executable.ts deleted file mode 100644 index ccce0cf..0000000 --- a/test/fixtures/binaries/nested/non-executable.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("nested/non-executable.ts"); diff --git a/test/fixtures/binaries/non-executable.js b/test/fixtures/binaries/non-executable.js index 537d37b..c7e396d 100644 --- a/test/fixtures/binaries/non-executable.js +++ b/test/fixtures/binaries/non-executable.js @@ -1 +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 index b80b92f..c8aa3ca 100644 --- a/test/fixtures/binaries/package.json +++ b/test/fixtures/binaries/package.json @@ -1,7 +1,7 @@ { "name": "foo", "bin": { - "bar": "./executable.ts", - "baz": "./nested/non-executable.ts" + "bar-cli": "./executable.ts", + "baz-cli": "./nested/non-executable.sh" } } diff --git a/test/fixtures/package-nested/foo/bar/.gitkeep b/test/fixtures/package-nested/foo/bar/.gitkeep deleted file mode 100644 index e69de29..0000000 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/helpers/cli.ts b/test/helpers/cli.ts index a9da3aa..72c33bd 100644 --- a/test/helpers/cli.ts +++ b/test/helpers/cli.ts @@ -1,11 +1,12 @@ /* 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, parseCommandString } from "execa"; +import { execa, type ExecaError, parseCommandString } from "execa"; import { getExecutableBinPath } from "get-executable-bin-path"; import type { RequireExactlyOne as OneOf } from "type-fest"; -import { withFixture } from "./util.ts"; +import { trimLines, withFixture } from "./util.ts"; export const test = anyTest as TestFn<{ binPath: string; @@ -41,14 +42,25 @@ type VerifyCliMacroArgs = [ expected: string; }> & { args?: string; + cwd?: string; fixture?: string; }, ]; -export const verifyCli = test.macro(async (t, { args = "", error, expected, fixture }) => { - const cwd = fixture ? await withFixture(t, fixture) : undefined; - const { all: output, exitCode } = await $(t.context.binPath, parseCommandString(args), { cwd }); +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 }); - t.is(output, expected ?? error); + 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 index 25f9560..575292c 100644 --- a/test/helpers/util.ts +++ b/test/helpers/util.ts @@ -24,16 +24,18 @@ export const withTemporaryDirectory = async (t: ExecutionContext) => { /** 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 = path.join(temporaryDir, name); - const originalFixture = atFixture(name); - const isDirectory = !originalFixture.includes("."); + const fixture = atFixture(name); + const isDirectory = !fixture.includes("."); if (isDirectory) { - await fs.cp(originalFixture, temporaryDir, { recursive: true }); - } else { - await fs.copyFile(originalFixture, fixture); + await fs.cp(fixture, temporaryDir, { recursive: true }); + return temporaryDir; } - return fixture; + 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/read-package-json.ts b/test/read-package-json.ts index c7bea00..58bf8cb 100644 --- a/test/read-package-json.ts +++ b/test/read-package-json.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import nodePath from "node:path"; import test from "ava"; import * as tq from "test-quadruple"; import type { Binary } from "../src/utils.ts"; @@ -22,9 +22,11 @@ type MacroArgs = [{ const verify = test.macro(async (t, { cwd, expected, fixture: fixtureName }) => { const fixture = await withFixture(t, fixtureName); - const { readPackageJson } = await withCwd(cwd ? path.join(fixture, cwd) : fixture); + const { readPackageJson } = await withCwd(cwd ? nodePath.join(fixture, cwd) : fixture); + + const _binaries = await readPackageJson(); + const binaries = _binaries?.map(({ name, path }) => ({ name, path })); - const binaries = await readPackageJson(); t.deepEqual(binaries, expected); }); @@ -56,7 +58,7 @@ test("returns binaries from object `bin` field", verify, { test("searches up for nearest `package.json`", verify, { cwd: "foo/bar", - expected: [{ name: "foo-cli", path: "foo.js" }], + expected: [{ name: "bar-cli", path: "./bar/cli.js" }], fixture: "package-nested", }); diff --git a/test/snapshots/cli.ts.md b/test/snapshots/cli.ts.md index a02c899..d74acdd 100644 --- a/test/snapshots/cli.ts.md +++ b/test/snapshots/cli.ts.md @@ -4,6 +4,29 @@ 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 diff --git a/test/snapshots/cli.ts.snap b/test/snapshots/cli.ts.snap index 0546c9d7265a6881a9f3818b87a5deefa7fb7426..b0b74744241b5432f4b303736b01d03719ba1a0c 100644 GIT binary patch literal 409 zcmV;K0cQR|RzVr?5m2___1z&G%19H#H zhARZr0w0SA00000000B+kugrgFcgMc5JJkv4Sp(yR%+5yz{-Fa5Mp>~UJ_H+j`Cb+ zI)UH_hygKB2JV2w4Y);5f|8U{D!2gD%d_lH@BQ{SjYDnw1G#x-r<~>sN$sT|Wt~CA zFc!*WBd5erCaHCDJ&huAbkcm^Y-~5m)!b_AY`ir+;8-YO4*>SSWA3ZCz-dM!fG=Yn&w1|(JQk)u#^*>itP z!%VZR434c0&IKXmfh)_TH!dcDq!l)Myu*G4Dvr>q*;ak^@Pq@Ud2tFQ7%CBE5)dTi zkW8{$v$wBvwjNWlq;saISWa1wyvG44%@Lekq-=c!YjqKqy^M8;?Z`yl%8ROB`YC>- z#PvSYwqtAkzE+)I$uet2hLqRjT)r&HZ~J~(J8Sd*!My!uK3!Y?+txn-ZrKnb_5}a{ D^4z_b literal 393 zcmV;40e1dDRzVdmU+LZw@AjI&e`IESH?I^zs zO(#Gc0Wly3%D^3vxB<85Nl=nfpn?k^y!@8^>Ah#aMI3579Lx0+JLNQsr1nCPvd+-< z{c?7c7|JBIPOcVFM2=3HZ`+OCM!A|hjlHecrUx7gCF}vf0eHqk6&ETfr=xvYPMBh-9KWWG_Oyg1QR8qOag+W9Fj@) zYxd@4#nv_zOFCzYimxea%R3B6X^!CRI%VrKSgVV;)H2o~wo?;%8!xJU>8JRS64(1k n+m5aEhgx-hCCjW88B$)8tLE*u=F|Vy|90!|@C<@5`2zp|_ldaj diff --git a/xo.config.js b/xo.config.js index e7d711b..fbeb4de 100644 --- a/xo.config.js +++ b/xo.config.js @@ -29,9 +29,5 @@ export default [...configs.xo, ...configs.dprint, { allowNumber: false, allowString: true, // diff from xo }], - "no-warning-comments": "off", - "unicorn/expiring-todo-comments": ["error", { - allowWarningComments: false, - }], }, }]; From 7eeeed86f9e8d104ed223e668ffd197ff4d56f2c Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 12:41:41 -0500 Subject: [PATCH 12/17] clean ups --- .npmrc | 1 + dprint.json | 6 +++++- package.json | 11 ++++++++--- readme.md | 13 +++++++++++-- src/cli.ts | 4 ++-- test/helpers/util.ts | 2 +- test/snapshots/cli.ts.md | 12 ++++++------ test/snapshots/cli.ts.snap | Bin 409 -> 412 bytes xo.config.js | 26 +------------------------- 9 files changed, 35 insertions(+), 40 deletions(-) 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/dprint.json b/dprint.json index cc1c520..19dd8a5 100644 --- a/dprint.json +++ b/dprint.json @@ -1,4 +1,5 @@ { + "$schema": "https://dprint.dev/schemas/v0.json", "includes": ["**/*.{js,ts,json,jsonc,yml,markdown}"], "extends": ["node_modules/@tommy-mitchell/dprint-config/index.json"], "plugins": [ @@ -8,5 +9,8 @@ "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 19bffce..a3010e9 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "build": "tsc -p tsconfig.build.json && node dist/cli.js --pkg", "lint": "dprint check && xo", "format": "dprint fmt && xo --fix", - "test": "NODE_OPTIONS=\"--disable-warning=DeprecationWarning\" c8 ava" + "test": "c8 ava" }, "files": [ "dist" @@ -39,7 +39,7 @@ }, "devDependencies": { "@tommy-mitchell/dprint-config": "^0.8.0", - "@tommy-mitchell/eslint-config-xo": "^2.0.0-beta.10", + "@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", @@ -58,6 +58,11 @@ "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 5cc8bad..3bb5630 100644 --- a/readme.md +++ b/readme.md @@ -4,6 +4,15 @@ 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 ```sh @@ -42,8 +51,8 @@ Examples ✖ Failed to execify "baz/xyz.sh", file not found $ execify --pkg - ✔ Execified "dist/foo.js" (foo-cli) - ✔ Execified "dist/bar.js" (bar-cli) + ✔ Execified "./dist/foo.js" (foo-cli) + ✔ Execified "./dist/bar.js" (bar-cli) ``` ## Related diff --git a/src/cli.ts b/src/cli.ts index 8e1038e..b381fb2 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,8 +19,8 @@ const cli = meow(` ✖ Failed to execify "baz/xyz.sh", file not found $ execify --pkg - ✔ Execified "dist/foo.js" (foo-cli) - ✔ Execified "dist/bar.js" (bar-cli) + ✔ Execified "./dist/foo.js" (foo-cli) + ✔ Execified "./dist/bar.js" (bar-cli) `, { description: false, flags: { diff --git a/test/helpers/util.ts b/test/helpers/util.ts index 575292c..8631523 100644 --- a/test/helpers/util.ts +++ b/test/helpers/util.ts @@ -10,7 +10,7 @@ const atFixture = (name: string) => path.join(__dirname, "..", "fixtures", name) /** Makes a temporary directory and registers a teardown to remove it. */ export const withTemporaryDirectory = async (t: ExecutionContext) => { - // TODO [engine:node@>=24]: use mkdtempDisposable + // TODO[engine:node@>=24]: use mkdtempDisposable // eslint-disable-next-line unicorn/max-nested-calls const temporaryDir = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "execify-cli-")); diff --git a/test/snapshots/cli.ts.md b/test/snapshots/cli.ts.md index d74acdd..fee2624 100644 --- a/test/snapshots/cli.ts.md +++ b/test/snapshots/cli.ts.md @@ -23,8 +23,8 @@ Generated by [AVA](https://avajs.dev). ✖ Failed to execify "baz/xyz.sh", file not found␊ ␊ $ execify --pkg␊ - ✔ Execified "dist/foo.js" (foo-cli)␊ - ✔ Execified "dist/bar.js" (bar-cli)␊ + ✔ Execified "./dist/foo.js" (foo-cli)␊ + ✔ Execified "./dist/bar.js" (bar-cli)␊ ` ## shows help (--help) @@ -46,8 +46,8 @@ Generated by [AVA](https://avajs.dev). ✖ Failed to execify "baz/xyz.sh", file not found␊ ␊ $ execify --pkg␊ - ✔ Execified "dist/foo.js" (foo-cli)␊ - ✔ Execified "dist/bar.js" (bar-cli)␊ + ✔ Execified "./dist/foo.js" (foo-cli)␊ + ✔ Execified "./dist/bar.js" (bar-cli)␊ ` ## shows help (-h) @@ -69,6 +69,6 @@ Generated by [AVA](https://avajs.dev). ✖ Failed to execify "baz/xyz.sh", file not found␊ ␊ $ execify --pkg␊ - ✔ Execified "dist/foo.js" (foo-cli)␊ - ✔ Execified "dist/bar.js" (bar-cli)␊ + ✔ 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 index b0b74744241b5432f4b303736b01d03719ba1a0c..83b84619a4138e479e8b260c55c1da2557bcb89a 100644 GIT binary patch literal 412 zcmV;N0b~9_RzV%CY*aLtq@RVbfRyZoi~M9y?Q&!dI$<&pB?yw%L>z=z) z8W);nNjSAO910>TLsyrx{r?5m2___1z&G%19H#H zhARZr0w0SA00000000B+kugrgFcgMc5JJkv4Sp(yR%+5yz{-Fa5Mp>~UJ_H+j`Cb+ zI)UH_hygKB2JV2w4Y);5f|8U{D!2gD%d_lH@BQ{SjYDnw1G#x-r<~>sN$sT|Wt~CA zFc!*WBd5erCaHCDJ&huAbkcm^Y-~5m)!b_AY`ir+;8-YO4*>SSWA3ZCz-dM!fG=Yn&w1|(JQk)u#^*>itP z!%VZR434c0&IKXmfh)_TH!dcDq!l)Myu*G4Dvr>q*;ak^@Pq@Ud2tFQ7%CBE5)dTi zkW8{$v$wBvwjNWlq;saISWa1wyvG44%@Lekq-=c!YjqKqy^M8;?Z`yl%8ROB`YC>- z#PvSYwqtAkzE+)I$uet2hLqRjT)r&HZ~J~(J8Sd*!My!uK3!Y?+txn-ZrKnb_5}a{ D^4z_b diff --git a/xo.config.js b/xo.config.js index fbeb4de..24d3639 100644 --- a/xo.config.js +++ b/xo.config.js @@ -1,33 +1,9 @@ import * as configs from "@tommy-mitchell/eslint-config-xo"; /** @type {import('xo').FlatXoConfig} */ -export default [...configs.xo, ...configs.dprint, { - ignores: ["test/fixtures"], -}, { +export default [...configs.xo, ...configs.dprint, { ignores: ["test/fixtures"] }, { rules: { "no-bitwise": "off", "unicorn/no-process-exit": "off", }, -}, { - files: "package.json", - rules: { - "package-json/dependency-version-range": ["error", { - exceptions: ["typescript"], - }], - }, -}, { - // TODO: move to main config - rules: { - "@typescript-eslint/consistent-type-imports": ["error", { - disallowTypeAnnotations: false, - fixStyle: "inline-type-imports", - }], - "@typescript-eslint/strict-boolean-expressions": ["error", { - allowNullableBoolean: true, - allowNullableObject: true, - allowNullableString: true, // diff from xo - allowNumber: false, - allowString: true, // diff from xo - }], - }, }]; From 1ddc465a3301d0e3b015543ab0651c1cf8b6c182 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 14:29:01 -0500 Subject: [PATCH 13/17] clean up helpers/utils --- package.json | 1 - src/helpers.ts | 32 +++++++++++++++++++------------- src/types.ts | 5 +++++ src/utils.ts | 20 +++++++------------- test/helpers/cli.ts | 10 +++++----- 5 files changed, 36 insertions(+), 32 deletions(-) create mode 100644 src/types.ts diff --git a/package.json b/package.json index a3010e9..7fcbcd9 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "get-executable-bin-path": "^1.0.0", "is-executable": "^2.0.1", "test-quadruple": "^1.0.0", - "type-fest": "^5.10.0", "typescript": "~6.0.3", "xo": "^5.0.1" }, diff --git a/src/helpers.ts b/src/helpers.ts index a77867d..ef286cf 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,13 +1,15 @@ +import nodePath from "node:path"; import process from "node:process"; +import type { OneOf } from "./types.ts"; import { type Binary, hasShebang, log, readPackageJson, setExecutableBit } from "./utils.ts"; -type GetFilesArguments = { +type Input = { input: string[]; usePackage?: boolean; }; /** Collates input file paths and binaries from `package.json`, deduplicating any input paths in `package.json`. */ -export const getBinaries = async ({ input, usePackage }: GetFilesArguments): Promise => { +export const getBinaries = async ({ input, usePackage }: Input): Promise => { const binaries: Binary[] = []; if (usePackage) { @@ -19,27 +21,31 @@ export const getBinaries = async ({ input, usePackage }: GetFilesArguments): Pro } binaries.push(...packageBinaries); - input = input.filter(path => packageBinaries.every(binary => binary.path !== path && binary.path !== `./${path}`)); } - return [...binaries, ...input.map(path => ({ path }))]; + for (const path of input) { + const absolutePath = nodePath.resolve(process.cwd(), path); + + if (binaries.every(binary => binary.absolutePath !== absolutePath)) { + binaries.push({ absolutePath, path }); + } + } + + return binaries; }; -type ExecifyResult = { +type ExecifyResult = OneOf<{ didExecify: boolean; - error?: never; hasShebang: boolean; -} | { - didExecify?: never; +}, { error: string; - hasShebang?: never; -}; +}>; -const execifySingle = async (filePath: string): Promise => { +const execifySingle = async (path: string): Promise => { try { return { - didExecify: await setExecutableBit(filePath), - hasShebang: await hasShebang(filePath), + didExecify: await setExecutableBit(path), + hasShebang: await hasShebang(path), }; } catch (error) { let message = String(error); 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 index 3a2d601..eb92f6d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,12 +16,9 @@ export type Binary = { path: string; }; -type PackageBinary = [ - name: string, - path: string, -]; +type PackageBinary = [name: string, path: string]; -const resolveBinary = ([name, path]: PackageBinary, packageJsonPath: string): Binary => ({ +const resolveBinaryFrom = ([name, path]: PackageBinary, packageJsonPath: string): Binary => ({ absolutePath: nodePath.resolve(nodePath.dirname(packageJsonPath), path), name, path, @@ -35,7 +32,7 @@ export const readPackageJson = async (): Promise => { return; } - const { packageJson, path: packageJsonPath } = maybePackageJson; + const { packageJson, path } = maybePackageJson; const { bin, name } = packageJson; if (!bin) { @@ -43,8 +40,8 @@ export const readPackageJson = async (): Promise => { } return typeof bin === "string" - ? [resolveBinary([name, bin], packageJsonPath)] - : Object.entries(bin).map(binary => resolveBinary(binary, packageJsonPath)); + ? [resolveBinaryFrom([name, bin], path)] + : Object.entries(bin).map(binary => resolveBinaryFrom(binary, path)); }; const EXECUTABLE_MASK = fsConstants.S_IXUSR | fsConstants.S_IXGRP | fsConstants.S_IXOTH; @@ -54,18 +51,15 @@ const EXECUTABLE_MASK = fsConstants.S_IXUSR | fsConstants.S_IXGRP | fsConstants. export const setExecutableBit = async (path: string): Promise => { const stats = await fs.stat(path); - // Same as 'chmod +x' if ((stats.mode & EXECUTABLE_MASK) !== EXECUTABLE_MASK) { - await fs.chmod(path, stats.mode | EXECUTABLE_MASK); + await fs.chmod(path, stats.mode | EXECUTABLE_MASK); // Same as 'chmod +x' return true; } return false; }; -const SHEBANG_REGEX = /^#!.+/v; - export const hasShebang = async (path: string): Promise => { const content = await fs.readFile(path, "utf8"); - return SHEBANG_REGEX.test(content); + return content.startsWith("#!"); }; diff --git a/test/helpers/cli.ts b/test/helpers/cli.ts index 72c33bd..882ffbb 100644 --- a/test/helpers/cli.ts +++ b/test/helpers/cli.ts @@ -5,7 +5,7 @@ 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 { RequireExactlyOne as OneOf } from "type-fest"; +import type { OneOf } from "../../src/types.ts"; import { trimLines, withFixture } from "./util.ts"; export const test = anyTest as TestFn<{ @@ -37,10 +37,10 @@ test.afterEach.always(t => { export const $ = execa({ all: true, env: { NO_COLOR: "1" }, reject: false }); type VerifyCliMacroArgs = [ - OneOf<{ - error: string; - expected: string; - }> & { + OneOf< + { error: string; }, + { expected: string; } + > & { args?: string; cwd?: string; fixture?: string; From d9da6761926bb1fa1e6de63f47395aca48b9053d Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 15:03:52 -0500 Subject: [PATCH 14/17] subpath imports in tests --- package.json | 3 +++ test/helpers/cli.ts | 2 +- test/helpers/util.ts | 5 +---- test/read-package-json.ts | 2 +- test/set-executable-bit.ts | 2 +- tsconfig.build.json | 8 ++++---- tsconfig.json | 6 +++--- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 7fcbcd9..841530c 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,8 @@ "//": "Suppress loader warnings from esmock", "NODE_OPTIONS": "--disable-warning=DeprecationWarning" } + }, + "imports": { + "#/*.ts": "./src/*.ts" } } diff --git a/test/helpers/cli.ts b/test/helpers/cli.ts index 882ffbb..963c32b 100644 --- a/test/helpers/cli.ts +++ b/test/helpers/cli.ts @@ -5,7 +5,7 @@ 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 type { OneOf } from "#/types.ts"; import { trimLines, withFixture } from "./util.ts"; export const test = anyTest as TestFn<{ diff --git a/test/helpers/util.ts b/test/helpers/util.ts index 8631523..2684bac 100644 --- a/test/helpers/util.ts +++ b/test/helpers/util.ts @@ -1,12 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import type { ExecutionContext } from "ava"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const atFixture = (name: string) => path.join(__dirname, "..", "fixtures", name); +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) => { diff --git a/test/read-package-json.ts b/test/read-package-json.ts index 58bf8cb..b1352e2 100644 --- a/test/read-package-json.ts +++ b/test/read-package-json.ts @@ -1,7 +1,7 @@ import nodePath from "node:path"; import test from "ava"; import * as tq from "test-quadruple"; -import type { Binary } from "../src/utils.ts"; +import type { Binary } from "#/utils.ts"; import { withFixture } from "./helpers/util.ts"; const withCwd = async (cwd: string) => ( diff --git a/test/set-executable-bit.ts b/test/set-executable-bit.ts index c3af09b..7d8d470 100644 --- a/test/set-executable-bit.ts +++ b/test/set-executable-bit.ts @@ -1,7 +1,7 @@ /* eslint-disable unicorn/consistent-boolean-name -- test file */ import test from "ava"; import { isExecutable } from "is-executable"; -import { setExecutableBit } from "../src/utils.ts"; +import { setExecutableBit } from "#/utils.ts"; import { withFixture } from "./helpers/util.ts"; test("sets bitmask", async t => { diff --git a/tsconfig.build.json b/tsconfig.build.json index 607528d..5059108 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,10 +2,10 @@ "extends": "./tsconfig.json", "include": ["src"], "compilerOptions": { - "outDir": "dist", - "rootDir": "./src", - "noEmit": false, "declaration": false, - "removeComments": true + "noEmit": false, + "outDir": "dist", + "removeComments": true, + "rootDir": "./src" } } diff --git a/tsconfig.json b/tsconfig.json index 04fbdef..0d95292 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +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"], - "rewriteRelativeImportExtensions": true, - "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, }, } From 147b9b00d287a40011598792df67bf2be420b654 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 15:37:20 -0500 Subject: [PATCH 15/17] more cleanup --- dprint.json | 2 +- package.json | 2 ++ src/helpers.ts | 4 ++-- src/utils.ts | 2 +- test/helpers/util.ts | 3 +-- test/{set-executable-bit.ts => make-executable.ts} | 12 ++++++------ test/read-package-json.ts | 2 +- 7 files changed, 14 insertions(+), 13 deletions(-) rename test/{set-executable-bit.ts => make-executable.ts} (70%) diff --git a/dprint.json b/dprint.json index 19dd8a5..575e807 100644 --- a/dprint.json +++ b/dprint.json @@ -1,6 +1,6 @@ { "$schema": "https://dprint.dev/schemas/v0.json", - "includes": ["**/*.{js,ts,json,jsonc,yml,markdown}"], + "includes": ["**/*.{js,ts,json,jsonc,yml,md}"], "extends": ["node_modules/@tommy-mitchell/dprint-config/index.json"], "plugins": [ "npm:@dprint/json@0.25.0", diff --git a/package.json b/package.json index 841530c..f72dadb 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "executable", "chmod", "chmod+x", + "shebang", + "hashbang", "binary", "bin" ], diff --git a/src/helpers.ts b/src/helpers.ts index ef286cf..d40d3f2 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,7 +1,7 @@ import nodePath from "node:path"; import process from "node:process"; import type { OneOf } from "./types.ts"; -import { type Binary, hasShebang, log, readPackageJson, setExecutableBit } from "./utils.ts"; +import { type Binary, hasShebang, log, makeExecutable, readPackageJson } from "./utils.ts"; type Input = { input: string[]; @@ -44,7 +44,7 @@ type ExecifyResult = OneOf<{ const execifySingle = async (path: string): Promise => { try { return { - didExecify: await setExecutableBit(path), + didExecify: await makeExecutable(path), hasShebang: await hasShebang(path), }; } catch (error) { diff --git a/src/utils.ts b/src/utils.ts index eb92f6d..438a2d4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -48,7 +48,7 @@ const EXECUTABLE_MASK = fsConstants.S_IXUSR | fsConstants.S_IXGRP | fsConstants. /** 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 setExecutableBit = async (path: string): Promise => { +export const makeExecutable = async (path: string): Promise => { const stats = await fs.stat(path); if ((stats.mode & EXECUTABLE_MASK) !== EXECUTABLE_MASK) { diff --git a/test/helpers/util.ts b/test/helpers/util.ts index 2684bac..1784383 100644 --- a/test/helpers/util.ts +++ b/test/helpers/util.ts @@ -7,7 +7,6 @@ const atFixture = (name: string) => path.join(import.meta.dirname, "..", "fixtur /** Makes a temporary directory and registers a teardown to remove it. */ export const withTemporaryDirectory = async (t: ExecutionContext) => { - // TODO[engine:node@>=24]: use mkdtempDisposable // eslint-disable-next-line unicorn/max-nested-calls const temporaryDir = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "execify-cli-")); @@ -23,7 +22,7 @@ export const withFixture = async (t: ExecutionContext, name: string) => { const temporaryDir = await withTemporaryDirectory(t); const fixture = atFixture(name); - const isDirectory = !fixture.includes("."); + const isDirectory = !fixture.includes("."); // None of the fixtures have dots ¯\_(ツ)_/¯ if (isDirectory) { await fs.cp(fixture, temporaryDir, { recursive: true }); diff --git a/test/set-executable-bit.ts b/test/make-executable.ts similarity index 70% rename from test/set-executable-bit.ts rename to test/make-executable.ts index 7d8d470..9411596 100644 --- a/test/set-executable-bit.ts +++ b/test/make-executable.ts @@ -1,15 +1,15 @@ /* eslint-disable unicorn/consistent-boolean-name -- test file */ import test from "ava"; import { isExecutable } from "is-executable"; -import { setExecutableBit } from "#/utils.ts"; +import { makeExecutable } from "#/utils.ts"; 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 setExecutableBit(fixture); - t.true(result, "setExecutableBit should return true!"); + const result = await makeExecutable(fixture); + t.true(result, "Should have made fixture executable!"); t.true(await isExecutable(fixture), "Fixture should be executable!"); }); @@ -17,13 +17,13 @@ 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 setExecutableBit(fixture); - t.false(result, "setExecutableBit should return false!"); + 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 () => setExecutableBit("NON-EXISTENT-FILE"), + async () => makeExecutable("NON-EXISTENT-FILE"), { code: "ENOENT", instanceOf: Error }, ); }); diff --git a/test/read-package-json.ts b/test/read-package-json.ts index b1352e2..6dd8b53 100644 --- a/test/read-package-json.ts +++ b/test/read-package-json.ts @@ -56,7 +56,7 @@ test("returns binaries from object `bin` field", verify, { fixture: "package-bin-object-multiple", }); -test("searches up for nearest `package.json`", verify, { +test("searches for nearest `package.json`", verify, { cwd: "foo/bar", expected: [{ name: "bar-cli", path: "./bar/cli.js" }], fixture: "package-nested", From 6d73d54f05e5e7cd87591e2527c2884038f79124 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 15:54:30 -0500 Subject: [PATCH 16/17] can't use `#/` in node 22 :( --- package.json | 2 +- test/helpers/cli.ts | 2 +- test/make-executable.ts | 2 +- test/read-package-json.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index f72dadb..95755fb 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,6 @@ } }, "imports": { - "#/*.ts": "./src/*.ts" + "#src/*.ts": "./src/*.ts" } } diff --git a/test/helpers/cli.ts b/test/helpers/cli.ts index 963c32b..f60960b 100644 --- a/test/helpers/cli.ts +++ b/test/helpers/cli.ts @@ -5,7 +5,7 @@ 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 "#/types.ts"; +import type { OneOf } from "#src/types.ts"; import { trimLines, withFixture } from "./util.ts"; export const test = anyTest as TestFn<{ diff --git a/test/make-executable.ts b/test/make-executable.ts index 9411596..7ceb7cb 100644 --- a/test/make-executable.ts +++ b/test/make-executable.ts @@ -1,7 +1,7 @@ /* eslint-disable unicorn/consistent-boolean-name -- test file */ import test from "ava"; import { isExecutable } from "is-executable"; -import { makeExecutable } from "#/utils.ts"; +import { makeExecutable } from "#src/utils.ts"; // TODO[engine:node@>=24.14]: use #/ import { withFixture } from "./helpers/util.ts"; test("sets bitmask", async t => { diff --git a/test/read-package-json.ts b/test/read-package-json.ts index 6dd8b53..0b7f6e1 100644 --- a/test/read-package-json.ts +++ b/test/read-package-json.ts @@ -1,7 +1,7 @@ import nodePath from "node:path"; import test from "ava"; import * as tq from "test-quadruple"; -import type { Binary } from "#/utils.ts"; +import type { Binary } from "#src/utils.ts"; import { withFixture } from "./helpers/util.ts"; const withCwd = async (cwd: string) => ( From f7abd2d6178dc992e5cff3f1d2c133e17925ea73 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 1 Oct 2026 15:54:55 -0500 Subject: [PATCH 17/17] fix lint --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 95755fb..82803b8 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "repository": "tommy-mitchell/execify-cli", "author": "Tommy Mitchell (https://tommymitchell.io)", "type": "module", + "imports": { + "#src/*.ts": "./src/*.ts" + }, "bin": { "execify": "./dist/cli.js" }, @@ -65,8 +68,5 @@ "//": "Suppress loader warnings from esmock", "NODE_OPTIONS": "--disable-warning=DeprecationWarning" } - }, - "imports": { - "#src/*.ts": "./src/*.ts" } }