diff --git a/.changeset/slimy-cameras-jog.md b/.changeset/slimy-cameras-jog.md new file mode 100644 index 0000000..ba52b20 --- /dev/null +++ b/.changeset/slimy-cameras-jog.md @@ -0,0 +1,5 @@ +--- +"@swc/cli": patch +--- + +feat(cli): Added cli-config-file option. diff --git a/packages/cli/package.json b/packages/cli/package.json index e566aab..2e5780f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,7 +47,7 @@ "dependencies": { "@mole-inc/bin-wrapper": "^8.0.1", "@swc/counter": "workspace:^", - "commander": "^7.1.0", + "commander": "^8.3.0", "fast-glob": "^3.2.5", "minimatch": "^9.0.3", "piscina": "^4.3.0", diff --git a/packages/cli/src/spack/options.ts b/packages/cli/src/spack/options.ts index 2200a11..775ef76 100644 --- a/packages/cli/src/spack/options.ts +++ b/packages/cli/src/spack/options.ts @@ -9,25 +9,26 @@ export interface SpackCliOptions { debug: boolean; } -commander.option("--config [path]", "Path to a spack.config.js file to use."); +const program = new commander.Command(); +program.option("--config [path]", "Path to a spack.config.js file to use."); // TODO: allow using ts. See: https://github.com/swc-project/swc/issues/841 -commander.option("--mode ", "Mode to use"); -commander.option("--target [browser | node]", "Target runtime environment"); +program.option("--mode ", "Mode to use"); +program.option("--target [browser | node]", "Target runtime environment"); -commander.option( +program.option( "--context [path]", `The base directory (absolute path!) for resolving the 'entry'` + ` option. If 'output.pathinfo' is set, the included pathinfo is shortened to this directory`, "The current directory" ); -commander.option("--entry [list]", "List of entries", collect); +program.option("--entry [list]", "List of entries", collect); -// commander.option('-W --watch', `Enter watch mode, which rebuilds on file change.`) +// program.option('-W --watch', `Enter watch mode, which rebuilds on file change.`) -commander.option("--debug", `Switch loaders to debug mode`); -// commander.option('--devtool', `Select a developer tool to enhance debugging.`) +program.option("--debug", `Switch loaders to debug mode`); +// program.option('--devtool', `Select a developer tool to enhance debugging.`) // -d shortcut for --debug --devtool eval-cheap-module-source-map // --output-pathinfo [여부] @@ -40,11 +41,11 @@ commander.option("--debug", `Switch loaders to debug mode`); // --module-bind-pre Bind an extension to a pre loader [문자열] // Output options: -commander.option( +program.option( "-o --output", `The output path and file for compilation assets` ); -commander.option("--output-path", `The output directory as **absolute path**`); +program.option("--output-path", `The output directory as **absolute path**`); // --output-filename Specifies the name of each output file on disk. // You must **not** specify an absolute path here! // The `output.path` option determines the location @@ -158,7 +159,7 @@ commander.option("--output-path", `The output directory as **absolute path**`); // --silent Prevent output from being displayed in stdout [boolean] // --json, -j Prints the result as JSON. [boolean] -commander.version( +program.version( `@swc/cli: ${pkg.version} @swc/core: ${swcCoreVersion}` ); @@ -168,7 +169,7 @@ export default async function parseSpackArgs(args: string[]): Promise<{ spackOptions: BundleOptions; }> { // - const cmd = commander.parse(args); + const cmd = program.parse(args); const opts = cmd.opts(); const cliOptions: SpackCliOptions = { diff --git a/packages/cli/src/swc/__mocks__/fs.ts b/packages/cli/src/swc/__mocks__/fs.ts index 38ddac1..9d8afab 100644 --- a/packages/cli/src/swc/__mocks__/fs.ts +++ b/packages/cli/src/swc/__mocks__/fs.ts @@ -3,22 +3,34 @@ import type { Stats } from "fs"; export interface MockHelpers { resetMockStats: () => void; + resetMockFiles: () => void; setMockStats: (stats: Record) => void; + setMockFile: (path: string, contents: string) => void; } const fsMock = jest.createMockFromModule("fs"); let mockStats: Record = {}; +let mockFiles: Record = {}; function setMockStats(stats: Record) { Object.entries(stats).forEach(([path, stats]) => { mockStats[path] = stats; }); } + +function setMockFile(path: string, contents: string) { + mockFiles[path] = contents; +} + function resetMockStats() { mockStats = {}; } +function resetMockFiles() { + mockFiles = {}; +} + export function stat(path: string, cb: (err?: Error, stats?: Stats) => void) { const result = mockStats[path]; if (result instanceof Error) { @@ -28,9 +40,21 @@ export function stat(path: string, cb: (err?: Error, stats?: Stats) => void) { } } +export function readFileSync(path: string): string { + if (!mockFiles[path]) { + throw new Error("Non existent."); + } + + return mockFiles[path]; +} + fsMock.setMockStats = setMockStats; fsMock.resetMockStats = resetMockStats; +fsMock.setMockFile = setMockFile; +fsMock.resetMockFiles = resetMockFiles; + fsMock.stat = stat as typeof fs.stat; +fsMock.readFileSync = readFileSync as typeof fs.readFileSync; export default fsMock; diff --git a/packages/cli/src/swc/__tests__/options.test.ts b/packages/cli/src/swc/__tests__/options.test.ts index 4021bf8..7330bd9 100644 --- a/packages/cli/src/swc/__tests__/options.test.ts +++ b/packages/cli/src/swc/__tests__/options.test.ts @@ -1,5 +1,9 @@ import type { Options } from "@swc/core"; import deepmerge from "deepmerge"; +import fs from "fs"; +import { resolve } from "path"; + +jest.mock("fs"); import parserArgs, { CliOptions, initProgram } from "../options"; @@ -56,6 +60,7 @@ describe("parserArgs", () => { beforeEach(() => { defaultResult = createDefaultResult(); initProgram(); + (fs as any).resetMockFiles(); }); it("minimal args returns default result", async () => { @@ -91,7 +96,7 @@ describe("parserArgs", () => { "src", ]; const result = parserArgs(args); - expect(result.cliOptions.outFileExtension).toEqual("js"); + expect(result!.cliOptions.outFileExtension).toEqual("js"); }); }); @@ -270,7 +275,7 @@ describe("parserArgs", () => { const expectedOptions = deepmerge(defaultResult.swcOptions, { jsc: { transform: { react: { development: true } } }, }); - expect(result.swcOptions).toEqual(expectedOptions); + expect(result!.swcOptions).toEqual(expectedOptions); }); it("react development and commonjs (two config options)", async () => { @@ -288,7 +293,7 @@ describe("parserArgs", () => { jsc: { transform: { react: { development: true } } }, module: { type: "commonjs" }, }); - expect(result.swcOptions).toEqual(expectedOptions); + expect(result!.swcOptions).toEqual(expectedOptions); }); it("react development and commonjs (comma-separated)", async () => { @@ -304,7 +309,7 @@ describe("parserArgs", () => { jsc: { transform: { react: { development: true } } }, module: { type: "commonjs" }, }); - expect(result.swcOptions).toEqual(expectedOptions); + expect(result!.swcOptions).toEqual(expectedOptions); }); it("no equals sign", async () => { @@ -319,7 +324,110 @@ describe("parserArgs", () => { const expectedOptions = deepmerge(defaultResult.swcOptions, { no_equals: true, }); - expect(result.swcOptions).toEqual(expectedOptions); + expect(result!.swcOptions).toEqual(expectedOptions); + }); + }); + + describe("--cli-config-file", () => { + it("reads a JSON config file with both camel and kebab case options", async () => { + (fs as any).setMockFile( + resolve(process.cwd(), "/swc/cli.json"), + JSON.stringify({ + outFileExtension: "mjs", + deleteDirOnStart: "invalid", + "delete-dir-on-start": true, + }) + ); + + const args = [ + "node", + "/path/to/node_modules/swc-cli/bin/swc.js", + "src", + "--cli-config-file", + "/swc/cli.json", + ]; + const result = parserArgs(args); + const expectedOptions = deepmerge(defaultResult, { + cliOptions: { outFileExtension: "mjs", deleteDirOnStart: true }, + }); + + expect(result).toEqual(expectedOptions); + }); + + it("reads a JSON but options are overriden from CLI", async () => { + (fs as any).setMockFile( + resolve(process.cwd(), "/swc/cli.json"), + JSON.stringify({ + outFileExtension: "mjs", + "delete-dir-on-start": true, + }) + ); + + const args = [ + "node", + "/path/to/node_modules/swc-cli/bin/swc.js", + "src", + "--cli-config-file", + "/swc/cli.json", + "--out-file-extension", + "cjs", + ]; + const result = parserArgs(args); + const expectedOptions = deepmerge(defaultResult, { + cliOptions: { outFileExtension: "cjs", deleteDirOnStart: true }, + }); + + expect(result).toEqual(expectedOptions); + }); + + describe("exits", () => { + let mockExit: jest.SpyInstance; + let mockConsoleError: jest.SpyInstance; + + beforeEach(() => { + mockExit = jest + .spyOn(process, "exit") + // @ts-expect-error + .mockImplementation(() => {}); + mockConsoleError = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + }); + + afterEach(() => { + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + + it("if the config file is missing", async () => { + const args = [ + "node", + "/path/to/node_modules/swc-cli/bin/swc.js", + "src", + "--cli-config-file", + "/swc/cli.json", + ]; + + parserArgs(args); + expect(mockExit).toHaveBeenCalledWith(2); + expect(mockConsoleError).toHaveBeenCalledTimes(2); + }); + + it("if the config file is not valid JSON", async () => { + (fs as any).setMockFile("/swc/cli.json", "INVALID"); + + const args = [ + "node", + "/path/to/node_modules/swc-cli/bin/swc.js", + "src", + "--cli-config-file", + "/swc/cli.json", + ]; + + parserArgs(args); + expect(mockExit).toHaveBeenCalledWith(2); + expect(mockConsoleError).toHaveBeenCalledTimes(2); + }); }); }); }); diff --git a/packages/cli/src/swc/options.ts b/packages/cli/src/swc/options.ts index 7c3340d..f7cd544 100644 --- a/packages/cli/src/swc/options.ts +++ b/packages/cli/src/swc/options.ts @@ -1,6 +1,8 @@ -import commander from "commander"; +import commander, { Command, OptionValues } from "commander"; import { version as swcCoreVersion } from "@swc/core"; import type { Options } from "@swc/core"; +import { readFileSync } from "fs"; +import { resolve } from "path"; const DEFAULT_EXTENSIONS = [ ".js", @@ -16,7 +18,7 @@ const DEFAULT_EXTENSIONS = [ const pkg = require("../../package.json"); -let program: commander.Command; +let program: Command; export const DEFAULT_OUT_FILE_EXTENSION = "js"; export const initProgram = () => { @@ -34,6 +36,12 @@ export const initProgram = () => { program.option("--config-file [path]", "Path to a .swcrc file to use"); + program.option( + "--cli-config-file [path]", + "Path to a JSON file containing CLI options. " + + "Options provided directly via command line override the ones in the configuration file." + ); + program.option( "--env-name [name]", "The name of the 'env' to use when loading configs and plugins. " + @@ -168,6 +176,69 @@ function unstringify(val: string): any { } } +function loadCLIConfigFile( + program: Command, + opts: OptionValues, + path: string +): OptionValues { + let configOpts: OptionValues; + let contents: string; + + // Parse the JSON file + try { + contents = readFileSync(resolve(process.cwd(), path), "utf-8"); + } catch (e) { + throw new Error(`Cannot read CLI config file "${path}".`); + } + + try { + configOpts = JSON.parse(contents); + } catch (e) { + throw new Error( + `CLI config file "${path}" is not a well-formed JSON file.` + ); + } + + // Convert kebab case options in camel case one + configOpts = Object.fromEntries( + Object.entries(configOpts).map(([key, value]) => { + const camelCaseKey = key.replace(/(-[-a-z])/g, (_, m) => + m.substring(1).toUpperCase() + ); + return [camelCaseKey, value]; + }) + ); + + // Split existing options in default and provided one + const defaults = []; + const provided = []; + + for (const pair of Object.entries(opts)) { + if (program.getOptionValueSource(pair[0]) === "default") { + defaults.push(pair); + } else { + provided.push(pair); + } + } + + // Merge options back with right priority + return { + ...Object.fromEntries(defaults), + ...configOpts, + ...Object.fromEntries(provided), + }; +} + +function verifyArgsErrors(errors: string[]): void { + if (errors.length) { + console.error("swc:"); + for (const error of errors) { + console.error(" " + error); + } + process.exit(2); + } +} + function collect( value: string, previousValue?: string[] @@ -207,7 +278,16 @@ export interface CliOptions { export default function parserArgs(args: string[]) { program.parse(args); - const opts = program.opts(); + let opts = program.opts(); + + if (opts.cliConfigFile) { + try { + opts = loadCLIConfigFile(program, opts, opts.cliConfigFile); + } catch (e: any) { + verifyArgsErrors([e.message]); + return; + } + } const filenames = program.args; const errors = []; @@ -253,13 +333,7 @@ export default function parserArgs(args: string[]) { } } - if (errors.length) { - console.error("swc:"); - for (const error of errors) { - console.error(" " + error); - } - process.exit(2); - } + verifyArgsErrors(errors); const swcOptions: Options = { jsc: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cce36d..7980748 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: workspace:^ version: link:../counter commander: - specifier: ^7.1.0 - version: 7.1.0 + specifier: ^8.3.0 + version: 8.3.0 fast-glob: specifier: ^3.2.5 version: 3.3.0 @@ -3492,14 +3492,15 @@ packages: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} dev: true - /commander@7.1.0: - resolution: {integrity: sha512-pRxBna3MJe6HKnBGsDyMv8ETbptw3axEdYHoqNh7gu5oDcew8fs0xnivZGm06Ogk8zGAJ9VX+OPEr2GXEQK4dg==} - engines: {node: '>= 10'} - dev: false - /commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + dev: true + + /commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + dev: false /comment-json@4.2.3: resolution: {integrity: sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==} @@ -5249,6 +5250,7 @@ packages: /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + requiresBuild: true dependencies: safer-buffer: 2.1.2 dev: true @@ -7442,6 +7444,7 @@ packages: /node-addon-api@3.2.1: resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} + requiresBuild: true optional: true /node-fetch@2.6.12: @@ -7471,6 +7474,7 @@ packages: /node-gyp-build@4.8.0: resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==} hasBin: true + requiresBuild: true optional: true /node-gyp@10.0.1: @@ -10112,7 +10116,7 @@ packages: '@mole-inc/bin-wrapper': 8.0.1 '@swc/core': 1.3.107 '@swc/counter': link:packages/counter - commander: 7.2.0 + commander: 8.3.0 fast-glob: 3.3.0 minimatch: 9.0.3 piscina: 4.3.1