bun-workspaces 1.10.0 → 1.11.1

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.
Files changed (34) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/2392.mjs +10 -1
  4. package/src/5166.mjs +1 -0
  5. package/src/affected/gitAffectedFiles.mjs +26 -1
  6. package/src/ai/mcp/bwMcpServer.mjs +5 -1
  7. package/src/ai/mcp/serverState.mjs +10 -1
  8. package/src/ai/mcp/tools.mjs +1 -1
  9. package/src/cli/commands/commandHandlerUtils.mjs +11 -10
  10. package/src/cli/commands/commands.mjs +1 -1
  11. package/src/cli/commands/handleSimpleCommands.mjs +7 -6
  12. package/src/cli/commands/listAffected.mjs +17 -13
  13. package/src/cli/commands/mcp.mjs +11 -1
  14. package/src/cli/commands/runScript/output/renderGroupedOutput.mjs +3 -2
  15. package/src/cli/commands/runScript/output/renderPlainOutput.mjs +4 -1
  16. package/src/cli/commands/runScript/scriptRunFlow.mjs +8 -3
  17. package/src/cli/createCli.mjs +8 -2
  18. package/src/cli/globalOptions/globalOptions.mjs +5 -4
  19. package/src/cli/index.d.ts +35 -2
  20. package/src/config/rootConfig/loadRootConfig.mjs +2 -1
  21. package/src/config/userEnvVars/userEnvVars.mjs +12 -1
  22. package/src/config/util/loadConfig.mjs +23 -2
  23. package/src/config/workspaceConfig/loadWorkspaceConfig.mjs +2 -1
  24. package/src/index.d.ts +11 -0
  25. package/src/internal/core/language/string/index.mjs +1 -0
  26. package/src/internal/core/language/string/sanitizeOutput.mjs +15 -0
  27. package/src/internal/core/runtime/onExit.mjs +8 -1
  28. package/src/internal/core/runtime/tempFile.mjs +20 -2
  29. package/src/project/implementations/fileSystemProject/fileSystemProject.mjs +39 -14
  30. package/src/runScript/runScript.mjs +2 -2
  31. package/src/runScript/scriptExecution.mjs +1 -1
  32. package/src/runScript/subprocesses.mjs +55 -6
  33. package/src/runScript/workspaceScriptMetadata.mjs +24 -1
  34. package/src/workspaces/findWorkspaces.mjs +2 -0
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <a href="https://bunworkspaces.com">
2
- <img src="./workspaces/web/documentation-website/src/pages/public/images/png/bwunster-bg-banner-wide_3000x900.png" alt="bun-workspaces" width="100%" />
2
+ <img src="./workspaces/web/documentation-website/src/pages/public/images/png/bwunster-bg-rect-title-wide_3000x900.png" alt="bun-workspaces" width="100%" />
3
3
  </a>
4
4
 
5
5
  <br/>
@@ -8,6 +8,8 @@ Full Documentation: [https://bunworkspaces.com](https://bunworkspaces.com)
8
8
 
9
9
  Changelog: [GitHub Releases](https://github.com/bun-workspaces/bun-workspaces/releases)
10
10
 
11
+ Example Projects: [Repository](https://github.com/bun-workspaces/bun-workspaces-examples)
12
+
11
13
  # bun-workspaces
12
14
 
13
15
  A [monorepo](http://sonarsource.com/resources/library/monorepo/) tool that enhances native [Bun workspaces](https://bun.sh/docs/install/workspaces).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-workspaces",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "description": "A monorepo management tool for Bun, with a CLI and API to enhance Bun's native workspaces.",
5
5
  "license": "MIT",
6
6
  "exports": {
package/src/2392.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { OUTPUT_STYLE_VALUES, SCRIPT_SHELL_OPTIONS } from "./4427.mjs";
2
2
  import { LOG_LEVELS } from "./8126.mjs";
3
+ import { getUserEnvVarName } from "./5166.mjs";
3
4
 
4
5
  const JSON_FLAGS = ["-j", "--json"];
5
6
  const PRETTY_FLAGS = ["-p", "--pretty"];
@@ -142,7 +143,7 @@ const CLI_COMMANDS_CONFIG = {
142
143
  isGlobal: true,
143
144
  aliases: [],
144
145
  description:
145
- "Start the bun-workspaces MCP (Model Context Protocol) server over stdio",
146
+ "Start the bun-workspaces MCP (Model Context Protocol) server over stdio. Defaults to skipping executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}) for agent security. Pass the global --no-disable-executable-configs flag to allow them.",
146
147
  options: {},
147
148
  },
148
149
  listAffected: {
@@ -431,6 +432,14 @@ const CLI_GLOBAL_OPTIONS_CONFIG = {
431
432
  values: null,
432
433
  param: "",
433
434
  },
435
+ disableExecutableConfigs: {
436
+ mainOption: "--disable-executable-configs",
437
+ shortOption: "",
438
+ description: `Skip evaluating executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}). Only jsonc/json/package.json configs are read, for untrusted contexts. Can also be set via env var ${getUserEnvVarName("disableExecutableConfigsDefault")}=true.`,
439
+ defaultValue: "",
440
+ values: null,
441
+ param: "",
442
+ },
434
443
  };
435
444
  const getCliGlobalOptionConfig = (optionName) =>
436
445
  CLI_GLOBAL_OPTIONS_CONFIG[optionName];
package/src/5166.mjs CHANGED
@@ -3,6 +3,7 @@ const USER_ENV_VARS = {
3
3
  scriptShellDefault: "BW_SHELL_DEFAULT",
4
4
  includeRootWorkspaceDefault: "BW_INCLUDE_ROOT_WORKSPACE_DEFAULT",
5
5
  affectedBaseRefDefault: "BW_AFFECTED_BASE_REF_DEFAULT",
6
+ disableExecutableConfigsDefault: "BW_DISABLE_EXECUTABLE_CONFIGS_DEFAULT",
6
7
  };
7
8
  const getUserEnvVarName = (key) => USER_ENV_VARS[key];
8
9
 
@@ -3,7 +3,29 @@ import path from "path";
3
3
  import { defineErrors } from "../internal/core/index.mjs";
4
4
  import { createSubprocess } from "../runScript/subprocesses.mjs";
5
5
 
6
- const GIT_AFFECTED_ERRORS = defineErrors("NoGitRepository", "GitCommandFailed");
6
+ const GIT_AFFECTED_ERRORS = defineErrors(
7
+ "NoGitRepository",
8
+ "GitCommandFailed",
9
+ "InvalidGitRef",
10
+ );
11
+ /**
12
+ * Reject ref values that look like CLI options to avoid argument injection
13
+ * (e.g. a ref of `--upload-pack=...` being interpreted as a git option when
14
+ * passed as a positional argument). Git itself forbids refs starting with
15
+ * `-` via `git check-ref-format`, so this rejects only inputs that could
16
+ * never be a real ref.
17
+ */ const assertValidGitRef = (ref, label) => {
18
+ if (typeof ref !== "string" || ref.length === 0) {
19
+ throw new GIT_AFFECTED_ERRORS.InvalidGitRef(
20
+ `${label} must be a non-empty string`,
21
+ );
22
+ }
23
+ if (ref.startsWith("-")) {
24
+ throw new GIT_AFFECTED_ERRORS.InvalidGitRef(
25
+ `${label} cannot start with "-" (got ${JSON.stringify(ref)})`,
26
+ );
27
+ }
28
+ };
7
29
  const GIT_AFFECTED_FILE_REASONS = ["diff", "staged", "unstaged", "untracked"];
8
30
  const runGit = async (args, cwd) => {
9
31
  const proc = createSubprocess(["git", ...args], {
@@ -61,6 +83,7 @@ const resolveGitRoot = async (rootDirectory) => {
61
83
  ref,
62
84
  projectRelativePath,
63
85
  }) => {
86
+ assertValidGitRef(ref, "ref");
64
87
  const gitRoot = fs.realpathSync.native(
65
88
  path.resolve(await resolveGitRoot(rootDirectory)),
66
89
  );
@@ -106,6 +129,8 @@ const getGitAffectedFiles = async (options) => {
106
129
  ignoreUnstaged,
107
130
  ignoreUncommitted,
108
131
  } = options;
132
+ assertValidGitRef(baseRef, "baseRef");
133
+ assertValidGitRef(headRef, "headRef");
109
134
  const gitRoot = fs.realpathSync.native(
110
135
  path.resolve(await resolveGitRoot(rootDirectory)),
111
136
  );
@@ -1,7 +1,10 @@
1
1
  import package_0 from "../../../package.json";
2
2
  import { createMcpServer } from "./core/index.mjs";
3
3
  import { registerBwResources } from "./resources.mjs";
4
- import { setServerWorkingDirectory } from "./serverState.mjs";
4
+ import {
5
+ setServerEnableExecutableConfigs,
6
+ setServerWorkingDirectory,
7
+ } from "./serverState.mjs";
5
8
  import { registerBwTools } from "./tools.mjs";
6
9
 
7
10
  const SERVER_INSTRUCTIONS = `
@@ -32,6 +35,7 @@ $ bw run lint "alias:my-alias-*" "path:packages/**/*" "not:path:my-path/*" # use
32
35
  (end bun-workspaces MCP instructions)
33
36
  `.trim();
34
37
  const startBwMcpServer = async (options) => {
38
+ setServerEnableExecutableConfigs(options.enableExecutableConfigs ?? false);
35
39
  setServerWorkingDirectory(options.initialWorkingDirectory);
36
40
  const server = createMcpServer({
37
41
  name: "bun-workspaces",
@@ -2,19 +2,28 @@ import { createFileSystemProject } from "../../project/implementations/fileSyste
2
2
 
3
3
  const SERVER_STATE = {
4
4
  workingDirectory: null,
5
+ enableExecutableConfigs: false,
5
6
  };
6
7
  const setServerWorkingDirectory = (directory) => {
7
8
  SERVER_STATE.workingDirectory = directory;
8
9
  };
10
+ const setServerEnableExecutableConfigs = (enabled) => {
11
+ SERVER_STATE.enableExecutableConfigs = enabled;
12
+ };
9
13
  const getServerProject = () => {
10
14
  if (!SERVER_STATE.workingDirectory) return null;
11
15
  try {
12
16
  return createFileSystemProject({
13
17
  rootDirectory: SERVER_STATE.workingDirectory,
18
+ disableExecutableConfigs: !SERVER_STATE.enableExecutableConfigs,
14
19
  });
15
20
  } catch {
16
21
  return null;
17
22
  }
18
23
  };
19
24
 
20
- export { getServerProject, setServerWorkingDirectory };
25
+ export {
26
+ getServerProject,
27
+ setServerEnableExecutableConfigs,
28
+ setServerWorkingDirectory,
29
+ };
@@ -202,7 +202,7 @@ const registerBwTools = (server) => {
202
202
  {
203
203
  name: "set_working_directory",
204
204
  description:
205
- "Set the working directory used by this MCP server. All subsequent project queries will reflect the new directory.",
205
+ "Set the working directory used by this MCP server. All subsequent project queries will reflect the new directory. By default the server skips executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}) in the target directory so that pointing the server at an unfamiliar project does not evaluate its code. Only bw.root.{jsonc,json}, bw.workspace.{jsonc,json}, and the package.json bw key are read. Start the server with --no-disable-executable-configs to allow executable configs.",
206
206
  inputSchema: {
207
207
  type: "object",
208
208
  properties: {
@@ -1,4 +1,5 @@
1
1
  import { Option } from "../../internal/bundledDeps/commander.mjs";
2
+ import { sanitizeOutput } from "../../internal/core/index.mjs";
2
3
  import { BunWorkspacesError } from "../../internal/core/error/index.mjs";
3
4
  import { createLogger, logger } from "../../internal/logger/index.mjs";
4
5
  import { getCliCommandConfig } from "../../2392.mjs";
@@ -15,18 +16,18 @@ import { getCliCommandConfig } from "../../2392.mjs";
15
16
  .filter(Boolean)
16
17
  .map((value) => value.replace(/\\\s/g, " "));
17
18
  const createWorkspaceInfoLines = (workspace) => [
18
- `Workspace: ${workspace.name}${workspace.isRoot ? " (root)" : ""}`,
19
- ` - Aliases: ${workspace.aliases.join(", ")}`,
20
- ` - Path: ${workspace.path}`,
21
- ` - Glob Match: ${workspace.matchPattern}`,
22
- ` - Scripts: ${workspace.scripts.join(", ")}`,
23
- ` - Tags: ${workspace.tags.join(", ")}`,
24
- ` - Dependencies: ${workspace.dependencies.join(", ")}`,
25
- ` - Dependents: ${workspace.dependents.join(", ")}`,
19
+ `Workspace: ${sanitizeOutput(workspace.name)}${workspace.isRoot ? " (root)" : ""}`,
20
+ ` - Aliases: ${workspace.aliases.map(sanitizeOutput).join(", ")}`,
21
+ ` - Path: ${sanitizeOutput(workspace.path)}`,
22
+ ` - Glob Match: ${sanitizeOutput(workspace.matchPattern)}`,
23
+ ` - Scripts: ${workspace.scripts.map(sanitizeOutput).join(", ")}`,
24
+ ` - Tags: ${workspace.tags.map(sanitizeOutput).join(", ")}`,
25
+ ` - Dependencies: ${workspace.dependencies.map(sanitizeOutput).join(", ")}`,
26
+ ` - Dependents: ${workspace.dependents.map(sanitizeOutput).join(", ")}`,
26
27
  ];
27
28
  const createScriptInfoLines = (script, workspaces) => [
28
- `Script: ${script}`,
29
- ...workspaces.map((workspace) => ` - ${workspace.name}`),
29
+ `Script: ${sanitizeOutput(script)}`,
30
+ ...workspaces.map((workspace) => ` - ${sanitizeOutput(workspace.name)}`),
30
31
  ];
31
32
  const createJsonLines = (data, options) =>
32
33
  JSON.stringify(data, null, options.pretty ? 2 : undefined).split("\n");
@@ -21,10 +21,10 @@ const defineProjectCommands = (context) => {
21
21
  scriptInfo(context);
22
22
  listTags(context);
23
23
  tagInfo(context);
24
- mcpServer(context);
25
24
  runScript(context);
26
25
  listAffected(context);
27
26
  runAffected(context);
27
+ mcpServer(context);
28
28
  };
29
29
 
30
30
  export { defineGlobalCommands, defineProjectCommands };
@@ -1,4 +1,5 @@
1
1
  import { getDoctorInfo } from "../../doctor/index.mjs";
2
+ import { sanitizeOutput } from "../../internal/core/index.mjs";
2
3
  import { logger } from "../../internal/logger/index.mjs";
3
4
  import {
4
5
  commandOutputLogger,
@@ -65,7 +66,7 @@ const listWorkspaces = handleProjectCommand(
65
66
  } else {
66
67
  workspaces.forEach((workspace) => {
67
68
  if (options.nameOnly) {
68
- lines.push(workspace.name);
69
+ lines.push(sanitizeOutput(workspace.name));
69
70
  } else {
70
71
  lines.push(...createWorkspaceInfoLines(workspace));
71
72
  }
@@ -108,7 +109,7 @@ const listScripts = handleProjectCommand(
108
109
  .sort(({ name: nameA }, { name: nameB }) => nameA.localeCompare(nameB))
109
110
  .forEach(({ name, workspaces }) => {
110
111
  if (options.nameOnly) {
111
- lines.push(name);
112
+ lines.push(sanitizeOutput(name));
112
113
  } else {
113
114
  lines.push(...createScriptInfoLines(name, workspaces));
114
115
  }
@@ -161,7 +162,7 @@ const scriptInfo = handleProjectCommand(
161
162
  options,
162
163
  )
163
164
  : options.workspacesOnly
164
- ? scriptMetadata.workspaces.map(({ name }) => name)
165
+ ? scriptMetadata.workspaces.map(({ name }) => sanitizeOutput(name))
165
166
  : createScriptInfoLines(script, scriptMetadata.workspaces)
166
167
  ).join("\n"),
167
168
  );
@@ -189,10 +190,10 @@ const listTags = handleProjectCommand("listTags", ({ project }, options) => {
189
190
  }
190
191
  tags.forEach(({ tag, workspaces }) => {
191
192
  if (options.nameOnly) {
192
- lines.push(tag);
193
+ lines.push(sanitizeOutput(tag));
193
194
  } else {
194
195
  lines.push(
195
- `Tag: ${tag}\n${workspaces.map((name) => ` - ${name}`).join("\n")}`,
196
+ `Tag: ${sanitizeOutput(tag)}\n${workspaces.map((name) => ` - ${sanitizeOutput(name)}`).join("\n")}`,
196
197
  );
197
198
  }
198
199
  });
@@ -217,7 +218,7 @@ const handleSimpleCommands_tagInfo = handleProjectCommand(
217
218
  commandOutputLogger.info(
218
219
  options.json
219
220
  ? createJsonLines(tagInfo, options).join("\n")
220
- : `Tag: ${tagInfo.name}\n${tagInfo.workspaces.map((name) => ` - ${name}`).join("\n")}`,
221
+ : `Tag: ${sanitizeOutput(tagInfo.name)}\n${tagInfo.workspaces.map((name) => ` - ${sanitizeOutput(name)}`).join("\n")}`,
221
222
  );
222
223
  },
223
224
  );
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import { sanitizeOutput } from "../../internal/core/index.mjs";
2
3
  import { logger } from "../../internal/logger/index.mjs";
3
4
  import {
4
5
  commandOutputLogger,
@@ -19,34 +20,35 @@ const formatGitHeader = (metadata) => {
19
20
  };
20
21
  const formatDependencyChain = (dependency) => {
21
22
  const segments = dependency.chain.map((entry, index) => {
22
- if (index === 0 || !entry.edgeSource) return entry.workspaceName;
23
- return `\x1b[90m--[${entry.edgeSource}]->\x1b[0m ${entry.workspaceName}`;
23
+ const safeName = sanitizeOutput(entry.workspaceName);
24
+ if (index === 0 || !entry.edgeSource) return safeName;
25
+ return `\x1b[90m--[${entry.edgeSource}]->\x1b[0m ${safeName}`;
24
26
  });
25
27
  return segments.join(" ");
26
28
  };
27
29
  const formatSourceMarker = (change) =>
28
30
  change.source === "devDependencies" ? " (dev)" : "";
29
31
  const formatExternalDepEntryShort = (change) =>
30
- `${change.name}${formatSourceMarker(change)}`;
32
+ `${sanitizeOutput(change.name)}${formatSourceMarker(change)}`;
31
33
  const formatExternalDepEntryDetailed = (change) => {
32
34
  const versions =
33
35
  change.baseVersion === null && change.headVersion === null
34
36
  ? "lockfile changed; precise diff unavailable"
35
- : `${change.baseVersion ?? "(absent)"} -> ${change.headVersion ?? "(absent)"}`;
36
- return `${change.name}${formatSourceMarker(change)} \x1b[90m[${versions}]\x1b[0m`;
37
+ : `${sanitizeOutput(change.baseVersion ?? "(absent)")} -> ${sanitizeOutput(change.headVersion ?? "(absent)")}`;
38
+ return `${sanitizeOutput(change.name)}${formatSourceMarker(change)} \x1b[90m[${versions}]\x1b[0m`;
37
39
  };
38
40
  const createWorkspaceSummaryLines = (result) => {
39
41
  const { workspace, affectedReasons } = result;
40
42
  const lines = [
41
- `\x1b[1mWorkspace: ${workspace.name}\x1b[0m`,
42
- `Path: ${workspace.path}`,
43
+ `\x1b[1mWorkspace: ${sanitizeOutput(workspace.name)}\x1b[0m`,
44
+ `Path: ${sanitizeOutput(workspace.path)}`,
43
45
  ];
44
46
  lines.push(
45
47
  `\x1b[96mChanged input files:\x1b[0m ${affectedReasons.changedFiles.length}`,
46
48
  );
47
49
  if (affectedReasons.dependencies.length) {
48
50
  lines.push(
49
- `\x1b[96mAffected dependencies:\x1b[0m ${affectedReasons.dependencies.map(({ dependencyName }) => dependencyName).join(", ")}`,
51
+ `\x1b[96mAffected dependencies:\x1b[0m ${affectedReasons.dependencies.map(({ dependencyName }) => sanitizeOutput(dependencyName)).join(", ")}`,
50
52
  );
51
53
  } else {
52
54
  lines.push(`\x1b[96mAffected dependencies:\x1b[0m (none)`);
@@ -63,8 +65,8 @@ const createWorkspaceSummaryLines = (result) => {
63
65
  const createWorkspaceDetailedLines = (result) => {
64
66
  const { workspace, affectedReasons } = result;
65
67
  const lines = [
66
- `\x1b[1mWorkspace: ${workspace.name}\x1b[0m`,
67
- `Path: ${workspace.path}`,
68
+ `\x1b[1mWorkspace: ${sanitizeOutput(workspace.name)}\x1b[0m`,
69
+ `Path: ${sanitizeOutput(workspace.path)}`,
68
70
  ];
69
71
  if (affectedReasons.changedFiles.length) {
70
72
  lines.push("\x1b[96mChanged input files:\x1b[0m");
@@ -73,7 +75,7 @@ const createWorkspaceDetailedLines = (result) => {
73
75
  ?.filter((reason) => reason !== "diff")
74
76
  .join(", ");
75
77
  lines.push(
76
- ` - ${path.relative(workspace.path, file.projectFilePath)} \x1b[90m(input: ${JSON.stringify(file.inputMatch)})${reasons ? ` [${reasons}]` : ""}\x1b[0m`,
78
+ ` - ${sanitizeOutput(path.relative(workspace.path, file.projectFilePath))} \x1b[90m(input: ${JSON.stringify(file.inputMatch)})${reasons ? ` [${reasons}]` : ""}\x1b[0m`,
77
79
  );
78
80
  }
79
81
  } else {
@@ -82,7 +84,7 @@ const createWorkspaceDetailedLines = (result) => {
82
84
  if (affectedReasons.dependencies.length) {
83
85
  lines.push("\x1b[96mAffected dependencies:\x1b[0m");
84
86
  for (const dependency of affectedReasons.dependencies) {
85
- lines.push(` - ${dependency.dependencyName}`);
87
+ lines.push(` - ${sanitizeOutput(dependency.dependencyName)}`);
86
88
  lines.push(` chain: ${formatDependencyChain(dependency)}`);
87
89
  }
88
90
  } else {
@@ -153,7 +155,9 @@ const listAffected = handleProjectCommand(
153
155
  if (!options.explain) {
154
156
  if (affectedResults.length) {
155
157
  commandOutputLogger.info(
156
- affectedResults.map(({ workspace }) => workspace.name).join("\n"),
158
+ affectedResults
159
+ .map(({ workspace }) => sanitizeOutput(workspace.name))
160
+ .join("\n"),
157
161
  );
158
162
  } else {
159
163
  logger.info("No affected workspaces");
@@ -1,13 +1,23 @@
1
1
  import { startBwMcpServer } from "../../ai/mcp/index.mjs";
2
+ import { getUserBoolEnvVar } from "../../config/userEnvVars/index.mjs";
2
3
  import { logger } from "../../internal/logger/index.mjs";
3
4
  import { handleGlobalCommand } from "./commandHandlerUtils.mjs";
4
5
 
5
6
  const mcpServer = handleGlobalCommand(
6
7
  "mcpServer",
7
- async ({ workingDirectory }) => {
8
+ async ({ workingDirectory, disableExecutableConfigs }) => {
8
9
  logger.printLevel = "silent";
10
+ // mcp-server is the only command that defaults to *disabled* when
11
+ // neither the global flag nor the env var is set, because the server
12
+ // can be redirected to arbitrary directories at runtime via the
13
+ // set_working_directory tool. Precedence: CLI flag > env var > true.
14
+ const effectiveDisable =
15
+ disableExecutableConfigs ??
16
+ getUserBoolEnvVar("disableExecutableConfigsDefault") ??
17
+ true;
9
18
  await startBwMcpServer({
10
19
  initialWorkingDirectory: workingDirectory,
20
+ enableExecutableConfigs: !effectiveDisable,
11
21
  });
12
22
  },
13
23
  );
@@ -1,4 +1,4 @@
1
- import { runOnExit } from "../../../../internal/core/index.mjs";
1
+ import { runOnExit, sanitizeOutput } from "../../../../internal/core/index.mjs";
2
2
  import {
3
3
  TypedEventTarget,
4
4
  createTypedEventFactory,
@@ -158,7 +158,8 @@ const renderGroupedOutput = async (
158
158
  statusText += ` (signal: ${state.signal})`;
159
159
  }
160
160
  const workspaceLine =
161
- textOps[BORDER_COLOR]("Workspace: ") + textOps.bold(workspace.name);
161
+ textOps[BORDER_COLOR]("Workspace: ") +
162
+ textOps.bold(sanitizeOutput(workspace.name));
162
163
  const statusLine =
163
164
  textOps[BORDER_COLOR](" Status: ") +
164
165
  textOps[STATUS_COLORS[state.status]](statusText);
@@ -1,3 +1,4 @@
1
+ import { sanitizeOutput } from "../../../../internal/core/index.mjs";
1
2
  import { sanitizeChunk } from "./sanitizeChunk.mjs";
2
3
 
3
4
  async function* generatePlainOutputLines(
@@ -6,7 +7,9 @@ async function* generatePlainOutputLines(
6
7
  ) {
7
8
  const workspaceLineBuffers = {};
8
9
  const formatLine = (line, workspaceName) => {
9
- const prefixedLine = prefix ? `[${workspaceName}] ${line}` : line;
10
+ const prefixedLine = prefix
11
+ ? `[${sanitizeOutput(workspaceName)}] ${line}`
12
+ : line;
10
13
  return `\x1b[0m${prefixedLine}`;
11
14
  };
12
15
  for await (const { metadata, chunk } of output.text()) {
@@ -1,6 +1,9 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { expandHomePath } from "../../../internal/core/index.mjs";
3
+ import {
4
+ expandHomePath,
5
+ sanitizeOutput,
6
+ } from "../../../internal/core/index.mjs";
4
7
  import { logger } from "../../../internal/logger/index.mjs";
5
8
  import {
6
9
  getDefaultOutputStyle,
@@ -130,13 +133,15 @@ const handleScriptRunFlow = async ({
130
133
  exitResults.scriptResults.forEach(
131
134
  ({ success, metadata: { workspace }, exitCode }) => {
132
135
  const isSkipped = exitCode === -1;
136
+ const safeWorkspaceName = sanitizeOutput(workspace.name);
137
+ const safeScriptName = sanitizeOutput(scriptName ?? "");
133
138
  if (isSkipped) {
134
139
  logger.info(
135
- `➖ ${workspace.name}: ${scriptName} (skipped due to dependency failure)`,
140
+ `➖ ${safeWorkspaceName}: ${safeScriptName} (skipped due to dependency failure)`,
136
141
  );
137
142
  } else {
138
143
  logger.info(
139
- `${success ? "✅" : "❌"} ${workspace.name}: ${scriptName}${exitCode ? ` (exited with code ${exitCode})` : ""}`,
144
+ `${success ? "✅" : "❌"} ${safeWorkspaceName}: ${safeScriptName}${exitCode ? ` (exited with code ${exitCode})` : ""}`,
140
145
  );
141
146
  }
142
147
  },
@@ -82,8 +82,12 @@ const createCli = ({ defaultCwd = process.cwd(), defaultMiddleware } = {}) => {
82
82
  process.exit(1);
83
83
  return;
84
84
  }
85
- const { project, projectError, workingDirectory } =
86
- initializeWithGlobalOptions(program, args, middleware);
85
+ const {
86
+ project,
87
+ projectError,
88
+ workingDirectory,
89
+ disableExecutableConfigs,
90
+ } = initializeWithGlobalOptions(program, args, middleware);
87
91
  middleware.findProject({
88
92
  ...defaultContext,
89
93
  project,
@@ -103,6 +107,7 @@ const createCli = ({ defaultCwd = process.cwd(), defaultMiddleware } = {}) => {
103
107
  terminalWidth,
104
108
  terminalHeight,
105
109
  workingDirectory,
110
+ disableExecutableConfigs,
106
111
  });
107
112
  defineGlobalCommands({
108
113
  program,
@@ -112,6 +117,7 @@ const createCli = ({ defaultCwd = process.cwd(), defaultMiddleware } = {}) => {
112
117
  terminalWidth,
113
118
  terminalHeight,
114
119
  workingDirectory,
120
+ disableExecutableConfigs,
115
121
  });
116
122
  logger.debug(`Commands initialized. Parsing args...`);
117
123
  middleware.preParse({
@@ -18,10 +18,8 @@ const ERRORS = defineErrors(
18
18
  const addGlobalOption = (program, optionName, defaultOverride) => {
19
19
  const { mainOption, shortOption, description, param, values, defaultValue } =
20
20
  getCliGlobalOptionConfig(optionName);
21
- let option = new Option(
22
- `${shortOption} ${mainOption}${param ? ` <${param}>` : ""}`,
23
- description,
24
- );
21
+ const flagsString = `${shortOption ? `${shortOption} ` : ""}${mainOption}${param ? ` <${param}>` : ""}`;
22
+ let option = new Option(flagsString, description);
25
23
  const effectiveDefaultValue = defaultOverride ?? defaultValue;
26
24
  if (effectiveDefaultValue) {
27
25
  option = option.default(effectiveDefaultValue);
@@ -106,6 +104,7 @@ const defineGlobalOptions = (program, args, middleware) => {
106
104
  }
107
105
  addGlobalOption(program, "logLevel");
108
106
  addGlobalOption(program, "includeRoot");
107
+ addGlobalOption(program, "disableExecutableConfigs");
109
108
  return {
110
109
  cwd,
111
110
  };
@@ -119,6 +118,7 @@ const applyGlobalOptions = (options) => {
119
118
  project = createFileSystemProject({
120
119
  rootDirectory: options.cwd,
121
120
  includeRootWorkspace: options.includeRoot,
121
+ disableExecutableConfigs: options.disableExecutableConfigs,
122
122
  });
123
123
  logger.debug(
124
124
  `Project: ${JSON.stringify(project.name)} (${project.workspaces.length} workspace${project.workspaces.length === 1 ? "" : "s"})`,
@@ -134,6 +134,7 @@ const applyGlobalOptions = (options) => {
134
134
  project,
135
135
  projectError: error,
136
136
  workingDirectory: options.cwd,
137
+ disableExecutableConfigs: options.disableExecutableConfigs,
137
138
  };
138
139
  };
139
140
  const initializeWithGlobalOptions = (program, args, middleware) => {
@@ -553,7 +553,7 @@ export declare const CLI_COMMANDS_CONFIG: {
553
553
  readonly command: "mcp-server";
554
554
  readonly isGlobal: true;
555
555
  readonly aliases: [];
556
- readonly description: "Start the bun-workspaces MCP (Model Context Protocol) server over stdio";
556
+ readonly description: "Start the bun-workspaces MCP (Model Context Protocol) server over stdio. Defaults to skipping executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}) for agent security. Pass the global --no-disable-executable-configs flag to allow them.";
557
557
  readonly options: {};
558
558
  };
559
559
  readonly listAffected: {
@@ -916,7 +916,7 @@ export declare const getCliCommandConfig: (commandName: CliCommandName) =>
916
916
  readonly command: "mcp-server";
917
917
  readonly isGlobal: true;
918
918
  readonly aliases: [];
919
- readonly description: "Start the bun-workspaces MCP (Model Context Protocol) server over stdio";
919
+ readonly description: "Start the bun-workspaces MCP (Model Context Protocol) server over stdio. Defaults to skipping executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}) for agent security. Pass the global --no-disable-executable-configs flag to allow them.";
920
920
  readonly options: {};
921
921
  }
922
922
  | {
@@ -1150,6 +1150,7 @@ export interface CliGlobalOptions {
1150
1150
  cwd: string;
1151
1151
  includeRoot: boolean;
1152
1152
  workspaceRoot: boolean;
1153
+ disableExecutableConfigs: boolean;
1153
1154
  }
1154
1155
  export interface CliGlobalOptionConfig {
1155
1156
  mainOption: string;
@@ -1194,6 +1195,19 @@ export declare const getCliGlobalOptionConfig: (
1194
1195
  readonly defaultValue: "";
1195
1196
  readonly values: null;
1196
1197
  readonly param: "";
1198
+ }
1199
+ | {
1200
+ readonly mainOption: "--disable-executable-configs";
1201
+ readonly shortOption: "";
1202
+ readonly description:
1203
+ | "Skip evaluating executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}). Only jsonc/json/package.json configs are read, for untrusted contexts. Can also be set via env var BW_PARALLEL_MAX_DEFAULT=true."
1204
+ | "Skip evaluating executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}). Only jsonc/json/package.json configs are read, for untrusted contexts. Can also be set via env var BW_SHELL_DEFAULT=true."
1205
+ | "Skip evaluating executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}). Only jsonc/json/package.json configs are read, for untrusted contexts. Can also be set via env var BW_INCLUDE_ROOT_WORKSPACE_DEFAULT=true."
1206
+ | "Skip evaluating executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}). Only jsonc/json/package.json configs are read, for untrusted contexts. Can also be set via env var BW_AFFECTED_BASE_REF_DEFAULT=true."
1207
+ | "Skip evaluating executable config files (bw.root.{ts,js}, bw.workspace.{ts,js}). Only jsonc/json/package.json configs are read, for untrusted contexts. Can also be set via env var BW_DISABLE_EXECUTABLE_CONFIGS_DEFAULT=true.";
1208
+ readonly defaultValue: "";
1209
+ readonly values: null;
1210
+ readonly param: "";
1197
1211
  };
1198
1212
  export declare const getCliGlobalOptionNames: () => CliGlobalOptionName[];
1199
1213
  /**
@@ -1375,6 +1389,17 @@ export type CreateFileSystemProjectOptions = {
1375
1389
  name?: string;
1376
1390
  /** Whether to include the root workspace as a normal workspace. This overrides any config or env var settings. */
1377
1391
  includeRootWorkspace?: boolean;
1392
+ /**
1393
+ * When true, skip discovery of `.ts`/`.js` config files (`bw.root.{ts,js}`,
1394
+ * `bw.workspace.{ts,js}`) so no executable code is loaded from the project,
1395
+ * for untrusted contexts.
1396
+ *
1397
+ * `.jsonc`/`.json` configs and the `package.json` `bw` key still resolve.
1398
+ *
1399
+ * When omitted, the `BW_DISABLE_EXECUTABLE_CONFIGS_DEFAULT` user env var is
1400
+ * consulted (`"true"` or `"false"`). If neither is set, defaults to false.
1401
+ */
1402
+ disableExecutableConfigs?: boolean;
1378
1403
  };
1379
1404
  export type InlineScriptOptions = {
1380
1405
  /** A name to act as a label for the inline script */
@@ -1525,6 +1550,13 @@ export type GlobalCommandContext = {
1525
1550
  terminalWidth: number;
1526
1551
  terminalHeight: number;
1527
1552
  workingDirectory: string;
1553
+ /**
1554
+ * Value of the global `--disable-executable-configs` flag.
1555
+ * `undefined` means the flag was not passed; commands are free to
1556
+ * apply their own default (notably the mcp-server command defaults
1557
+ * to disabled for security).
1558
+ */
1559
+ disableExecutableConfigs: boolean | undefined;
1528
1560
  };
1529
1561
  export type ProjectCommandContext = GlobalCommandContext & {
1530
1562
  project: FileSystemProject;
@@ -1697,6 +1729,7 @@ export declare const initializeWithGlobalOptions: (
1697
1729
  }>;
1698
1730
  projectError: Error | null;
1699
1731
  workingDirectory: string;
1732
+ disableExecutableConfigs: boolean;
1700
1733
  };
1701
1734
 
1702
1735
  export {};
@@ -5,13 +5,14 @@ import {
5
5
  ROOT_CONFIG_PACKAGE_JSON_KEY,
6
6
  } from "../../8529.mjs";
7
7
 
8
- const loadRootConfig = (rootDirectory) => {
8
+ const loadRootConfig = (rootDirectory, loadOptions = {}) => {
9
9
  const config = loadConfig(
10
10
  "root",
11
11
  rootDirectory,
12
12
  ROOT_CONFIG_FILE_NAME,
13
13
  ROOT_CONFIG_PACKAGE_JSON_KEY,
14
14
  (content) => resolveRootConfig(content),
15
+ loadOptions,
15
16
  );
16
17
  return config ?? createDefaultRootConfig();
17
18
  };
@@ -1,5 +1,16 @@
1
1
  import { USER_ENV_VARS } from "../../5166.mjs";
2
2
 
3
3
  const getUserEnvVar = (key) => process.env[USER_ENV_VARS[key]];
4
+ /**
5
+ * Parse a user env var into a strict boolean tri-state. Returns `true` for
6
+ * "true", `false` for "false", and `undefined` for any other value (including
7
+ * unset) — so callers can distinguish "user explicitly set false" from
8
+ * "user did not set anything."
9
+ */ const getUserBoolEnvVar = (key) => {
10
+ const value = getUserEnvVar(key);
11
+ if (value === "true") return true;
12
+ if (value === "false") return false;
13
+ return undefined;
14
+ };
4
15
 
5
- export { getUserEnvVar };
16
+ export { getUserBoolEnvVar, getUserEnvVar };
@@ -111,9 +111,23 @@ const LOCATION_FINDERS = {
111
111
  return null;
112
112
  },
113
113
  };
114
- const getConfigLocation = (name, directory, fileName, packageJsonKey) => {
114
+ const isExecutableLocationType = (locationType) =>
115
+ locationType === "tsFile" || locationType === "jsFile";
116
+ const getConfigLocation = (
117
+ name,
118
+ directory,
119
+ fileName,
120
+ packageJsonKey,
121
+ loadOptions = {},
122
+ ) => {
115
123
  const locations = [];
116
124
  for (const locationType of CONFIG_LOCATION_TYPES) {
125
+ if (
126
+ loadOptions.disableExecutableConfigs &&
127
+ isExecutableLocationType(locationType)
128
+ ) {
129
+ continue;
130
+ }
117
131
  const location = LOCATION_FINDERS[locationType](
118
132
  directory,
119
133
  fileName,
@@ -136,8 +150,15 @@ const loadConfig = (
136
150
  fileName,
137
151
  packageJsonKey,
138
152
  processContent,
153
+ loadOptions = {},
139
154
  ) => {
140
- const location = getConfigLocation(name, directory, fileName, packageJsonKey);
155
+ const location = getConfigLocation(
156
+ name,
157
+ directory,
158
+ fileName,
159
+ packageJsonKey,
160
+ loadOptions,
161
+ );
141
162
  if (!location) {
142
163
  return null;
143
164
  }
@@ -5,13 +5,14 @@ import {
5
5
  } from "./workspaceConfig.mjs";
6
6
  import { WORKSPACE_CONFIG_FILE_NAME } from "../../8529.mjs";
7
7
 
8
- const loadWorkspaceConfig = (workspacePath) => {
8
+ const loadWorkspaceConfig = (workspacePath, loadOptions = {}) => {
9
9
  const config = loadConfig(
10
10
  "workspace",
11
11
  workspacePath,
12
12
  WORKSPACE_CONFIG_FILE_NAME,
13
13
  /* inlined export .WORKSPACE_CONFIG_PACKAGE_JSON_KEY */ "bw",
14
14
  (content) => resolveWorkspaceConfig(content),
15
+ loadOptions,
15
16
  );
16
17
  return config ?? createDefaultWorkspaceConfig();
17
18
  };
package/src/index.d.ts CHANGED
@@ -600,6 +600,17 @@ export type CreateFileSystemProjectOptions = {
600
600
  name?: string;
601
601
  /** Whether to include the root workspace as a normal workspace. This overrides any config or env var settings. */
602
602
  includeRootWorkspace?: boolean;
603
+ /**
604
+ * When true, skip discovery of `.ts`/`.js` config files (`bw.root.{ts,js}`,
605
+ * `bw.workspace.{ts,js}`) so no executable code is loaded from the project,
606
+ * for untrusted contexts.
607
+ *
608
+ * `.jsonc`/`.json` configs and the `package.json` `bw` key still resolve.
609
+ *
610
+ * When omitted, the `BW_DISABLE_EXECUTABLE_CONFIGS_DEFAULT` user env var is
611
+ * consulted (`"true"` or `"false"`). If neither is set, defaults to false.
612
+ */
613
+ disableExecutableConfigs?: boolean;
603
614
  };
604
615
  export type InlineScriptOptions = {
605
616
  /** A name to act as a label for the inline script */
@@ -1,3 +1,4 @@
1
1
  export * from "./id.mjs";
2
+ export * from "./sanitizeOutput.mjs";
2
3
 
3
4
  export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Strip ANSI escape sequences and other terminal-disruptive control
3
+ * characters from a string before rendering it. Use this for values
4
+ * sourced from package.json, config files, or other untrusted inputs to
5
+ * prevent terminal-escape injection in bw's CLI output (e.g., a workspace
6
+ * name containing `\x1b[2J` clearing the user's screen during `bw info`).
7
+ *
8
+ * Preserves `\n` and `\t`. Strips ANSI sequences plus C0/C1 controls
9
+ * other than newline and tab.
10
+ */ // eslint-disable-next-line no-control-regex
11
+ const DISRUPTIVE_CONTROLS_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g;
12
+ const sanitizeOutput = (value) =>
13
+ Bun.stripANSI(value).replace(DISRUPTIVE_CONTROLS_REGEX, "");
14
+
15
+ export { sanitizeOutput };
@@ -33,7 +33,14 @@ const registerListeners = () => {
33
33
  const handleSignal = () => {
34
34
  runAllHandlers(signal);
35
35
  process.off(signal, handleSignal);
36
- process.kill(0, signal);
36
+ // Re-raise the signal on ourselves so we exit with the conventional
37
+ // 128 + signum code via the signal's default action. Descendant
38
+ // cleanup is handled per-child by the subprocess registry (see
39
+ // src/runScript/subprocesses.ts), which kills each tracked child's
40
+ // process group individually. Broadcasting here via `kill(0, signal)`
41
+ // would also signal anyone sharing our pgid (e.g. a vitest worker
42
+ // that imported this code), which can deadlock the host runner.
43
+ process.kill(process.pid, signal);
37
44
  };
38
45
  process.on(signal, handleSignal);
39
46
  }
@@ -7,7 +7,21 @@ import { BUN_WORKSPACES_VERSION } from "../../version.mjs";
7
7
  import { createShortId } from "../language/string/id.mjs";
8
8
  import { runOnExit } from "./onExit.mjs";
9
9
 
10
- const getTempBasePackageDir = () => path.join(os.tmpdir(), "bun-workspaces");
10
+ /**
11
+ * Per-user suffix on the temp base dir prevents a different local user from
12
+ * pre-creating or symlink-squatting `/tmp/bun-workspaces` to steer our writes.
13
+ * On platforms without a numeric uid (Windows), `os.tmpdir()` is already
14
+ * per-user so the suffix becomes inert.
15
+ */ const getUserSuffix = () => {
16
+ try {
17
+ const { uid } = os.userInfo();
18
+ return uid >= 0 ? `-${uid}` : "";
19
+ } catch {
20
+ return "";
21
+ }
22
+ };
23
+ const getTempBasePackageDir = () =>
24
+ path.join(os.tmpdir(), `bun-workspaces${getUserSuffix()}`);
11
25
  const getTempParentDir = () =>
12
26
  path.join(getTempBasePackageDir(), BUN_WORKSPACES_VERSION);
13
27
  class TempDir {
@@ -18,10 +32,14 @@ class TempDir {
18
32
  }
19
33
  initialize(clean = false) {
20
34
  if (fs.existsSync(this.dir)) return;
35
+ // Pass mode at creation time so the dir is never briefly readable by
36
+ // other local users between mkdir and a subsequent chmod (closes the
37
+ // TOCTOU window where another process could enter the dir before the
38
+ // mode is tightened).
21
39
  fs.mkdirSync(this.dir, {
22
40
  recursive: true,
41
+ mode: 448,
23
42
  });
24
- fs.chmodSync(this.dir, 448);
25
43
  if (clean) {
26
44
  for (const dir of fs.readdirSync(path.resolve(getTempBasePackageDir()))) {
27
45
  if (dir !== BUN_WORKSPACES_VERSION) {
@@ -1,7 +1,10 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { loadRootConfig } from "../../../config/index.mjs";
4
- import { getUserEnvVar } from "../../../config/userEnvVars/index.mjs";
4
+ import {
5
+ getUserBoolEnvVar,
6
+ getUserEnvVar,
7
+ } from "../../../config/userEnvVars/index.mjs";
5
8
  import { parse, quote } from "../../../internal/bundledDeps/shellQuote.mjs";
6
9
  import {
7
10
  DEFAULT_TEMP_DIR,
@@ -70,17 +73,22 @@ const serializeArgs = (args, metadata, shell) => {
70
73
  return args
71
74
  .map((arg) =>
72
75
  quoteArg(
73
- interpolateWorkspaceScriptMetadata(arg, metadata, shell),
76
+ interpolateWorkspaceScriptMetadata({
77
+ text: arg,
78
+ metadata,
79
+ shell,
80
+ }),
74
81
  shell,
75
82
  ),
76
83
  )
77
84
  .join(" ");
78
85
  }
79
- const interpolated = interpolateWorkspaceScriptMetadata(
80
- args,
86
+ const interpolated = interpolateWorkspaceScriptMetadata({
87
+ text: args,
81
88
  metadata,
82
89
  shell,
83
- );
90
+ quoteValues: true,
91
+ });
84
92
  // Escape backslashes in interpolated values before POSIX parse on Windows,
85
93
  // so that path separators survive parse's escape processing (\\→\)
86
94
  const parseInput =
@@ -128,6 +136,11 @@ class _FileSystemProject extends ProjectBase {
128
136
  typeofName: "boolean",
129
137
  optional: true,
130
138
  },
139
+ "disableExecutableConfigs option": {
140
+ value: options.disableExecutableConfigs,
141
+ typeofName: "boolean",
142
+ optional: true,
143
+ },
131
144
  },
132
145
  {
133
146
  throw: true,
@@ -141,7 +154,16 @@ class _FileSystemProject extends ProjectBase {
141
154
  process.cwd(),
142
155
  expandHomePath(options.rootDirectory ?? ""),
143
156
  );
144
- const rootConfig = loadRootConfig(this.rootDirectory);
157
+ // Root config can't supply a default for this — the config file itself
158
+ // is what we're deciding whether to evaluate. Precedence is therefore
159
+ // option > BW_DISABLE_EXECUTABLE_CONFIGS_DEFAULT env var > false.
160
+ const loadConfigOptions = {
161
+ disableExecutableConfigs:
162
+ options.disableExecutableConfigs ??
163
+ getUserBoolEnvVar("disableExecutableConfigsDefault") ??
164
+ false,
165
+ };
166
+ const rootConfig = loadRootConfig(this.rootDirectory, loadConfigOptions);
145
167
  const { workspaces, workspaceMap, rootWorkspace } = findWorkspaces({
146
168
  rootDirectory: this.rootDirectory,
147
169
  includeRootWorkspace:
@@ -149,6 +171,7 @@ class _FileSystemProject extends ProjectBase {
149
171
  rootConfig.defaults.includeRootWorkspace ??
150
172
  getUserEnvVar("includeRootWorkspaceDefault") === "true",
151
173
  workspacePatternConfigs: rootConfig.workspacePatternConfigs,
174
+ loadConfigOptions,
152
175
  });
153
176
  this.rootWorkspace = rootWorkspace;
154
177
  this.workspaces = workspaces;
@@ -262,11 +285,12 @@ class _FileSystemProject extends ProjectBase {
262
285
  };
263
286
  const args = serializeArgs(options.args, workspaceScriptMetadata, shell);
264
287
  const script = options.inline
265
- ? interpolateWorkspaceScriptMetadata(
266
- options.script,
267
- workspaceScriptMetadata,
288
+ ? interpolateWorkspaceScriptMetadata({
289
+ text: options.script,
290
+ metadata: workspaceScriptMetadata,
268
291
  shell,
269
- ) + (args ? " " + args : "")
292
+ quoteValues: true,
293
+ }) + (args ? " " + args : "")
270
294
  : options.script;
271
295
  if (!options.inline && checkIsRecursiveScript(workspace.name, script)) {
272
296
  throw new PROJECT_ERRORS.RecursiveWorkspaceScript(
@@ -473,11 +497,12 @@ class _FileSystemProject extends ProjectBase {
473
497
  shell,
474
498
  );
475
499
  const script = options.inline
476
- ? interpolateWorkspaceScriptMetadata(
477
- options.script,
478
- workspaceScriptMetadata,
500
+ ? interpolateWorkspaceScriptMetadata({
501
+ text: options.script,
502
+ metadata: workspaceScriptMetadata,
479
503
  shell,
480
- ) + (args ? " " + args : "")
504
+ quoteValues: true,
505
+ }) + (args ? " " + args : "")
481
506
  : options.script;
482
507
  const scriptCommand = options.inline
483
508
  ? {
@@ -3,7 +3,7 @@ import {
3
3
  createProcessOutput,
4
4
  } from "./output/index.mjs";
5
5
  import { createScriptExecutor } from "./scriptExecution.mjs";
6
- import { createSubprocess } from "./subprocesses.mjs";
6
+ import { createSubprocess, killSubprocessTree } from "./subprocesses.mjs";
7
7
 
8
8
  const SIGNAL_MAP = {
9
9
  130: "SIGINT",
@@ -79,7 +79,7 @@ const SIGNAL_MAP = {
79
79
  output: processOutput,
80
80
  exit,
81
81
  metadata,
82
- kill: (exit) => proc.kill(exit),
82
+ kill: (exit) => killSubprocessTree(proc, exit ?? "SIGTERM"),
83
83
  };
84
84
  };
85
85
 
@@ -19,7 +19,7 @@ const createShellScript = (command) => {
19
19
  return DEFAULT_TEMP_DIR.createFile({
20
20
  name: fileName,
21
21
  content: command,
22
- mode: 493,
22
+ mode: 448,
23
23
  });
24
24
  };
25
25
  const createScriptExecutor = (command, shell) => {
@@ -2,18 +2,54 @@ import { IS_WINDOWS, runOnExit } from "../internal/core/index.mjs";
2
2
  import { logger } from "../internal/logger/index.mjs";
3
3
 
4
4
  const SUBPROCESS_REGISTRY = {};
5
+ /**
6
+ * Kill a tracked subprocess together with any descendants it has spawned.
7
+ *
8
+ * On POSIX, subprocesses are spawned with `detached: true`, which makes each
9
+ * child the leader of its own process group (pgid === pid). Signalling
10
+ * `-pid` therefore delivers the signal to every descendant in that group,
11
+ * not just the direct child. This is required for the common case of a
12
+ * shell wrapping a temp script: a plain `subprocess.kill()` only reaches
13
+ * the shell, leaving any grandchild (e.g. `bun build`) orphaned and
14
+ * reparented to init when the shell exits.
15
+ *
16
+ * On Windows there's no equivalent process-group semantics, so we fall back
17
+ * to a direct kill via the Bun.Subprocess handle.
18
+ */ const killSubprocessTree = (subprocess, signal) => {
19
+ if (IS_WINDOWS) {
20
+ subprocess.kill(signal);
21
+ return;
22
+ }
23
+ try {
24
+ process.kill(-subprocess.pid, signal);
25
+ } catch (error) {
26
+ const code = error.code;
27
+ if (code === "ESRCH") return;
28
+ if (code === "EPERM") {
29
+ try {
30
+ subprocess.kill(signal);
31
+ } catch (innerError) {
32
+ if (innerError.code !== "ESRCH") {
33
+ throw innerError;
34
+ }
35
+ }
36
+ return;
37
+ }
38
+ throw error;
39
+ }
40
+ };
5
41
  runOnExit((codeOrSignal) => {
6
42
  Object.values(SUBPROCESS_REGISTRY).forEach((subprocess) => {
7
43
  /**
8
44
  * @todo Windows support for killing subprocesses is needed.
9
45
  * subprocess.kill() will throw with not-implemented error
10
46
  */ if (!subprocess.killed && subprocess.exitCode === null && !IS_WINDOWS) {
47
+ const signal =
48
+ typeof codeOrSignal === "string" ? codeOrSignal : "SIGTERM";
11
49
  logger.debug(
12
- `Killing subprocess ${subprocess.pid} with signal ${codeOrSignal}`,
13
- );
14
- subprocess.kill(
15
- typeof codeOrSignal === "string" ? codeOrSignal : "SIGTERM",
50
+ `Killing subprocess ${subprocess.pid} with signal ${signal}`,
16
51
  );
52
+ killSubprocessTree(subprocess, signal);
17
53
  }
18
54
  });
19
55
  });
@@ -23,7 +59,20 @@ runOnExit((codeOrSignal) => {
23
59
  argv,
24
60
  options,
25
61
  ) => {
26
- const subprocess = Bun.spawn(argv, options);
62
+ const subprocess = Bun.spawn(argv, {
63
+ ...options,
64
+ // On POSIX, each tracked subprocess becomes the leader of its own
65
+ // process group so the registry can kill its full descendant tree via
66
+ // `process.kill(-pid, signal)` (see killSubprocessTree). Scoping the
67
+ // kill per child keeps the blast radius off the parent's process group,
68
+ // which matters when bun-workspaces is loaded inside another runner
69
+ // (e.g. a vitest worker) that shares our pgid.
70
+ ...(IS_WINDOWS
71
+ ? {}
72
+ : {
73
+ detached: true,
74
+ }),
75
+ });
27
76
  logger.debug(`Subprocess spawned with pid ${subprocess.pid}`);
28
77
  SUBPROCESS_REGISTRY[subprocess.pid] = subprocess;
29
78
  subprocess.exited.finally(() => {
@@ -32,4 +81,4 @@ runOnExit((codeOrSignal) => {
32
81
  return subprocess;
33
82
  };
34
83
 
35
- export { createSubprocess };
84
+ export { createSubprocess, killSubprocessTree };
@@ -1,6 +1,16 @@
1
+ import { quote } from "../internal/bundledDeps/shellQuote.mjs";
1
2
  import { BunWorkspacesError, IS_WINDOWS } from "../internal/core/index.mjs";
2
3
  import { getWorkspaceScriptMetadataConfig } from "../3725.mjs";
3
4
 
5
+ /**
6
+ * Wrap a value so that, when it is concatenated into a shell command for
7
+ * `shell`, the receiving shell parses it as a single literal token. Used
8
+ * when substituted metadata values land in a shell-interpretable context
9
+ * (inline command bodies, string-form `--args` before POSIX parse).
10
+ */ const quoteShellValue = (value, shell) =>
11
+ IS_WINDOWS && shell === "system"
12
+ ? `"${value.replace(/"/g, '""')}"`
13
+ : quote([value]);
4
14
  const createScriptRuntimeEnvVars = (metadata) => {
5
15
  const keys = [
6
16
  "projectPath",
@@ -16,7 +26,12 @@ const createScriptRuntimeEnvVars = (metadata) => {
16
26
  return acc;
17
27
  }, {});
18
28
  };
19
- const interpolateWorkspaceScriptMetadata = (text, metadata, shell) => {
29
+ const interpolateWorkspaceScriptMetadata = ({
30
+ text,
31
+ metadata,
32
+ shell,
33
+ quoteValues = false,
34
+ }) => {
20
35
  const keys = [
21
36
  "projectPath",
22
37
  "projectName",
@@ -33,6 +48,13 @@ const interpolateWorkspaceScriptMetadata = (text, metadata, shell) => {
33
48
  (k) => getWorkspaceScriptMetadataConfig(k).inlineName === match,
34
49
  );
35
50
  const value = metadata[key];
51
+ if (quoteValues) {
52
+ // Preserve "empty substitution is invisible" — quoting an empty value
53
+ // would inject a literal `''` shell token (an empty positional arg),
54
+ // which changes argv length for commands like `echo <scriptName>`
55
+ // when no inline scriptName is set.
56
+ return value === "" ? "" : quoteShellValue(value, shell);
57
+ }
36
58
  if (IS_WINDOWS && shell === "bun") {
37
59
  return value.replace(/\\/g, "\\\\");
38
60
  }
@@ -57,4 +79,5 @@ export {
57
79
  createScriptRuntimeEnvVars,
58
80
  getWorkspaceScriptMetadata,
59
81
  interpolateWorkspaceScriptMetadata,
82
+ quoteShellValue,
60
83
  };
@@ -67,6 +67,7 @@ const findWorkspaces = ({
67
67
  workspaceGlobs: _workspaceGlobs,
68
68
  includeRootWorkspace = false,
69
69
  workspacePatternConfigs,
70
+ loadConfigOptions,
70
71
  }) => {
71
72
  rootDirectory = path.resolve(rootDirectory);
72
73
  logger.debug(`Finding workspaces in ${rootDirectory}`);
@@ -109,6 +110,7 @@ const findWorkspaces = ({
109
110
  );
110
111
  const workspaceConfig = loadWorkspaceConfig(
111
112
  path.dirname(packageJsonPath),
113
+ loadConfigOptions,
112
114
  );
113
115
  if (workspaceConfig) {
114
116
  for (const alias of workspaceConfig.aliases) {