builderman 1.5.2 → 1.5.3

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.
@@ -2,7 +2,7 @@ import * as path from "node:path";
2
2
  import * as fs from "node:fs";
3
3
  import { $TASK_INTERNAL, $PIPELINE_INTERNAL } from "./constants.js";
4
4
  import { PipelineError } from "../errors.js";
5
- import { parseCommandLine } from "./util.js";
5
+ import { parseCommandLine, resolveExecutable } from "./util.js";
6
6
  /**
7
7
  * Executes a task (either a regular task or a nested pipeline).
8
8
  */
@@ -229,23 +229,45 @@ function executeRegularTask(task, taskId, taskName, context, callbacks) {
229
229
  callbacks.onTaskFailed(taskId, pipelineError);
230
230
  return;
231
231
  }
232
- const accumulatedPath = [
233
- path.join(taskCwd, "node_modules", ".bin"),
234
- path.join(process.cwd(), "node_modules", ".bin"),
235
- process.env.PATH,
236
- ]
237
- .filter(Boolean)
238
- .join(process.platform === "win32" ? ";" : ":");
239
232
  // Merge environment variables in order: process.env -> pipeline.env -> task.env -> command.env
240
233
  const accumulatedEnv = {
241
234
  ...process.env,
242
- PATH: accumulatedPath,
243
- Path: accumulatedPath,
235
+ // We'll compute PATH/Path below once we've built the accumulatedPath string
244
236
  ...config?.env,
245
237
  ...taskEnv,
246
238
  ...commandEnv,
247
239
  };
248
- const child = spawnFn(cmd, args, {
240
+ const accumulatedPath = [
241
+ path.join(taskCwd, "node_modules", ".bin"),
242
+ path.join(process.cwd(), "node_modules", ".bin"),
243
+ process.env.PATH,
244
+ ]
245
+ .filter(Boolean)
246
+ .join(process.platform === "win32" ? ";" : ":");
247
+ // Ensure PATH is set consistently (Windows is case-insensitive)
248
+ accumulatedEnv.PATH = accumulatedPath;
249
+ accumulatedEnv.Path = accumulatedPath;
250
+ let finalCmd;
251
+ let finalArgs;
252
+ if (process.platform === "win32" && !cmd.includes("\\") && !cmd.includes("/")) {
253
+ // On Windows, for bare commands like "pnpm" we delegate resolution to cmd.exe
254
+ // so that PATHEXT and other shell semantics are respected. This closely matches
255
+ // Node's spawn behavior when using shell: true, but keeps a consistent API.
256
+ finalCmd = process.env.ComSpec || "cmd.exe";
257
+ finalArgs = ["/d", "/s", "/c", commandString];
258
+ }
259
+ else {
260
+ // Resolve executable from PATH (needed when shell: false)
261
+ const { cmd: resolvedCmd, needsCmdWrapper } = resolveExecutable(cmd, accumulatedPath);
262
+ finalCmd = resolvedCmd;
263
+ finalArgs = args;
264
+ // On Windows, .cmd and .bat files need to be run via cmd.exe when shell: false
265
+ if (needsCmdWrapper) {
266
+ finalCmd = process.env.ComSpec || "cmd.exe";
267
+ finalArgs = ["/c", resolvedCmd, ...args];
268
+ }
269
+ }
270
+ const child = spawnFn(finalCmd, finalArgs, {
249
271
  cwd: taskCwd,
250
272
  stdio: ["inherit", "pipe", "pipe"],
251
273
  shell: false,
@@ -14,3 +14,19 @@ export declare function parseCommandLine(command: string): {
14
14
  cmd: string;
15
15
  args: string[];
16
16
  };
17
+ /**
18
+ * Resolves an executable command from PATH.
19
+ * When using spawn with shell: false, Node.js doesn't automatically resolve
20
+ * executables from PATH like a shell would. This function searches PATH
21
+ * directories to find the executable.
22
+ *
23
+ * On Windows, .cmd and .bat files need to be executed via cmd.exe when using shell: false.
24
+ *
25
+ * @param cmd - The command name to resolve (e.g., "pnpm", "node")
26
+ * @param pathEnv - The PATH environment variable value
27
+ * @returns An object with the resolved command and whether it needs cmd.exe wrapper on Windows
28
+ */
29
+ export declare function resolveExecutable(cmd: string, pathEnv: string | undefined): {
30
+ cmd: string;
31
+ needsCmdWrapper: boolean;
32
+ };
@@ -1,3 +1,5 @@
1
+ import * as path from "node:path";
2
+ import * as fs from "node:fs";
1
3
  import { $TASK_INTERNAL } from "./constants.js";
2
4
  export function validateTasks(tasks) {
3
5
  if (tasks?.some((dep) => !($TASK_INTERNAL in dep))) {
@@ -54,3 +56,50 @@ export function parseCommandLine(command) {
54
56
  const [cmd, ...args] = tokens;
55
57
  return { cmd, args };
56
58
  }
59
+ /**
60
+ * Resolves an executable command from PATH.
61
+ * When using spawn with shell: false, Node.js doesn't automatically resolve
62
+ * executables from PATH like a shell would. This function searches PATH
63
+ * directories to find the executable.
64
+ *
65
+ * On Windows, .cmd and .bat files need to be executed via cmd.exe when using shell: false.
66
+ *
67
+ * @param cmd - The command name to resolve (e.g., "pnpm", "node")
68
+ * @param pathEnv - The PATH environment variable value
69
+ * @returns An object with the resolved command and whether it needs cmd.exe wrapper on Windows
70
+ */
71
+ export function resolveExecutable(cmd, pathEnv) {
72
+ // If cmd is already an absolute path or contains path separators, return as-is
73
+ if (path.isAbsolute(cmd) || cmd.includes(path.sep)) {
74
+ return { cmd, needsCmdWrapper: false };
75
+ }
76
+ // If no PATH provided, return original cmd
77
+ if (!pathEnv) {
78
+ return { cmd, needsCmdWrapper: false };
79
+ }
80
+ const pathDirs = pathEnv.split(process.platform === "win32" ? ";" : ":");
81
+ // On Windows, check for .exe, .cmd, .bat extensions
82
+ const extensions = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
83
+ for (const dir of pathDirs) {
84
+ if (!dir)
85
+ continue;
86
+ for (const ext of extensions) {
87
+ const candidate = path.join(dir, `${cmd}${ext}`);
88
+ try {
89
+ // Check if file exists and is executable
90
+ // On Windows, we also need to check if it's a file (not a directory)
91
+ const stats = fs.statSync(candidate, { throwIfNoEntry: false });
92
+ if (stats && stats.isFile()) {
93
+ // On Windows, .cmd and .bat files need to be run via cmd.exe when shell: false
94
+ const needsCmdWrapper = process.platform === "win32" && (ext === ".cmd" || ext === ".bat");
95
+ return { cmd: candidate, needsCmdWrapper };
96
+ }
97
+ }
98
+ catch {
99
+ // Continue searching
100
+ }
101
+ }
102
+ }
103
+ // If not found, return original cmd (spawn will handle the error)
104
+ return { cmd, needsCmdWrapper: false };
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "builderman",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "Simple task runner for building and developing projects.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",