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"
}
}