diff --git a/.changeset/lovely-rats-live.md b/.changeset/lovely-rats-live.md new file mode 100644 index 000000000000..2b08b4e9ed48 --- /dev/null +++ b/.changeset/lovely-rats-live.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +feat: add experimental_patchConfig() + +`experimental_patchConfig()` can add to a user's config file. It preserves comments if its a `wrangler.jsonc`. However, it is not suitable for `wrangler.toml` with comments as we cannot preserve comments on write. diff --git a/packages/wrangler/src/__tests__/configuration.test.ts b/packages/wrangler/src/__tests__/configuration.test.ts index 01d2b05185ef..5083e0a84602 100644 --- a/packages/wrangler/src/__tests__/configuration.test.ts +++ b/packages/wrangler/src/__tests__/configuration.test.ts @@ -6045,15 +6045,19 @@ describe("experimental_readRawConfig()", () => { runInTempDir(); it(`should find a ${configType} config file given a specific path`, () => { fs.mkdirSync("../folder", { recursive: true }); - writeWranglerConfig({}, `../folder/config.${configType}`); + writeWranglerConfig( + { name: "config-one" }, + `../folder/config.${configType}` + ); const result = experimental_readRawConfig({ config: `../folder/config.${configType}`, }); - expect(result.rawConfig).toEqual({ - compatibility_date: "2022-01-12", - name: "test-name", - }); + expect(result.rawConfig).toEqual( + expect.objectContaining({ + name: "config-one", + }) + ); }); it("should find a config file given a specific script", () => { @@ -6072,18 +6076,20 @@ describe("experimental_readRawConfig()", () => { let result = experimental_readRawConfig({ script: "./path/to/index.js", }); - expect(result.rawConfig).toEqual({ - compatibility_date: "2022-01-12", - name: "config-one", - }); + expect(result.rawConfig).toEqual( + expect.objectContaining({ + name: "config-one", + }) + ); result = experimental_readRawConfig({ script: "../folder/index.js", }); - expect(result.rawConfig).toEqual({ - compatibility_date: "2022-01-12", - name: "config-two", - }); + expect(result.rawConfig).toEqual( + expect.objectContaining({ + name: "config-two", + }) + ); }); } ); diff --git a/packages/wrangler/src/__tests__/patch-config.test.ts b/packages/wrangler/src/__tests__/patch-config.test.ts new file mode 100644 index 000000000000..1f12784883f7 --- /dev/null +++ b/packages/wrangler/src/__tests__/patch-config.test.ts @@ -0,0 +1,876 @@ +import { writeFileSync } from "node:fs"; +import dedent from "ts-dedent"; +import { experimental_patchConfig } from "../config/patch-config"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { writeWranglerConfig } from "./helpers/write-wrangler-config"; +import type { RawConfig } from "../config"; + +type TestCase = { + name: string; + original: RawConfig; + additivePatch: RawConfig; + replacingPatch: RawConfig; + expectedToml: string; + expectedJson: string; +}; +const testCases: TestCase[] = [ + { + name: "add a binding", + original: {}, + additivePatch: { + kv_namespaces: [ + { + binding: "KV", + }, + ], + }, + replacingPatch: { + kv_namespaces: [ + { + binding: "KV", + }, + ], + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + + [[kv_namespaces]] + binding = "KV" + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name", + "kv_namespaces": [ + { + "binding": "KV" + } + ] + } + `, + }, + { + name: "add a second binding of the same type", + original: { + kv_namespaces: [ + { + binding: "KV", + }, + ], + }, + additivePatch: { + kv_namespaces: [ + { + binding: "KV2", + }, + ], + }, + replacingPatch: { + kv_namespaces: [ + { + binding: "KV", + }, + { + binding: "KV2", + }, + ], + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + + [[kv_namespaces]] + binding = "KV" + + [[kv_namespaces]] + binding = "KV2" + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name", + "kv_namespaces": [ + { + "binding": "KV" + }, + { + "binding": "KV2" + } + ] + } + `, + }, + { + name: "add a new D1 binding when only KV bindings exist", + original: { + kv_namespaces: [ + { + binding: "KV", + }, + ], + }, + additivePatch: { + d1_databases: [ + { + binding: "DB", + }, + ], + }, + replacingPatch: { + d1_databases: [ + { + binding: "DB", + }, + ], + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + + [[kv_namespaces]] + binding = "KV" + + [[d1_databases]] + binding = "DB" + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name", + "kv_namespaces": [ + { + "binding": "KV" + } + ], + "d1_databases": [ + { + "binding": "DB" + } + ] + } + `, + }, + { + name: "add a new field", + original: { + kv_namespaces: [ + { + binding: "KV", + }, + ], + }, + additivePatch: { + compatibility_flags: ["nodejs_compat"], + }, + replacingPatch: { + compatibility_flags: ["nodejs_compat"], + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + compatibility_flags = [ "nodejs_compat" ] + + [[kv_namespaces]] + binding = "KV" + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name", + "kv_namespaces": [ + { + "binding": "KV" + } + ], + "compatibility_flags": [ + "nodejs_compat" + ] + } + `, + }, + { + name: "make multiple edits at the same time", + original: { + kv_namespaces: [ + { + binding: "KV", + }, + ], + d1_databases: [ + { + binding: "DB", + }, + ], + }, + additivePatch: { + kv_namespaces: [ + { + binding: "KV2", + }, + { + binding: "KV3", + }, + ], + d1_databases: [ + { + binding: "DB2", + }, + ], + }, + replacingPatch: { + kv_namespaces: [ + { + binding: "KV", + }, + { + binding: "KV2", + }, + { + binding: "KV3", + }, + ], + d1_databases: [ + { + binding: "DB", + }, + { + binding: "DB2", + }, + ], + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + + [[kv_namespaces]] + binding = "KV" + + [[kv_namespaces]] + binding = "KV2" + + [[kv_namespaces]] + binding = "KV3" + + [[d1_databases]] + binding = "DB" + + [[d1_databases]] + binding = "DB2" + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name", + "kv_namespaces": [ + { + "binding": "KV" + }, + { + "binding": "KV2" + }, + { + "binding": "KV3" + } + ], + "d1_databases": [ + { + "binding": "DB" + }, + { + "binding": "DB2" + } + ] + } + `, + }, +]; + +const replacingOnlyTestCases: Omit[] = [ + { + name: "edit a binding", + original: {}, + replacingPatch: { + kv_namespaces: [ + { + binding: "KV2", + }, + ], + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + + [[kv_namespaces]] + binding = "KV2" + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name", + "kv_namespaces": [ + { + "binding": "KV2" + } + ] + } + `, + }, + { + name: "add a field to an existing binding", + original: { + kv_namespaces: [ + { + binding: "KV", + }, + ], + }, + replacingPatch: { + kv_namespaces: [ + { + binding: "KV", + id: "1234", + }, + ], + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + + [[kv_namespaces]] + binding = "KV" + id = "1234" + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name", + "kv_namespaces": [ + { + "binding": "KV", + "id": "1234" + } + ] + } + `, + }, + { + name: "delete an existing binding so that none are left", + original: { + compatibility_flags: ["test-flag"], + }, + replacingPatch: { + compatibility_flags: undefined, + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name" + } + `, + }, + // This one doesn't work because the jsonc-parser leaves behind a stray bracket when deleting + // there are possibly solutions that I am not inclined to solve right now + // e.g. passing in {binding: undefined} in the patch instead and cleaning up empty objects + // { + // name: "delete an existing binding (but some bindings of that type are still left)", + // original: { + // kv_namespaces: [ + // { + // binding: "KV", + // }, + // { + // binding: "KV2", + // }, + // ], + // }, + // replacingPatch: { + // kv_namespaces: [ + // { + // binding: "KV", + // }, + // undefined, + // ], + // }, + // expectedToml: dedent` + // compatibility_date = "2022-01-12" + // name = "test-name" + + // [[kv_namespaces]] + // binding = "KV" + + // `, + // expectedJson: dedent` + // { + // "compatibility_date": "2022-01-12", + // "name": "test-name", + // "kv_namespaces": [ + // { + // "binding": "KV" + // } + // ] + // } + // `, + // }, + { + name: "edit a compat flag", + original: {}, + replacingPatch: { + compatibility_flags: ["no_nodejs_compat"], + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + compatibility_flags = [ "no_nodejs_compat" ] + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name", + "compatibility_flags": [ + "no_nodejs_compat" + ] + } + `, + }, + { + name: "add a compat flag", + original: { compatibility_flags: ["nodejs_compat"] }, + replacingPatch: { + compatibility_flags: ["nodejs_compat", "flag"], + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + compatibility_flags = [ "nodejs_compat", "flag" ] + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name", + "compatibility_flags": [ + "nodejs_compat", + "flag" + ] + } + `, + }, + { + name: "delete a compat flag", + original: {}, + replacingPatch: { + compatibility_flags: undefined, + }, + expectedToml: dedent` + compatibility_date = "2022-01-12" + name = "test-name" + + `, + expectedJson: dedent` + { + "compatibility_date": "2022-01-12", + "name": "test-name" + } + `, + }, +]; + +describe("experimental_patchConfig()", () => { + runInTempDir(); + describe.each([true, false])("isArrayInsertion = %o", (isArrayInsertion) => { + describe.each(testCases)( + `$name`, + ({ + original, + replacingPatch, + additivePatch, + expectedJson, + expectedToml, + }) => { + it.each(["json", "toml"])("%s", (configType) => { + writeWranglerConfig( + original, + configType === "json" ? "./wrangler.json" : "./wrangler.toml" + ); + const result = experimental_patchConfig( + configType === "json" ? "./wrangler.json" : "./wrangler.toml", + isArrayInsertion ? additivePatch : replacingPatch, + isArrayInsertion + ); + expect(result).not.toBeFalsy(); + expect(result).toEqual( + `${configType === "json" ? expectedJson : expectedToml}` + ); + }); + } + ); + }); + describe("isArrayInsertion = false", () => { + describe.each(replacingOnlyTestCases)( + `$name`, + ({ original, replacingPatch, expectedJson, expectedToml }) => { + it.each(["json", "toml"])("%s", (configType) => { + writeWranglerConfig( + original, + configType === "json" ? "./wrangler.json" : "./wrangler.toml" + ); + const result = experimental_patchConfig( + configType === "json" ? "./wrangler.json" : "./wrangler.toml", + replacingPatch, + false + ); + expect(result).not.toBeFalsy(); + expect(result).toEqual( + `${configType === "json" ? expectedJson : expectedToml}` + ); + }); + } + ); + }); + + describe("jsonc", () => { + describe("add multiple bindings", () => { + it("isArrayInsertion = true", () => { + const jsonc = ` + { + // a comment + "compatibility_date": "2022-01-12", + "name": "test-name", + "kv_namespaces": [ + { + // more comments! + "binding": "KV" + } + ], + "d1_databases": [ + /** + * multiline comment + */ + { + "binding": "DB" + } + ] + } + `; + writeFileSync("./wrangler.jsonc", jsonc); + const patch = { + kv_namespaces: [ + { + binding: "KV2", + }, + { + binding: "KV3", + }, + ], + d1_databases: [ + { + binding: "DB2", + }, + ], + }; + const result = experimental_patchConfig("./wrangler.jsonc", patch); + expect(result).not.toBeFalsy(); + expect(result).toMatchInlineSnapshot(` + "{ + // a comment + \\"compatibility_date\\": \\"2022-01-12\\", + \\"name\\": \\"test-name\\", + \\"kv_namespaces\\": [ + { + // more comments! + \\"binding\\": \\"KV\\" + }, + { + \\"binding\\": \\"KV2\\" + }, + { + \\"binding\\": \\"KV3\\" + } + ], + \\"d1_databases\\": [ + /** + * multiline comment + */ + { + \\"binding\\": \\"DB\\" + }, + { + \\"binding\\": \\"DB2\\" + } + ] + }" + `); + }); + it("isArrayInsertion = false ", () => { + const jsonc = dedent` + { + // a comment + "compatibility_date": "2022-01-12", + "name": "test-name", + "kv_namespaces": [ + { + // more comments! + "binding": "KV" + } + ], + "d1_databases": [ + /** + * multiline comment + */ + { + "binding": "DB" + } + ] + } + `; + writeFileSync("./wrangler.jsonc", jsonc); + const patch = { + kv_namespaces: [ + { + binding: "KV", + }, + { + binding: "KV2", + }, + { + binding: "KV3", + }, + ], + d1_databases: [ + { + binding: "DB", + }, + { + binding: "DB2", + }, + ], + }; + const result = experimental_patchConfig( + "./wrangler.jsonc", + patch, + false + ); + expect(result).not.toBeFalsy(); + expect(result).toMatchInlineSnapshot(` + "{ + // a comment + \\"compatibility_date\\": \\"2022-01-12\\", + \\"name\\": \\"test-name\\", + \\"kv_namespaces\\": [ + { + // more comments! + \\"binding\\": \\"KV\\" + }, + { + \\"binding\\": \\"KV2\\" + }, + { + \\"binding\\": \\"KV3\\" + } + ], + \\"d1_databases\\": [ + /** + * multiline comment + */ + { + \\"binding\\": \\"DB\\" + }, + { + \\"binding\\": \\"DB2\\" + } + ] + }" + `); + }); + }); + describe("edit existing bindings", () => { + it("isArrayInsertion = false", () => { + const jsonc = ` + { + // comment one + "compatibility_date": "2022-01-12", + // comment two + "name": "test-name", + "kv_namespaces": [ + { + // comment three + "binding": "KV" + // comment four + }, + { + // comment five + "binding": "KV2" + // comment six + } + ] + } + `; + writeFileSync("./wrangler.jsonc", jsonc); + const patch = { + compatibility_date: "2024-27-09", + kv_namespaces: [ + { + binding: "KV", + id: "hello-id", + }, + { + binding: "KV2", + }, + ], + }; + const result = experimental_patchConfig( + "./wrangler.jsonc", + patch, + false + ); + expect(result).not.toBeFalsy(); + expect(result).toMatchInlineSnapshot(` + "{ + // comment one + \\"compatibility_date\\": \\"2024-27-09\\", + // comment two + \\"name\\": \\"test-name\\", + \\"kv_namespaces\\": [ + { + // comment three + \\"binding\\": \\"KV\\", + \\"id\\": \\"hello-id\\" + // comment four + }, + { + // comment five + \\"binding\\": \\"KV2\\" + // comment six + } + ] + }" + `); + }); + }); + + describe("edit existing bindings with patch array in a different order (will mess up comments)", () => { + it("isArrayInsertion = false", () => { + const jsonc = ` + { + // comment one + "compatibility_date": "2022-01-12", + // comment two + "name": "test-name", + "kv_namespaces": [ + { + // comment three + "binding": "KV" + // comment four + }, + { + // comment five + "binding": "KV2" + // comment six + } + ] + } + `; + writeFileSync("./wrangler.jsonc", jsonc); + const patch = { + compatibility_date: "2024-27-09", + kv_namespaces: [ + { + binding: "KV2", + }, + { + binding: "KV", + id: "hello-id", + }, + ], + }; + const result = experimental_patchConfig( + "./wrangler.jsonc", + patch, + false + ); + expect(result).not.toBeFalsy(); + // Note that the comments have stayed in place! + // However, I don't think we can reasonably expect to bring comments along when an array has been reordered + expect(result).toMatchInlineSnapshot(` + "{ + // comment one + \\"compatibility_date\\": \\"2024-27-09\\", + // comment two + \\"name\\": \\"test-name\\", + \\"kv_namespaces\\": [ + { + // comment three + \\"binding\\": \\"KV2\\" + // comment four + }, + { + // comment five + \\"binding\\": \\"KV\\", + \\"id\\": \\"hello-id\\" + // comment six + } + ] + }" + `); + }); + }); + + describe("delete existing bindings (cannot preserve comments)", () => { + it("isArrayInsertion = false", () => { + const jsonc = ` + { + // comment one + "compatibility_date": "2022-01-12", + // comment two + "name": "test-name", + "kv_namespaces": [ + { + // comment three + "binding": "KV" + // comment four + }, + { + // comment five + "binding": "KV2" + // comment six + } + ] + } + `; + writeFileSync("./wrangler.jsonc", jsonc); + const patch = { + compatibility_date: "2024-27-09", + kv_namespaces: undefined, + }; + const result = experimental_patchConfig( + "./wrangler.jsonc", + patch, + false + ); + expect(result).not.toBeFalsy(); + expect(result).toMatchInlineSnapshot(` + "{ + // comment one + \\"compatibility_date\\": \\"2024-27-09\\", + // comment two + \\"name\\": \\"test-name\\" + }" + `); + }); + }); + }); +}); diff --git a/packages/wrangler/src/cli.ts b/packages/wrangler/src/cli.ts index 6da850958a54..f3139526b21f 100644 --- a/packages/wrangler/src/cli.ts +++ b/packages/wrangler/src/cli.ts @@ -60,3 +60,4 @@ const generateASSETSBinding: ( export { generateASSETSBinding as unstable_generateASSETSBinding }; export { experimental_readRawConfig } from "./config"; +export { experimental_patchConfig } from "./config/patch-config"; diff --git a/packages/wrangler/src/config/patch-config.ts b/packages/wrangler/src/config/patch-config.ts new file mode 100644 index 000000000000..38b09ce2b4e3 --- /dev/null +++ b/packages/wrangler/src/config/patch-config.ts @@ -0,0 +1,97 @@ +import { writeFileSync } from "fs"; +import TOML from "@iarna/toml"; +import { applyEdits, format, modify } from "jsonc-parser"; +import { parseJSONC, parseTOML, readFileSync } from "../parse"; +import type { RawConfig } from "./config"; +import type { JSONPath } from "jsonc-parser"; + +export const experimental_patchConfig = ( + configPath: string, + /** + * if you want to add something new, e.g. a binding, you can just provide that {kv_namespace:[{binding:"KV"}]} + * and set isArrayInsertion = true + * + * if you want to edit or delete existing array elements, you have to provide the whole array + * e.g. {kv_namespace:[{binding:"KV", id:"new-id"}, {binding:"KV2", id:"untouched"}]} + * and set isArrayInsertion = false + */ + patch: RawConfig, + isArrayInsertion: boolean = true +) => { + let configString = readFileSync(configPath); + + if (configPath.endsWith("toml")) { + // the TOML parser we use does not preserve comments + if (configString.includes("#")) { + throw new PatchConfigError( + "cannot patch .toml config if comments are present" + ); + } else { + // for simplicity, use the JSONC editor to make all edits + // toml -> js object -> json string -> edits -> js object -> toml + configString = JSON.stringify(parseTOML(configString)); + } + } + + const patchPaths: JSONPath[] = []; + getJSONPath(patch, patchPaths, isArrayInsertion); + for (const patchPath of patchPaths) { + const value = patchPath.pop(); + const edit = modify(configString, patchPath, value, { + isArrayInsertion, + }); + configString = applyEdits(configString, edit); + } + const formatEdit = format(configString, undefined, {}); + configString = applyEdits(configString, formatEdit); + + if (configPath.endsWith(".toml")) { + configString = TOML.stringify(parseJSONC(configString)); + } + writeFileSync(configPath, configString); + return configString; +}; + +/** + * + * Gets all the JSON paths for a given object by recursing through the object, recording the properties encountered. + * e.g. {a : { b: "c", d: ["e", "f"]}} -> [["a", "b", "c"], ["a", "d", 0], ["a", "d", 1]] + * The jsonc-parser library requires JSON paths for each edit. + * Note the final 'path' segment is the value we want to insert, + * so in the above example,["a", "b"] would be the path and we would insert "c" + * + * If isArrayInsertion = false, when we encounter an array, we use the item index as part of the path and continue + * If isArrayInsertion = false, we stop recursing down and treat the whole array item as the final path segment/value. + * + */ +const getJSONPath = ( + obj: RawConfig, + allPaths: JSONPath[], + isArrayInsertion: boolean, + prevPath: JSONPath = [] +) => { + for (const [k, v] of Object.entries(obj)) { + const currentPath = [...prevPath, k]; + if (Array.isArray(v)) { + v.forEach((x, i) => { + if (isArrayInsertion) { + // makes sure we insert new array items at the end + allPaths.push([...currentPath, -1, x]); + } else if (typeof x === "object") { + getJSONPath(x, allPaths, isArrayInsertion, [...currentPath, i]); + } else { + allPaths.push([...currentPath, i, x]); + } + }); + } else if (typeof v === "object") { + getJSONPath(v, allPaths, isArrayInsertion, currentPath); + } else { + allPaths.push([...currentPath, v]); + } + } +}; + +/** + * Custom error class for config patching errors + */ +export class PatchConfigError extends Error {}