@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.
@@ -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
- xImpl();
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
+ }