bun-workspaces 0.1.0-alpha-test-publish-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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Scott Morse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # bun-workspaces
2
+
3
+ This is a CLI meant to help manage [Bun workspaces](https://bun.sh/docs/install/workspaces).
4
+
5
+ This was created primarily due to issues and limitations with Bun's `--filter` option for running commands from workspaces.
6
+
7
+ ## Installation
8
+
9
+ You can install the CLI in your project or simply use `bunx bun-workspaces`.
10
+
11
+ ```bash
12
+ $ bun add --dev bun-workspaces
13
+ $ bunx bun-workspaces --help
14
+ ```
15
+
16
+ ### Examples
17
+
18
+ You might consider making a shorter alias in your `.bashrc`, `.zshrc`, or similar shell configuration file, such as `alias bw="bunx bun-workspaces"`, for convenience.
19
+
20
+ ```bash
21
+ alias bw="bunx bun-workspaces"
22
+
23
+ # List all workspaces
24
+ bw list-workspaces
25
+ bw ls
26
+ # List workspace names only
27
+ bw list-workspaces --name-only
28
+
29
+ # List all workspace scripts
30
+ bw list-scripts
31
+ # List script names only
32
+ bw list-scripts --name-only
33
+
34
+ # Get info about a workspace
35
+ bw workspace-info my-workspace
36
+ bw info my-workspace
37
+
38
+ # Get info about a script
39
+ bw script-info my-script
40
+ # Only print list of workspace names that have the script
41
+ bw script-info my-script --workspaces-only
42
+
43
+ # Run a script for all
44
+ # workspaces that have it
45
+ # in their `scripts` field
46
+ bw run-script my-script
47
+
48
+ # Run a script for a specific workspace
49
+ bw run-script my-script my-workspace
50
+
51
+ # Run a script for multiple workspaces
52
+ bw run-script my-script workspace-a workspace-b
53
+
54
+ # Run script in parallel for all workspaces
55
+ bw run-script my-script --parallel
56
+
57
+ # Append args to each script call
58
+ bw run-script my-script --args "--my --args"
59
+
60
+ # Help (--help can also be passed to any command)
61
+ bw help
62
+ bw --help
63
+
64
+ # Pass --cwd to any command
65
+ bw --cwd /path/to/your/project ls
66
+ bw --cwd /path/to/your/project run-script my-script
67
+ ```
package/bin/cli.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { createCli } from "bun-workspaces";
3
+
4
+ createCli().run();
@@ -0,0 +1,45 @@
1
+ import js from "@eslint/js";
2
+ import importPlugin from "eslint-plugin-import";
3
+ import typescriptEslint from "typescript-eslint";
4
+
5
+ export default [
6
+ ...typescriptEslint.config(
7
+ js.configs.recommended,
8
+ typescriptEslint.configs.recommended,
9
+ ),
10
+ {
11
+ plugins: {
12
+ import: importPlugin,
13
+ },
14
+ rules: {
15
+ "@typescript-eslint/no-empty-interface": "off",
16
+ "@typescript-eslint/no-empty-function": "off",
17
+ "no-empty": "warn",
18
+ "@typescript-eslint/no-extra-semi": "off",
19
+ "@typescript-eslint/no-explicit-any": "off",
20
+
21
+ "@typescript-eslint/no-unused-vars": [
22
+ "warn",
23
+ {
24
+ varsIgnorePattern: "^_",
25
+ argsIgnorePattern: "^_",
26
+ destructuredArrayIgnorePattern: "^_",
27
+ caughtErrorsIgnorePattern: "^_",
28
+ },
29
+ ],
30
+
31
+ eqeqeq: "error",
32
+ "prefer-const": "error",
33
+
34
+ "import/order": [
35
+ "warn",
36
+ {
37
+ alphabetize: {
38
+ order: "asc",
39
+ caseInsensitive: true,
40
+ },
41
+ },
42
+ ],
43
+ },
44
+ },
45
+ ];
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "bun-workspaces",
3
+ "version": "0.1.0-alpha-test-publish-0",
4
+ "main": "src/index.ts",
5
+ "bin": {
6
+ "bun-workspaces": "NODE_ENV=production ./bin/cli.js"
7
+ },
8
+ "custom": {
9
+ "bunVersion": {
10
+ "build": "1.1.38",
11
+ "libraryConsumer": "^1.1.x"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "cli": "NODE_ENV=production bun run bin/cli.js",
16
+ "cli:dev": "NODE_ENV=development bun run bin/cli.js",
17
+ "type-check": "tsc --noEmit",
18
+ "lint": "eslint .",
19
+ "format": "prettier --write .",
20
+ "format-check": "prettier --check ."
21
+ },
22
+ "devDependencies": {
23
+ "@types/bun": "^1.1.14",
24
+ "@typescript-eslint/eslint-plugin": "^8.17.0",
25
+ "@typescript-eslint/parser": "^8.17.0",
26
+ "bun-workspaces": "file:.",
27
+ "eslint": "^9.16.0",
28
+ "eslint-plugin-import": "^2.31.0",
29
+ "prettier": "^3.4.2",
30
+ "typescript-eslint": "^8.17.0"
31
+ },
32
+ "dependencies": {
33
+ "commander": "^12.1.0",
34
+ "pino": "^9.5.0",
35
+ "pino-pretty": "^13.0.0"
36
+ },
37
+ "peerDependencies": {
38
+ "typescript": "^5.0.0"
39
+ }
40
+ }
package/src/cli/cli.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { createCommand, Command } from "commander";
2
+ import packageJson from "../../package.json";
3
+ import {
4
+ getRequiredBunVersion,
5
+ validateCurrentBunVersion,
6
+ } from "../internal/bunVersion";
7
+ import { logger } from "../internal/logger";
8
+ import { initializeWithGlobalOptions } from "./globalOptions";
9
+ import { defineProjectCommands } from "./projectCommands";
10
+ import { OUTPUT_CONFIG } from "./output";
11
+
12
+ export interface RunCliOptions {
13
+ argv?: string | string[];
14
+ }
15
+
16
+ export interface CliProgram {
17
+ run: (options?: RunCliOptions) => Promise<void>;
18
+ }
19
+
20
+ export interface CreateCliProgramOptions {
21
+ writeOut?: (s: string) => void;
22
+ writeErr?: (s: string) => void;
23
+ handleError?: (error: Error) => void;
24
+ postInit?: (program: Command) => unknown;
25
+ defaultCwd?: string;
26
+ }
27
+
28
+ export const createCliProgram = ({
29
+ writeOut = OUTPUT_CONFIG.writeOut,
30
+ writeErr = OUTPUT_CONFIG.writeErr,
31
+ handleError,
32
+ postInit,
33
+ defaultCwd = process.cwd(),
34
+ }: CreateCliProgramOptions = {}): CliProgram => {
35
+ const run = async ({ argv = process.argv }: RunCliOptions = {}) => {
36
+ const errorListener =
37
+ handleError ??
38
+ ((error) => {
39
+ logger.error(error);
40
+ logger.error("Unhandled rejection");
41
+ process.exit(1);
42
+ });
43
+
44
+ process.on("unhandledRejection", errorListener);
45
+
46
+ try {
47
+ const program = createCommand("bunx bun-workspaces")
48
+ .description("CLI for utilities for Bun workspaces")
49
+ .version(packageJson.version)
50
+ .configureOutput({
51
+ writeOut,
52
+ writeErr,
53
+ });
54
+
55
+ postInit?.(program);
56
+
57
+ if (!validateCurrentBunVersion()) {
58
+ logger.error(
59
+ `Bun version mismatch. Required: ${getRequiredBunVersion()}, Found: ${
60
+ Bun.version
61
+ }`,
62
+ );
63
+ process.exit(1);
64
+ }
65
+
66
+ const args = typeof argv === "string" ? argv.split(" ") : argv;
67
+
68
+ const { project } = initializeWithGlobalOptions(
69
+ program,
70
+ args,
71
+ defaultCwd,
72
+ );
73
+ if (!project) return;
74
+
75
+ defineProjectCommands({
76
+ program,
77
+ project,
78
+ printLines: (...lines: string[]) => writeOut(lines.join("\n") + "\n"),
79
+ });
80
+
81
+ await program.parseAsync(args);
82
+ } catch (error) {
83
+ errorListener(error as Error);
84
+ } finally {
85
+ process.off("unhandledRejection", errorListener);
86
+ }
87
+ };
88
+
89
+ return {
90
+ run,
91
+ };
92
+ };
@@ -0,0 +1,84 @@
1
+ import path from "path";
2
+ import { type Command, Option } from "commander";
3
+ import { logger } from "../internal/logger";
4
+ import { createProject } from "../project";
5
+
6
+ const LOG_LEVELS = ["silent", "error", "warn", "info", "debug"] as const;
7
+
8
+ export type LogLevel = (typeof LOG_LEVELS)[number];
9
+
10
+ export interface CliGlobalOptions {
11
+ logLevel: LogLevel;
12
+ cwd: string;
13
+ }
14
+
15
+ export type CliGlobalOptionName = keyof CliGlobalOptions;
16
+
17
+ const defineGlobalOptions = (program: Command, defaultCwd: string) => {
18
+ const globalOptions: {
19
+ [K in CliGlobalOptionName]: {
20
+ shortName: string;
21
+ description: string;
22
+ defaultValue: CliGlobalOptions[K];
23
+ values?: readonly CliGlobalOptions[K][];
24
+ param?: string;
25
+ };
26
+ } = {
27
+ logLevel: {
28
+ shortName: "l",
29
+ description: "Log levels",
30
+ defaultValue: logger.level as LogLevel,
31
+ values: LOG_LEVELS,
32
+ param: "level",
33
+ },
34
+ cwd: {
35
+ shortName: "d",
36
+ description: "Working directory",
37
+ defaultValue: defaultCwd ?? process.cwd(),
38
+ param: "dir",
39
+ },
40
+ };
41
+
42
+ Object.entries(globalOptions).forEach(
43
+ ([name, { shortName, description, defaultValue, param, values }]) => {
44
+ const option = new Option(
45
+ `-${shortName} --${name}${param ? ` <${param}>` : ""}`,
46
+ description,
47
+ ).default(defaultValue);
48
+
49
+ program.addOption(values?.length ? option.choices(values) : option);
50
+ },
51
+ );
52
+ };
53
+
54
+ const applyGlobalOptions = (options: CliGlobalOptions) => {
55
+ logger.level = options.logLevel;
56
+ logger.debug("Log level: " + options.logLevel);
57
+
58
+ const project = createProject({
59
+ rootDir: options.cwd,
60
+ });
61
+
62
+ logger.debug(
63
+ `Project: ${JSON.stringify(project.name)} (${
64
+ project.workspaces.length
65
+ } workspace${project.workspaces.length === 1 ? "" : "s"})`,
66
+ );
67
+ logger.debug("Project root: " + path.resolve(project.rootDir));
68
+
69
+ return { project };
70
+ };
71
+
72
+ export const initializeWithGlobalOptions = (
73
+ program: Command,
74
+ args: string[],
75
+ defaultCwd: string,
76
+ ) => {
77
+ defineGlobalOptions(program, defaultCwd);
78
+
79
+ program.allowUnknownOption(true);
80
+ program.parseOptions(args);
81
+ program.allowUnknownOption(false);
82
+
83
+ return applyGlobalOptions(program.opts() as CliGlobalOptions);
84
+ };
@@ -0,0 +1 @@
1
+ export { createCliProgram as createCli } from "./cli";
@@ -0,0 +1,6 @@
1
+ import { IS_TEST } from "../internal/env";
2
+
3
+ export const OUTPUT_CONFIG = {
4
+ writeOut: (s: string) => !IS_TEST && process.stdout.write(s),
5
+ writeErr: (s: string) => !IS_TEST && process.stderr.write(s),
6
+ };
@@ -0,0 +1,283 @@
1
+ import { type Command } from "commander";
2
+ import { logger } from "../internal/logger";
3
+ import type { Project } from "../project";
4
+ import type { Workspace } from "../workspaces";
5
+
6
+ export interface ProjectCommandsContext {
7
+ project: Project;
8
+ program: Command;
9
+ printLines: (...lines: string[]) => void;
10
+ }
11
+
12
+ const createWorkspaceInfoLines = (workspace: Workspace) => [
13
+ `Workspace: ${workspace.name}`,
14
+ ` - Path: ${workspace.path}`,
15
+ ` - Glob Match: ${workspace.matchPattern}`,
16
+ ` - Scripts: ${Object.keys(workspace.packageJson.scripts).sort().join(", ")}`,
17
+ ];
18
+
19
+ const createScriptInfoLines = (script: string, workspaces: Workspace[]) => [
20
+ `Script: ${script}`,
21
+ ...workspaces.map((workspace) => ` - ${workspace.name}`),
22
+ ];
23
+
24
+ const listWorkspaces = ({
25
+ program,
26
+ project,
27
+ printLines,
28
+ }: ProjectCommandsContext) => {
29
+ program
30
+ .command("list-workspaces")
31
+ .aliases(["ls", "list"])
32
+ .description("List all workspaces")
33
+ .option("--name-only", "Only show workspace names")
34
+ .action((options) => {
35
+ logger.debug("Command: List workspaces");
36
+
37
+ if (options.more) {
38
+ logger.debug("Showing more metadata");
39
+ }
40
+
41
+ const lines: string[] = [];
42
+ project.workspaces.forEach((workspace) => {
43
+ if (options.nameOnly) {
44
+ lines.push(workspace.name);
45
+ } else {
46
+ lines.push(...createWorkspaceInfoLines(workspace));
47
+ }
48
+ });
49
+
50
+ if (!lines.length) {
51
+ lines.push("No workspaces found");
52
+ }
53
+
54
+ printLines(...lines);
55
+ });
56
+ };
57
+
58
+ const listScripts = ({
59
+ program,
60
+ project,
61
+ printLines,
62
+ }: ProjectCommandsContext) => {
63
+ program
64
+ .command("list-scripts")
65
+ .description("List all scripts available with their workspaces")
66
+ .option("--name-only", "Only show script names")
67
+ .action((options) => {
68
+ logger.debug("Command: List scripts");
69
+
70
+ const scripts = project.listScriptsWithWorkspaces();
71
+ const lines: string[] = [];
72
+ Object.values(scripts)
73
+ .sort(({ name: nameA }, { name: nameB }) => nameA.localeCompare(nameB))
74
+ .forEach(({ name, workspaces }) => {
75
+ if (options.nameOnly) {
76
+ lines.push(name);
77
+ } else {
78
+ lines.push(...createScriptInfoLines(name, workspaces));
79
+ }
80
+ });
81
+
82
+ if (!lines.length) {
83
+ lines.push("No scripts found");
84
+ }
85
+
86
+ printLines(...lines);
87
+ });
88
+ };
89
+
90
+ const workspaceInfo = ({
91
+ program,
92
+ project,
93
+ printLines,
94
+ }: ProjectCommandsContext) => {
95
+ program
96
+ .command("workspace-info <workspace>")
97
+ .aliases(["info"])
98
+ .description("Show information about a workspace")
99
+ .action((workspaceName) => {
100
+ logger.debug(`Command: Workspace info for ${workspaceName}`);
101
+
102
+ const workspace = project.findWorkspaceByName(workspaceName);
103
+ if (!workspace) {
104
+ logger.error(`Workspace not found: ${JSON.stringify(workspaceName)}`);
105
+ return;
106
+ }
107
+
108
+ printLines(...createWorkspaceInfoLines(workspace));
109
+ });
110
+ };
111
+
112
+ const scriptInfo = ({
113
+ program,
114
+ project,
115
+ printLines,
116
+ }: ProjectCommandsContext) => {
117
+ program
118
+ .command("script-info <script>")
119
+ .description("Show information about a script")
120
+ .option("--workspaces-only", "Only show script's workspace names")
121
+ .action((script, options) => {
122
+ logger.debug(`Command: Script info for ${script}`);
123
+
124
+ const scripts = project.listScriptsWithWorkspaces();
125
+ const scriptMetadata = scripts[script];
126
+ if (!scriptMetadata) {
127
+ printLines(
128
+ `Script not found: ${JSON.stringify(
129
+ script,
130
+ )} (available: ${Object.keys(scripts).join(", ")})`,
131
+ );
132
+ return;
133
+ }
134
+ printLines(
135
+ ...(options.workspacesOnly
136
+ ? scriptMetadata.workspaces.map(({ name }) => name)
137
+ : createScriptInfoLines(script, scriptMetadata.workspaces)),
138
+ );
139
+ });
140
+ };
141
+
142
+ const runScript = ({
143
+ program,
144
+ project,
145
+ printLines,
146
+ }: ProjectCommandsContext) => {
147
+ program
148
+ .command("run <script> [workspaces...]")
149
+ .description("Run a script in all workspaces")
150
+ .option("--parallel", "Run the scripts in parallel")
151
+ .option("--args <args>", "Args to append to the script command", "")
152
+ .action(async (script: string, workspaces: string[], options) => {
153
+ logger.debug(
154
+ `Command: Run script ${JSON.stringify(script)} for ${
155
+ workspaces.length
156
+ ? "workspaces " + workspaces.join(", ")
157
+ : "all workspaces"
158
+ } (parallel: ${!!options.parallel}, method: ${JSON.stringify(
159
+ options.method,
160
+ )}, args: ${JSON.stringify(options.args)})`,
161
+ );
162
+
163
+ workspaces = workspaces.length
164
+ ? workspaces
165
+ : project.listWorkspacesWithScript(script).map(({ name }) => name);
166
+
167
+ if (!workspaces.length) {
168
+ program.error(
169
+ `No workspaces found for script ${JSON.stringify(script)}`,
170
+ );
171
+ }
172
+
173
+ let scriptCommands: ReturnType<typeof project.createScriptCommand>[];
174
+ try {
175
+ scriptCommands = workspaces.map((workspaceName) =>
176
+ project.createScriptCommand({
177
+ scriptName: script,
178
+ workspaceName,
179
+ method: "cd",
180
+ args: options.args.replace(/<workspace>/g, workspaceName),
181
+ }),
182
+ );
183
+ } catch (error) {
184
+ program.error((error as Error).message);
185
+ throw error;
186
+ }
187
+
188
+ const runCommand = async ({
189
+ command,
190
+ scriptName,
191
+ workspace,
192
+ }: (typeof scriptCommands)[number]) => {
193
+ const splitCommand = command.command.split(/\s+/g);
194
+
195
+ logger.debug(
196
+ `Running script ${scriptName} in workspace ${workspace.name} (cwd: ${
197
+ command.cwd
198
+ }): ${splitCommand.join(" ")}`,
199
+ );
200
+
201
+ const silent = logger.level === "silent";
202
+
203
+ if (!silent) {
204
+ printLines(
205
+ `Running script ${JSON.stringify(
206
+ scriptName,
207
+ )} in workspace ${JSON.stringify(workspace.name)}`,
208
+ );
209
+ }
210
+
211
+ const proc = Bun.spawn(command.command.split(/\s+/g), {
212
+ cwd: command.cwd,
213
+ env: process.env,
214
+ stdout: silent ? "ignore" : "inherit",
215
+ stderr: silent ? "ignore" : "inherit",
216
+ });
217
+
218
+ await proc.exited;
219
+
220
+ return {
221
+ scriptName,
222
+ workspace,
223
+ command,
224
+ success: proc.exitCode === 0,
225
+ };
226
+ };
227
+
228
+ const handleError = (error: unknown, workspace: string) => {
229
+ logger.error(error);
230
+ program.error(
231
+ `Script failed in ${workspace} (error: ${JSON.stringify((error as Error).message ?? error)})`,
232
+ );
233
+ };
234
+
235
+ const handleResult = ({
236
+ scriptName,
237
+ workspace,
238
+ success,
239
+ }: (typeof scriptCommands)[number] & { success: boolean }) => {
240
+ logger.info(
241
+ `${success ? "✅" : "❌"} ${workspace.name}: ${scriptName}`,
242
+ );
243
+ if (!success) {
244
+ program.error(
245
+ `Script ${scriptName} failed in workspace ${workspace.name}`,
246
+ );
247
+ }
248
+ };
249
+
250
+ if (options.parallel) {
251
+ let i = 0;
252
+ for await (const result of await Promise.allSettled(
253
+ scriptCommands.map(runCommand),
254
+ )) {
255
+ if (result.status === "rejected") {
256
+ handleError(result.reason, workspaces[i]);
257
+ } else {
258
+ handleResult(result.value);
259
+ }
260
+ i++;
261
+ }
262
+ } else {
263
+ let i = 0;
264
+ for (const command of scriptCommands) {
265
+ try {
266
+ const result = await runCommand(command);
267
+ handleResult(result);
268
+ } catch (error) {
269
+ handleError(error, workspaces[i]);
270
+ }
271
+ }
272
+ i++;
273
+ }
274
+ });
275
+ };
276
+
277
+ export const defineProjectCommands = (context: ProjectCommandsContext) => {
278
+ listWorkspaces(context);
279
+ listScripts(context);
280
+ workspaceInfo(context);
281
+ scriptInfo(context);
282
+ runScript(context);
283
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./workspaces";
2
+ export * from "./project";
3
+ export * from "./cli";
@@ -0,0 +1,24 @@
1
+ import rootPackageJson from "../../package.json";
2
+
3
+ export const LIBRARY_CONSUMER_BUN_VERSION =
4
+ rootPackageJson.custom.bunVersion.libraryConsumer;
5
+
6
+ export const BUILD_BUN_VERSION = rootPackageJson.custom.bunVersion.build;
7
+
8
+ export const getRequiredBunVersion = (build?: boolean) =>
9
+ build ? BUILD_BUN_VERSION : LIBRARY_CONSUMER_BUN_VERSION;
10
+
11
+ /**
12
+ * Validates that the provided version satisfies the required Bun version
13
+ * specified in the root `package.json`.
14
+ */
15
+ export const validateBunVersion = (version: string, build?: boolean) =>
16
+ Bun.semver.satisfies(version, getRequiredBunVersion(build));
17
+
18
+ /**
19
+ *
20
+ * Validates that the Bun version of the current script satisfies the
21
+ * required Bun version specified in the root `package.json`.
22
+ */
23
+ export const validateCurrentBunVersion = (build?: boolean) =>
24
+ validateBunVersion(Bun.version, build);
@@ -0,0 +1 @@
1
+ export const IS_TEST = process.env.NODE_ENV === "test";
@@ -0,0 +1,38 @@
1
+ export class BunWorkspacesError extends Error {
2
+ name = "BunWorkspacesError";
3
+ }
4
+
5
+ export type DefinedErrors<ErrorName extends string> = {
6
+ [name in ErrorName]: typeof BunWorkspacesError;
7
+ };
8
+
9
+ export const defineErrors = <ErrorName extends string>(
10
+ ...errors: ErrorName[]
11
+ ): DefinedErrors<ErrorName> =>
12
+ errors.reduce((acc, error) => {
13
+ acc[error] = class extends BunWorkspacesError {
14
+ constructor(message?: string) {
15
+ super(message);
16
+ this.name = error;
17
+ }
18
+ name = error;
19
+ };
20
+
21
+ Object.defineProperty(acc[error].prototype.constructor, "name", {
22
+ value: error,
23
+ });
24
+
25
+ Object.defineProperty(acc[error].constructor, "name", {
26
+ value: error,
27
+ });
28
+
29
+ Object.defineProperty(acc[error].prototype, "name", {
30
+ value: error,
31
+ });
32
+
33
+ Object.defineProperty(acc[error], "name", {
34
+ value: error,
35
+ });
36
+
37
+ return acc;
38
+ }, {} as DefinedErrors<ErrorName>);
@@ -0,0 +1,17 @@
1
+ import createLogger from "pino";
2
+
3
+ export const logger = createLogger({
4
+ msgPrefix: "[bun-workspaces] ",
5
+ level:
6
+ process.env.NODE_ENV === "test"
7
+ ? "silent"
8
+ : process.env.NODE_ENV === "development"
9
+ ? "debug"
10
+ : "info",
11
+ transport: {
12
+ target: "pino-pretty",
13
+ options: {
14
+ color: true,
15
+ },
16
+ },
17
+ });
@@ -0,0 +1,6 @@
1
+ import { defineErrors } from "../internal/error";
2
+
3
+ export const ERRORS = defineErrors(
4
+ "ProjectWorkspaceNotFound",
5
+ "WorkspaceScriptDoesNotExist",
6
+ );
@@ -0,0 +1,6 @@
1
+ export {
2
+ type Project,
3
+ type CreateProjectOptions,
4
+ type CreateProjectScriptCommandOptions,
5
+ createProject,
6
+ } from "./project";
@@ -0,0 +1,122 @@
1
+ import path from "path";
2
+ import { findWorkspacesFromPackage, type Workspace } from "../workspaces";
3
+ import { ERRORS } from "./errors";
4
+ import {
5
+ createScriptCommand,
6
+ type CreateScriptCommandOptions,
7
+ type ScriptCommand,
8
+ } from "./scriptCommand";
9
+
10
+ export interface ScriptMetadata {
11
+ name: string;
12
+ workspaces: Workspace[];
13
+ }
14
+
15
+ export type CreateProjectScriptCommandOptions = Omit<
16
+ CreateScriptCommandOptions,
17
+ "workspace" | "rootDir"
18
+ > & {
19
+ workspaceName: string;
20
+ };
21
+
22
+ export interface CreateProjectScriptCommandResult {
23
+ command: ScriptCommand;
24
+ scriptName: string;
25
+ workspace: Workspace;
26
+ }
27
+
28
+ export interface Project {
29
+ name: string;
30
+ rootDir: string;
31
+ workspaces: Workspace[];
32
+ listWorkspacesWithScript(scriptName: string): Workspace[];
33
+ listScriptsWithWorkspaces(): Record<string, ScriptMetadata>;
34
+ findWorkspaceByName(workspaceName: string): Workspace | null;
35
+ createScriptCommand(
36
+ options: CreateProjectScriptCommandOptions,
37
+ ): CreateProjectScriptCommandResult;
38
+ }
39
+
40
+ export interface CreateProjectOptions {
41
+ rootDir: string;
42
+ }
43
+
44
+ class _Project implements Project {
45
+ public readonly rootDir: string;
46
+ public readonly workspaces: Workspace[];
47
+ public readonly name: string;
48
+ constructor(private options: CreateProjectOptions) {
49
+ this.rootDir = options.rootDir;
50
+ const { name, workspaces } = findWorkspacesFromPackage({
51
+ rootDir: options.rootDir,
52
+ });
53
+ this.name = name;
54
+ this.workspaces = workspaces;
55
+ }
56
+
57
+ listWorkspacesWithScript(scriptName: string): Workspace[] {
58
+ return this.workspaces.filter(
59
+ (workspace) => workspace.packageJson.scripts?.[scriptName],
60
+ );
61
+ }
62
+
63
+ listScriptsWithWorkspaces(): Record<string, ScriptMetadata> {
64
+ const scripts = new Set<string>();
65
+ this.workspaces.forEach((workspace) => {
66
+ Object.keys(workspace.packageJson.scripts ?? {}).forEach((script) =>
67
+ scripts.add(script),
68
+ );
69
+ });
70
+ return Array.from(scripts)
71
+ .map((name) => ({
72
+ name,
73
+ workspaces: this.listWorkspacesWithScript(name),
74
+ }))
75
+ .reduce(
76
+ (acc, { name, workspaces }) => ({
77
+ ...acc,
78
+ [name]: { name, workspaces },
79
+ }),
80
+ {} as Record<string, ScriptMetadata>,
81
+ );
82
+ }
83
+
84
+ findWorkspaceByName(workspaceName: string): Workspace | null {
85
+ return (
86
+ this.workspaces.find((workspace) => workspace.name === workspaceName) ??
87
+ null
88
+ );
89
+ }
90
+
91
+ createScriptCommand(
92
+ options: CreateProjectScriptCommandOptions,
93
+ ): CreateProjectScriptCommandResult {
94
+ const workspace = this.findWorkspaceByName(options.workspaceName);
95
+ if (!workspace) {
96
+ throw new ERRORS.ProjectWorkspaceNotFound(
97
+ `Workspace not found: ${JSON.stringify(options.workspaceName)}`,
98
+ );
99
+ }
100
+ if (!workspace.packageJson.scripts?.[options.scriptName]) {
101
+ throw new ERRORS.WorkspaceScriptDoesNotExist(
102
+ `Script not found in workspace ${JSON.stringify(
103
+ workspace.name,
104
+ )}: ${JSON.stringify(options.scriptName)} (available: ${
105
+ Object.keys(workspace.packageJson.scripts).join(", ") || "none"
106
+ }`,
107
+ );
108
+ }
109
+ return {
110
+ workspace,
111
+ scriptName: options.scriptName,
112
+ command: createScriptCommand({
113
+ ...options,
114
+ workspace,
115
+ rootDir: path.resolve(this.rootDir),
116
+ }),
117
+ };
118
+ }
119
+ }
120
+
121
+ export const createProject = (options: CreateProjectOptions): Project =>
122
+ new _Project(options);
@@ -0,0 +1,40 @@
1
+ import path from "path";
2
+ import type { Workspace } from "../workspaces";
3
+
4
+ export const SCRIPT_COMMAND_METHODS = ["cd", "filter"] as const;
5
+
6
+ export type ScriptCommandMethod = (typeof SCRIPT_COMMAND_METHODS)[number];
7
+
8
+ export interface CreateScriptCommandOptions {
9
+ method: ScriptCommandMethod;
10
+ scriptName: string;
11
+ args: string;
12
+ workspace: Workspace;
13
+ rootDir: string;
14
+ }
15
+
16
+ const spaceArgs = (args: string) => (args ? ` ${args.trim()}` : "");
17
+
18
+ export interface ScriptCommand {
19
+ command: string;
20
+ cwd: string;
21
+ }
22
+
23
+ const METHODS: Record<
24
+ ScriptCommandMethod,
25
+ (options: CreateScriptCommandOptions) => ScriptCommand
26
+ > = {
27
+ cd: ({ scriptName, workspace, rootDir, args }) => ({
28
+ cwd: path.resolve(rootDir, workspace.path),
29
+ command: `bun --silent run ${scriptName}${spaceArgs(args)}`,
30
+ }),
31
+ filter: ({ scriptName, workspace, args, rootDir }) => ({
32
+ cwd: rootDir,
33
+ command: `bun --silent run --filter=${JSON.stringify(
34
+ workspace.name,
35
+ )} ${scriptName}${spaceArgs(args)}`,
36
+ }),
37
+ };
38
+
39
+ export const createScriptCommand = (options: CreateScriptCommandOptions) =>
40
+ METHODS[options.method](options);
@@ -0,0 +1,11 @@
1
+ import { defineErrors } from "../internal/error";
2
+
3
+ export const ERRORS = defineErrors(
4
+ "PackageNotFound",
5
+ "InvalidPackageJson",
6
+ "DuplicateWorkspaceName",
7
+ "NoWorkspaceName",
8
+ "InvalidScripts",
9
+ "InvalidWorkspaces",
10
+ "InvalidWorkspacePattern",
11
+ );
@@ -0,0 +1,111 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { Glob } from "bun";
4
+ import { logger } from "../internal/logger";
5
+ import { ERRORS } from "./errors";
6
+ import {
7
+ resolvePackageJsonContent,
8
+ resolvePackageJsonPath,
9
+ scanWorkspaceGlob,
10
+ } from "./packageJson";
11
+ import type { Workspace } from "./workspace";
12
+
13
+ export interface FindWorkspacesOptions {
14
+ rootDir: string;
15
+ workspaceGlobs: string[];
16
+ }
17
+
18
+ const validatePattern = (pattern: string) => {
19
+ if (pattern.startsWith("!")) {
20
+ logger.warn(
21
+ `Negation patterns are not supported by Bun workspaces: ${JSON.stringify(
22
+ pattern,
23
+ )}`,
24
+ );
25
+ return false;
26
+ }
27
+ return true;
28
+ };
29
+
30
+ const validateWorkspace = (workspace: Workspace, workspaces: Workspace[]) => {
31
+ if (workspaces.find((ws) => ws.path === workspace.path)) {
32
+ return false;
33
+ }
34
+
35
+ if (workspaces.find((ws) => ws.name === workspace.name)) {
36
+ throw new ERRORS.DuplicateWorkspaceName(
37
+ `Duplicate workspace name found: ${JSON.stringify(workspace.name)}`,
38
+ );
39
+ }
40
+
41
+ return true;
42
+ };
43
+
44
+ export const findWorkspaces = ({
45
+ rootDir,
46
+ workspaceGlobs,
47
+ }: FindWorkspacesOptions) => {
48
+ rootDir = path.resolve(rootDir);
49
+
50
+ const workspaces: Workspace[] = [];
51
+
52
+ for (const pattern of workspaceGlobs) {
53
+ if (!validatePattern(pattern)) continue;
54
+
55
+ const glob = new Glob(pattern);
56
+ for (const item of scanWorkspaceGlob(glob, rootDir)) {
57
+ const packageJsonPath = resolvePackageJsonPath(item);
58
+ if (packageJsonPath) {
59
+ const packageJsonContent = resolvePackageJsonContent(
60
+ packageJsonPath,
61
+ rootDir,
62
+ ["name", "scripts"],
63
+ );
64
+
65
+ const workspace: Workspace = {
66
+ name: packageJsonContent.name ?? "",
67
+ matchPattern: pattern,
68
+ path: path.relative(rootDir, path.dirname(packageJsonPath)),
69
+ packageJson: packageJsonContent,
70
+ };
71
+
72
+ if (validateWorkspace(workspace, workspaces)) {
73
+ workspaces.push(workspace);
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ workspaces.sort(
80
+ (a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path),
81
+ );
82
+
83
+ return { workspaces };
84
+ };
85
+
86
+ export interface FindWorkspacesFromPackageOptions {
87
+ rootDir: string;
88
+ }
89
+
90
+ export const findWorkspacesFromPackage = ({
91
+ rootDir,
92
+ }: FindWorkspacesFromPackageOptions) => {
93
+ const packageJsonPath = path.join(rootDir, "package.json");
94
+ if (!fs.existsSync(packageJsonPath)) {
95
+ throw new ERRORS.PackageNotFound(
96
+ `No package.json found at ${packageJsonPath}`,
97
+ );
98
+ }
99
+
100
+ const packageJson = resolvePackageJsonContent(packageJsonPath, rootDir, [
101
+ "workspaces",
102
+ ]);
103
+
104
+ return {
105
+ ...findWorkspaces({
106
+ rootDir,
107
+ workspaceGlobs: packageJson.workspaces ?? [],
108
+ }),
109
+ name: packageJson.name ?? "",
110
+ };
111
+ };
@@ -0,0 +1,8 @@
1
+ export {
2
+ findWorkspaces,
3
+ findWorkspacesFromPackage,
4
+ type FindWorkspacesFromPackageOptions,
5
+ type FindWorkspacesOptions,
6
+ } from "./findWorkspaces";
7
+ export { type Workspace } from "./workspace";
8
+ export type { ResolvedPackageJsonContent } from "./packageJson";
@@ -0,0 +1,158 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { Glob } from "bun";
4
+ import { logger } from "../internal/logger";
5
+ import { ERRORS } from "./errors";
6
+
7
+ export const resolvePackageJsonPath = (directoryItem: string) => {
8
+ if (path.basename(directoryItem) === "package.json") {
9
+ return directoryItem;
10
+ }
11
+ if (fs.existsSync(path.join(directoryItem, "package.json"))) {
12
+ return path.join(directoryItem, "package.json");
13
+ }
14
+ return "";
15
+ };
16
+
17
+ export type ResolvedPackageJsonContent = {
18
+ name: string;
19
+ workspaces: string[];
20
+ scripts: Record<string, string>;
21
+ } & Record<string, unknown>;
22
+
23
+ type UnknownPackageJson = Record<string, unknown>;
24
+
25
+ export const scanWorkspaceGlob = (glob: Glob, rootDir: string) =>
26
+ glob.scanSync({
27
+ cwd: rootDir,
28
+ onlyFiles: false,
29
+ absolute: true,
30
+ });
31
+
32
+ const validateJsonRoot = (json: UnknownPackageJson) => {
33
+ if (!json || typeof json !== "object" || Array.isArray(json)) {
34
+ throw new ERRORS.InvalidPackageJson(
35
+ `Expected package.json to be an object, got ${typeof json}`,
36
+ );
37
+ }
38
+ };
39
+
40
+ const validateName = (json: UnknownPackageJson) => {
41
+ if (typeof json.name !== "string") {
42
+ throw new ERRORS.NoWorkspaceName(
43
+ `Expected package.json to have a string "name" field${
44
+ json.name !== undefined ? ` (Received ${json.name})` : ""
45
+ }`,
46
+ );
47
+ }
48
+
49
+ return json.name;
50
+ };
51
+
52
+ const validateWorkspacePattern = (
53
+ workspacePattern: string,
54
+ rootDir: string,
55
+ ) => {
56
+ if (typeof workspacePattern !== "string") {
57
+ throw new ERRORS.InvalidWorkspacePattern(
58
+ `Expected workspace pattern to be a string, got ${typeof workspacePattern}`,
59
+ );
60
+ }
61
+
62
+ if (!workspacePattern.trim()) {
63
+ return false;
64
+ }
65
+
66
+ const absolutePattern = path.resolve(rootDir, workspacePattern);
67
+ if (!absolutePattern.startsWith(rootDir)) {
68
+ throw new ERRORS.InvalidWorkspacePattern(
69
+ `Cannot resolve workspace pattern outside of root directory ${rootDir}: ${absolutePattern}`,
70
+ );
71
+ }
72
+
73
+ return true;
74
+ };
75
+
76
+ const validateWorkspacePatterns = (
77
+ json: UnknownPackageJson,
78
+ rootDir: string,
79
+ ) => {
80
+ const workspaces: string[] = [];
81
+ if (json.workspaces) {
82
+ if (!Array.isArray(json.workspaces)) {
83
+ throw new ERRORS.InvalidWorkspaces(
84
+ `Expected package.json to have an array "workspaces" field`,
85
+ );
86
+ }
87
+
88
+ for (const workspacePattern of json.workspaces) {
89
+ if (validateWorkspacePattern(workspacePattern, rootDir)) {
90
+ workspaces.push(workspacePattern);
91
+ }
92
+ }
93
+ }
94
+
95
+ return workspaces;
96
+ };
97
+
98
+ const validateScripts = (json: UnknownPackageJson) => {
99
+ if (
100
+ json.scripts &&
101
+ (typeof json.scripts !== "object" || Array.isArray(json.scripts))
102
+ ) {
103
+ throw new ERRORS.InvalidScripts(
104
+ `Expected package.json to have an object "scripts" field`,
105
+ );
106
+ }
107
+
108
+ if (json.scripts) {
109
+ for (const value of Object.values(json.scripts)) {
110
+ if (typeof value !== "string") {
111
+ throw new ERRORS.InvalidScripts(
112
+ `Expected workspace "${json.name}" script "${
113
+ json.scripts
114
+ }" to be a string, got ${typeof value}`,
115
+ );
116
+ }
117
+ }
118
+ }
119
+
120
+ return {
121
+ ...(json.scripts as Record<string, string>),
122
+ };
123
+ };
124
+
125
+ export const resolvePackageJsonContent = (
126
+ packageJsonPath: string,
127
+ rootDir: string,
128
+ validations: ("workspaces" | "name" | "scripts")[],
129
+ ): ResolvedPackageJsonContent => {
130
+ rootDir = path.resolve(rootDir);
131
+
132
+ let json: UnknownPackageJson = {};
133
+ try {
134
+ json = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
135
+ } catch (error) {
136
+ logger.error(error);
137
+ throw new ERRORS.InvalidPackageJson(
138
+ `Failed to read and parse package.json at ${packageJsonPath}: ${
139
+ (error as Error).message
140
+ }`,
141
+ );
142
+ }
143
+
144
+ validateJsonRoot(json);
145
+
146
+ return {
147
+ ...json,
148
+ name: validations.includes("name")
149
+ ? validateName(json)
150
+ : ((json.name as string) ?? ""),
151
+ workspaces: validations.includes("workspaces")
152
+ ? validateWorkspacePatterns(json, rootDir)
153
+ : ((json?.workspaces ?? []) as string[]),
154
+ scripts: validations.includes("scripts")
155
+ ? validateScripts(json)
156
+ : ((json.scripts ?? {}) as Record<string, string>),
157
+ };
158
+ };
@@ -0,0 +1,12 @@
1
+ import type { ResolvedPackageJsonContent } from "./packageJson";
2
+
3
+ export interface Workspace {
4
+ /** The name of the workspace from its `package.json` */
5
+ name: string;
6
+ /** The relative path to the workspace from the root `package.json` */
7
+ path: string;
8
+ /** The pattern from `"workspaces"` in the root `package.json`that this workspace was matched from*/
9
+ matchPattern: string;
10
+ /** The contents of the workspace's package.json, with `"workspaces"` and `"scripts"` resolved */
11
+ packageJson: ResolvedPackageJsonContent;
12
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Enable latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "ESNext",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+
22
+ // Some stricter flags (disabled by default)
23
+ "noUnusedLocals": false,
24
+ "noUnusedParameters": false,
25
+ "noPropertyAccessFromIndexSignature": false
26
+ },
27
+ "include": ["src", "bin"]
28
+ }