@somewhatabstract/x 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dependabot.yml +28 -0
- package/.github/workflows/codeql-analysis.yml +29 -29
- package/.github/workflows/dependabot-pr-approval.yml +36 -0
- package/.github/workflows/nodejs.yml +84 -86
- package/.github/workflows/release.yml +4 -7
- package/.vscode/settings.json +19 -0
- package/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +3 -3
- package/README.md +132 -1
- package/biome.json +39 -0
- package/dist/x.mjs +278 -3
- package/package.json +14 -4
- package/src/__tests__/build-environment.test.ts +285 -0
- package/src/__tests__/discover-packages.test.ts +196 -0
- package/src/__tests__/errors.test.ts +59 -0
- package/src/__tests__/execute-script.test.ts +1042 -0
- package/src/__tests__/find-matching-bins.test.ts +506 -0
- package/src/__tests__/find-workspace-root.test.ts +73 -0
- package/src/__tests__/is-node-executable.test.ts +125 -0
- package/src/__tests__/resolve-bin-path.test.ts +344 -0
- package/src/__tests__/x-impl.test.ts +306 -7
- package/src/__tests__/x.test.ts +236 -0
- package/src/bin/x.ts +55 -1
- package/src/build-environment.ts +98 -0
- package/src/discover-packages.ts +35 -0
- package/src/errors.ts +10 -0
- package/src/execute-script.ts +56 -0
- package/src/find-matching-bins.ts +72 -0
- package/src/find-workspace-root.ts +24 -0
- package/src/is-node-executable.ts +16 -0
- package/src/resolve-bin-path.ts +48 -0
- package/src/x-impl.ts +95 -4
- package/tsconfig-types.json +2 -4
- package/tsconfig.json +5 -13
- package/tsdown.config.ts +1 -1
- package/vitest.config.ts +1 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest";
|
|
2
|
+
|
|
3
|
+
// Utility to wait for all pending promises and timers to settle
|
|
4
|
+
const flushPromises = () =>
|
|
5
|
+
new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
6
|
+
|
|
7
|
+
// Build a mock yargs chain that returns the given parsed args from parseSync
|
|
8
|
+
const buildYargsMock = (parsedArgs: Record<string, unknown>) => {
|
|
9
|
+
const yargsChain: Record<string, unknown> = {
|
|
10
|
+
usage: vi.fn().mockReturnThis(),
|
|
11
|
+
option: vi.fn().mockReturnThis(),
|
|
12
|
+
help: vi.fn().mockReturnThis(),
|
|
13
|
+
alias: vi.fn().mockReturnThis(),
|
|
14
|
+
version: vi.fn().mockReturnThis(),
|
|
15
|
+
example: vi.fn().mockReturnThis(),
|
|
16
|
+
strict: vi.fn().mockReturnThis(),
|
|
17
|
+
parseSync: vi.fn().mockReturnValue(parsedArgs),
|
|
18
|
+
// Needed when the command builder callback calls yargs.positional()
|
|
19
|
+
positional: vi.fn().mockReturnThis(),
|
|
20
|
+
};
|
|
21
|
+
// Invoke the builder callback so its body is exercised
|
|
22
|
+
yargsChain.command = vi
|
|
23
|
+
.fn()
|
|
24
|
+
.mockImplementation(
|
|
25
|
+
(_name: unknown, _desc: unknown, builder: unknown) => {
|
|
26
|
+
if (typeof builder === "function") {
|
|
27
|
+
builder(yargsChain);
|
|
28
|
+
}
|
|
29
|
+
return yargsChain;
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
return {default: vi.fn().mockReturnValue(yargsChain)};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("bin/x", () => {
|
|
36
|
+
let processExitSpy: ReturnType<typeof vi.spyOn>;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.resetModules();
|
|
40
|
+
processExitSpy = vi
|
|
41
|
+
.spyOn(process, "exit")
|
|
42
|
+
.mockImplementation(() => undefined as never);
|
|
43
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should call xImpl with the script name from argv", async () => {
|
|
51
|
+
// Arrange
|
|
52
|
+
const xImplMock = vi.fn().mockResolvedValue({exitCode: 0});
|
|
53
|
+
vi.doMock("../x-impl", () => ({xImpl: xImplMock}));
|
|
54
|
+
vi.doMock("yargs", () =>
|
|
55
|
+
buildYargsMock({
|
|
56
|
+
"script-name": "my-script",
|
|
57
|
+
_: [],
|
|
58
|
+
"dry-run": false,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Act
|
|
63
|
+
await import("../bin/x");
|
|
64
|
+
await flushPromises();
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(xImplMock).toHaveBeenCalledWith(
|
|
68
|
+
"my-script",
|
|
69
|
+
expect.any(Array),
|
|
70
|
+
expect.any(Object),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should call xImpl with args from argv underscore field", async () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
const xImplMock = vi.fn().mockResolvedValue({exitCode: 0});
|
|
77
|
+
vi.doMock("../x-impl", () => ({xImpl: xImplMock}));
|
|
78
|
+
vi.doMock("yargs", () =>
|
|
79
|
+
buildYargsMock({
|
|
80
|
+
"script-name": "my-script",
|
|
81
|
+
_: ["--flag", "value"],
|
|
82
|
+
"dry-run": false,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Act
|
|
87
|
+
await import("../bin/x");
|
|
88
|
+
await flushPromises();
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
expect(xImplMock).toHaveBeenCalledWith(
|
|
92
|
+
expect.any(String),
|
|
93
|
+
["--flag", "value"],
|
|
94
|
+
expect.any(Object),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should call xImpl with dryRun true when dry-run argv is true", async () => {
|
|
99
|
+
// Arrange
|
|
100
|
+
const xImplMock = vi.fn().mockResolvedValue({exitCode: 0});
|
|
101
|
+
vi.doMock("../x-impl", () => ({xImpl: xImplMock}));
|
|
102
|
+
vi.doMock("yargs", () =>
|
|
103
|
+
buildYargsMock({
|
|
104
|
+
"script-name": "my-script",
|
|
105
|
+
_: [],
|
|
106
|
+
"dry-run": true,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Act
|
|
111
|
+
await import("../bin/x");
|
|
112
|
+
await flushPromises();
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
115
|
+
expect(xImplMock).toHaveBeenCalledWith(
|
|
116
|
+
expect.any(String),
|
|
117
|
+
expect.any(Array),
|
|
118
|
+
{dryRun: true},
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should call xImpl with dryRun false when dry-run argv is false", async () => {
|
|
123
|
+
// Arrange
|
|
124
|
+
const xImplMock = vi.fn().mockResolvedValue({exitCode: 0});
|
|
125
|
+
vi.doMock("../x-impl", () => ({xImpl: xImplMock}));
|
|
126
|
+
vi.doMock("yargs", () =>
|
|
127
|
+
buildYargsMock({
|
|
128
|
+
"script-name": "my-script",
|
|
129
|
+
_: [],
|
|
130
|
+
"dry-run": false,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Act
|
|
135
|
+
await import("../bin/x");
|
|
136
|
+
await flushPromises();
|
|
137
|
+
|
|
138
|
+
// Assert
|
|
139
|
+
expect(xImplMock).toHaveBeenCalledWith(
|
|
140
|
+
expect.any(String),
|
|
141
|
+
expect.any(Array),
|
|
142
|
+
{dryRun: false},
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should default to empty array when argv underscore field is falsy", async () => {
|
|
147
|
+
// Arrange
|
|
148
|
+
const xImplMock = vi.fn().mockResolvedValue({exitCode: 0});
|
|
149
|
+
vi.doMock("../x-impl", () => ({xImpl: xImplMock}));
|
|
150
|
+
vi.doMock("yargs", () =>
|
|
151
|
+
buildYargsMock({
|
|
152
|
+
"script-name": "my-script",
|
|
153
|
+
_: null,
|
|
154
|
+
"dry-run": false,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Act
|
|
159
|
+
await import("../bin/x");
|
|
160
|
+
await flushPromises();
|
|
161
|
+
|
|
162
|
+
// Assert
|
|
163
|
+
expect(xImplMock).toHaveBeenCalledWith(
|
|
164
|
+
expect.any(String),
|
|
165
|
+
[],
|
|
166
|
+
expect.any(Object),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("should exit with the exit code returned by xImpl", async () => {
|
|
171
|
+
// Arrange
|
|
172
|
+
vi.doMock("../x-impl", () => ({
|
|
173
|
+
xImpl: vi.fn().mockResolvedValue({exitCode: 42}),
|
|
174
|
+
}));
|
|
175
|
+
vi.doMock("yargs", () =>
|
|
176
|
+
buildYargsMock({
|
|
177
|
+
"script-name": "my-script",
|
|
178
|
+
_: [],
|
|
179
|
+
"dry-run": false,
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Act
|
|
184
|
+
await import("../bin/x");
|
|
185
|
+
await flushPromises();
|
|
186
|
+
|
|
187
|
+
// Assert
|
|
188
|
+
expect(processExitSpy).toHaveBeenCalledWith(42);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should exit with 1 when xImpl throws an unexpected error", async () => {
|
|
192
|
+
// Arrange
|
|
193
|
+
vi.doMock("../x-impl", () => ({
|
|
194
|
+
xImpl: vi.fn().mockRejectedValue(new Error("Unexpected")),
|
|
195
|
+
}));
|
|
196
|
+
vi.doMock("yargs", () =>
|
|
197
|
+
buildYargsMock({
|
|
198
|
+
"script-name": "my-script",
|
|
199
|
+
_: [],
|
|
200
|
+
"dry-run": false,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Act
|
|
205
|
+
await import("../bin/x");
|
|
206
|
+
await flushPromises();
|
|
207
|
+
|
|
208
|
+
// Assert
|
|
209
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should log error details when xImpl throws an unexpected error", async () => {
|
|
213
|
+
// Arrange
|
|
214
|
+
const unexpectedError = new Error("Something went wrong");
|
|
215
|
+
vi.doMock("../x-impl", () => ({
|
|
216
|
+
xImpl: vi.fn().mockRejectedValue(unexpectedError),
|
|
217
|
+
}));
|
|
218
|
+
vi.doMock("yargs", () =>
|
|
219
|
+
buildYargsMock({
|
|
220
|
+
"script-name": "my-script",
|
|
221
|
+
_: [],
|
|
222
|
+
"dry-run": false,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Act
|
|
227
|
+
await import("../bin/x");
|
|
228
|
+
await flushPromises();
|
|
229
|
+
|
|
230
|
+
// Assert
|
|
231
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
232
|
+
"Unexpected error:",
|
|
233
|
+
unexpectedError,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/bin/x.ts
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import yargs from "yargs";
|
|
3
|
+
import {hideBin} from "yargs/helpers";
|
|
2
4
|
import {xImpl} from "../x-impl";
|
|
3
|
-
|
|
5
|
+
|
|
6
|
+
const argv = yargs(hideBin(process.argv))
|
|
7
|
+
.usage("Usage: $0 <script-name> [...args]")
|
|
8
|
+
.command(
|
|
9
|
+
"$0 <script-name> [args..]",
|
|
10
|
+
"Execute a bin script from any package in the workspace",
|
|
11
|
+
(yargs) => {
|
|
12
|
+
return yargs
|
|
13
|
+
.positional("script-name", {
|
|
14
|
+
describe: "Name of the bin script to execute",
|
|
15
|
+
type: "string",
|
|
16
|
+
demandOption: true,
|
|
17
|
+
})
|
|
18
|
+
.positional("args", {
|
|
19
|
+
describe: "Arguments to pass to the script",
|
|
20
|
+
type: "string",
|
|
21
|
+
array: true,
|
|
22
|
+
default: [],
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
.option("dry-run", {
|
|
27
|
+
alias: "d",
|
|
28
|
+
describe: "Show what would be executed without running it",
|
|
29
|
+
type: "boolean",
|
|
30
|
+
default: false,
|
|
31
|
+
})
|
|
32
|
+
.help()
|
|
33
|
+
.alias("help", "h")
|
|
34
|
+
.version()
|
|
35
|
+
.alias("version", "v")
|
|
36
|
+
.example("$0 tsc --noEmit", "Run TypeScript compiler from any package")
|
|
37
|
+
.example("$0 eslint src/", "Run ESLint from any package that provides it")
|
|
38
|
+
.example("$0 --dry-run jest", "Preview which jest would be executed")
|
|
39
|
+
.strict()
|
|
40
|
+
.parseSync();
|
|
41
|
+
|
|
42
|
+
// Extract script name and args
|
|
43
|
+
const scriptName = argv["script-name"] as string;
|
|
44
|
+
const args = (argv._ as string[]) || [];
|
|
45
|
+
const options = {
|
|
46
|
+
dryRun: argv["dry-run"] as boolean,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Run the implementation and exit with the appropriate code
|
|
50
|
+
xImpl(scriptName, args, options)
|
|
51
|
+
.then((result) => {
|
|
52
|
+
process.exit(result.exitCode);
|
|
53
|
+
})
|
|
54
|
+
.catch((error) => {
|
|
55
|
+
console.error("Unexpected error:", error);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build environment variables for script execution that mimics npm/pnpm behavior.
|
|
6
|
+
* The environment is set as if the package were installed at the workspace root.
|
|
7
|
+
*
|
|
8
|
+
* @param workspaceRoot - Path to the workspace root
|
|
9
|
+
* @param currentEnv - Current environment variables (usually process.env)
|
|
10
|
+
* @returns Environment object to pass to child_process.spawn
|
|
11
|
+
*/
|
|
12
|
+
export async function buildEnvironment(
|
|
13
|
+
workspaceRoot: string,
|
|
14
|
+
currentEnv: NodeJS.ProcessEnv,
|
|
15
|
+
): Promise<NodeJS.ProcessEnv> {
|
|
16
|
+
// Read workspace root's package.json for metadata
|
|
17
|
+
let workspacePackageJson: Record<string, unknown> = {};
|
|
18
|
+
try {
|
|
19
|
+
const packageJsonPath = path.join(workspaceRoot, "package.json");
|
|
20
|
+
const content = await fs.readFile(packageJsonPath, "utf-8");
|
|
21
|
+
workspacePackageJson = JSON.parse(content);
|
|
22
|
+
} catch {
|
|
23
|
+
// If we can't read package.json, continue with empty metadata
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Get our own package version for user agent
|
|
27
|
+
let ourPackageVersion = "0.0.0-development";
|
|
28
|
+
try {
|
|
29
|
+
const ownPackageJsonPath = path.join(__dirname, "..", "package.json");
|
|
30
|
+
const ownPackageJsonContent = await fs.readFile(
|
|
31
|
+
ownPackageJsonPath,
|
|
32
|
+
"utf-8",
|
|
33
|
+
);
|
|
34
|
+
const ownPackageJson = JSON.parse(ownPackageJsonContent);
|
|
35
|
+
if (typeof ownPackageJson.version === "string") {
|
|
36
|
+
ourPackageVersion = ownPackageJson.version;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// If we can't read our own package.json, continue with fallback version
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build the environment
|
|
43
|
+
const env: NodeJS.ProcessEnv = {
|
|
44
|
+
// Preserve all existing environment variables
|
|
45
|
+
...currentEnv,
|
|
46
|
+
|
|
47
|
+
// PATH: Prepend workspace root's node_modules/.bin
|
|
48
|
+
PATH: [
|
|
49
|
+
path.join(workspaceRoot, "node_modules", ".bin"),
|
|
50
|
+
currentEnv.PATH,
|
|
51
|
+
]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join(path.delimiter),
|
|
54
|
+
|
|
55
|
+
// npm lifecycle variables
|
|
56
|
+
npm_command: "exec",
|
|
57
|
+
npm_execpath: process.execPath,
|
|
58
|
+
npm_node_execpath: process.execPath,
|
|
59
|
+
NODE: process.execPath,
|
|
60
|
+
INIT_CWD: process.cwd(),
|
|
61
|
+
|
|
62
|
+
// User agent
|
|
63
|
+
npm_config_user_agent: `x/${ourPackageVersion} node/${process.version} ${process.platform} ${process.arch}`,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Add npm_package_* variables from workspace root's package.json
|
|
67
|
+
if (workspacePackageJson.name) {
|
|
68
|
+
env.npm_package_name = workspacePackageJson.name as string;
|
|
69
|
+
}
|
|
70
|
+
if (workspacePackageJson.version) {
|
|
71
|
+
env.npm_package_version = workspacePackageJson.version as string;
|
|
72
|
+
}
|
|
73
|
+
if (workspacePackageJson.description) {
|
|
74
|
+
env.npm_package_description =
|
|
75
|
+
workspacePackageJson.description as string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Add other common package.json fields
|
|
79
|
+
const commonFields = [
|
|
80
|
+
"author",
|
|
81
|
+
"license",
|
|
82
|
+
"homepage",
|
|
83
|
+
"repository",
|
|
84
|
+
"bugs",
|
|
85
|
+
"keywords",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
for (const field of commonFields) {
|
|
89
|
+
if (workspacePackageJson[field]) {
|
|
90
|
+
const value = workspacePackageJson[field];
|
|
91
|
+
// Convert objects to JSON strings
|
|
92
|
+
env[`npm_package_${field}`] =
|
|
93
|
+
typeof value === "string" ? value : JSON.stringify(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return env;
|
|
98
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {getPackages} from "@manypkg/get-packages";
|
|
2
|
+
import {HandledError} from "./errors";
|
|
3
|
+
|
|
4
|
+
export interface PackageInfo {
|
|
5
|
+
name: string;
|
|
6
|
+
path: string;
|
|
7
|
+
version: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Discover all packages in the workspace using @manypkg/get-packages.
|
|
12
|
+
* Supports multiple package managers: npm, yarn, pnpm, lerna, bun, rush.
|
|
13
|
+
*
|
|
14
|
+
* @param workspaceRoot - The absolute path to the workspace root
|
|
15
|
+
* @returns Array of package information
|
|
16
|
+
* @throws {HandledError} If package discovery fails
|
|
17
|
+
*/
|
|
18
|
+
export async function discoverPackages(
|
|
19
|
+
workspaceRoot: string,
|
|
20
|
+
): Promise<PackageInfo[]> {
|
|
21
|
+
try {
|
|
22
|
+
const result = await getPackages(workspaceRoot);
|
|
23
|
+
|
|
24
|
+
return result.packages.map((pkg) => ({
|
|
25
|
+
name: pkg.packageJson.name,
|
|
26
|
+
path: pkg.dir,
|
|
27
|
+
version: pkg.packageJson.version || "unknown",
|
|
28
|
+
}));
|
|
29
|
+
} catch (error: unknown) {
|
|
30
|
+
if (error instanceof HandledError) {
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
throw new HandledError(`Failed to discover packages`, {cause: error});
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error class for known/expected errors that should be displayed to the user
|
|
3
|
+
* without a stack trace.
|
|
4
|
+
*/
|
|
5
|
+
export class HandledError extends Error {
|
|
6
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
7
|
+
super(message, options);
|
|
8
|
+
this.name = "HandledError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {spawn} from "node:child_process";
|
|
2
|
+
import {buildEnvironment} from "./build-environment";
|
|
3
|
+
import type {BinInfo} from "./find-matching-bins";
|
|
4
|
+
import {isNodeExecutable} from "./is-node-executable";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Execute a bin script with the given arguments.
|
|
8
|
+
* Executes the script as if the package were installed at the workspace root,
|
|
9
|
+
* with npm/pnpm-style environment variables.
|
|
10
|
+
*
|
|
11
|
+
* @param bin - The bin info to execute
|
|
12
|
+
* @param args - Arguments to pass to the script
|
|
13
|
+
* @param workspaceRoot - Path to the workspace root
|
|
14
|
+
* @returns A promise that resolves with the exit code
|
|
15
|
+
*/
|
|
16
|
+
export async function executeScript(
|
|
17
|
+
bin: BinInfo,
|
|
18
|
+
args: string[],
|
|
19
|
+
workspaceRoot: string,
|
|
20
|
+
): Promise<number> {
|
|
21
|
+
// Build environment with npm/pnpm-style variables
|
|
22
|
+
const env = await buildEnvironment(workspaceRoot, process.env);
|
|
23
|
+
|
|
24
|
+
// For .js/.mjs/.cjs files, invoke via node (matching npm behavior).
|
|
25
|
+
// The original binPath casing is preserved in the spawn call.
|
|
26
|
+
const [executable, spawnArgs] = isNodeExecutable(bin.binPath)
|
|
27
|
+
? [process.execPath, [bin.binPath, ...args]]
|
|
28
|
+
: [bin.binPath, args];
|
|
29
|
+
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const child = spawn(executable, spawnArgs, {
|
|
32
|
+
// Don't change directory - execute in current directory
|
|
33
|
+
// as if the bin were installed at workspace root
|
|
34
|
+
stdio: "inherit",
|
|
35
|
+
env,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
child.on("error", (_) => {
|
|
39
|
+
// Handle spawn errors (ENOENT, EACCES, etc.)
|
|
40
|
+
resolve(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
child.on("exit", (code, signal) => {
|
|
44
|
+
// If killed by signal, we could use exit code 128 + signal number,
|
|
45
|
+
// (a common convention), however, for simplicity, we'll just
|
|
46
|
+
// return 1 for any signal-based termination as it's not clear
|
|
47
|
+
// that we need to distinguish between different signals in this
|
|
48
|
+
// context.
|
|
49
|
+
if (signal) {
|
|
50
|
+
resolve(1);
|
|
51
|
+
} else {
|
|
52
|
+
resolve(code ?? 1);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type {PackageInfo} from "./discover-packages";
|
|
4
|
+
import {resolveBinPath} from "./resolve-bin-path";
|
|
5
|
+
|
|
6
|
+
export interface BinInfo {
|
|
7
|
+
packageName: string;
|
|
8
|
+
packagePath: string;
|
|
9
|
+
binName: string;
|
|
10
|
+
binPath: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find all packages that have a bin script matching the given name.
|
|
15
|
+
*
|
|
16
|
+
* @param packages - List of packages from discoverPackages
|
|
17
|
+
* @param binName - Name of the bin script to find
|
|
18
|
+
* @returns Array of matching bin information
|
|
19
|
+
*/
|
|
20
|
+
export async function findMatchingBins(
|
|
21
|
+
packages: PackageInfo[],
|
|
22
|
+
binName: string,
|
|
23
|
+
): Promise<BinInfo[]> {
|
|
24
|
+
const matches: BinInfo[] = [];
|
|
25
|
+
|
|
26
|
+
for (const pkg of packages) {
|
|
27
|
+
try {
|
|
28
|
+
const packageJsonPath = path.join(pkg.path, "package.json");
|
|
29
|
+
const packageJsonContent = await fs.readFile(
|
|
30
|
+
packageJsonPath,
|
|
31
|
+
"utf-8",
|
|
32
|
+
);
|
|
33
|
+
const packageJson = JSON.parse(packageJsonContent);
|
|
34
|
+
|
|
35
|
+
const bin = packageJson.bin;
|
|
36
|
+
const resolvedBinPath = resolveBinPath(pkg, bin, binName);
|
|
37
|
+
if (!resolvedBinPath) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
matches.push({
|
|
42
|
+
packageName: pkg.name,
|
|
43
|
+
packagePath: pkg.path,
|
|
44
|
+
binName: binName,
|
|
45
|
+
binPath: resolvedBinPath,
|
|
46
|
+
});
|
|
47
|
+
} catch (error: unknown) {
|
|
48
|
+
// Skip packages that can't be read. Log malformed JSON so it can be diagnosed.
|
|
49
|
+
if (error instanceof SyntaxError) {
|
|
50
|
+
// Invalid JSON in package.json
|
|
51
|
+
console.warn(
|
|
52
|
+
`Warning: Failed to parse package.json for package "${pkg.name}" at "${pkg.path}": invalid JSON.`,
|
|
53
|
+
);
|
|
54
|
+
} else if (
|
|
55
|
+
error &&
|
|
56
|
+
typeof error === "object" &&
|
|
57
|
+
"code" in error &&
|
|
58
|
+
error.code === "ENOENT"
|
|
59
|
+
) {
|
|
60
|
+
// package.json not found - this can be expected for some paths
|
|
61
|
+
} else {
|
|
62
|
+
// Other unexpected errors when reading package.json
|
|
63
|
+
console.warn(
|
|
64
|
+
`Warning: Could not read package.json for package "${pkg.name}" at "${pkg.path}":`,
|
|
65
|
+
error,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return matches;
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {findRoot} from "@manypkg/find-root";
|
|
2
|
+
import {HandledError} from "./errors";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find the workspace root using @manypkg/find-root.
|
|
6
|
+
* Supports multiple package managers: npm, yarn, pnpm, lerna, bun, rush.
|
|
7
|
+
*
|
|
8
|
+
* @param startDir - Directory to start searching from (defaults to cwd)
|
|
9
|
+
* @returns The absolute path to the workspace root
|
|
10
|
+
* @throws {HandledError} If workspace root cannot be found
|
|
11
|
+
*/
|
|
12
|
+
export async function findWorkspaceRoot(
|
|
13
|
+
startDir: string = process.cwd(),
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
try {
|
|
16
|
+
const result = await findRoot(startDir);
|
|
17
|
+
return result.rootDir;
|
|
18
|
+
} catch (error: unknown) {
|
|
19
|
+
throw new HandledError(
|
|
20
|
+
"Could not find workspace root. Make sure you're in a monorepo workspace.",
|
|
21
|
+
{cause: error},
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determine if a bin file should be invoked via the Node executable,
|
|
3
|
+
* based on its file extension (case-insensitive).
|
|
4
|
+
* Matches npm's behavior for .js, .mjs, and .cjs files.
|
|
5
|
+
*
|
|
6
|
+
* @param binPath - The path to the bin file
|
|
7
|
+
* @returns True if the file should be invoked via node
|
|
8
|
+
*/
|
|
9
|
+
export function isNodeExecutable(binPath: string): boolean {
|
|
10
|
+
const lower = binPath.toLowerCase();
|
|
11
|
+
return (
|
|
12
|
+
lower.endsWith(".js") ||
|
|
13
|
+
lower.endsWith(".mjs") ||
|
|
14
|
+
lower.endsWith(".cjs")
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type {PackageInfo} from "./discover-packages";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a bin script to the actual file path.
|
|
6
|
+
*
|
|
7
|
+
* This function ensures that the resolved bin path is within the package
|
|
8
|
+
* directory to prevent path traversal issues.
|
|
9
|
+
*
|
|
10
|
+
* @param pkg - The package information containing the path to the package
|
|
11
|
+
* @param bin - The bin entry from package.json, which can be a string or an
|
|
12
|
+
* object
|
|
13
|
+
* @param binName - The name of the bin as specified in package.json
|
|
14
|
+
* @returns The resolved absolute path to the bin script, or null if invalid
|
|
15
|
+
|
|
16
|
+
*/
|
|
17
|
+
export function resolveBinPath(
|
|
18
|
+
pkg: PackageInfo,
|
|
19
|
+
bin: string | undefined | null | Record<string, string>,
|
|
20
|
+
binName: string,
|
|
21
|
+
): string | null {
|
|
22
|
+
// bin can be a string or an object
|
|
23
|
+
const binPath: string | null = !bin
|
|
24
|
+
? null
|
|
25
|
+
: typeof bin === "string" && pkg.name === binName
|
|
26
|
+
? // If bin is a string, the bin name is the package name
|
|
27
|
+
bin
|
|
28
|
+
: // If bin is an object, check if it has the requested bin name
|
|
29
|
+
typeof bin === "object" && bin[binName]
|
|
30
|
+
? bin[binName]
|
|
31
|
+
: null;
|
|
32
|
+
|
|
33
|
+
if (!binPath) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const packageDir = path.resolve(pkg.path);
|
|
38
|
+
const resolvedBinPath = path.resolve(pkg.path, binPath);
|
|
39
|
+
// Ensure the bin path stays within the package directory
|
|
40
|
+
if (
|
|
41
|
+
resolvedBinPath !== packageDir &&
|
|
42
|
+
!resolvedBinPath.startsWith(packageDir + path.sep)
|
|
43
|
+
) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return resolvedBinPath;
|
|
48
|
+
}
|