From b2db5696e3d8e19cb8698f0ab2adf26df64eaaee Mon Sep 17 00:00:00 2001 From: Jason Rodriguez Date: Tue, 8 Apr 2025 08:47:27 -0700 Subject: [PATCH 01/52] Branch app entry point to support both legacy and beta versions (#113) * Moves Legacy CLI into a `legacy` folder and provides a new entrypoint to the beta CLI - adds a `--legacy (-l)` CLI option that forces the application to run in legacy mode. - adds a new warning message when the CLI is run in legacy mode. * Update lib/legacy/index.ts Co-authored-by: Marla Hoggard --------- Co-authored-by: Marla Hoggard --- lib/ditto.ts | 286 +----------------- lib/{ => legacy}/__mocks__/api.ts | 0 lib/{ => legacy}/add-project.ts | 0 lib/{ => legacy}/api.ts | 0 lib/{ => legacy}/component-folders.ts | 0 lib/{ => legacy}/config.test.ts | 0 lib/{ => legacy}/config.ts | 0 lib/{ => legacy}/consts.ts | 0 lib/{ => legacy}/generate-suggestions.test.ts | 0 lib/{ => legacy}/generate-suggestions.ts | 0 .../http/__mocks__/fetchComponentFolders.ts | 0 .../http/__mocks__/fetchComponents.ts | 0 .../http/__mocks__/fetchVariants.ts | 0 .../http/fetchComponentFolders.ts | 0 lib/{ => legacy}/http/fetchComponents.ts | 0 lib/{ => legacy}/http/fetchVariants.ts | 0 lib/{ => legacy}/http/http.test.ts | 0 lib/{ => legacy}/http/importComponents.ts | 0 lib/{ => legacy}/importComponents.ts | 0 lib/legacy/index.ts | 281 +++++++++++++++++ lib/{ => legacy}/init/init.ts | 0 lib/{ => legacy}/init/project.test.ts | 0 lib/{ => legacy}/init/project.ts | 0 lib/{ => legacy}/init/token.test.ts | 0 lib/{ => legacy}/init/token.ts | 0 lib/{ => legacy}/output.ts | 0 lib/{ => legacy}/pull-lib.test.ts | 0 lib/{ => legacy}/pull.test.ts | 0 lib/{ => legacy}/pull.ts | 0 lib/{ => legacy}/remove-project.ts | 0 lib/{ => legacy}/replace.test.ts | 0 lib/{ => legacy}/replace.ts | 0 lib/{ => legacy}/types.ts | 0 lib/{ => legacy}/utils/cleanFileName.test.ts | 0 lib/{ => legacy}/utils/cleanFileName.ts | 0 lib/{ => legacy}/utils/createSentryContext.ts | 0 .../utils/determineModuleType.test.ts | 0 lib/{ => legacy}/utils/determineModuleType.ts | 0 lib/{ => legacy}/utils/generateIOSBundles.ts | 0 lib/{ => legacy}/utils/generateJsDriver.ts | 0 .../utils/generateJsDriverTypeFile.ts | 0 lib/{ => legacy}/utils/generateSwiftDriver.ts | 0 lib/{ => legacy}/utils/getSelectedProjects.ts | 0 .../utils/processMetaOption.test.ts | 0 lib/{ => legacy}/utils/processMetaOption.ts | 0 lib/{ => legacy}/utils/projectsToText.ts | 0 lib/{ => legacy}/utils/promptForProject.ts | 0 lib/{ => legacy}/utils/quit.ts | 0 lib/{ => legacy}/utils/sourcesToText.ts | 0 lib/src/commands/pull.ts | 3 + lib/src/index.ts | 128 ++++++++ lib/src/output.ts | 21 ++ lib/src/utils/quit.ts | 7 + 53 files changed, 456 insertions(+), 270 deletions(-) rename lib/{ => legacy}/__mocks__/api.ts (100%) rename lib/{ => legacy}/add-project.ts (100%) rename lib/{ => legacy}/api.ts (100%) rename lib/{ => legacy}/component-folders.ts (100%) rename lib/{ => legacy}/config.test.ts (100%) rename lib/{ => legacy}/config.ts (100%) rename lib/{ => legacy}/consts.ts (100%) rename lib/{ => legacy}/generate-suggestions.test.ts (100%) rename lib/{ => legacy}/generate-suggestions.ts (100%) rename lib/{ => legacy}/http/__mocks__/fetchComponentFolders.ts (100%) rename lib/{ => legacy}/http/__mocks__/fetchComponents.ts (100%) rename lib/{ => legacy}/http/__mocks__/fetchVariants.ts (100%) rename lib/{ => legacy}/http/fetchComponentFolders.ts (100%) rename lib/{ => legacy}/http/fetchComponents.ts (100%) rename lib/{ => legacy}/http/fetchVariants.ts (100%) rename lib/{ => legacy}/http/http.test.ts (100%) rename lib/{ => legacy}/http/importComponents.ts (100%) rename lib/{ => legacy}/importComponents.ts (100%) create mode 100644 lib/legacy/index.ts rename lib/{ => legacy}/init/init.ts (100%) rename lib/{ => legacy}/init/project.test.ts (100%) rename lib/{ => legacy}/init/project.ts (100%) rename lib/{ => legacy}/init/token.test.ts (100%) rename lib/{ => legacy}/init/token.ts (100%) rename lib/{ => legacy}/output.ts (100%) rename lib/{ => legacy}/pull-lib.test.ts (100%) rename lib/{ => legacy}/pull.test.ts (100%) rename lib/{ => legacy}/pull.ts (100%) rename lib/{ => legacy}/remove-project.ts (100%) rename lib/{ => legacy}/replace.test.ts (100%) rename lib/{ => legacy}/replace.ts (100%) rename lib/{ => legacy}/types.ts (100%) rename lib/{ => legacy}/utils/cleanFileName.test.ts (100%) rename lib/{ => legacy}/utils/cleanFileName.ts (100%) rename lib/{ => legacy}/utils/createSentryContext.ts (100%) rename lib/{ => legacy}/utils/determineModuleType.test.ts (100%) rename lib/{ => legacy}/utils/determineModuleType.ts (100%) rename lib/{ => legacy}/utils/generateIOSBundles.ts (100%) rename lib/{ => legacy}/utils/generateJsDriver.ts (100%) rename lib/{ => legacy}/utils/generateJsDriverTypeFile.ts (100%) rename lib/{ => legacy}/utils/generateSwiftDriver.ts (100%) rename lib/{ => legacy}/utils/getSelectedProjects.ts (100%) rename lib/{ => legacy}/utils/processMetaOption.test.ts (100%) rename lib/{ => legacy}/utils/processMetaOption.ts (100%) rename lib/{ => legacy}/utils/projectsToText.ts (100%) rename lib/{ => legacy}/utils/promptForProject.ts (100%) rename lib/{ => legacy}/utils/quit.ts (100%) rename lib/{ => legacy}/utils/sourcesToText.ts (100%) create mode 100644 lib/src/commands/pull.ts create mode 100644 lib/src/index.ts create mode 100644 lib/src/output.ts create mode 100644 lib/src/utils/quit.ts diff --git a/lib/ditto.ts b/lib/ditto.ts index b64b0f9..0a97f4c 100755 --- a/lib/ditto.ts +++ b/lib/ditto.ts @@ -1,285 +1,31 @@ #!/usr/bin/env node // This is the main entry point for the ditto-cli command. -import { program } from "commander"; + // to use V8's code cache to speed up instantiation time import "v8-compile-cache"; -import fs from "fs"; -import path from "path"; import * as Sentry from "@sentry/node"; import { version as release } from "../package.json"; -import { init, needsTokenOrSource } from "./init/init"; -import { pull } from "./pull"; -import { quit } from "./utils/quit"; -import addProject from "./add-project"; -import removeProject from "./remove-project"; -import { replace } from "./replace"; -import { generateSuggestions } from "./generate-suggestions"; -import processMetaOption from "./utils/processMetaOption"; -import { importComponents } from "./importComponents"; -import { showComponentFolders } from "./component-folders"; +import legacyAppEntry from "./legacy"; +import appEntry from "./src"; +import output from "./src/output"; +// Initialize Sentry const environment = process.env.ENV || "development"; Sentry.init({ dsn: process.env.SENTRY_DSN, environment, release }); -function getVersion(): string { - const packageJsonPath = path.join(__dirname, "..", "package.json"); - const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8"); - const packageJson = JSON.parse(packageJsonContent) as { version: string }; - return packageJson.version; -} - -const VERSION = getVersion(); - -const CONFIG_FILE_RELIANT_COMMANDS = [ - "pull", - "none", - "project", - "project add", - "project remove", -]; - -type Command = - | "pull" - | "project" - | "project add" - | "project remove" - | "component-folders" - | "generate-suggestions" - | "replace" - | "import-components"; - -interface CommandConfig { - name: T; - description: string; - commands?: CommandConfig<"add" | "remove">[]; - flags?: { - [flag: string]: { description: string; processor?: (value: string) => any }; - }; -} - -const COMMANDS: CommandConfig[] = [ - { - name: "pull", - description: "Sync copy from Ditto into the current working directory", - flags: { - "--sample-data": { - description: "Include sample data. Currently only supports variants.", - }, - }, - }, - { - name: "project", - description: "Add a Ditto project to sync copy from", - commands: [ - { - name: "add", - description: "Add a Ditto project to sync copy from", - }, - { - name: "remove", - description: "Stop syncing copy from a Ditto project", - }, - ], - }, - { - name: "component-folders", - description: - "List component folders in your workspace. More information about component folders can be found here: https://www.dittowords.com/docs/component-folders.", - flags: { - "-s, --sample-data": { - description: "Includes the sample components folder in the output", - }, - }, - }, - { - name: "generate-suggestions", - description: "Find text that can be potentially replaced with Ditto text", - flags: { - "-d, --directory [value]": { - description: "Directory to search for text", - }, - "-f, --files [value]": { - description: "Files to search for text (will override -d)", - processor: (value: string) => value.split(","), - }, - "-cf, --component-folder [value]": { - description: "Component folder to search for matches", - }, - }, - }, - { - name: "replace", - description: "Find and replace Ditto text with code", - flags: { - "-ln, --line-numbers [value]": { - description: "Only replace text on a specific line number", - processor: (value: string) => value.split(",").map(Number), - }, - }, - }, - { - name: "import-components", - description: - "Import components via a file. For more information please see: https://www.dittowords.com/docs/importing-string-files.", - flags: { - "-t, --text [value]": { - description: "Text column index (.csv format only)", - }, - "-n, --component-name [value]": { - description: "Name column indexes (comma separated) (.csv format only)", - }, - "-no, --notes [value]": { - description: "Notes column index (.csv format only)", - }, - "-t, --tags [value]": { - description: "Tags column index (.csv format only)", - }, - "-s, --status [value]": { - description: "Status column index (.csv format only)", - }, - "-c, --component-id [value]": { - description: "Component ID column index (.csv format only)", - }, - }, - }, -]; - -const setupCommands = () => { - program.name("ditto-cli"); - - COMMANDS.forEach((commandConfig) => { - const cmd = program - .command(commandConfig.name) - .description(commandConfig.description) - .action((options) => { - return executeCommand(commandConfig.name, options); - }); - - if (commandConfig.flags) { - Object.entries(commandConfig.flags).forEach( - ([flags, { description, processor }]) => { - if (processor) { - cmd.option(flags, description, processor); - } else { - cmd.option(flags, description); - } - } - ); - } - - if ("commands" in commandConfig && commandConfig.commands) { - commandConfig.commands.forEach((nestedCommand) => { - cmd - .command(nestedCommand.name) - .description(nestedCommand.description) - .action((str, options) => { - if (commandConfig.name === "project") { - const command = - `${commandConfig.name} ${nestedCommand.name}` as Command; - - return executeCommand(command, options); - } - }); - }); - } - }); -}; - -const setupOptions = () => { - program.option( - "-m, --meta ", - "Include arbitrary data in requests to the Ditto API. Ex: -m githubActionRequest:true trigger:manual" - ); - program.version(VERSION, "-v, --version", "Output the current version"); -}; - -const executeCommand = async ( - command: Command | "none", - options: any -): Promise => { - const needsInitialization = - CONFIG_FILE_RELIANT_COMMANDS.includes(command) && needsTokenOrSource(); - - if (needsInitialization) { - try { - await init(); - } catch (error) { - await quit("Exiting Ditto CLI..."); - return; - } - } - - const { meta } = program.opts(); - switch (command) { - case "none": - case "pull": { - return pull({ - meta: processMetaOption(meta), - includeSampleData: options.sampleData || false, - }); - } - case "project": - case "project add": { - // initialization already includes the selection of a source, - // so if `project add` is called during initialization, don't - // prompt the user to select a source again - if (needsInitialization) return; - - return addProject(); - } - case "project remove": { - return removeProject(); - } - case "component-folders": { - return showComponentFolders({ - showSampleData: options.sampleData, - }); - } - case "generate-suggestions": { - return generateSuggestions({ - directory: options.directory, - files: options.files, - componentFolder: options.componentFolder, - }); - } - case "replace": { - return replace(options.args, { - ...(options?.lineNumbers ? { lineNumbers: options.lineNumbers } : {}), - }); - } - case "import-components": { - if (options.args.length === 0) { - console.info("Please provide a file path."); - return; - } - return importComponents(options.args[0], { - csvColumnMapping: { - name: options.componentName, - text: options.text, - notes: options.notes, - tags: options.tags, - status: options.status, - componentId: options.componentId, - }, - }); - } - default: { - await quit("Exiting Ditto CLI..."); - return; - } - } -}; - const main = async () => { - setupCommands(); - setupOptions(); - - if (process.argv.length <= 2 && process.argv[1].includes("ditto-cli")) { - await executeCommand("none", []); - return; + // Check for --legacy flag and run in legacy mode if present + if (process.argv.includes("--legacy")) { + console.log( + output.warnText( + "\nDitto CLI is running in legacy mode. This mode is deprecated and will be removed in a future release.\n" + ) + ); + legacyAppEntry(); + } else { + // Run in Beta mode + appEntry(); } - - program.parse(process.argv); }; main(); diff --git a/lib/__mocks__/api.ts b/lib/legacy/__mocks__/api.ts similarity index 100% rename from lib/__mocks__/api.ts rename to lib/legacy/__mocks__/api.ts diff --git a/lib/add-project.ts b/lib/legacy/add-project.ts similarity index 100% rename from lib/add-project.ts rename to lib/legacy/add-project.ts diff --git a/lib/api.ts b/lib/legacy/api.ts similarity index 100% rename from lib/api.ts rename to lib/legacy/api.ts diff --git a/lib/component-folders.ts b/lib/legacy/component-folders.ts similarity index 100% rename from lib/component-folders.ts rename to lib/legacy/component-folders.ts diff --git a/lib/config.test.ts b/lib/legacy/config.test.ts similarity index 100% rename from lib/config.test.ts rename to lib/legacy/config.test.ts diff --git a/lib/config.ts b/lib/legacy/config.ts similarity index 100% rename from lib/config.ts rename to lib/legacy/config.ts diff --git a/lib/consts.ts b/lib/legacy/consts.ts similarity index 100% rename from lib/consts.ts rename to lib/legacy/consts.ts diff --git a/lib/generate-suggestions.test.ts b/lib/legacy/generate-suggestions.test.ts similarity index 100% rename from lib/generate-suggestions.test.ts rename to lib/legacy/generate-suggestions.test.ts diff --git a/lib/generate-suggestions.ts b/lib/legacy/generate-suggestions.ts similarity index 100% rename from lib/generate-suggestions.ts rename to lib/legacy/generate-suggestions.ts diff --git a/lib/http/__mocks__/fetchComponentFolders.ts b/lib/legacy/http/__mocks__/fetchComponentFolders.ts similarity index 100% rename from lib/http/__mocks__/fetchComponentFolders.ts rename to lib/legacy/http/__mocks__/fetchComponentFolders.ts diff --git a/lib/http/__mocks__/fetchComponents.ts b/lib/legacy/http/__mocks__/fetchComponents.ts similarity index 100% rename from lib/http/__mocks__/fetchComponents.ts rename to lib/legacy/http/__mocks__/fetchComponents.ts diff --git a/lib/http/__mocks__/fetchVariants.ts b/lib/legacy/http/__mocks__/fetchVariants.ts similarity index 100% rename from lib/http/__mocks__/fetchVariants.ts rename to lib/legacy/http/__mocks__/fetchVariants.ts diff --git a/lib/http/fetchComponentFolders.ts b/lib/legacy/http/fetchComponentFolders.ts similarity index 100% rename from lib/http/fetchComponentFolders.ts rename to lib/legacy/http/fetchComponentFolders.ts diff --git a/lib/http/fetchComponents.ts b/lib/legacy/http/fetchComponents.ts similarity index 100% rename from lib/http/fetchComponents.ts rename to lib/legacy/http/fetchComponents.ts diff --git a/lib/http/fetchVariants.ts b/lib/legacy/http/fetchVariants.ts similarity index 100% rename from lib/http/fetchVariants.ts rename to lib/legacy/http/fetchVariants.ts diff --git a/lib/http/http.test.ts b/lib/legacy/http/http.test.ts similarity index 100% rename from lib/http/http.test.ts rename to lib/legacy/http/http.test.ts diff --git a/lib/http/importComponents.ts b/lib/legacy/http/importComponents.ts similarity index 100% rename from lib/http/importComponents.ts rename to lib/legacy/http/importComponents.ts diff --git a/lib/importComponents.ts b/lib/legacy/importComponents.ts similarity index 100% rename from lib/importComponents.ts rename to lib/legacy/importComponents.ts diff --git a/lib/legacy/index.ts b/lib/legacy/index.ts new file mode 100644 index 0000000..591368d --- /dev/null +++ b/lib/legacy/index.ts @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// This is the main entry point for the legacy ditto-cli command. +import { program } from "commander"; +// to use V8's code cache to speed up instantiation time +import "v8-compile-cache"; +import fs from "fs"; +import path from "path"; +import { init, needsTokenOrSource } from "./init/init"; +import { pull } from "./pull"; +import { quit } from "./utils/quit"; +import addProject from "./add-project"; +import removeProject from "./remove-project"; +import { replace } from "./replace"; +import { generateSuggestions } from "./generate-suggestions"; +import processMetaOption from "./utils/processMetaOption"; +import { importComponents } from "./importComponents"; +import { showComponentFolders } from "./component-folders"; + +function getVersion(): string { + const packageJsonPath = path.join(__dirname, "../..", "package.json"); + const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8"); + const packageJson = JSON.parse(packageJsonContent) as { version: string }; + return packageJson.version; +} + +const VERSION = getVersion(); + +const CONFIG_FILE_RELIANT_COMMANDS = [ + "pull", + "none", + "project", + "project add", + "project remove", +]; + +type Command = + | "pull" + | "project" + | "project add" + | "project remove" + | "component-folders" + | "generate-suggestions" + | "replace" + | "import-components"; + +interface CommandConfig { + name: T; + description: string; + commands?: CommandConfig<"add" | "remove">[]; + flags?: { + [flag: string]: { description: string; processor?: (value: string) => any }; + }; +} + +const COMMANDS: CommandConfig[] = [ + { + name: "pull", + description: "Sync copy from Ditto into the current working directory", + flags: { + "--sample-data": { + description: "Include sample data. Currently only supports variants.", + }, + }, + }, + { + name: "project", + description: "Add a Ditto project to sync copy from", + commands: [ + { + name: "add", + description: "Add a Ditto project to sync copy from", + }, + { + name: "remove", + description: "Stop syncing copy from a Ditto project", + }, + ], + }, + { + name: "component-folders", + description: + "List component folders in your workspace. More information about component folders can be found here: https://www.dittowords.com/docs/component-folders.", + flags: { + "-s, --sample-data": { + description: "Includes the sample components folder in the output", + }, + }, + }, + { + name: "generate-suggestions", + description: "Find text that can be potentially replaced with Ditto text", + flags: { + "-d, --directory [value]": { + description: "Directory to search for text", + }, + "-f, --files [value]": { + description: "Files to search for text (will override -d)", + processor: (value: string) => value.split(","), + }, + "-cf, --component-folder [value]": { + description: "Component folder to search for matches", + }, + }, + }, + { + name: "replace", + description: "Find and replace Ditto text with code", + flags: { + "-ln, --line-numbers [value]": { + description: "Only replace text on a specific line number", + processor: (value: string) => value.split(",").map(Number), + }, + }, + }, + { + name: "import-components", + description: + "Import components via a file. For more information please see: https://www.dittowords.com/docs/importing-string-files.", + flags: { + "-t, --text [value]": { + description: "Text column index (.csv format only)", + }, + "-n, --component-name [value]": { + description: "Name column indexes (comma separated) (.csv format only)", + }, + "-no, --notes [value]": { + description: "Notes column index (.csv format only)", + }, + "-t, --tags [value]": { + description: "Tags column index (.csv format only)", + }, + "-s, --status [value]": { + description: "Status column index (.csv format only)", + }, + "-c, --component-id [value]": { + description: "Component ID column index (.csv format only)", + }, + }, + }, +]; + +const setupCommands = () => { + program.name("ditto-cli"); + + COMMANDS.forEach((commandConfig) => { + const cmd = program + .command(commandConfig.name) + .description(commandConfig.description) + .action((options) => { + return executeCommand(commandConfig.name, options); + }); + + if (commandConfig.flags) { + Object.entries(commandConfig.flags).forEach( + ([flags, { description, processor }]) => { + if (processor) { + cmd.option(flags, description, processor); + } else { + cmd.option(flags, description); + } + } + ); + } + + if ("commands" in commandConfig && commandConfig.commands) { + commandConfig.commands.forEach((nestedCommand) => { + cmd + .command(nestedCommand.name) + .description(nestedCommand.description) + .action((str, options) => { + if (commandConfig.name === "project") { + const command = + `${commandConfig.name} ${nestedCommand.name}` as Command; + + return executeCommand(command, options); + } + }); + }); + } + }); +}; + +const setupOptions = () => { + program.option("-l, --legacy", "Run in legacy mode"); + program.option( + "-m, --meta ", + "Include arbitrary data in requests to the Ditto API. Ex: -m githubActionRequest:true trigger:manual" + ); + program.version(VERSION, "-v, --version", "Output the current version"); +}; + +const executeCommand = async ( + command: Command | "none", + options: any +): Promise => { + const needsInitialization = + CONFIG_FILE_RELIANT_COMMANDS.includes(command) && needsTokenOrSource(); + + if (needsInitialization) { + try { + await init(); + } catch (error) { + await quit("Exiting Ditto CLI..."); + return; + } + } + + const { meta } = program.opts(); + switch (command) { + case "none": + case "pull": { + return pull({ + meta: processMetaOption(meta), + includeSampleData: options.sampleData || false, + }); + } + case "project": + case "project add": { + // initialization already includes the selection of a source, + // so if `project add` is called during initialization, don't + // prompt the user to select a source again + if (needsInitialization) return; + + return addProject(); + } + case "project remove": { + return removeProject(); + } + case "component-folders": { + return showComponentFolders({ + showSampleData: options.sampleData, + }); + } + case "generate-suggestions": { + return generateSuggestions({ + directory: options.directory, + files: options.files, + componentFolder: options.componentFolder, + }); + } + case "replace": { + return replace(options.args, { + ...(options?.lineNumbers ? { lineNumbers: options.lineNumbers } : {}), + }); + } + case "import-components": { + if (options.args.length === 0) { + console.info("Please provide a file path."); + return; + } + return importComponents(options.args[0], { + csvColumnMapping: { + name: options.componentName, + text: options.text, + notes: options.notes, + tags: options.tags, + status: options.status, + componentId: options.componentId, + }, + }); + } + default: { + await quit("Exiting Ditto CLI..."); + return; + } + } +}; + +const legacyAppEntry = async () => { + setupCommands(); + setupOptions(); + + if (process.argv.length <= 2 && process.argv[1].includes("ditto-cli")) { + await executeCommand("none", []); + return; + } + + program.parse(process.argv); +}; + +export default legacyAppEntry; diff --git a/lib/init/init.ts b/lib/legacy/init/init.ts similarity index 100% rename from lib/init/init.ts rename to lib/legacy/init/init.ts diff --git a/lib/init/project.test.ts b/lib/legacy/init/project.test.ts similarity index 100% rename from lib/init/project.test.ts rename to lib/legacy/init/project.test.ts diff --git a/lib/init/project.ts b/lib/legacy/init/project.ts similarity index 100% rename from lib/init/project.ts rename to lib/legacy/init/project.ts diff --git a/lib/init/token.test.ts b/lib/legacy/init/token.test.ts similarity index 100% rename from lib/init/token.test.ts rename to lib/legacy/init/token.test.ts diff --git a/lib/init/token.ts b/lib/legacy/init/token.ts similarity index 100% rename from lib/init/token.ts rename to lib/legacy/init/token.ts diff --git a/lib/output.ts b/lib/legacy/output.ts similarity index 100% rename from lib/output.ts rename to lib/legacy/output.ts diff --git a/lib/pull-lib.test.ts b/lib/legacy/pull-lib.test.ts similarity index 100% rename from lib/pull-lib.test.ts rename to lib/legacy/pull-lib.test.ts diff --git a/lib/pull.test.ts b/lib/legacy/pull.test.ts similarity index 100% rename from lib/pull.test.ts rename to lib/legacy/pull.test.ts diff --git a/lib/pull.ts b/lib/legacy/pull.ts similarity index 100% rename from lib/pull.ts rename to lib/legacy/pull.ts diff --git a/lib/remove-project.ts b/lib/legacy/remove-project.ts similarity index 100% rename from lib/remove-project.ts rename to lib/legacy/remove-project.ts diff --git a/lib/replace.test.ts b/lib/legacy/replace.test.ts similarity index 100% rename from lib/replace.test.ts rename to lib/legacy/replace.test.ts diff --git a/lib/replace.ts b/lib/legacy/replace.ts similarity index 100% rename from lib/replace.ts rename to lib/legacy/replace.ts diff --git a/lib/types.ts b/lib/legacy/types.ts similarity index 100% rename from lib/types.ts rename to lib/legacy/types.ts diff --git a/lib/utils/cleanFileName.test.ts b/lib/legacy/utils/cleanFileName.test.ts similarity index 100% rename from lib/utils/cleanFileName.test.ts rename to lib/legacy/utils/cleanFileName.test.ts diff --git a/lib/utils/cleanFileName.ts b/lib/legacy/utils/cleanFileName.ts similarity index 100% rename from lib/utils/cleanFileName.ts rename to lib/legacy/utils/cleanFileName.ts diff --git a/lib/utils/createSentryContext.ts b/lib/legacy/utils/createSentryContext.ts similarity index 100% rename from lib/utils/createSentryContext.ts rename to lib/legacy/utils/createSentryContext.ts diff --git a/lib/utils/determineModuleType.test.ts b/lib/legacy/utils/determineModuleType.test.ts similarity index 100% rename from lib/utils/determineModuleType.test.ts rename to lib/legacy/utils/determineModuleType.test.ts diff --git a/lib/utils/determineModuleType.ts b/lib/legacy/utils/determineModuleType.ts similarity index 100% rename from lib/utils/determineModuleType.ts rename to lib/legacy/utils/determineModuleType.ts diff --git a/lib/utils/generateIOSBundles.ts b/lib/legacy/utils/generateIOSBundles.ts similarity index 100% rename from lib/utils/generateIOSBundles.ts rename to lib/legacy/utils/generateIOSBundles.ts diff --git a/lib/utils/generateJsDriver.ts b/lib/legacy/utils/generateJsDriver.ts similarity index 100% rename from lib/utils/generateJsDriver.ts rename to lib/legacy/utils/generateJsDriver.ts diff --git a/lib/utils/generateJsDriverTypeFile.ts b/lib/legacy/utils/generateJsDriverTypeFile.ts similarity index 100% rename from lib/utils/generateJsDriverTypeFile.ts rename to lib/legacy/utils/generateJsDriverTypeFile.ts diff --git a/lib/utils/generateSwiftDriver.ts b/lib/legacy/utils/generateSwiftDriver.ts similarity index 100% rename from lib/utils/generateSwiftDriver.ts rename to lib/legacy/utils/generateSwiftDriver.ts diff --git a/lib/utils/getSelectedProjects.ts b/lib/legacy/utils/getSelectedProjects.ts similarity index 100% rename from lib/utils/getSelectedProjects.ts rename to lib/legacy/utils/getSelectedProjects.ts diff --git a/lib/utils/processMetaOption.test.ts b/lib/legacy/utils/processMetaOption.test.ts similarity index 100% rename from lib/utils/processMetaOption.test.ts rename to lib/legacy/utils/processMetaOption.test.ts diff --git a/lib/utils/processMetaOption.ts b/lib/legacy/utils/processMetaOption.ts similarity index 100% rename from lib/utils/processMetaOption.ts rename to lib/legacy/utils/processMetaOption.ts diff --git a/lib/utils/projectsToText.ts b/lib/legacy/utils/projectsToText.ts similarity index 100% rename from lib/utils/projectsToText.ts rename to lib/legacy/utils/projectsToText.ts diff --git a/lib/utils/promptForProject.ts b/lib/legacy/utils/promptForProject.ts similarity index 100% rename from lib/utils/promptForProject.ts rename to lib/legacy/utils/promptForProject.ts diff --git a/lib/utils/quit.ts b/lib/legacy/utils/quit.ts similarity index 100% rename from lib/utils/quit.ts rename to lib/legacy/utils/quit.ts diff --git a/lib/utils/sourcesToText.ts b/lib/legacy/utils/sourcesToText.ts similarity index 100% rename from lib/utils/sourcesToText.ts rename to lib/legacy/utils/sourcesToText.ts diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts new file mode 100644 index 0000000..7bee53f --- /dev/null +++ b/lib/src/commands/pull.ts @@ -0,0 +1,3 @@ +export const pull = async () => { + console.log("pull"); +}; diff --git a/lib/src/index.ts b/lib/src/index.ts new file mode 100644 index 0000000..0b9d7de --- /dev/null +++ b/lib/src/index.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// This is the main entry point for the ditto-cli command. +import { program } from "commander"; +// to use V8's code cache to speed up instantiation time +import "v8-compile-cache"; +import fs from "fs"; +import path from "path"; +import { pull } from "./commands/pull"; +import { quit } from "./utils/quit"; + +function getVersion(): string { + const packageJsonPath = path.join(__dirname, "../../package.json"); + const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8"); + const packageJson = JSON.parse(packageJsonContent) as { version: string }; + return packageJson.version; +} + +const VERSION = getVersion(); + +const CONFIG_FILE_RELIANT_COMMANDS = [ + "pull", + "none", + "project", + "project add", + "project remove", +]; + +type Command = + | "pull" + | "project" + | "project add" + | "project remove" + | "component-folders" + | "generate-suggestions" + | "replace" + | "import-components"; + +interface CommandConfig { + name: T; + description: string; + commands?: CommandConfig<"add" | "remove">[]; + flags?: { + [flag: string]: { description: string; processor?: (value: string) => any }; + }; +} + +const COMMANDS: CommandConfig[] = [ + { + name: "pull", + description: "Sync copy from Ditto into the current working directory", + }, +]; + +const setupCommands = () => { + program.name("ditto-cli"); + + COMMANDS.forEach((commandConfig) => { + const cmd = program + .command(commandConfig.name) + .description(commandConfig.description) + .action((options) => { + return executeCommand(commandConfig.name, options); + }); + + if (commandConfig.flags) { + Object.entries(commandConfig.flags).forEach( + ([flags, { description, processor }]) => { + if (processor) { + cmd.option(flags, description, processor); + } else { + cmd.option(flags, description); + } + } + ); + } + + if ("commands" in commandConfig && commandConfig.commands) { + commandConfig.commands.forEach((nestedCommand) => { + cmd + .command(nestedCommand.name) + .description(nestedCommand.description) + .action((str, options) => { + if (commandConfig.name === "project") { + const command = + `${commandConfig.name} ${nestedCommand.name}` as Command; + + return executeCommand(command, options); + } + }); + }); + } + }); +}; + +const setupOptions = () => { + program.option("-l, --legacy", "Run in legacy mode"); + program.version(VERSION, "-v, --version", "Output the current version"); +}; + +const executeCommand = async ( + command: Command | "none", + options: any +): Promise => { + switch (command) { + case "none": + case "pull": { + return pull(); + } + default: { + await quit("Exiting Ditto CLI..."); + return; + } + } +}; + +const appEntry = async () => { + setupCommands(); + setupOptions(); + + if (process.argv.length <= 2 && process.argv[1].includes("ditto-cli")) { + await executeCommand("none", []); + return; + } + + program.parse(process.argv); +}; + +export default appEntry; diff --git a/lib/src/output.ts b/lib/src/output.ts new file mode 100644 index 0000000..74499d0 --- /dev/null +++ b/lib/src/output.ts @@ -0,0 +1,21 @@ +import chalk from "chalk"; + +export const errorText = (msg: string) => chalk.magenta(msg); +export const warnText = (msg: string) => chalk.yellow(msg); +export const info = (msg: string) => chalk.blueBright(msg); +export const success = (msg: string) => chalk.green(msg); +export const url = (msg: string) => chalk.blueBright.underline(msg); +export const subtle = (msg: string) => chalk.grey(msg); +export const write = (msg: string) => chalk.white(msg); +export const nl = () => console.log("\n"); + +export default { + errorText, + warnText, + info, + success, + url, + subtle, + write, + nl, +}; diff --git a/lib/src/utils/quit.ts b/lib/src/utils/quit.ts new file mode 100644 index 0000000..cdb6a91 --- /dev/null +++ b/lib/src/utils/quit.ts @@ -0,0 +1,7 @@ +import * as Sentry from "@sentry/node"; + +export async function quit(message: string | null, exitCode = 2) { + if (message) console.log(`\n${message}\n`); + await Sentry.flush(); + process.exit(exitCode); +} From 347bfd9fe43dfe3b0da39594da60db4f318a3074 Mon Sep 17 00:00:00 2001 From: Jason Rodriguez Date: Tue, 8 Apr 2025 14:59:11 -0700 Subject: [PATCH 02/52] Jrod/dit 10044 top level cli branching (#115) * Moves Legacy CLI into a `legacy` folder and provides a new entrypoint to the beta CLI - adds a `--legacy (-l)` CLI option that forces the application to run in legacy mode. - adds a new warning message when the CLI is run in legacy mode. * Update lib/legacy/index.ts Co-authored-by: Marla Hoggard * Cleanup build scripts and upgrade typescript and esbuild (#114) * Modernize build system and upgrade esbuild and typescript - Additionally removes the v8-compile-cache that provides little value in modern node applications of this size. - Adds minification support via esbuild as well as cleaner map files. - Updates package.json "files" property to now only ship required files to npm. --------- Co-authored-by: Marla Hoggard --- esbuild.mjs | 33 +++++ etsc.config.js | 13 -- lib/ditto.ts | 3 - lib/legacy/index.ts | 16 +-- lib/src/index.ts | 16 +-- package.json | 25 ++-- tsconfig.json | 17 ++- yarn.lock | 318 ++++++++++++++++++++++---------------------- 8 files changed, 221 insertions(+), 220 deletions(-) create mode 100644 esbuild.mjs delete mode 100644 etsc.config.js diff --git a/esbuild.mjs b/esbuild.mjs new file mode 100644 index 0000000..5b3edf5 --- /dev/null +++ b/esbuild.mjs @@ -0,0 +1,33 @@ +import * as esbuild from "esbuild"; + +/** + * @type {esbuild.BuildOptions} + */ +const config = { + entryPoints: ["lib/ditto.ts"], + bundle: true, + metafile: true, + keepNames: true, + tsconfig: "tsconfig.json", + sourcemap: process.env.ENV === "production" ? "external" : "both", + minify: process.env.ENV === "production", + outdir: "bin", + target: "es2020", + packages: "external", + platform: "node", +}; + +async function main() { + const result = await esbuild.build(config); + // Output build metafile so we can analyze the bundle + // size over time and check if anything unexpected is being bundled in. + if (process.env.ENV === "production") { + console.log( + await esbuild.analyzeMetafile(result.metafile, { + verbose: true, + }) + ); + } +} + +main(); diff --git a/etsc.config.js b/etsc.config.js deleted file mode 100644 index 2fda0d6..0000000 --- a/etsc.config.js +++ /dev/null @@ -1,13 +0,0 @@ -const dotenv = require("dotenv"); -dotenv.config(); - -module.exports = { - esbuild: { - platform: "node", - packages: "external", - define: { - "process.env.ENV": `"${process.env.ENV || "production"}"`, - "process.env.SENTRY_DSN": `"${process.env.SENTRY_DSN}"`, - }, - }, -}; diff --git a/lib/ditto.ts b/lib/ditto.ts index 0a97f4c..c1d72cf 100755 --- a/lib/ditto.ts +++ b/lib/ditto.ts @@ -1,8 +1,5 @@ #!/usr/bin/env node // This is the main entry point for the ditto-cli command. - -// to use V8's code cache to speed up instantiation time -import "v8-compile-cache"; import * as Sentry from "@sentry/node"; import { version as release } from "../package.json"; import legacyAppEntry from "./legacy"; diff --git a/lib/legacy/index.ts b/lib/legacy/index.ts index 591368d..8291766 100644 --- a/lib/legacy/index.ts +++ b/lib/legacy/index.ts @@ -1,10 +1,6 @@ #!/usr/bin/env node // This is the main entry point for the legacy ditto-cli command. import { program } from "commander"; -// to use V8's code cache to speed up instantiation time -import "v8-compile-cache"; -import fs from "fs"; -import path from "path"; import { init, needsTokenOrSource } from "./init/init"; import { pull } from "./pull"; import { quit } from "./utils/quit"; @@ -15,15 +11,7 @@ import { generateSuggestions } from "./generate-suggestions"; import processMetaOption from "./utils/processMetaOption"; import { importComponents } from "./importComponents"; import { showComponentFolders } from "./component-folders"; - -function getVersion(): string { - const packageJsonPath = path.join(__dirname, "../..", "package.json"); - const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8"); - const packageJson = JSON.parse(packageJsonContent) as { version: string }; - return packageJson.version; -} - -const VERSION = getVersion(); +import { version } from "../../package.json"; const CONFIG_FILE_RELIANT_COMMANDS = [ "pull", @@ -186,7 +174,7 @@ const setupOptions = () => { "-m, --meta ", "Include arbitrary data in requests to the Ditto API. Ex: -m githubActionRequest:true trigger:manual" ); - program.version(VERSION, "-v, --version", "Output the current version"); + program.version(version, "-v, --version", "Output the current version"); }; const executeCommand = async ( diff --git a/lib/src/index.ts b/lib/src/index.ts index 0b9d7de..6cb10cc 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -1,21 +1,9 @@ #!/usr/bin/env node // This is the main entry point for the ditto-cli command. import { program } from "commander"; -// to use V8's code cache to speed up instantiation time -import "v8-compile-cache"; -import fs from "fs"; -import path from "path"; import { pull } from "./commands/pull"; import { quit } from "./utils/quit"; - -function getVersion(): string { - const packageJsonPath = path.join(__dirname, "../../package.json"); - const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8"); - const packageJson = JSON.parse(packageJsonContent) as { version: string }; - return packageJson.version; -} - -const VERSION = getVersion(); +import { version } from "../../package.json"; const CONFIG_FILE_RELIANT_COMMANDS = [ "pull", @@ -94,7 +82,7 @@ const setupCommands = () => { const setupOptions = () => { program.option("-l, --legacy", "Run in legacy mode"); - program.version(VERSION, "-v, --version", "Output the current version"); + program.version(version, "-v, --version", "Output the current version"); }; const executeCommand = async ( diff --git a/package.json b/package.json index 77290c1..7ce14c5 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,12 @@ "version": "4.5.2", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", - "main": "bin/index.js", + "main": "bin/ditto.js", "scripts": { - "prepublishOnly": "ENV=production etsc && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", + "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", - "start": "tsc --noEmit --excludeFiles './**/*.test.ts' && etsc && node bin/ditto.js", - "sync": "tsc --noEmit --excludeFiles './**/*.test.ts' && etsc && node bin/ditto.js pull", - "dev": "tsc --noEmit --excludeFiles './**/*.test.ts' && etsc --watch" + "start": "node esbuild.mjs && node bin/ditto.js", + "sync": "node esbuild.mjs && node bin/ditto.js pull" }, "repository": { "type": "git", @@ -32,6 +31,14 @@ "bin": { "ditto-cli": "bin/ditto.js" }, + "files": [ + "bin", + "!bin/ditto.js.map", + "package.json", + "yarn.lock", + "README.md", + "LICENSE" + ], "devDependencies": { "@babel/preset-env": "^7.20.2", "@babel/preset-typescript": "^7.18.6", @@ -43,8 +50,7 @@ "@types/node": "^18.0.0", "babel-jest": "^29.3.1", "dotenv": "^16.3.1", - "esbuild": "^0.19.2", - "esbuild-node-tsc": "^2.0.5", + "esbuild": "^0.25.2", "husky": "^7.0.4", "jest": "^29.3.1", "lint-staged": "^11.2.4", @@ -53,7 +59,7 @@ "source-map": "^0.7.3", "tempy": "^0.6.0", "ts-node": "^10.9.2", - "typescript": "^4.7.4" + "typescript": "^5.8.3" }, "dependencies": { "@babel/core": "^7.11.4", @@ -72,8 +78,7 @@ "glob": "^9.3.4", "js-yaml": "^4.1.0", "memfs": "^4.7.7", - "ora": "^5.0.0", - "v8-compile-cache": "^2.1.1" + "ora": "^5.0.0" }, "lint-staged": { "src/**/*.{js,jsx,ts,tsx,css,json}": "prettier --write" diff --git a/tsconfig.json b/tsconfig.json index 567548a..c9a1022 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,21 @@ { - "extends": "@tsconfig/node16/tsconfig.json", "compilerOptions": { - "allowJs": true, + "lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"], + "module": "commonjs", + "target": "es2019", + + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "allowJs": false, "outDir": "bin", - "allowSyntheticDefaultImports": true, "resolveJsonModule": true, - "strictNullChecks": true, "sourceMap": true, // Set `sourceRoot` to "/" to strip the build path prefix from // generated source code references. This will improve issue grouping in Sentry. - "sourceRoot": "/", - "strict": true + "sourceRoot": "/" }, "include": ["lib/**/*.ts"] } diff --git a/yarn.lock b/yarn.lock index 1396785..9ef48a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1080,115 +1080,130 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@esbuild/android-arm64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.2.tgz#bc35990f412a749e948b792825eef7df0ce0e073" - integrity sha512-lsB65vAbe90I/Qe10OjkmrdxSX4UJDjosDgb8sZUKcg3oefEuW2OT2Vozz8ef7wrJbMcmhvCC+hciF8jY/uAkw== - -"@esbuild/android-arm@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.2.tgz#edd1c8f23ba353c197f5b0337123c58ff2a56999" - integrity sha512-tM8yLeYVe7pRyAu9VMi/Q7aunpLwD139EY1S99xbQkT4/q2qa6eA4ige/WJQYdJ8GBL1K33pPFhPfPdJ/WzT8Q== - -"@esbuild/android-x64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.2.tgz#2dcdd6e6f1f2d82ea1b746abd8da5b284960f35a" - integrity sha512-qK/TpmHt2M/Hg82WXHRc/W/2SGo/l1thtDHZWqFq7oi24AjZ4O/CpPSu6ZuYKFkEgmZlFoa7CooAyYmuvnaG8w== - -"@esbuild/darwin-arm64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.2.tgz#55b36bc06d76f5c243987c1f93a11a80d8fc3b26" - integrity sha512-Ora8JokrvrzEPEpZO18ZYXkH4asCdc1DLdcVy8TGf5eWtPO1Ie4WroEJzwI52ZGtpODy3+m0a2yEX9l+KUn0tA== - -"@esbuild/darwin-x64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.2.tgz#982524af33a6424a3b5cb44bbd52559623ad719c" - integrity sha512-tP+B5UuIbbFMj2hQaUr6EALlHOIOmlLM2FK7jeFBobPy2ERdohI4Ka6ZFjZ1ZYsrHE/hZimGuU90jusRE0pwDw== - -"@esbuild/freebsd-arm64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.2.tgz#8e478a0856645265fe79eac4b31b52193011ee06" - integrity sha512-YbPY2kc0acfzL1VPVK6EnAlig4f+l8xmq36OZkU0jzBVHcOTyQDhnKQaLzZudNJQyymd9OqQezeaBgkTGdTGeQ== - -"@esbuild/freebsd-x64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.2.tgz#01b96604f2540db023c73809bb8ae6cd1692d6f3" - integrity sha512-nSO5uZT2clM6hosjWHAsS15hLrwCvIWx+b2e3lZ3MwbYSaXwvfO528OF+dLjas1g3bZonciivI8qKR/Hm7IWGw== - -"@esbuild/linux-arm64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.2.tgz#7e5d2c7864c5c83ec789b59c77cd9c20d2594916" - integrity sha512-ig2P7GeG//zWlU0AggA3pV1h5gdix0MA3wgB+NsnBXViwiGgY77fuN9Wr5uoCrs2YzaYfogXgsWZbm+HGr09xg== - -"@esbuild/linux-arm@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.2.tgz#c32ae97bc0246664a1cfbdb4a98e7b006d7db8ae" - integrity sha512-Odalh8hICg7SOD7XCj0YLpYCEc+6mkoq63UnExDCiRA2wXEmGlK5JVrW50vZR9Qz4qkvqnHcpH+OFEggO3PgTg== - -"@esbuild/linux-ia32@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.2.tgz#3fc4f0fa026057fe885e4a180b3956e704f1ceaa" - integrity sha512-mLfp0ziRPOLSTek0Gd9T5B8AtzKAkoZE70fneiiyPlSnUKKI4lp+mGEnQXcQEHLJAcIYDPSyBvsUbKUG2ri/XQ== - -"@esbuild/linux-loong64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.2.tgz#633bcaea443f3505fb0ed109ab840c99ad3451a4" - integrity sha512-hn28+JNDTxxCpnYjdDYVMNTR3SKavyLlCHHkufHV91fkewpIyQchS1d8wSbmXhs1fiYDpNww8KTFlJ1dHsxeSw== - -"@esbuild/linux-mips64el@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.2.tgz#e0bff2898c46f52be7d4dbbcca8b887890805823" - integrity sha512-KbXaC0Sejt7vD2fEgPoIKb6nxkfYW9OmFUK9XQE4//PvGIxNIfPk1NmlHmMg6f25x57rpmEFrn1OotASYIAaTg== - -"@esbuild/linux-ppc64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.2.tgz#d75798da391f54a9674f8c143b9a52d1dbfbfdde" - integrity sha512-dJ0kE8KTqbiHtA3Fc/zn7lCd7pqVr4JcT0JqOnbj4LLzYnp+7h8Qi4yjfq42ZlHfhOCM42rBh0EwHYLL6LEzcw== - -"@esbuild/linux-riscv64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.2.tgz#012409bd489ed1bb9b775541d4a46c5ded8e6dd8" - integrity sha512-7Z/jKNFufZ/bbu4INqqCN6DDlrmOTmdw6D0gH+6Y7auok2r02Ur661qPuXidPOJ+FSgbEeQnnAGgsVynfLuOEw== - -"@esbuild/linux-s390x@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.2.tgz#ece3ed75c5a150de8a5c110f02e97d315761626b" - integrity sha512-U+RinR6aXXABFCcAY4gSlv4CL1oOVvSSCdseQmGO66H+XyuQGZIUdhG56SZaDJQcLmrSfRmx5XZOWyCJPRqS7g== - -"@esbuild/linux-x64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.2.tgz#dea187019741602d57aaf189a80abba261fbd2aa" - integrity sha512-oxzHTEv6VPm3XXNaHPyUTTte+3wGv7qVQtqaZCrgstI16gCuhNOtBXLEBkBREP57YTd68P0VgDgG73jSD8bwXQ== - -"@esbuild/netbsd-x64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.2.tgz#bbfd7cf9ab236a23ee3a41b26f0628c57623d92a" - integrity sha512-WNa5zZk1XpTTwMDompZmvQLHszDDDN7lYjEHCUmAGB83Bgs20EMs7ICD+oKeT6xt4phV4NDdSi/8OfjPbSbZfQ== - -"@esbuild/openbsd-x64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.2.tgz#fa5c4c6ee52a360618f00053652e2902e1d7b4a7" - integrity sha512-S6kI1aT3S++Dedb7vxIuUOb3oAxqxk2Rh5rOXOTYnzN8JzW1VzBd+IqPiSpgitu45042SYD3HCoEyhLKQcDFDw== - -"@esbuild/sunos-x64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.2.tgz#52a2ac8ac6284c02d25df22bb4cfde26fbddd68d" - integrity sha512-VXSSMsmb+Z8LbsQGcBMiM+fYObDNRm8p7tkUDMPG/g4fhFX5DEFmjxIEa3N8Zr96SjsJ1woAhF0DUnS3MF3ARw== - -"@esbuild/win32-arm64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.2.tgz#719ed5870855de8537aef8149694a97d03486804" - integrity sha512-5NayUlSAyb5PQYFAU9x3bHdsqB88RC3aM9lKDAz4X1mo/EchMIT1Q+pSeBXNgkfNmRecLXA0O8xP+x8V+g/LKg== - -"@esbuild/win32-ia32@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.2.tgz#24832223880b0f581962c8660f8fb8797a1e046a" - integrity sha512-47gL/ek1v36iN0wL9L4Q2MFdujR0poLZMJwhO2/N3gA89jgHp4MR8DKCmwYtGNksbfJb9JoTtbkoe6sDhg2QTA== - -"@esbuild/win32-x64@0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.2.tgz#1205014625790c7ff0e471644a878a65d1e34ab0" - integrity sha512-tcuhV7ncXBqbt/Ybf0IyrMcwVOAPDckMK9rXNHtF17UTK18OKLpg08glminN06pt2WCoALhXdLfSPbVvK/6fxw== +"@esbuild/aix-ppc64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz#b87036f644f572efb2b3c75746c97d1d2d87ace8" + integrity sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag== + +"@esbuild/android-arm64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz#5ca7dc20a18f18960ad8d5e6ef5cf7b0a256e196" + integrity sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w== + +"@esbuild/android-arm@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.2.tgz#3c49f607b7082cde70c6ce0c011c362c57a194ee" + integrity sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA== + +"@esbuild/android-x64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.2.tgz#8a00147780016aff59e04f1036e7cb1b683859e2" + integrity sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg== + +"@esbuild/darwin-arm64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz#486efe7599a8d90a27780f2bb0318d9a85c6c423" + integrity sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA== + +"@esbuild/darwin-x64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz#95ee222aacf668c7a4f3d7ee87b3240a51baf374" + integrity sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA== + +"@esbuild/freebsd-arm64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz#67efceda8554b6fc6a43476feba068fb37fa2ef6" + integrity sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w== + +"@esbuild/freebsd-x64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz#88a9d7ecdd3adadbfe5227c2122d24816959b809" + integrity sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ== + +"@esbuild/linux-arm64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz#87be1099b2bbe61282333b084737d46bc8308058" + integrity sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g== + +"@esbuild/linux-arm@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz#72a285b0fe64496e191fcad222185d7bf9f816f6" + integrity sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g== + +"@esbuild/linux-ia32@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz#337a87a4c4dd48a832baed5cbb022be20809d737" + integrity sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ== + +"@esbuild/linux-loong64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz#1b81aa77103d6b8a8cfa7c094ed3d25c7579ba2a" + integrity sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w== + +"@esbuild/linux-mips64el@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz#afbe380b6992e7459bf7c2c3b9556633b2e47f30" + integrity sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q== + +"@esbuild/linux-ppc64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz#6bf8695cab8a2b135cca1aa555226dc932d52067" + integrity sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g== + +"@esbuild/linux-riscv64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz#43c2d67a1a39199fb06ba978aebb44992d7becc3" + integrity sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw== + +"@esbuild/linux-s390x@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz#419e25737ec815c6dce2cd20d026e347cbb7a602" + integrity sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q== + +"@esbuild/linux-x64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz#22451f6edbba84abe754a8cbd8528ff6e28d9bcb" + integrity sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg== + +"@esbuild/netbsd-arm64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz#744affd3b8d8236b08c5210d828b0698a62c58ac" + integrity sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw== + +"@esbuild/netbsd-x64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz#dbbe7521fd6d7352f34328d676af923fc0f8a78f" + integrity sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg== + +"@esbuild/openbsd-arm64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz#f9caf987e3e0570500832b487ce3039ca648ce9f" + integrity sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg== + +"@esbuild/openbsd-x64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz#d2bb6a0f8ffea7b394bb43dfccbb07cabd89f768" + integrity sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw== + +"@esbuild/sunos-x64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz#49b437ed63fe333b92137b7a0c65a65852031afb" + integrity sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA== + +"@esbuild/win32-arm64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz#081424168463c7d6c7fb78f631aede0c104373cf" + integrity sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q== + +"@esbuild/win32-ia32@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz#3f9e87143ddd003133d21384944a6c6cadf9693f" + integrity sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg== + +"@esbuild/win32-x64@0.25.2": + version "0.25.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz#839f72c2decd378f86b8f525e1979a97b920c67d" + integrity sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA== "@eslint/eslintrc@^0.4.3": version "0.4.3" @@ -2466,40 +2481,36 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -esbuild-node-tsc@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/esbuild-node-tsc/-/esbuild-node-tsc-2.0.5.tgz#7d1db077efa6e745b48ec6ead5ce7121cdf03cfc" - integrity sha512-FPHgaamMLJ6nfx4+p+2pF9deu/yfhTitYqf/xsjFnVDQeWEpBo78uiAp9UoCXY29noamXipAWMmMo6suBvjQQw== - dependencies: - yargs "^17.6.2" - -esbuild@^0.19.2: - version "0.19.2" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.2.tgz#b1541828a89dfb6f840d38538767c6130dca2aac" - integrity sha512-G6hPax8UbFakEj3hWO0Vs52LQ8k3lnBhxZWomUJDxfz3rZTLqF5k/FCzuNdLx2RbpBiQQF9H9onlDDH1lZsnjg== +esbuild@^0.25.2: + version "0.25.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.2.tgz#55a1d9ebcb3aa2f95e8bba9e900c1a5061bc168b" + integrity sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ== optionalDependencies: - "@esbuild/android-arm" "0.19.2" - "@esbuild/android-arm64" "0.19.2" - "@esbuild/android-x64" "0.19.2" - "@esbuild/darwin-arm64" "0.19.2" - "@esbuild/darwin-x64" "0.19.2" - "@esbuild/freebsd-arm64" "0.19.2" - "@esbuild/freebsd-x64" "0.19.2" - "@esbuild/linux-arm" "0.19.2" - "@esbuild/linux-arm64" "0.19.2" - "@esbuild/linux-ia32" "0.19.2" - "@esbuild/linux-loong64" "0.19.2" - "@esbuild/linux-mips64el" "0.19.2" - "@esbuild/linux-ppc64" "0.19.2" - "@esbuild/linux-riscv64" "0.19.2" - "@esbuild/linux-s390x" "0.19.2" - "@esbuild/linux-x64" "0.19.2" - "@esbuild/netbsd-x64" "0.19.2" - "@esbuild/openbsd-x64" "0.19.2" - "@esbuild/sunos-x64" "0.19.2" - "@esbuild/win32-arm64" "0.19.2" - "@esbuild/win32-ia32" "0.19.2" - "@esbuild/win32-x64" "0.19.2" + "@esbuild/aix-ppc64" "0.25.2" + "@esbuild/android-arm" "0.25.2" + "@esbuild/android-arm64" "0.25.2" + "@esbuild/android-x64" "0.25.2" + "@esbuild/darwin-arm64" "0.25.2" + "@esbuild/darwin-x64" "0.25.2" + "@esbuild/freebsd-arm64" "0.25.2" + "@esbuild/freebsd-x64" "0.25.2" + "@esbuild/linux-arm" "0.25.2" + "@esbuild/linux-arm64" "0.25.2" + "@esbuild/linux-ia32" "0.25.2" + "@esbuild/linux-loong64" "0.25.2" + "@esbuild/linux-mips64el" "0.25.2" + "@esbuild/linux-ppc64" "0.25.2" + "@esbuild/linux-riscv64" "0.25.2" + "@esbuild/linux-s390x" "0.25.2" + "@esbuild/linux-x64" "0.25.2" + "@esbuild/netbsd-arm64" "0.25.2" + "@esbuild/netbsd-x64" "0.25.2" + "@esbuild/openbsd-arm64" "0.25.2" + "@esbuild/openbsd-x64" "0.25.2" + "@esbuild/sunos-x64" "0.25.2" + "@esbuild/win32-arm64" "0.25.2" + "@esbuild/win32-ia32" "0.25.2" + "@esbuild/win32-x64" "0.25.2" escalade@^3.1.1: version "3.1.1" @@ -4495,10 +4506,10 @@ type-fest@^0.21.3: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== -typescript@^4.7.4: - version "4.7.4" - resolved "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz" - integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== +typescript@^5.8.3: + version "5.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" @@ -4550,7 +4561,7 @@ v8-compile-cache-lib@^3.0.1: resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== -v8-compile-cache@^2.0.3, v8-compile-cache@^2.1.1: +v8-compile-cache@^2.0.3: version "2.1.1" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz" integrity sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ== @@ -4674,19 +4685,6 @@ yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.1.1" -yargs@^17.6.2: - version "17.7.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" - integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== - dependencies: - cliui "^8.0.1" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.1.1" - yn@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" From b83b2c257d8828ce66bd212c354a1531b463b5be Mon Sep 17 00:00:00 2001 From: Jason Rodriguez Date: Tue, 15 Apr 2025 14:20:12 -0700 Subject: [PATCH 03/52] Jrod/dit 10045 reintroduce pull command with ns cli (#116) * Moves Legacy CLI into a `legacy` folder and provides a new entrypoint to the beta CLI - adds a `--legacy (-l)` CLI option that forces the application to run in legacy mode. - adds a new warning message when the CLI is run in legacy mode. * Modernize build system and upgrade esbuild and typescript - Additionally removes the v8-compile-cache that provides little value in modern node applications of this size. - Adds minification support via esbuild as well as cleaner map files. - Updates package.json "files" property to now only ship required files to npm. * Add CONTRIBUTING.md file with guidelines for getting started with the CLI code * Adds IS_LOCAL flag for improved debugging experience in beta CLI * Adds support for fetching and saving API tokens via the beta cli. * Remove CONTRIBUTING.md file as part of project restructuring. * Cleanup file structure and prepare for tests * Introduce basic pull command * Full implementation of generating basic i18Next File * Enhance I18NextFormatter to conditionally generate driver files based on configuration. Update error handling in handleOutput to provide clearer output format messages. Add default value for generateDriverFile in ZI18NextOutput schema. * Add support for json formats with frameworks * Address PR feedback * Add documentation for generateImportStatements method in I18NextFramework class * Update GitHub Actions to use latest versions of actions/cache, actions/checkout, and actions/setup-node * Update GitHub Actions workflow to use Ubuntu 24.04 for testing environment * Remove obsolete test files for JSON and configuration services * Refactor API token handling and HTTP client configuration. Introduce default interceptor for axios requests and update token validation logic to improve error handling and context management. * add comments and small nit changes * Update lib/src/services/projectConfig.ts Co-authored-by: Marla Hoggard * Update lib/src/http/client.ts Co-authored-by: Marla Hoggard * Update lib/src/http/checkToken.ts Co-authored-by: Marla Hoggard * Remove default data parameter from readGlobalConfigData function and return an empty object if parsing fails. * Update lib/src/http/checkToken.ts Co-authored-by: Marla Hoggard * Refactor JSONFormatter to include a new line for improved readability and update fetchText function to stringify filters in API request parameters. * Refactor JSONFormatter to use a variable for filename construction, improving code clarity and maintainability. --------- Co-authored-by: Marla Hoggard --- .../install-node-dependencies/action.yml | 2 +- .github/workflows/required-checks.yml | 14 +- lib/ditto.ts | 4 +- lib/legacy/init/token.ts | 3 +- lib/legacy/utils/promptForProject.ts | 2 +- lib/src/commands/pull.ts | 7 +- lib/src/formatters/frameworks/base.ts | 13 ++ lib/src/formatters/frameworks/i18next.ts | 94 +++++++++++ lib/src/formatters/frameworks/index.ts | 10 ++ lib/src/formatters/index.ts | 18 +++ lib/src/formatters/json.ts | 91 +++++++++++ .../mixins/javascriptCodegenMixin.ts | 44 ++++++ lib/src/formatters/shared/base.ts | 44 ++++++ .../shared/fileTypes/JSONOutputFile.ts | 25 +++ .../shared/fileTypes/JavascriptOutputFile.ts | 28 ++++ .../formatters/shared/fileTypes/OutputFile.ts | 33 ++++ lib/src/formatters/shared/index.test.ts | 72 +++++++++ lib/src/formatters/shared/index.ts | 62 ++++++++ lib/src/http/checkToken.ts | 65 ++++++++ lib/src/http/client.ts | 18 +++ lib/src/http/textItems.ts | 50 ++++++ lib/src/http/variables.ts | 81 ++++++++++ lib/src/index.ts | 73 ++++----- lib/src/outputs/index.ts | 9 ++ lib/src/outputs/json.ts | 16 ++ lib/src/outputs/shared.ts | 10 ++ lib/src/services/apiToken.test.ts | 27 ++++ lib/src/services/apiToken.ts | 149 ++++++++++++++++++ lib/src/services/globalConfig.ts | 58 +++++++ lib/src/services/projectConfig.ts | 48 ++++++ lib/src/utils/appContext.ts | 92 +++++++++++ lib/src/utils/fileSystem.ts | 63 ++++++++ lib/src/{output.ts => utils/logger.ts} | 6 +- lib/src/utils/messages.ts | 13 ++ lib/src/utils/quit.ts | 3 +- package.json | 7 +- yarn.lock | 5 + 37 files changed, 1299 insertions(+), 60 deletions(-) create mode 100644 lib/src/formatters/frameworks/base.ts create mode 100644 lib/src/formatters/frameworks/i18next.ts create mode 100644 lib/src/formatters/frameworks/index.ts create mode 100644 lib/src/formatters/index.ts create mode 100644 lib/src/formatters/json.ts create mode 100644 lib/src/formatters/mixins/javascriptCodegenMixin.ts create mode 100644 lib/src/formatters/shared/base.ts create mode 100644 lib/src/formatters/shared/fileTypes/JSONOutputFile.ts create mode 100644 lib/src/formatters/shared/fileTypes/JavascriptOutputFile.ts create mode 100644 lib/src/formatters/shared/fileTypes/OutputFile.ts create mode 100644 lib/src/formatters/shared/index.test.ts create mode 100644 lib/src/formatters/shared/index.ts create mode 100644 lib/src/http/checkToken.ts create mode 100644 lib/src/http/client.ts create mode 100644 lib/src/http/textItems.ts create mode 100644 lib/src/http/variables.ts create mode 100644 lib/src/outputs/index.ts create mode 100644 lib/src/outputs/json.ts create mode 100644 lib/src/outputs/shared.ts create mode 100644 lib/src/services/apiToken.test.ts create mode 100644 lib/src/services/apiToken.ts create mode 100644 lib/src/services/globalConfig.ts create mode 100644 lib/src/services/projectConfig.ts create mode 100644 lib/src/utils/appContext.ts create mode 100644 lib/src/utils/fileSystem.ts rename lib/src/{output.ts => utils/logger.ts} (80%) create mode 100644 lib/src/utils/messages.ts diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 52b5c8a..65b7e5b 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -10,7 +10,7 @@ inputs: runs: using: "composite" steps: - - uses: actions/cache@v3 + - uses: actions/cache@v4 env: cache-name: node_modules-cache with: diff --git a/.github/workflows/required-checks.yml b/.github/workflows/required-checks.yml index 0c1fe59..b827de6 100644 --- a/.github/workflows/required-checks.yml +++ b/.github/workflows/required-checks.yml @@ -1,23 +1,23 @@ name: "Required Checks" -on: - pull_request: - branches: - - master +on: pull_request jobs: jest-tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 timeout-minutes: 5 strategy: fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 + - uses: ./.github/actions/install-node-dependencies + - name: Jest Tests run: | npx jest --ci --silent --maxWorkers=1 diff --git a/lib/ditto.ts b/lib/ditto.ts index c1d72cf..5f3cc54 100755 --- a/lib/ditto.ts +++ b/lib/ditto.ts @@ -4,7 +4,7 @@ import * as Sentry from "@sentry/node"; import { version as release } from "../package.json"; import legacyAppEntry from "./legacy"; import appEntry from "./src"; -import output from "./src/output"; +import logger from "./src/utils/logger"; // Initialize Sentry const environment = process.env.ENV || "development"; @@ -14,7 +14,7 @@ const main = async () => { // Check for --legacy flag and run in legacy mode if present if (process.argv.includes("--legacy")) { console.log( - output.warnText( + logger.warnText( "\nDitto CLI is running in legacy mode. This mode is deprecated and will be removed in a future release.\n" ) ); diff --git a/lib/legacy/init/token.ts b/lib/legacy/init/token.ts index f996093..c5e1f9c 100644 --- a/lib/legacy/init/token.ts +++ b/lib/legacy/init/token.ts @@ -126,9 +126,8 @@ export const collectAndSaveToken = async (message: string | null = null) => { console.log( `Thanks for authenticating. We'll save the key to: ${output.info( consts.CONFIG_FILE - )}` + )}\n` ); - output.nl(); config.saveToken(consts.CONFIG_FILE, consts.API_HOST, token); return token; diff --git a/lib/legacy/utils/promptForProject.ts b/lib/legacy/utils/promptForProject.ts index 5c8a650..fc45e73 100644 --- a/lib/legacy/utils/promptForProject.ts +++ b/lib/legacy/utils/promptForProject.ts @@ -35,7 +35,7 @@ const promptForProject = async ({ projects, limit = 10, }: ProjectPromptParams) => { - output.nl(); + output.write("\n"); const choices = projects.map(formatProjectChoice); const prompt = new AutoComplete({ diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts index 7bee53f..031a40f 100644 --- a/lib/src/commands/pull.ts +++ b/lib/src/commands/pull.ts @@ -1,3 +1,8 @@ +import appContext from "../utils/appContext"; +import formatOutput from "../formatters"; + export const pull = async () => { - console.log("pull"); + for (const output of appContext.selectedProjectConfigOutputs) { + await formatOutput(output, appContext.projectConfig); + } }; diff --git a/lib/src/formatters/frameworks/base.ts b/lib/src/formatters/frameworks/base.ts new file mode 100644 index 0000000..c8c13ce --- /dev/null +++ b/lib/src/formatters/frameworks/base.ts @@ -0,0 +1,13 @@ +import OutputFile from "../shared/fileTypes/OutputFile"; + +export default class BaseFramework { + protected format: string; + + constructor(format: string) { + this.format = format; + } + + process(...args: any[]): OutputFile[] { + throw new Error("Not implemented"); + } +} diff --git a/lib/src/formatters/frameworks/i18next.ts b/lib/src/formatters/frameworks/i18next.ts new file mode 100644 index 0000000..749ae6a --- /dev/null +++ b/lib/src/formatters/frameworks/i18next.ts @@ -0,0 +1,94 @@ +import appContext from "../../utils/appContext"; +import JavascriptOutputFile from "../shared/fileTypes/JavascriptOutputFile"; +import OutputFile from "../shared/fileTypes/OutputFile"; +import { applyMixins } from "../shared"; +import javascriptCodegenMixin from "../mixins/javascriptCodegenMixin"; +import JSONOutputFile from "../shared/fileTypes/JSONOutputFile"; +import BaseFramework from "./base"; + +export default class I18NextFramework extends applyMixins( + BaseFramework, + javascriptCodegenMixin +) { + process( + outputJsonFiles: Record> + ) { + const outputDir = appContext.projectConfigDir; + // Generate Driver file + + const driverFile = new JavascriptOutputFile({ + filename: "index", + path: outputDir, + }); + + const filesGroupedByVariantId = Object.values(outputJsonFiles).reduce( + (acc, file) => { + const variantId = file.metadata.variantId; + acc[variantId] ??= []; + acc[variantId].push(file); + return acc; + }, + {} as Record + ); + + driverFile.content += this.generateImportStatements(outputJsonFiles); + + driverFile.content += `\n`; + + driverFile.content += this.generateDefaultExportString( + filesGroupedByVariantId + ); + + return [driverFile]; + } + + /** + * Generates the import statements for the driver file. One import per generated json file. + * @param outputJsonFiles - The output json files. + * @returns The import statements, stringified. + */ + private generateImportStatements( + outputJsonFiles: Record> + ) { + let importStatements = ""; + for (const file of Object.values(outputJsonFiles)) { + importStatements += this.codegenDefaultImport( + this.sanitizeStringForJSVariableName(file.filename), + `./${file.filenameWithExtension}` + ); + } + return importStatements; + } + + /** + * Generates the default export for the driver file. By default this is an object with the json imports grouped by variant id. + * @param filesGroupedByVariantId - The files grouped by variant id. + * @returns The default export, stringified. + */ + private generateDefaultExportString( + filesGroupedByVariantId: Record + ) { + const variantIds = Object.keys(filesGroupedByVariantId); + + let defaultExportObjectString = "{\n"; + + for (let i = 0; i < variantIds.length; i++) { + const variantId = variantIds[i]; + const files = filesGroupedByVariantId[variantId]; + + defaultExportObjectString += `${this.codegenPad(1)}"${variantId}": {\n`; + for (const file of files) { + defaultExportObjectString += `${this.codegenPad( + 2 + )}...${this.sanitizeStringForJSVariableName(file.filename)},\n`; + } + defaultExportObjectString += `${this.codegenPad(1)}}${ + i < variantIds.length - 1 ? `,\n` : `\n` + }`; + } + + defaultExportObjectString += `}`; + + return this.codegenDefaultExport(defaultExportObjectString); + } +} diff --git a/lib/src/formatters/frameworks/index.ts b/lib/src/formatters/frameworks/index.ts new file mode 100644 index 0000000..d85bad8 --- /dev/null +++ b/lib/src/formatters/frameworks/index.ts @@ -0,0 +1,10 @@ +import I18NextFramework from "./i18next"; + +export function getFrameworkProcessor(framework: string) { + switch (framework) { + case "i18next": + return new I18NextFramework(framework); + default: + throw new Error(`Unsupported framework: ${framework}`); + } +} diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts new file mode 100644 index 0000000..72e8438 --- /dev/null +++ b/lib/src/formatters/index.ts @@ -0,0 +1,18 @@ +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import JSONFormatter from "./json"; + +export default function handleOutput( + output: Output, + projectConfig: ProjectConfigYAML +) { + switch (output.format) { + case "json": + return new JSONFormatter(output, projectConfig).format( + output, + projectConfig + ); + default: + throw new Error(`Unsupported output format: ${output}`); + } +} diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts new file mode 100644 index 0000000..aa8f38b --- /dev/null +++ b/lib/src/formatters/json.ts @@ -0,0 +1,91 @@ +import fetchText, { PullFilters, TextItemsResponse } from "../http/textItems"; +import fetchVariables, { Variable, VariablesResponse } from "../http/variables"; +import BaseFormatter from "./shared/base"; +import OutputFile from "./shared/fileTypes/OutputFile"; +import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; +import appContext from "../utils/appContext"; +import { applyMixins } from "./shared"; +import { getFrameworkProcessor } from "./frameworks"; + +type JSONAPIData = { + textItems: TextItemsResponse; + variablesById: Record; +}; + +export default class JSONFormatter extends applyMixins( + BaseFormatter) { + + protected async fetchAPIData() { + const filters = this.generatePullFilter(); + const textItems = await fetchText(filters); + const variables = await fetchVariables(); + + const variablesById = variables.reduce((acc, variable) => { + acc[variable.id] = variable; + return acc; + }, {} as Record); + + return { textItems, variablesById }; + } + + protected async transformAPIData(data: JSONAPIData) { + const outputDir = appContext.projectConfigDir; + + let outputJsonFiles: Record< + string, + JSONOutputFile<{ variantId: string }> + > = {}; + + const variablesOutputFile = new JSONOutputFile({ + filename: "variables", + path: appContext.projectConfigDir, + }); + + for (let i = 0; i < data.textItems.length; i++) { + const textItem = data.textItems[i]; + + const fileName = `${textItem.projectId}___${textItem.variantId || "base"}`; + + outputJsonFiles[fileName] ??= new JSONOutputFile({ + filename: fileName, + path: outputDir, + metadata: { variantId: textItem.variantId || "base" }, + }); + + + outputJsonFiles[fileName].content[textItem.id] = textItem.text; + for (const variableId of textItem.variableIds) { + const variable = data.variablesById[variableId]; + variablesOutputFile.content[variableId] = variable.data; + } + } + + let results: OutputFile[] = [ + ...Object.values(outputJsonFiles), + variablesOutputFile, + ] + + if (this.output.framework) { + // process framework + results.push(...getFrameworkProcessor(this.output.framework).process(outputJsonFiles)); + } + + return results; + } + + private generatePullFilter() { + let filters: PullFilters = { + projects: this.projectConfig.projects, + variants: this.projectConfig.variants, + }; + if (this.output.projects) { + filters.projects = this.output.projects; + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } +} diff --git a/lib/src/formatters/mixins/javascriptCodegenMixin.ts b/lib/src/formatters/mixins/javascriptCodegenMixin.ts new file mode 100644 index 0000000..f453c1e --- /dev/null +++ b/lib/src/formatters/mixins/javascriptCodegenMixin.ts @@ -0,0 +1,44 @@ +import { Constructor } from "../shared"; + +interface NamedImport { + name: string; + alias?: string; +} + +export default function javascriptCodegenMixin( + Base: TBase +) { + return class JavascriptCodegenHelpers extends Base { + protected indentSpaces: number = 2; + + protected sanitizeStringForJSVariableName(str: string) { + return str.replace(/[^a-zA-Z0-9]/g, "_"); + } + + protected codegenNamedImport(modules: NamedImport[], moduleName: string) { + const formattedModules = modules + .map((m) => { + if (m.alias) { + return `${m.name} as ${m.alias}`; + } + return m.name; + }) + .sort() + .join(", "); + + return `import { ${formattedModules} } from "${moduleName}";\n`; + } + + protected codegenDefaultImport(module: string, moduleName: string) { + return `import ${module} from "${moduleName}";\n`; + } + + protected codegenDefaultExport(module: string) { + return `export default ${module};`; + } + + protected codegenPad(depth: number) { + return " ".repeat(depth * this.indentSpaces); + } + }; +} diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts new file mode 100644 index 0000000..264a75b --- /dev/null +++ b/lib/src/formatters/shared/base.ts @@ -0,0 +1,44 @@ +import { Output } from "../../outputs"; +import { writeFile } from "../../utils/fileSystem"; +import logger from "../../utils/logger"; +import { ProjectConfigYAML } from "../../services/projectConfig"; +import OutputFile from "./fileTypes/OutputFile"; + +export default class BaseFormatter { + protected output: Output; + protected projectConfig: ProjectConfigYAML; + + constructor(output: Output, projectConfig: ProjectConfigYAML) { + this.output = output; + this.projectConfig = projectConfig; + } + + protected async fetchAPIData(): Promise { + return {} as APIDataType; + } + + protected async transformAPIData(data: APIDataType): Promise { + return []; + } + + async format( + output: Output, + projectConfig: ProjectConfigYAML + ): Promise { + const data = await this.fetchAPIData(); + const files = await this.transformAPIData(data); + await this.writeFiles(files); + } + + private async writeFiles(files: OutputFile[]): Promise { + await Promise.all( + files.map((file) => + writeFile(file.fullPath, file.formattedContent).then(() => { + logger.writeLine( + `Successfully saved to ${logger.info(file.fullPath)}` + ); + }) + ) + ); + } +} diff --git a/lib/src/formatters/shared/fileTypes/JSONOutputFile.ts b/lib/src/formatters/shared/fileTypes/JSONOutputFile.ts new file mode 100644 index 0000000..64cae1e --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/JSONOutputFile.ts @@ -0,0 +1,25 @@ +import OutputFile from "./OutputFile"; + +export default class JSONOutputFile extends OutputFile< + Record, + MetadataType +> { + constructor(config: { + filename: string; + path: string; + content?: Record; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "json", + content: config.content ?? {}, + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return JSON.stringify(this.content, null, 2); + } +} diff --git a/lib/src/formatters/shared/fileTypes/JavascriptOutputFile.ts b/lib/src/formatters/shared/fileTypes/JavascriptOutputFile.ts new file mode 100644 index 0000000..fda195a --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/JavascriptOutputFile.ts @@ -0,0 +1,28 @@ +import { ensureEndsWithNewLine } from "../../../utils/fileSystem"; +import OutputFile from "./OutputFile"; + +export default class JavascriptOutputFile extends OutputFile< + string, + MetadataType +> { + indentSpaces: number = 2; + + constructor(config: { + filename: string; + path: string; + content?: string; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "js", + content: config.content ?? "", + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return ensureEndsWithNewLine(this.content ?? ""); + } +} diff --git a/lib/src/formatters/shared/fileTypes/OutputFile.ts b/lib/src/formatters/shared/fileTypes/OutputFile.ts new file mode 100644 index 0000000..664f26d --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/OutputFile.ts @@ -0,0 +1,33 @@ +export default class OutputFile { + filename: string; + path: string; + extension: string; + content: ContentType; + metadata: MetadataType; + + constructor(config: { + filename: string; + path: string; + extension: string; + content: ContentType; + metadata?: MetadataType; + }) { + this.filename = config.filename; + this.path = config.path; + this.extension = config.extension; + this.content = config.content; + this.metadata = config.metadata ?? ({} as MetadataType); + } + + get fullPath() { + return `${this.path}/${this.filename}.${this.extension}`; + } + + get filenameWithExtension() { + return `${this.filename}.${this.extension}`; + } + + get formattedContent(): string { + throw new Error("Not implemented"); + } +} diff --git a/lib/src/formatters/shared/index.test.ts b/lib/src/formatters/shared/index.test.ts new file mode 100644 index 0000000..817e516 --- /dev/null +++ b/lib/src/formatters/shared/index.test.ts @@ -0,0 +1,72 @@ +import { kMaxLength } from "buffer"; +import { applyMixins, Constructor } from "./index"; + +afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); +}); + +describe("applyMixins", () => { + // Base class + class Base { + baseMethod() { + return "base"; + } + } + + // First mixin + const TimestampMixin = (base: TBase) => { + return class extends base { + timestamp = Date.now(); + getTimestamp() { + return this.timestamp; + } + }; + }; + + // Second mixin + const LoggingMixin = (base: TBase) => { + return class extends base { + log(message: string) { + console.log(message); + return message; + } + }; + }; + + it("should apply a single mixin", () => { + const MixedClass = applyMixins(Base, TimestampMixin); + const instance = new MixedClass(); + + expect(instance.baseMethod()).toBe("base"); + expect(instance.getTimestamp()).toBeDefined(); + expect(typeof instance.getTimestamp()).toBe("number"); + }); + + it("should apply multiple mixins", () => { + const MixedClass = applyMixins(Base, TimestampMixin, LoggingMixin); + const instance = new MixedClass(); + + // Base class methods + expect(instance.baseMethod()).toBe("base"); + + // First mixin methods + expect(instance.getTimestamp()).toBeDefined(); + expect(typeof instance.getTimestamp()).toBe("number"); + + // Second mixin methods + const message = "test message"; + const consoleSpy = jest.spyOn(console, "log").mockImplementation(); + expect(instance.log(message)).toBe(message); + expect(consoleSpy).toHaveBeenCalledWith(message); + consoleSpy.mockRestore(); + }); + + it("should maintain the prototype chain", () => { + const MixedClass = applyMixins(Base, TimestampMixin, LoggingMixin); + const instance = new MixedClass(); + + expect(instance instanceof Base).toBe(true); + expect(instance instanceof MixedClass).toBe(true); + }); +}); diff --git a/lib/src/formatters/shared/index.ts b/lib/src/formatters/shared/index.ts new file mode 100644 index 0000000..c6a5323 --- /dev/null +++ b/lib/src/formatters/shared/index.ts @@ -0,0 +1,62 @@ +export type Constructor = new (...args: any[]) => T; + +type Mixin = ( + base: TBase +) => TReturn; + +// Overloads for up to 5 mixins +export function applyMixins(base: TBase): TBase; +export function applyMixins( + base: TBase, + m1: Mixin +): T1; +export function applyMixins< + TBase extends Constructor, + T1 extends Constructor, + T2 extends Constructor +>(base: TBase, m1: Mixin, m2: Mixin): T2; +export function applyMixins< + TBase extends Constructor, + T1 extends Constructor, + T2 extends Constructor, + T3 extends Constructor +>(base: TBase, m1: Mixin, m2: Mixin, m3: Mixin): T3; +export function applyMixins< + TBase extends Constructor, + T1 extends Constructor, + T2 extends Constructor, + T3 extends Constructor, + T4 extends Constructor +>( + base: TBase, + m1: Mixin, + m2: Mixin, + m3: Mixin, + m4: Mixin +): T4; +export function applyMixins< + TBase extends Constructor, + T1 extends Constructor, + T2 extends Constructor, + T3 extends Constructor, + T4 extends Constructor, + T5 extends Constructor +>( + base: TBase, + m1: Mixin, + m2: Mixin, + m3: Mixin, + m4: Mixin, + m5: Mixin +): T5; + +// Implementation +export function applyMixins( + base: Constructor, + ...mixins: Mixin[] +): Constructor { + if (mixins.length > 5) { + throw new Error("Maximum of 5 mixins supported"); + } + return mixins.reduce((acc, mixin) => mixin(acc), base); +} diff --git a/lib/src/http/checkToken.ts b/lib/src/http/checkToken.ts new file mode 100644 index 0000000..f3bf38f --- /dev/null +++ b/lib/src/http/checkToken.ts @@ -0,0 +1,65 @@ +import { defaultInterceptor } from "./client"; +import logger from "../utils/logger"; +import axios, { AxiosError } from "axios"; +import appContext from "../utils/appContext"; + +export default async function checkToken(token: string) { + try { + const httpClient = axios.create({}); + + httpClient.interceptors.request.use(defaultInterceptor(token)); + + const response = await httpClient.get("/token-check"); + + if (response.status === 200) { + return { success: true }; + } + + return { + success: false, + output: [ + logger.errorText("This API key isn't valid. Please try another."), + ], + }; + } catch (e: unknown) { + if (!(e instanceof AxiosError)) { + return { + success: false, + output: [ + logger.warnText( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ), + ], + }; + } + + if (e.code === "ENOTFOUND") { + return { + success: false, + output: [ + logger.errorText( + `Can't connect to API: ${logger.url(appContext.apiHost)}` + ), + ], + }; + } + + if (e.response?.status === 401 || e.response?.status === 404) { + return { + success: false, + output: [ + logger.errorText("This API key isn't valid. Please try another."), + ], + }; + } + + return { + success: false, + output: [ + logger.errorText( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ), + ], + }; + } +} diff --git a/lib/src/http/client.ts b/lib/src/http/client.ts new file mode 100644 index 0000000..e3df3cf --- /dev/null +++ b/lib/src/http/client.ts @@ -0,0 +1,18 @@ +import axios, { InternalAxiosRequestConfig } from "axios"; +import appContext from "../utils/appContext"; + +export function defaultInterceptor(token?: string) { + return function (config: InternalAxiosRequestConfig) { + config.baseURL = appContext.apiHost; + config.headers["x-ditto-client-id"] = appContext.clientId; + config.headers["x-ditto-app"] = "cli"; + config.headers.Authorization = token || appContext.apiToken; + return config; + }; +} + +const httpClient = axios.create({}); + +httpClient.interceptors.request.use(defaultInterceptor()); + +export default httpClient; diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts new file mode 100644 index 0000000..18e86f9 --- /dev/null +++ b/lib/src/http/textItems.ts @@ -0,0 +1,50 @@ +import httpClient from "./client"; +import { AxiosError } from "axios"; +import { z } from "zod"; + +export interface PullFilters { + projects?: { id: string }[]; + variants?: { id: string }[]; +} + +const TextItemsResponse = z.array( + z.object({ + id: z.string(), + text: z.string(), + status: z.string(), + notes: z.string(), + tags: z.array(z.string()), + variableIds: z.array(z.string()), + projectId: z.string(), + variantId: z.string().nullable(), + }) +); + +export type TextItemsResponse = z.infer; + +export default async function fetchText(filters?: PullFilters) { + try { + const response = await httpClient.get("/v2/textItems", { + params: { + filter: JSON.stringify(filters), + }, + }); + + return TextItemsResponse.parse(response.data); + } catch (e: unknown) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + + // Handle invalid filters + if (e.response?.status === 400) { + throw new Error( + "Invalid filters. Please check your filters and try again." + ); + } + + throw e; + } +} diff --git a/lib/src/http/variables.ts b/lib/src/http/variables.ts new file mode 100644 index 0000000..da89480 --- /dev/null +++ b/lib/src/http/variables.ts @@ -0,0 +1,81 @@ +import httpClient from "./client"; +import { AxiosError } from "axios"; +import { z } from "zod"; + +const ZBaseVariable = z.object({ + id: z.string(), + name: z.string(), +}); + +const ZVariableNumber = ZBaseVariable.merge( + z.object({ + type: z.literal("number"), + data: z.object({ + example: z.union([z.number(), z.string()]), + fallback: z.union([z.number(), z.string()]).optional(), + }), + }) +); + +const ZVariableString = ZBaseVariable.merge( + z.object({ + type: z.literal("string"), + data: z.object({ + example: z.string(), + fallback: z.string().optional(), + }), + }) +); + +const ZVariableHyperlink = ZBaseVariable.merge( + z.object({ + type: z.literal("hyperlink"), + data: z.object({ + text: z.string(), + url: z.string(), + }), + }) +); + +const ZVariableList = ZBaseVariable.merge( + z.object({ + type: z.literal("list"), + data: z.array(z.string()), + }) +); + +const ZVariableMap = ZBaseVariable.merge( + z.object({ + type: z.literal("map"), + data: z.record(z.string()), + }) +); + +const ZVariable = z.discriminatedUnion("type", [ + ZVariableString, + ZVariableNumber, + ZVariableHyperlink, + ZVariableList, + ZVariableMap, +]); + +export type Variable = z.infer; + +const ZVariablesResponse = z.array(ZVariable); + +export type VariablesResponse = z.infer; + +export default async function fetchVariables() { + try { + const response = await httpClient.get("/v2/variables"); + + return ZVariablesResponse.parse(response.data); + } catch (e: unknown) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + throw e; + } +} diff --git a/lib/src/index.ts b/lib/src/index.ts index 6cb10cc..450de62 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -1,27 +1,16 @@ #!/usr/bin/env node // This is the main entry point for the ditto-cli command. +import * as Sentry from "@sentry/node"; import { program } from "commander"; import { pull } from "./commands/pull"; import { quit } from "./utils/quit"; import { version } from "../../package.json"; +import logger from "./utils/logger"; +import { initAPIToken } from "./services/apiToken"; +import { initProjectConfig } from "./services/projectConfig"; +import appContext from "./utils/appContext"; -const CONFIG_FILE_RELIANT_COMMANDS = [ - "pull", - "none", - "project", - "project add", - "project remove", -]; - -type Command = - | "pull" - | "project" - | "project add" - | "project remove" - | "component-folders" - | "generate-suggestions" - | "replace" - | "import-components"; +type Command = "pull"; interface CommandConfig { name: T; @@ -61,22 +50,6 @@ const setupCommands = () => { } ); } - - if ("commands" in commandConfig && commandConfig.commands) { - commandConfig.commands.forEach((nestedCommand) => { - cmd - .command(nestedCommand.name) - .description(nestedCommand.description) - .action((str, options) => { - if (commandConfig.name === "project") { - const command = - `${commandConfig.name} ${nestedCommand.name}` as Command; - - return executeCommand(command, options); - } - }); - }); - } }); }; @@ -89,15 +62,35 @@ const executeCommand = async ( command: Command | "none", options: any ): Promise => { - switch (command) { - case "none": - case "pull": { - return pull(); + try { + const token = await initAPIToken(); + appContext.setApiToken(token); + + await initProjectConfig(); + + switch (command) { + case "none": + case "pull": { + return await pull(); + } + default: { + await quit(`Invalid command: ${command}. Exiting Ditto CLI...`); + return; + } } - default: { - await quit("Exiting Ditto CLI..."); - return; + } catch (error) { + const eventId = Sentry.captureException(error); + const eventStr = `\n\nError ID: ${logger.info(eventId)}`; + + if (process.env.IS_LOCAL === "true") { + console.error(logger.info("Development stack trace:\n"), error); } + + return await quit( + logger.errorText( + "Something went wrong. Please contact support or try again later." + ) + eventStr + ); } }; diff --git a/lib/src/outputs/index.ts b/lib/src/outputs/index.ts new file mode 100644 index 0000000..303b95b --- /dev/null +++ b/lib/src/outputs/index.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { ZJSONOutput } from "./json"; + +/** + * The output config is a discriminated union of all the possible output formats. + */ +export const ZOutput = z.union([...ZJSONOutput.options]); + +export type Output = z.infer; diff --git a/lib/src/outputs/json.ts b/lib/src/outputs/json.ts new file mode 100644 index 0000000..b26342f --- /dev/null +++ b/lib/src/outputs/json.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { ZBaseOutputFilters } from "./shared"; + +const ZBaseJSONOutput = ZBaseOutputFilters.extend({ + format: z.literal("json"), + framework: z.undefined(), +}); + +const Zi18NextJSONOutput = ZBaseJSONOutput.extend({ + framework: z.literal("i18next"), +}); + +export const ZJSONOutput = z.discriminatedUnion("framework", [ + ZBaseJSONOutput, + Zi18NextJSONOutput, +]); diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts new file mode 100644 index 0000000..3c00cf1 --- /dev/null +++ b/lib/src/outputs/shared.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +/** + * These filters that are common to all outputs, used to filter the text items that are fetched from the API. + * They are all optional by defualt unless otherwise specified in the output config. + */ +export const ZBaseOutputFilters = z.object({ + projects: z.array(z.object({ id: z.string() })).optional(), + variants: z.array(z.object({ id: z.string() })).optional(), +}); diff --git a/lib/src/services/apiToken.test.ts b/lib/src/services/apiToken.test.ts new file mode 100644 index 0000000..0e01f3e --- /dev/null +++ b/lib/src/services/apiToken.test.ts @@ -0,0 +1,27 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; +import { _test as apiTokenTest } from "./apiToken"; + +const { getURLHostname } = apiTokenTest; + +describe("apiToken", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getURLHostname", () => { + it("should return hostname when URL contains protocol", () => { + const expectedHostName = "example.com"; + + const result = getURLHostname("https://example.com"); + + expect(result).toBe(expectedHostName); + }); + + it("should return host as is when no protocol present", () => { + const expectedHostName = "example.com"; + + const result = getURLHostname("example.com"); + expect(result).toBe("example.com"); + }); + }); +}); diff --git a/lib/src/services/apiToken.ts b/lib/src/services/apiToken.ts new file mode 100644 index 0000000..2a79370 --- /dev/null +++ b/lib/src/services/apiToken.ts @@ -0,0 +1,149 @@ +import appContext from "../utils/appContext"; +import fs from "fs"; +import URL from "url"; +import * as configService from "./globalConfig"; +import checkToken from "../http/checkToken"; +import logger from "../utils/logger"; +import { quit } from "../utils/quit"; +import * as Sentry from "@sentry/node"; +import { prompt } from "enquirer"; + +/** + * Initializes the API token + * @param token The token to initialize the API token with. If not provided, the token will be fetched from the global config file. + * @param configFile The path to the global config file + * @param host The host to initialize the API token for + * @returns The initialized API token + */ +export async function initAPIToken( + token: string | undefined = appContext.apiToken, + configFile: string = appContext.configFile, + host: string = appContext.apiHost +) { + if (token) { + return await validateToken(token); + } + + if (!fs.existsSync(configFile)) { + return await collectAndSaveToken(); + } + + const configData = configService.readGlobalConfigData(configFile); + const sanitizedHost = getURLHostname(host); + + if ( + !configData[sanitizedHost] || + !configData[sanitizedHost][0] || + configData[sanitizedHost][0].token === "" + ) { + return await collectAndSaveToken(sanitizedHost); + } + + return await validateToken(configData[sanitizedHost][0].token); +} + +/** + * Collects a token from the user and saves it to the global config file + * @param host The host to save the token for + * @returns The collected token + */ +async function collectAndSaveToken(host: string = appContext.apiHost) { + try { + const token = await collectToken(); + logger.writeLine( + `Thanks for authenticating. We'll save the key to: ${logger.info( + appContext.configFile + )}\n` + ); + const sanitizedHost = getURLHostname(host); + configService.saveToken(appContext.configFile, sanitizedHost, token); + appContext.setApiToken(token); + return token; + } catch (error) { + // https://github.com/enquirer/enquirer/issues/225#issue-516043136 + // Empty string corresponds to the user hitting Ctrl + C + if (error === "") { + await quit("", 0); + return ""; + } + + const eventId = Sentry.captureException(error); + const eventStr = `\n\nError ID: ${logger.info(eventId)}`; + + await quit( + logger.errorText( + "Something went wrong. Please contact support or try again later." + ) + eventStr + ); + return ""; + } +} + +/** + * Outputs instructions to the user and collects an API token + * @returns The collected token + */ +async function collectToken() { + const apiUrl = logger.url("https://app.dittowords.com/account/devtools"); + const breadcrumbs = logger.bold(logger.info("API Keys")); + const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl} under "${breadcrumbs}".`; + + logger.writeLine(tokenDescription); + + const response = await promptForApiToken(); + return response.token; +} + +/** + * Prompt the user for an API token + * @returns The collected token + */ +async function promptForApiToken() { + const response = await prompt<{ token: string }>({ + type: "input", + name: "token", + message: "What is your API key?", + // @ts-expect-error - Enquirer types are not updated for the validate function + validate: async (token) => { + console.log("token", token); + const result = await checkToken(token); + if (!result.success) { + return result.output?.join("\n") || "Invalid API key"; + } + return true; + }, + }); + + return response; +} + +/** + * Get the hostname from a URL string + * @param hostString + * @returns + */ +function getURLHostname(hostString: string) { + if (!hostString.includes("://")) return hostString; + return URL.parse(hostString).hostname || ""; +} + +/** + * Validate a token + * @param token The token to validate + * @returns The newly validated token + */ +async function validateToken(token: string) { + const response = await checkToken(token); + if (!response.success) { + return await collectAndSaveToken(); + } + + return token; +} + +export const _test = { + collectToken, + validateToken, + getURLHostname, + promptForApiToken, +}; diff --git a/lib/src/services/globalConfig.ts b/lib/src/services/globalConfig.ts new file mode 100644 index 0000000..b7d22e4 --- /dev/null +++ b/lib/src/services/globalConfig.ts @@ -0,0 +1,58 @@ +import appContext from "../utils/appContext"; +import fs from "fs"; +import yaml from "js-yaml"; +import { z } from "zod"; +import { createFileIfMissingSync } from "../utils/fileSystem"; + +const ZGlobalConfigYAML = z.record( + z.string(), + z.array( + z.object({ + token: z.string(), + }) + ) +); + +type GlobalConfigYAML = z.infer; + +/** + * Read data from a global config file + * @param file The path to the global config file + * @returns + */ +export function readGlobalConfigData( + file = appContext.configFile +): GlobalConfigYAML { + createFileIfMissingSync(file); + const fileContents = fs.readFileSync(file, "utf8"); + const yamlData = yaml.load(fileContents); + const parsedYAML = ZGlobalConfigYAML.safeParse(yamlData); + if (parsedYAML.success) { + return parsedYAML.data; + } + return {}; +} + +/** + * Write data to a global config file + * @param file The path to the global config file + * @param data The data to write to the file + */ +function writeGlobalConfigData(file: string, data: object) { + createFileIfMissingSync(file); + const existingData = readGlobalConfigData(file); + const yamlStr = yaml.dump({ ...existingData, ...data }); + fs.writeFileSync(file, yamlStr, "utf8"); +} + +/** + * Save a token to the global config file + * @param file The path to the global config file + * @param hostname The hostname to save the token for + * @param token The token to save + */ +export function saveToken(file: string, hostname: string, token: string) { + const data = readGlobalConfigData(file); + data[hostname] = [{ token }]; // only allow one token per host + writeGlobalConfigData(file, data); +} diff --git a/lib/src/services/projectConfig.ts b/lib/src/services/projectConfig.ts new file mode 100644 index 0000000..b788c38 --- /dev/null +++ b/lib/src/services/projectConfig.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import fs from "fs"; +import { createFileIfMissingSync } from "../utils/fileSystem"; +import appContext from "../utils/appContext"; +import yaml from "js-yaml"; +import { ZBaseOutputFilters } from "../outputs/shared"; +import { ZOutput } from "../outputs"; + +const ZProjectConfigYAML = ZBaseOutputFilters.extend({ + outputs: z.array(ZOutput), +}).strict(); + +export type ProjectConfigYAML = z.infer; + +export const DEFAULT_PROJECT_CONFIG_JSON: ProjectConfigYAML = { + projects: [], + variants: [], + outputs: [ + { + format: "json", + }, + ], +}; + +export async function initProjectConfig() { + const projectConfig = readProjectConfigData(); + appContext.setProjectConfig(projectConfig); +} + +/** + * Read data from a global config file + * @param file defaults to the projectConfigFile defined in appContext + * @param defaultData defaults to `{}` + * @returns + */ +function readProjectConfigData( + file = appContext.projectConfigFile, + defaultData: ProjectConfigYAML = DEFAULT_PROJECT_CONFIG_JSON +): ProjectConfigYAML { + createFileIfMissingSync(file, yaml.dump(defaultData)); + const fileContents = fs.readFileSync(file, "utf8"); + const yamlData = yaml.load(fileContents); + const parsedYAML = ZProjectConfigYAML.safeParse(yamlData); + if (!parsedYAML.success) { + throw new Error("Failed to parse project config file"); + } + return parsedYAML.data; +} diff --git a/lib/src/utils/appContext.ts b/lib/src/utils/appContext.ts new file mode 100644 index 0000000..0536e8e --- /dev/null +++ b/lib/src/utils/appContext.ts @@ -0,0 +1,92 @@ +import { homedir } from "os"; +import path from "path"; +import crypto from "crypto"; +import { + DEFAULT_PROJECT_CONFIG_JSON, + ProjectConfigYAML, +} from "../services/projectConfig"; + +/** + * This class is used to store the global CLI context. It is preserved across all methods + * and is used to store the running state of the CLI. + */ +class AppContext { + #apiHost: string; + #apiToken: string | undefined; + #configFile: string; + #projectConfigDir: string; + #projectConfigFile: string; + #clientId: string; + #projectConfig: ProjectConfigYAML; + + constructor() { + this.#apiHost = process.env.DITTO_API_HOST || "https://api.dittowords.com"; + this.#apiToken = process.env.DITTO_TOKEN; + this.#configFile = + process.env.DITTO_CONFIG_FILE || path.join(homedir(), ".config", "ditto"); + this.#projectConfigFile = + process.env.DITTO_PROJECT_CONFIG_FILE || + path.normalize(path.join("ditto", "config.yml")); + this.#projectConfigDir = path.normalize( + path.dirname(this.#projectConfigFile) + ); + this.#clientId = crypto.randomUUID(); + this.#projectConfig = DEFAULT_PROJECT_CONFIG_JSON; + } + + get apiHost() { + return this.#apiHost; + } + + set apiHost(value: string) { + this.#apiHost = value; + } + + get apiToken() { + return this.#apiToken; + } + + get apiTokenOrThrow() { + if (!this.#apiToken) { + throw new Error("No API Token found."); + } + return this.#apiToken; + } + + get configFile() { + return this.#configFile; + } + + get projectConfigFile() { + return this.#projectConfigFile; + } + + get clientId() { + return this.#clientId; + } + + setApiToken(value: string | undefined) { + this.#apiToken = value; + } + + get projectConfig() { + return this.#projectConfig; + } + + setProjectConfig(value: ProjectConfigYAML) { + this.#projectConfig = value; + } + + get selectedProjectConfigOutputs() { + // TODO: Filter out based on flags. + return this.#projectConfig.outputs; + } + + get projectConfigDir() { + return this.#projectConfigDir; + } +} + +const appContext = new AppContext(); + +export default appContext; diff --git a/lib/src/utils/fileSystem.ts b/lib/src/utils/fileSystem.ts new file mode 100644 index 0000000..be5843d --- /dev/null +++ b/lib/src/utils/fileSystem.ts @@ -0,0 +1,63 @@ +import path from "path"; +import fs from "fs"; +import fsPromises from "fs/promises"; +/** + * Creates a file with the given filename if it doesn't already exist. + * @param filename The path to the file to create. + * @param defaultContents The contents to write to the file if it doesn't already exist. Defaults to an empty string. + * @returns `true` if the file was created, `false` if it already exists. + */ +export function createFileIfMissingSync( + filename: string, + defaultContents: string = "" +) { + const dir = path.dirname(filename); + + // create the directory if it doesn't already exist + if (!fs.existsSync(dir)) fs.mkdirSync(dir); + + // create the file if it doesn't already exist + if (!fs.existsSync(filename)) { + // create the file, writing the `defaultContents` if provided + writeFileSync(filename, defaultContents); + return true; + } else { + return false; + } +} + +/** + * Creates a file with the given filename if it doesn't already exist. + * @param filename The path to the file to create. + * @param defaultContents The contents to write to the file if it doesn't already exist. Defaults to an empty string. + * @returns `true` if the file was created, `false` if it already exists. + */ +export async function createFileIfMissing( + filename: string, + defaultContents: string = "" +) { + const dir = path.dirname(filename); + + // create the directory if it doesn't already exist + if (!fs.existsSync(dir)) fs.mkdirSync(dir); + + // create the file if it doesn't already exist + if (!fs.existsSync(filename)) { + // create the file, writing the `defaultContents` if provided + await writeFile(filename, defaultContents); + return true; + } else { + return false; + } +} + +export function writeFileSync(filename: string, content: string) { + fs.writeFileSync(filename, content, "utf-8"); +} + +export async function writeFile(filename: string, content: string) { + await fsPromises.writeFile(filename, content, "utf-8"); +} + +export const ensureEndsWithNewLine = (str: string) => + str + (/[\r\n]$/.test(str) ? "" : "\n"); diff --git a/lib/src/output.ts b/lib/src/utils/logger.ts similarity index 80% rename from lib/src/output.ts rename to lib/src/utils/logger.ts index 74499d0..e75995b 100644 --- a/lib/src/output.ts +++ b/lib/src/utils/logger.ts @@ -7,7 +7,8 @@ export const success = (msg: string) => chalk.green(msg); export const url = (msg: string) => chalk.blueBright.underline(msg); export const subtle = (msg: string) => chalk.grey(msg); export const write = (msg: string) => chalk.white(msg); -export const nl = () => console.log("\n"); +export const bold = (msg: string) => chalk.bold(msg); +export const writeLine = (msg: string) => console.log(msg); export default { errorText, @@ -17,5 +18,6 @@ export default { url, subtle, write, - nl, + writeLine, + bold, }; diff --git a/lib/src/utils/messages.ts b/lib/src/utils/messages.ts new file mode 100644 index 0000000..0e5bf92 --- /dev/null +++ b/lib/src/utils/messages.ts @@ -0,0 +1,13 @@ +import boxen from "boxen"; +import chalk from "chalk"; +import logger from "./logger"; + +export function welcome() { + const msg = chalk.white(`${chalk.bold( + "Welcome to the", + chalk.magentaBright("Ditto CLI") + )}. + +We're glad to have you here.`); + logger.writeLine(boxen(msg, { padding: 1 })); +} diff --git a/lib/src/utils/quit.ts b/lib/src/utils/quit.ts index cdb6a91..07d4d07 100644 --- a/lib/src/utils/quit.ts +++ b/lib/src/utils/quit.ts @@ -1,7 +1,8 @@ import * as Sentry from "@sentry/node"; +import logger from "./logger"; export async function quit(message: string | null, exitCode = 2) { - if (message) console.log(`\n${message}\n`); + if (message) logger.writeLine(`\n${message}\n`); await Sentry.flush(); process.exit(exitCode); } diff --git a/package.json b/package.json index 7ce14c5..b3d5fb0 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "scripts": { "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", - "start": "node esbuild.mjs && node bin/ditto.js", - "sync": "node esbuild.mjs && node bin/ditto.js pull" + "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", + "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull" }, "repository": { "type": "git", @@ -78,7 +78,8 @@ "glob": "^9.3.4", "js-yaml": "^4.1.0", "memfs": "^4.7.7", - "ora": "^5.0.0" + "ora": "^5.0.0", + "zod": "^3.24.2" }, "lint-staged": { "src/**/*.{js,jsx,ts,tsx,css,json}": "prettier --write" diff --git a/yarn.lock b/yarn.lock index 9ef48a9..7094618 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4694,3 +4694,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.24.2: + version "3.24.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3" + integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ== From 9e4e966d11934e09b385a0dd314c81c617ceb38c Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Tue, 15 Apr 2025 16:13:15 -0700 Subject: [PATCH 04/52] update version --- README.md | 11 ++++++----- package.json | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e63b542..5fde594 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The official documentation can be found [here](http://developer.dittowords.com/c ## Installation ```sh -npm install --save-dev @dittowords/cli +npm install --save-dev @dittowords/cli@beta ``` It's recommended to install the CLI as a development dependency to ensure your whole team is on the same version. @@ -37,10 +37,11 @@ The first time you run the CLI, a `ditto/` folder will be created if it doesn't The default file looks like this: ```yml -sources: - components: true -variants: true -format: flat +projects: [], +variants: [], +outputs: + - format: json, + framework: i18next ``` For more information on configuring the CLI, see [http://developer.dittowords.com/cli-reference/configuration](http://developer.dittowords.com/cli-reference/configuration). diff --git a/package.json b/package.json index b3d5fb0..de6ba4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "4.5.2", + "version": "5.0.0-beta.0", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From dacff25e073c17d39d04253616afce35ac763dd6 Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Wed, 16 Apr 2025 09:15:27 -0700 Subject: [PATCH 05/52] Add environment variable definitions for production in esbuild configuration --- esbuild.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esbuild.mjs b/esbuild.mjs index 5b3edf5..57ba774 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -1,5 +1,13 @@ import * as esbuild from "esbuild"; +let define = {}; + +if (process.env.ENV === "production") { + for (const k in process.env) { + define[`process.env.${k}`] = JSON.stringify(process.env[k]); + } +} + /** * @type {esbuild.BuildOptions} */ @@ -15,6 +23,7 @@ const config = { target: "es2020", packages: "external", platform: "node", + define, }; async function main() { From 80b082c64df321b087c16564afaff2073f3e6031 Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Wed, 16 Apr 2025 09:15:45 -0700 Subject: [PATCH 06/52] Update package version to 5.0.0-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de6ba4f..c9f7663 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.0", + "version": "5.0.0-beta.1", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From f81e079536241e35435b53ccb10ead7c10ffebfe Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Wed, 16 Apr 2025 09:22:17 -0700 Subject: [PATCH 07/52] Refactor environment variable handling in esbuild configuration and update debug condition in error logging. Use a predefined list of keys for production environment variables and change the local debug check from IS_LOCAL to DEBUG. --- esbuild.mjs | 9 ++++++++- lib/src/index.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esbuild.mjs b/esbuild.mjs index 57ba774..383e680 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -1,9 +1,16 @@ import * as esbuild from "esbuild"; let define = {}; +const KEYS_TO_DEFINE = [ + "ENV", + "SENTRY_DSN", + "SENTRY_ORG", + "SENTRY_PROJECT", + "SENTRY_DSN", +]; if (process.env.ENV === "production") { - for (const k in process.env) { + for (const k of KEYS_TO_DEFINE) { define[`process.env.${k}`] = JSON.stringify(process.env[k]); } } diff --git a/lib/src/index.ts b/lib/src/index.ts index 450de62..f556eb3 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -82,7 +82,7 @@ const executeCommand = async ( const eventId = Sentry.captureException(error); const eventStr = `\n\nError ID: ${logger.info(eventId)}`; - if (process.env.IS_LOCAL === "true") { + if (process.env.DEBUG === "true") { console.error(logger.info("Development stack trace:\n"), error); } From 9767673307f3f60d93c89c31e15498cd2822131f Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Wed, 16 Apr 2025 14:38:31 -0400 Subject: [PATCH 08/52] Split up and write tests for all api token methods --- jest.config.ts | 1 + lib/src/index.ts | 2 +- lib/src/services/apiToken.test.ts | 27 ---- lib/src/services/apiToken.ts | 149 ------------------ .../apiToken/collectAndSaveToken.test.ts | 105 ++++++++++++ .../services/apiToken/collectAndSaveToken.ts | 46 ++++++ .../services/apiToken/collectToken.test.ts | 36 +++++ lib/src/services/apiToken/collectToken.ts | 17 ++ .../services/apiToken/getURLHostname.test.ts | 17 ++ lib/src/services/apiToken/getURLHostname.ts | 11 ++ .../services/apiToken/initAPIToken.test.ts | 112 +++++++++++++ lib/src/services/apiToken/initAPIToken.ts | 40 +++++ .../apiToken/promptForAPIToken.test.ts | 69 ++++++++ .../services/apiToken/promptForApiToken.ts | 26 +++ .../services/apiToken/validateToken.test.ts | 44 ++++++ lib/src/services/apiToken/validateToken.ts | 16 ++ 16 files changed, 541 insertions(+), 177 deletions(-) delete mode 100644 lib/src/services/apiToken.test.ts delete mode 100644 lib/src/services/apiToken.ts create mode 100644 lib/src/services/apiToken/collectAndSaveToken.test.ts create mode 100644 lib/src/services/apiToken/collectAndSaveToken.ts create mode 100644 lib/src/services/apiToken/collectToken.test.ts create mode 100644 lib/src/services/apiToken/collectToken.ts create mode 100644 lib/src/services/apiToken/getURLHostname.test.ts create mode 100644 lib/src/services/apiToken/getURLHostname.ts create mode 100644 lib/src/services/apiToken/initAPIToken.test.ts create mode 100644 lib/src/services/apiToken/initAPIToken.ts create mode 100644 lib/src/services/apiToken/promptForAPIToken.test.ts create mode 100644 lib/src/services/apiToken/promptForApiToken.ts create mode 100644 lib/src/services/apiToken/validateToken.test.ts create mode 100644 lib/src/services/apiToken/validateToken.ts diff --git a/jest.config.ts b/jest.config.ts index 08fec57..9607fea 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -11,6 +11,7 @@ const config: Config = { ], watchPathIgnorePatterns: ["/.testing/", "/testing/"], collectCoverageFrom: ["lib/**/*.{js,jsx,ts,tsx}"], + restoreMocks: true, }; export default config; diff --git a/lib/src/index.ts b/lib/src/index.ts index f556eb3..3c0414d 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -6,7 +6,7 @@ import { pull } from "./commands/pull"; import { quit } from "./utils/quit"; import { version } from "../../package.json"; import logger from "./utils/logger"; -import { initAPIToken } from "./services/apiToken"; +import initAPIToken from "./services/apiToken/initAPIToken"; import { initProjectConfig } from "./services/projectConfig"; import appContext from "./utils/appContext"; diff --git a/lib/src/services/apiToken.test.ts b/lib/src/services/apiToken.test.ts deleted file mode 100644 index 0e01f3e..0000000 --- a/lib/src/services/apiToken.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -import { _test as apiTokenTest } from "./apiToken"; - -const { getURLHostname } = apiTokenTest; - -describe("apiToken", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("getURLHostname", () => { - it("should return hostname when URL contains protocol", () => { - const expectedHostName = "example.com"; - - const result = getURLHostname("https://example.com"); - - expect(result).toBe(expectedHostName); - }); - - it("should return host as is when no protocol present", () => { - const expectedHostName = "example.com"; - - const result = getURLHostname("example.com"); - expect(result).toBe("example.com"); - }); - }); -}); diff --git a/lib/src/services/apiToken.ts b/lib/src/services/apiToken.ts deleted file mode 100644 index 2a79370..0000000 --- a/lib/src/services/apiToken.ts +++ /dev/null @@ -1,149 +0,0 @@ -import appContext from "../utils/appContext"; -import fs from "fs"; -import URL from "url"; -import * as configService from "./globalConfig"; -import checkToken from "../http/checkToken"; -import logger from "../utils/logger"; -import { quit } from "../utils/quit"; -import * as Sentry from "@sentry/node"; -import { prompt } from "enquirer"; - -/** - * Initializes the API token - * @param token The token to initialize the API token with. If not provided, the token will be fetched from the global config file. - * @param configFile The path to the global config file - * @param host The host to initialize the API token for - * @returns The initialized API token - */ -export async function initAPIToken( - token: string | undefined = appContext.apiToken, - configFile: string = appContext.configFile, - host: string = appContext.apiHost -) { - if (token) { - return await validateToken(token); - } - - if (!fs.existsSync(configFile)) { - return await collectAndSaveToken(); - } - - const configData = configService.readGlobalConfigData(configFile); - const sanitizedHost = getURLHostname(host); - - if ( - !configData[sanitizedHost] || - !configData[sanitizedHost][0] || - configData[sanitizedHost][0].token === "" - ) { - return await collectAndSaveToken(sanitizedHost); - } - - return await validateToken(configData[sanitizedHost][0].token); -} - -/** - * Collects a token from the user and saves it to the global config file - * @param host The host to save the token for - * @returns The collected token - */ -async function collectAndSaveToken(host: string = appContext.apiHost) { - try { - const token = await collectToken(); - logger.writeLine( - `Thanks for authenticating. We'll save the key to: ${logger.info( - appContext.configFile - )}\n` - ); - const sanitizedHost = getURLHostname(host); - configService.saveToken(appContext.configFile, sanitizedHost, token); - appContext.setApiToken(token); - return token; - } catch (error) { - // https://github.com/enquirer/enquirer/issues/225#issue-516043136 - // Empty string corresponds to the user hitting Ctrl + C - if (error === "") { - await quit("", 0); - return ""; - } - - const eventId = Sentry.captureException(error); - const eventStr = `\n\nError ID: ${logger.info(eventId)}`; - - await quit( - logger.errorText( - "Something went wrong. Please contact support or try again later." - ) + eventStr - ); - return ""; - } -} - -/** - * Outputs instructions to the user and collects an API token - * @returns The collected token - */ -async function collectToken() { - const apiUrl = logger.url("https://app.dittowords.com/account/devtools"); - const breadcrumbs = logger.bold(logger.info("API Keys")); - const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl} under "${breadcrumbs}".`; - - logger.writeLine(tokenDescription); - - const response = await promptForApiToken(); - return response.token; -} - -/** - * Prompt the user for an API token - * @returns The collected token - */ -async function promptForApiToken() { - const response = await prompt<{ token: string }>({ - type: "input", - name: "token", - message: "What is your API key?", - // @ts-expect-error - Enquirer types are not updated for the validate function - validate: async (token) => { - console.log("token", token); - const result = await checkToken(token); - if (!result.success) { - return result.output?.join("\n") || "Invalid API key"; - } - return true; - }, - }); - - return response; -} - -/** - * Get the hostname from a URL string - * @param hostString - * @returns - */ -function getURLHostname(hostString: string) { - if (!hostString.includes("://")) return hostString; - return URL.parse(hostString).hostname || ""; -} - -/** - * Validate a token - * @param token The token to validate - * @returns The newly validated token - */ -async function validateToken(token: string) { - const response = await checkToken(token); - if (!response.success) { - return await collectAndSaveToken(); - } - - return token; -} - -export const _test = { - collectToken, - validateToken, - getURLHostname, - promptForApiToken, -}; diff --git a/lib/src/services/apiToken/collectAndSaveToken.test.ts b/lib/src/services/apiToken/collectAndSaveToken.test.ts new file mode 100644 index 0000000..fc61de5 --- /dev/null +++ b/lib/src/services/apiToken/collectAndSaveToken.test.ts @@ -0,0 +1,105 @@ +import * as CollectToken from "./collectToken"; +import * as GetURLHostname from "./getURLHostname"; +import * as configService from "../globalConfig"; +import appContext from "../../utils/appContext"; +import * as utils from "../../utils/quit"; +import collectAndSaveToken from "./collectAndSaveToken"; + +describe("collectAndSaveToken", () => { + let priorToken: string | undefined; + let priorHost: string; + + let collectTokenSpy: jest.SpiedFunction; + let getURLHostnameSpy: jest.SpiedFunction; + let saveTokenSpy: jest.SpiedFunction; + let quitSpy: jest.SpiedFunction; + + const token = "token"; + const host = "host"; + const apiHost = "apiHost"; + const sanitizedHost = "hostname"; + + beforeEach(() => { + priorToken = appContext.apiToken; + priorHost = appContext.apiHost; + appContext.setApiToken(""); + appContext.apiHost = apiHost; + collectTokenSpy = jest.spyOn(CollectToken, "default"); + getURLHostnameSpy = jest + .spyOn(GetURLHostname, "default") + .mockReturnValue(sanitizedHost); + saveTokenSpy = jest + .spyOn(configService, "saveToken") + .mockImplementation(() => Promise.resolve()); + quitSpy = jest + .spyOn(utils, "quit") + .mockImplementation(() => Promise.resolve()); + }); + + afterEach(() => { + appContext.setApiToken(priorToken); + appContext.apiHost = priorHost; + jest.restoreAllMocks(); + }); + + it("collects, saves and returns a token", async () => { + collectTokenSpy.mockResolvedValue(token); + + expect(appContext.apiToken).toBe(""); + const result = await collectAndSaveToken(); + + expect(collectTokenSpy).toHaveBeenCalled(); + expect(getURLHostnameSpy).toHaveBeenCalledWith(appContext.apiHost); + expect(saveTokenSpy).toHaveBeenCalledWith( + appContext.configFile, + sanitizedHost, + token + ); + expect(result).toBe(token); + expect(appContext.apiToken).toBe(token); + }); + + it("uses the host if provided", async () => { + collectTokenSpy.mockResolvedValue(token); + + expect(appContext.apiToken).toBe(""); + + const result = await collectAndSaveToken(host); + expect(collectTokenSpy).toHaveBeenCalled(); + expect(getURLHostnameSpy).toHaveBeenCalledWith(host); + expect(saveTokenSpy).toHaveBeenCalledWith( + appContext.configFile, + sanitizedHost, + token + ); + expect(result).toBe(token); + }); + + it("handles empty string error", async () => { + collectTokenSpy.mockImplementation(() => Promise.reject("")); + + expect(appContext.apiToken).toBe(""); + const response = await collectAndSaveToken(); + + expect(collectTokenSpy).toHaveBeenCalled(); + expect(quitSpy).toHaveBeenCalledWith("", 0); + expect(appContext.apiToken).toBe(""); + expect(response).toBe(""); + expect(getURLHostnameSpy).not.toHaveBeenCalled(); + expect(saveTokenSpy).not.toHaveBeenCalled(); + }); + + it("handles other errors", async () => { + collectTokenSpy.mockImplementation(() => Promise.reject("some error")); + + expect(appContext.apiToken).toBe(""); + const response = await collectAndSaveToken(); + + expect(collectTokenSpy).toHaveBeenCalled(); + expect(quitSpy).toHaveBeenCalledWith(expect.stringContaining("Error ID:")); + expect(appContext.apiToken).toBe(""); + expect(response).toBe(""); + expect(getURLHostnameSpy).not.toHaveBeenCalled(); + expect(saveTokenSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/services/apiToken/collectAndSaveToken.ts b/lib/src/services/apiToken/collectAndSaveToken.ts new file mode 100644 index 0000000..08ba021 --- /dev/null +++ b/lib/src/services/apiToken/collectAndSaveToken.ts @@ -0,0 +1,46 @@ +import appContext from "../../utils/appContext"; +import * as configService from "../globalConfig"; +import logger from "../../utils/logger"; +import { quit } from "../../utils/quit"; +import * as Sentry from "@sentry/node"; +import collectToken from "./collectToken"; +import getURLHostname from "./getURLHostname"; + +/** + * Collects a token from the user and saves it to the global config file + * @param host The host to save the token for + * @returns The collected token + */ +export default async function collectAndSaveToken( + host: string = appContext.apiHost +) { + try { + const token = await collectToken(); + logger.writeLine( + `Thanks for authenticating. We'll save the key to: ${logger.info( + appContext.configFile + )}\n` + ); + const sanitizedHost = getURLHostname(host); + configService.saveToken(appContext.configFile, sanitizedHost, token); + appContext.setApiToken(token); + return token; + } catch (error) { + // https://github.com/enquirer/enquirer/issues/225#issue-516043136 + // Empty string corresponds to the user hitting Ctrl + C + if (error === "") { + await quit("", 0); + return ""; + } + + const eventId = Sentry.captureException(error); + const eventStr = `\n\nError ID: ${logger.info(eventId)}`; + + await quit( + logger.errorText( + "Something went wrong. Please contact support or try again later." + ) + eventStr + ); + return ""; + } +} diff --git a/lib/src/services/apiToken/collectToken.test.ts b/lib/src/services/apiToken/collectToken.test.ts new file mode 100644 index 0000000..4fa270c --- /dev/null +++ b/lib/src/services/apiToken/collectToken.test.ts @@ -0,0 +1,36 @@ +import collectToken from "./collectToken"; +import * as PromptForApiToken from "./promptForApiToken"; +import logger from "../../utils/logger"; + +describe("collectToken", () => { + let promptForApiTokenSpy: jest.SpiedFunction< + typeof PromptForApiToken.default + >; + + const token = "token"; + + beforeEach(() => { + logger.url = jest.fn((msg: string) => msg); + logger.bold = jest.fn((msg: string) => msg); + logger.info = jest.fn((msg: string) => msg); + logger.writeLine = jest.fn((msg: string) => {}); + + promptForApiTokenSpy = jest + .spyOn(PromptForApiToken, "default") + .mockResolvedValue({ token }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("prompts for API token and returns it", async () => { + const response = await collectToken(); + expect(response).toBe(token); + expect(logger.url).toHaveBeenCalled(); + expect(logger.bold).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.writeLine).toHaveBeenCalled(); + expect(promptForApiTokenSpy).toHaveBeenCalled(); + }); +}); diff --git a/lib/src/services/apiToken/collectToken.ts b/lib/src/services/apiToken/collectToken.ts new file mode 100644 index 0000000..eb7b717 --- /dev/null +++ b/lib/src/services/apiToken/collectToken.ts @@ -0,0 +1,17 @@ +import logger from "../../utils/logger"; +import promptForApiToken from "./promptForApiToken"; + +/** + * Outputs instructions to the user and collects an API token + * @returns The collected token + */ +export default async function collectToken() { + const apiUrl = logger.url("https://app.dittowords.com/account/devtools"); + const breadcrumbs = logger.bold(logger.info("API Keys")); + const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl} under "${breadcrumbs}".`; + + logger.writeLine(tokenDescription); + + const response = await promptForApiToken(); + return response.token; +} diff --git a/lib/src/services/apiToken/getURLHostname.test.ts b/lib/src/services/apiToken/getURLHostname.test.ts new file mode 100644 index 0000000..05d218a --- /dev/null +++ b/lib/src/services/apiToken/getURLHostname.test.ts @@ -0,0 +1,17 @@ +import getURLHostname from "./getURLHostname"; + +describe("getURLHostname", () => { + it("should return hostname when URL contains protocol", () => { + const expectedHostName = "example.com"; + + const result = getURLHostname("https://example.com"); + expect(result).toBe(expectedHostName); + }); + + it("should return host as is when no protocol present", () => { + const expectedHostName = "example.com"; + + const result = getURLHostname(expectedHostName); + expect(result).toBe(expectedHostName); + }); +}); diff --git a/lib/src/services/apiToken/getURLHostname.ts b/lib/src/services/apiToken/getURLHostname.ts new file mode 100644 index 0000000..a14c31e --- /dev/null +++ b/lib/src/services/apiToken/getURLHostname.ts @@ -0,0 +1,11 @@ +import URL from "url"; + +/** + * Get the hostname from a URL string + * @param hostString + * @returns + */ +export default function getURLHostname(hostString: string) { + if (!hostString.includes("://")) return hostString; + return URL.parse(hostString).hostname || ""; +} diff --git a/lib/src/services/apiToken/initAPIToken.test.ts b/lib/src/services/apiToken/initAPIToken.test.ts new file mode 100644 index 0000000..be5dd16 --- /dev/null +++ b/lib/src/services/apiToken/initAPIToken.test.ts @@ -0,0 +1,112 @@ +import fs from "fs"; +import * as ConfigService from "../globalConfig"; +import * as ValidateToken from "./validateToken"; +import * as CollectAndSaveToken from "./collectAndSaveToken"; +import * as GetURLHostname from "./getURLHostname"; +import initAPIToken from "./initAPIToken"; + +describe("initAPIToken", () => { + let validateTokenSpy: jest.SpiedFunction; + let collectAndSaveTokenSpy: jest.SpiedFunction< + typeof CollectAndSaveToken.default + >; + let existsSyncSpy: jest.SpyInstance; + let readGlobalConfigDataSpy: jest.SpiedFunction< + typeof ConfigService.readGlobalConfigData + >; + let getURLHostnameSpy: jest.SpiedFunction; + + beforeEach(() => { + validateTokenSpy = jest + .spyOn(ValidateToken, "default") + .mockImplementation((token: string) => Promise.resolve(token)); + collectAndSaveTokenSpy = jest + .spyOn(CollectAndSaveToken, "default") + .mockImplementation((host?: string) => { + if (host) { + return Promise.resolve("tokenWithHost"); + } else { + return Promise.resolve("newToken"); + } + }); + existsSyncSpy = jest.spyOn(fs, "existsSync"); + readGlobalConfigDataSpy = jest.spyOn(ConfigService, "readGlobalConfigData"); + getURLHostnameSpy = jest + .spyOn(GetURLHostname, "default") + .mockReturnValue("urlHostname"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should validate and return the token if provided", async () => { + const response = await initAPIToken("validToken"); + expect(response).toBe("validToken"); + expect(validateTokenSpy).toHaveBeenCalledWith("validToken"); + expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); + expect(readGlobalConfigDataSpy).not.toHaveBeenCalled(); + expect(getURLHostnameSpy).not.toHaveBeenCalled(); + }); + + it("should call collectAndSaveToken if no token is provided and config file does not exist", async () => { + existsSyncSpy.mockReturnValue(false); + const response = await initAPIToken("", "configFile"); + expect(response).toBe("newToken"); + expect(validateTokenSpy).not.toHaveBeenCalled(); + expect(collectAndSaveTokenSpy).toHaveBeenCalled(); + expect(existsSyncSpy).toHaveBeenCalledWith("configFile"); + expect(readGlobalConfigDataSpy).not.toHaveBeenCalled(); + expect(getURLHostnameSpy).not.toHaveBeenCalled(); + }); + + describe("should collect and save token based on config if config does not have a token", () => { + const expectCollectsFromConfig = () => { + expect(validateTokenSpy).not.toHaveBeenCalled(); + expect(existsSyncSpy).toHaveBeenCalledWith("configFile"); + expect(readGlobalConfigDataSpy).toHaveBeenCalledWith("configFile"); + expect(getURLHostnameSpy).toHaveBeenCalledWith("host"); + expect(collectAndSaveTokenSpy).toHaveBeenCalledWith("urlHostname"); + }; + + it("config[host] does not exist", async () => { + existsSyncSpy.mockReturnValue(true); + const configData = {}; + readGlobalConfigDataSpy.mockReturnValue(configData); + const response = await initAPIToken("", "configFile", "host"); + expect(response).toBe("tokenWithHost"); + expectCollectsFromConfig(); + }); + + it("config[host][0] does not exist", async () => { + existsSyncSpy.mockReturnValue(true); + const configData = { urlHostname: [] }; + readGlobalConfigDataSpy.mockReturnValue(configData); + const response = await initAPIToken(undefined, "configFile", "host"); + expect(response).toBe("tokenWithHost"); + expectCollectsFromConfig(); + }); + + it("config[host][0].token is empty string", async () => { + existsSyncSpy.mockReturnValue(true); + const configData = { urlHostname: [{ token: "" }] }; + readGlobalConfigDataSpy.mockReturnValue(configData); + const response = await initAPIToken("", "configFile", "host"); + expect(response).toBe("tokenWithHost"); + expectCollectsFromConfig(); + }); + }); + + it("should validate and return the token from the config file", async () => { + existsSyncSpy.mockReturnValue(true); + const configData = { urlHostname: [{ token: "myToken" }] }; + readGlobalConfigDataSpy.mockReturnValue(configData); + const response = await initAPIToken("", "configFile", "host"); + expect(response).toBe("myToken"); + expect(validateTokenSpy).toHaveBeenCalledWith("myToken"); + expect(existsSyncSpy).toHaveBeenCalledWith("configFile"); + expect(readGlobalConfigDataSpy).toHaveBeenCalledWith("configFile"); + expect(getURLHostnameSpy).toHaveBeenCalledWith("host"); + expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/services/apiToken/initAPIToken.ts b/lib/src/services/apiToken/initAPIToken.ts new file mode 100644 index 0000000..811b4de --- /dev/null +++ b/lib/src/services/apiToken/initAPIToken.ts @@ -0,0 +1,40 @@ +import appContext from "../../utils/appContext"; +import fs from "fs"; +import * as configService from "../globalConfig"; +import collectAndSaveToken from "./collectAndSaveToken"; +import validateToken from "./validateToken"; +import getURLHostname from "./getURLHostname"; + +/** + * Initializes the API token + * @param token The token to initialize the API token with. If not provided, the token will be fetched from the global config file. + * @param configFile The path to the global config file + * @param host The host to initialize the API token for + * @returns The initialized API token + */ +export default async function initAPIToken( + token: string | undefined = appContext.apiToken, + configFile: string = appContext.configFile, + host: string = appContext.apiHost +) { + if (token) { + return await validateToken(token); + } + + if (!fs.existsSync(configFile)) { + return await collectAndSaveToken(); + } + + const configData = configService.readGlobalConfigData(configFile); + const sanitizedHost = getURLHostname(host); + + if ( + !configData[sanitizedHost] || + !configData[sanitizedHost][0] || + configData[sanitizedHost][0].token === "" + ) { + return await collectAndSaveToken(sanitizedHost); + } + + return await validateToken(configData[sanitizedHost][0].token); +} diff --git a/lib/src/services/apiToken/promptForAPIToken.test.ts b/lib/src/services/apiToken/promptForAPIToken.test.ts new file mode 100644 index 0000000..f80e5c5 --- /dev/null +++ b/lib/src/services/apiToken/promptForAPIToken.test.ts @@ -0,0 +1,69 @@ +import Enquirer from "enquirer"; +import * as CheckToken from "../../http/checkToken"; +import promptForApiToken, { validate } from "./promptForApiToken"; + +jest.mock("enquirer", () => ({ + prompt: jest.fn(), +})); + +describe("promptForApiToken", () => { + const mockResponse = { token: "mockToken" }; + + beforeEach(() => { + (Enquirer.prompt as jest.Mock).mockResolvedValue(mockResponse); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should prompt for API token and return it", async () => { + const response = await promptForApiToken(); + expect(response).toEqual(mockResponse); + expect(Enquirer.prompt).toHaveBeenCalledWith({ + type: "input", + name: "token", + message: "What is your API key?", + validate: expect.any(Function), + }); + }); + + describe("validate", () => { + let checkTokenSpy: jest.SpiedFunction; + + beforeEach(() => { + checkTokenSpy = jest + .spyOn(CheckToken, "default") + .mockImplementation((token: string) => { + if (token === "good") { + return Promise.resolve({ success: true }); + } else if (token === "output") { + return Promise.resolve({ + success: false, + output: ["error", "message"], + }); + } else { + return Promise.resolve({ success: false }); + } + }); + }); + + it("should return true for valid token", async () => { + const result = await validate("good"); + expect(result).toBe(true); + expect(checkTokenSpy).toHaveBeenCalledWith("good"); + }); + + it("should return error message for invalid token", async () => { + const result = await validate("bad"); + expect(result).toBe("Invalid API key"); + expect(checkTokenSpy).toHaveBeenCalledWith("bad"); + }); + + it("should return output message for invalid token with output", async () => { + const result = await validate("output"); + expect(result).toBe("error\nmessage"); + expect(checkTokenSpy).toHaveBeenCalledWith("output"); + }); + }); +}); diff --git a/lib/src/services/apiToken/promptForApiToken.ts b/lib/src/services/apiToken/promptForApiToken.ts new file mode 100644 index 0000000..91da9b6 --- /dev/null +++ b/lib/src/services/apiToken/promptForApiToken.ts @@ -0,0 +1,26 @@ +import checkToken from "../../http/checkToken"; +import { prompt } from "enquirer"; + +export const validate = async (token: string) => { + const result = await checkToken(token); + if (!result.success) { + return result.output?.join("\n") || "Invalid API key"; + } + return true; +}; + +/** + * Prompt the user for an API token + * @returns The collected token + */ +export default async function promptForApiToken() { + const response = await prompt<{ token: string }>({ + type: "input", + name: "token", + message: "What is your API key?", + // @ts-expect-error - Enquirer types are not updated for the validate function + validate, + }); + + return response; +} diff --git a/lib/src/services/apiToken/validateToken.test.ts b/lib/src/services/apiToken/validateToken.test.ts new file mode 100644 index 0000000..d1045ee --- /dev/null +++ b/lib/src/services/apiToken/validateToken.test.ts @@ -0,0 +1,44 @@ +import * as CheckToken from "../../http/checkToken"; +import * as CollectAndSaveToken from "./collectAndSaveToken"; +import validateToken from "./validateToken"; + +describe("validateToken", () => { + let checkTokenSpy: jest.SpiedFunction; + let collectAndSaveTokenSpy: jest.SpiedFunction< + typeof CollectAndSaveToken.default + >; + + beforeEach(() => { + checkTokenSpy = jest + .spyOn(CheckToken, "default") + .mockImplementation((token: string) => { + if (token === "good") { + return Promise.resolve({ success: true }); + } else { + return Promise.resolve({ success: false }); + } + }); + collectAndSaveTokenSpy = jest + .spyOn(CollectAndSaveToken, "default") + .mockImplementation(() => Promise.resolve("newToken")); + }); + + afterEach(() => { + checkTokenSpy.mockRestore(); + collectAndSaveTokenSpy.mockRestore(); + }); + + it("should return to provided token if valid", async () => { + const response = await validateToken("good"); + expect(response).toBe("good"); + expect(checkTokenSpy).toHaveBeenCalledWith("good"); + expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); + }); + + it("should call collectAndSaveToken if token is invalid, and return its result", async () => { + const response = await validateToken("bad"); + expect(response).toBe("newToken"); + expect(checkTokenSpy).toHaveBeenCalledWith("bad"); + expect(collectAndSaveTokenSpy).toHaveBeenCalled(); + }); +}); diff --git a/lib/src/services/apiToken/validateToken.ts b/lib/src/services/apiToken/validateToken.ts new file mode 100644 index 0000000..11ec110 --- /dev/null +++ b/lib/src/services/apiToken/validateToken.ts @@ -0,0 +1,16 @@ +import checkToken from "../../http/checkToken"; +import collectAndSaveToken from "./collectAndSaveToken"; + +/** + * Validate a token + * @param token The token to validate + * @returns The newly validated token + */ +export default async function validateToken(token: string) { + const response = await checkToken(token); + if (!response.success) { + return await collectAndSaveToken(); + } + + return token; +} From 2f8472255eca4123710cefcc89092a9ef77bae92 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Wed, 16 Apr 2025 14:43:26 -0400 Subject: [PATCH 09/52] Keep it consistent, always use spy --- lib/src/services/apiToken/promptForAPIToken.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/src/services/apiToken/promptForAPIToken.test.ts b/lib/src/services/apiToken/promptForAPIToken.test.ts index f80e5c5..7e6bedf 100644 --- a/lib/src/services/apiToken/promptForAPIToken.test.ts +++ b/lib/src/services/apiToken/promptForAPIToken.test.ts @@ -2,15 +2,12 @@ import Enquirer from "enquirer"; import * as CheckToken from "../../http/checkToken"; import promptForApiToken, { validate } from "./promptForApiToken"; -jest.mock("enquirer", () => ({ - prompt: jest.fn(), -})); - describe("promptForApiToken", () => { const mockResponse = { token: "mockToken" }; + let promptSpy: jest.SpiedFunction; beforeEach(() => { - (Enquirer.prompt as jest.Mock).mockResolvedValue(mockResponse); + promptSpy = jest.spyOn(Enquirer, "prompt").mockResolvedValue(mockResponse); }); afterEach(() => { @@ -20,7 +17,7 @@ describe("promptForApiToken", () => { it("should prompt for API token and return it", async () => { const response = await promptForApiToken(); expect(response).toEqual(mockResponse); - expect(Enquirer.prompt).toHaveBeenCalledWith({ + expect(promptSpy).toHaveBeenCalledWith({ type: "input", name: "token", message: "What is your API key?", From ce0ef37f521aa0dcf1340845e2a0164a9d8686c7 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Wed, 16 Apr 2025 14:50:03 -0400 Subject: [PATCH 10/52] Use appContext directly --- .../services/apiToken/initAPIToken.test.ts | 35 ++++++++++++------- lib/src/services/apiToken/initAPIToken.ts | 21 ++++------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/lib/src/services/apiToken/initAPIToken.test.ts b/lib/src/services/apiToken/initAPIToken.test.ts index be5dd16..57aaca0 100644 --- a/lib/src/services/apiToken/initAPIToken.test.ts +++ b/lib/src/services/apiToken/initAPIToken.test.ts @@ -4,6 +4,7 @@ import * as ValidateToken from "./validateToken"; import * as CollectAndSaveToken from "./collectAndSaveToken"; import * as GetURLHostname from "./getURLHostname"; import initAPIToken from "./initAPIToken"; +import appContext from "../../utils/appContext"; describe("initAPIToken", () => { let validateTokenSpy: jest.SpiedFunction; @@ -15,8 +16,12 @@ describe("initAPIToken", () => { typeof ConfigService.readGlobalConfigData >; let getURLHostnameSpy: jest.SpiedFunction; + let priorToken: string | undefined; beforeEach(() => { + priorToken = appContext.apiToken; + appContext.setApiToken(""); + validateTokenSpy = jest .spyOn(ValidateToken, "default") .mockImplementation((token: string) => Promise.resolve(token)); @@ -37,11 +42,13 @@ describe("initAPIToken", () => { }); afterEach(() => { + appContext.setApiToken(priorToken); jest.restoreAllMocks(); }); it("should validate and return the token if provided", async () => { - const response = await initAPIToken("validToken"); + appContext.setApiToken("validToken"); + const response = await initAPIToken(); expect(response).toBe("validToken"); expect(validateTokenSpy).toHaveBeenCalledWith("validToken"); expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); @@ -51,11 +58,11 @@ describe("initAPIToken", () => { it("should call collectAndSaveToken if no token is provided and config file does not exist", async () => { existsSyncSpy.mockReturnValue(false); - const response = await initAPIToken("", "configFile"); + const response = await initAPIToken(); expect(response).toBe("newToken"); expect(validateTokenSpy).not.toHaveBeenCalled(); expect(collectAndSaveTokenSpy).toHaveBeenCalled(); - expect(existsSyncSpy).toHaveBeenCalledWith("configFile"); + expect(existsSyncSpy).toHaveBeenCalledWith(appContext.configFile); expect(readGlobalConfigDataSpy).not.toHaveBeenCalled(); expect(getURLHostnameSpy).not.toHaveBeenCalled(); }); @@ -63,9 +70,11 @@ describe("initAPIToken", () => { describe("should collect and save token based on config if config does not have a token", () => { const expectCollectsFromConfig = () => { expect(validateTokenSpy).not.toHaveBeenCalled(); - expect(existsSyncSpy).toHaveBeenCalledWith("configFile"); - expect(readGlobalConfigDataSpy).toHaveBeenCalledWith("configFile"); - expect(getURLHostnameSpy).toHaveBeenCalledWith("host"); + expect(existsSyncSpy).toHaveBeenCalledWith(appContext.configFile); + expect(readGlobalConfigDataSpy).toHaveBeenCalledWith( + appContext.configFile + ); + expect(getURLHostnameSpy).toHaveBeenCalledWith(appContext.apiHost); expect(collectAndSaveTokenSpy).toHaveBeenCalledWith("urlHostname"); }; @@ -73,7 +82,7 @@ describe("initAPIToken", () => { existsSyncSpy.mockReturnValue(true); const configData = {}; readGlobalConfigDataSpy.mockReturnValue(configData); - const response = await initAPIToken("", "configFile", "host"); + const response = await initAPIToken(); expect(response).toBe("tokenWithHost"); expectCollectsFromConfig(); }); @@ -82,7 +91,7 @@ describe("initAPIToken", () => { existsSyncSpy.mockReturnValue(true); const configData = { urlHostname: [] }; readGlobalConfigDataSpy.mockReturnValue(configData); - const response = await initAPIToken(undefined, "configFile", "host"); + const response = await initAPIToken(); expect(response).toBe("tokenWithHost"); expectCollectsFromConfig(); }); @@ -91,7 +100,7 @@ describe("initAPIToken", () => { existsSyncSpy.mockReturnValue(true); const configData = { urlHostname: [{ token: "" }] }; readGlobalConfigDataSpy.mockReturnValue(configData); - const response = await initAPIToken("", "configFile", "host"); + const response = await initAPIToken(); expect(response).toBe("tokenWithHost"); expectCollectsFromConfig(); }); @@ -101,12 +110,12 @@ describe("initAPIToken", () => { existsSyncSpy.mockReturnValue(true); const configData = { urlHostname: [{ token: "myToken" }] }; readGlobalConfigDataSpy.mockReturnValue(configData); - const response = await initAPIToken("", "configFile", "host"); + const response = await initAPIToken(); expect(response).toBe("myToken"); expect(validateTokenSpy).toHaveBeenCalledWith("myToken"); - expect(existsSyncSpy).toHaveBeenCalledWith("configFile"); - expect(readGlobalConfigDataSpy).toHaveBeenCalledWith("configFile"); - expect(getURLHostnameSpy).toHaveBeenCalledWith("host"); + expect(existsSyncSpy).toHaveBeenCalledWith(appContext.configFile); + expect(readGlobalConfigDataSpy).toHaveBeenCalledWith(appContext.configFile); + expect(getURLHostnameSpy).toHaveBeenCalledWith(appContext.apiHost); expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); }); }); diff --git a/lib/src/services/apiToken/initAPIToken.ts b/lib/src/services/apiToken/initAPIToken.ts index 811b4de..30a7638 100644 --- a/lib/src/services/apiToken/initAPIToken.ts +++ b/lib/src/services/apiToken/initAPIToken.ts @@ -6,27 +6,20 @@ import validateToken from "./validateToken"; import getURLHostname from "./getURLHostname"; /** - * Initializes the API token - * @param token The token to initialize the API token with. If not provided, the token will be fetched from the global config file. - * @param configFile The path to the global config file - * @param host The host to initialize the API token for + * Initializes the API token based on the appContext and config file. * @returns The initialized API token */ -export default async function initAPIToken( - token: string | undefined = appContext.apiToken, - configFile: string = appContext.configFile, - host: string = appContext.apiHost -) { - if (token) { - return await validateToken(token); +export default async function initAPIToken() { + if (appContext.apiToken) { + return await validateToken(appContext.apiToken); } - if (!fs.existsSync(configFile)) { + if (!fs.existsSync(appContext.configFile)) { return await collectAndSaveToken(); } - const configData = configService.readGlobalConfigData(configFile); - const sanitizedHost = getURLHostname(host); + const configData = configService.readGlobalConfigData(appContext.configFile); + const sanitizedHost = getURLHostname(appContext.apiHost); if ( !configData[sanitizedHost] || From d797808e184660c08631eb2ca764fb0f546ffb52 Mon Sep 17 00:00:00 2001 From: Jason Rodriguez Date: Wed, 16 Apr 2025 14:00:09 -0700 Subject: [PATCH 11/52] Jrod/dit 10122 add support for c flag for defining the location of the (#117) * Refactor CLI command handling and project configuration - Introduced a new `--config` flag for the `pull` command to specify the project config file path. - Updated `initProjectConfig` to accept options for configuration. - Enhanced `executeCommand` to handle command options more effectively. - Adjusted output directory handling in JSONFormatter for improved flexibility. - Added `outDir` to output filters in shared outputs for better configuration management. * Enhance file writing functions to create missing directories - Updated `writeFileSync` and `writeFile` functions to check for the existence of the target directory and create it recursively if it does not exist. This ensures that files can be written without errors related to missing directories. * Refactor JSON framework handling and enhance JSONFormatter - Moved framework processor logic to a new `json` directory for better organization. - Updated `JSONFormatter` to utilize the new `getFrameworkProcessor` function from the JSON framework. - Removed obsolete base framework and index files, streamlining the framework structure. - Introduced `BaseFramework` and `I18NextFramework` classes to handle specific framework processing. - Enhanced output directory handling in `BaseFormatter` for improved configuration management. * Refactor JSON framework handling and update framework processor logic - Updated `JSONFormatter` to correctly process the framework output. - Removed obsolete imports and files related to the i18next framework. - Simplified framework type handling in the framework processor. - Enhanced Zod schema for JSON output to directly specify the framework type. * Refactor output handling and improve configuration management - Simplified the `format` method in `BaseFormatter` and `JSONFormatter` by removing unnecessary parameters. - Updated output directory handling in `BaseFormatter` and `AppContext` for better consistency and clarity. - Enhanced the `outDir` retrieval logic in `AppContext` to prioritize project configuration settings. --- lib/src/formatters/frameworks/base.ts | 13 ---------- lib/src/formatters/frameworks/index.ts | 10 -------- lib/src/formatters/frameworks/json/base.ts | 21 ++++++++++++++++ .../frameworks/{ => json}/i18next.ts | 16 +++++------- lib/src/formatters/frameworks/json/index.ts | 15 +++++++++++ lib/src/formatters/index.ts | 5 +--- lib/src/formatters/json.ts | 9 +++---- lib/src/formatters/shared/base.ts | 8 +++--- lib/src/index.ts | 25 +++++++++++-------- lib/src/outputs/shared.ts | 1 + lib/src/services/projectConfig.ts | 5 ++-- lib/src/utils/appContext.ts | 7 +++++- lib/src/utils/fileSystem.ts | 7 ++++++ 13 files changed, 82 insertions(+), 60 deletions(-) delete mode 100644 lib/src/formatters/frameworks/base.ts delete mode 100644 lib/src/formatters/frameworks/index.ts create mode 100644 lib/src/formatters/frameworks/json/base.ts rename lib/src/formatters/frameworks/{ => json}/i18next.ts (84%) create mode 100644 lib/src/formatters/frameworks/json/index.ts diff --git a/lib/src/formatters/frameworks/base.ts b/lib/src/formatters/frameworks/base.ts deleted file mode 100644 index c8c13ce..0000000 --- a/lib/src/formatters/frameworks/base.ts +++ /dev/null @@ -1,13 +0,0 @@ -import OutputFile from "../shared/fileTypes/OutputFile"; - -export default class BaseFramework { - protected format: string; - - constructor(format: string) { - this.format = format; - } - - process(...args: any[]): OutputFile[] { - throw new Error("Not implemented"); - } -} diff --git a/lib/src/formatters/frameworks/index.ts b/lib/src/formatters/frameworks/index.ts deleted file mode 100644 index d85bad8..0000000 --- a/lib/src/formatters/frameworks/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import I18NextFramework from "./i18next"; - -export function getFrameworkProcessor(framework: string) { - switch (framework) { - case "i18next": - return new I18NextFramework(framework); - default: - throw new Error(`Unsupported framework: ${framework}`); - } -} diff --git a/lib/src/formatters/frameworks/json/base.ts b/lib/src/formatters/frameworks/json/base.ts new file mode 100644 index 0000000..4430b56 --- /dev/null +++ b/lib/src/formatters/frameworks/json/base.ts @@ -0,0 +1,21 @@ +import { Output } from "../../../outputs"; +import appContext from "../../../utils/appContext"; +import OutputFile from "../../shared/fileTypes/OutputFile"; + +export default class BaseFramework { + protected output: Output; + protected outDir: string; + + constructor(output: Output) { + this.output = output; + this.outDir = output.outDir ?? appContext.projectConfigDir; + } + + get framework() { + return this.output.framework; + } + + process(...args: any[]): OutputFile[] { + throw new Error("Not implemented"); + } +} diff --git a/lib/src/formatters/frameworks/i18next.ts b/lib/src/formatters/frameworks/json/i18next.ts similarity index 84% rename from lib/src/formatters/frameworks/i18next.ts rename to lib/src/formatters/frameworks/json/i18next.ts index 749ae6a..54c7c57 100644 --- a/lib/src/formatters/frameworks/i18next.ts +++ b/lib/src/formatters/frameworks/json/i18next.ts @@ -1,9 +1,8 @@ -import appContext from "../../utils/appContext"; -import JavascriptOutputFile from "../shared/fileTypes/JavascriptOutputFile"; -import OutputFile from "../shared/fileTypes/OutputFile"; -import { applyMixins } from "../shared"; -import javascriptCodegenMixin from "../mixins/javascriptCodegenMixin"; -import JSONOutputFile from "../shared/fileTypes/JSONOutputFile"; +import JavascriptOutputFile from "../../shared/fileTypes/JavascriptOutputFile"; +import OutputFile from "../../shared/fileTypes/OutputFile"; +import { applyMixins } from "../../shared"; +import javascriptCodegenMixin from "../../mixins/javascriptCodegenMixin"; +import JSONOutputFile from "../../shared/fileTypes/JSONOutputFile"; import BaseFramework from "./base"; export default class I18NextFramework extends applyMixins( @@ -13,12 +12,9 @@ export default class I18NextFramework extends applyMixins( process( outputJsonFiles: Record> ) { - const outputDir = appContext.projectConfigDir; - // Generate Driver file - const driverFile = new JavascriptOutputFile({ filename: "index", - path: outputDir, + path: this.outDir, }); const filesGroupedByVariantId = Object.values(outputJsonFiles).reduce( diff --git a/lib/src/formatters/frameworks/json/index.ts b/lib/src/formatters/frameworks/json/index.ts new file mode 100644 index 0000000..7e99d0d --- /dev/null +++ b/lib/src/formatters/frameworks/json/index.ts @@ -0,0 +1,15 @@ +import I18NextFramework from "./i18next"; +import { Output } from "../../../outputs"; + +export function getFrameworkProcessor(output: Output) { + if (!output.framework) { + throw new Error("Only call this function with a framework output"); + } + let frameworkType = output.framework; + switch (frameworkType) { + case "i18next": + return new I18NextFramework(output); + default: + throw new Error(`Unsupported JSON framework: ${frameworkType}`); + } +} diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index 72e8438..a2536fb 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -8,10 +8,7 @@ export default function handleOutput( ) { switch (output.format) { case "json": - return new JSONFormatter(output, projectConfig).format( - output, - projectConfig - ); + return new JSONFormatter(output, projectConfig).format(); default: throw new Error(`Unsupported output format: ${output}`); } diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index aa8f38b..14b3bfd 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -5,7 +5,7 @@ import OutputFile from "./shared/fileTypes/OutputFile"; import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; import appContext from "../utils/appContext"; import { applyMixins } from "./shared"; -import { getFrameworkProcessor } from "./frameworks"; +import { getFrameworkProcessor } from "./frameworks/json"; type JSONAPIData = { textItems: TextItemsResponse; @@ -29,7 +29,6 @@ export default class JSONFormatter extends applyMixins( } protected async transformAPIData(data: JSONAPIData) { - const outputDir = appContext.projectConfigDir; let outputJsonFiles: Record< string, @@ -38,7 +37,7 @@ export default class JSONFormatter extends applyMixins( const variablesOutputFile = new JSONOutputFile({ filename: "variables", - path: appContext.projectConfigDir, + path: this.outputDir, }); for (let i = 0; i < data.textItems.length; i++) { @@ -48,7 +47,7 @@ export default class JSONFormatter extends applyMixins( outputJsonFiles[fileName] ??= new JSONOutputFile({ filename: fileName, - path: outputDir, + path: this.outputDir, metadata: { variantId: textItem.variantId || "base" }, }); @@ -67,7 +66,7 @@ export default class JSONFormatter extends applyMixins( if (this.output.framework) { // process framework - results.push(...getFrameworkProcessor(this.output.framework).process(outputJsonFiles)); + results.push(...getFrameworkProcessor(this.output).process(outputJsonFiles)); } return results; diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index 264a75b..abb9cc1 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -3,14 +3,17 @@ import { writeFile } from "../../utils/fileSystem"; import logger from "../../utils/logger"; import { ProjectConfigYAML } from "../../services/projectConfig"; import OutputFile from "./fileTypes/OutputFile"; +import appContext from "../../utils/appContext"; export default class BaseFormatter { protected output: Output; protected projectConfig: ProjectConfigYAML; + protected outputDir: string; constructor(output: Output, projectConfig: ProjectConfigYAML) { this.output = output; this.projectConfig = projectConfig; + this.outputDir = output.outDir ?? appContext.outDir; } protected async fetchAPIData(): Promise { @@ -21,10 +24,7 @@ export default class BaseFormatter { return []; } - async format( - output: Output, - projectConfig: ProjectConfigYAML - ): Promise { + async format(): Promise { const data = await this.fetchAPIData(); const files = await this.transformAPIData(data); await this.writeFiles(files); diff --git a/lib/src/index.ts b/lib/src/index.ts index f556eb3..4a03718 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -9,6 +9,7 @@ import logger from "./utils/logger"; import { initAPIToken } from "./services/apiToken"; import { initProjectConfig } from "./services/projectConfig"; import appContext from "./utils/appContext"; +import type commander from "commander"; type Command = "pull"; @@ -24,7 +25,13 @@ interface CommandConfig { const COMMANDS: CommandConfig[] = [ { name: "pull", - description: "Sync copy from Ditto into the current working directory", + description: "Sync copy from Ditto", + flags: { + "-c, --config [value]": { + description: + "Relative path to the project config file. Defaults to `./ditto/config.yml`. Alternatively, you can set the DITTO_PROJECT_CONFIG_FILE environment variable.", + }, + }, }, ]; @@ -59,22 +66,23 @@ const setupOptions = () => { }; const executeCommand = async ( - command: Command | "none", - options: any + commandName: Command | "none", + command: commander.Command ): Promise => { try { + const options = command.opts(); const token = await initAPIToken(); appContext.setApiToken(token); - await initProjectConfig(); + await initProjectConfig(options); - switch (command) { + switch (commandName) { case "none": case "pull": { return await pull(); } default: { - await quit(`Invalid command: ${command}. Exiting Ditto CLI...`); + await quit(`Invalid command: ${commandName}. Exiting Ditto CLI...`); return; } } @@ -98,11 +106,6 @@ const appEntry = async () => { setupCommands(); setupOptions(); - if (process.argv.length <= 2 && process.argv[1].includes("ditto-cli")) { - await executeCommand("none", []); - return; - } - program.parse(process.argv); }; diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index 3c00cf1..2ba0b05 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -7,4 +7,5 @@ import { z } from "zod"; export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), variants: z.array(z.object({ id: z.string() })).optional(), + outDir: z.string().optional(), }); diff --git a/lib/src/services/projectConfig.ts b/lib/src/services/projectConfig.ts index b788c38..713b35b 100644 --- a/lib/src/services/projectConfig.ts +++ b/lib/src/services/projectConfig.ts @@ -18,12 +18,13 @@ export const DEFAULT_PROJECT_CONFIG_JSON: ProjectConfigYAML = { outputs: [ { format: "json", + framework: "i18next", }, ], }; -export async function initProjectConfig() { - const projectConfig = readProjectConfigData(); +export async function initProjectConfig(options: { config?: string }) { + const projectConfig = readProjectConfigData(options.config); appContext.setProjectConfig(projectConfig); } diff --git a/lib/src/utils/appContext.ts b/lib/src/utils/appContext.ts index 0536e8e..4093fef 100644 --- a/lib/src/utils/appContext.ts +++ b/lib/src/utils/appContext.ts @@ -18,7 +18,7 @@ class AppContext { #projectConfigFile: string; #clientId: string; #projectConfig: ProjectConfigYAML; - + #outDir: string; constructor() { this.#apiHost = process.env.DITTO_API_HOST || "https://api.dittowords.com"; this.#apiToken = process.env.DITTO_TOKEN; @@ -32,6 +32,7 @@ class AppContext { ); this.#clientId = crypto.randomUUID(); this.#projectConfig = DEFAULT_PROJECT_CONFIG_JSON; + this.#outDir = process.env.DITTO_OUT_DIR || this.projectConfigDir; } get apiHost() { @@ -85,6 +86,10 @@ class AppContext { get projectConfigDir() { return this.#projectConfigDir; } + + get outDir() { + return this.projectConfig.outDir || this.#outDir; + } } const appContext = new AppContext(); diff --git a/lib/src/utils/fileSystem.ts b/lib/src/utils/fileSystem.ts index be5843d..21e0e92 100644 --- a/lib/src/utils/fileSystem.ts +++ b/lib/src/utils/fileSystem.ts @@ -1,6 +1,7 @@ import path from "path"; import fs from "fs"; import fsPromises from "fs/promises"; + /** * Creates a file with the given filename if it doesn't already exist. * @param filename The path to the file to create. @@ -52,10 +53,16 @@ export async function createFileIfMissing( } export function writeFileSync(filename: string, content: string) { + if (!fs.existsSync(path.dirname(filename))) { + fs.mkdirSync(path.dirname(filename), { recursive: true }); + } fs.writeFileSync(filename, content, "utf-8"); } export async function writeFile(filename: string, content: string) { + if (!fs.existsSync(path.dirname(filename))) { + await fsPromises.mkdir(path.dirname(filename), { recursive: true }); + } await fsPromises.writeFile(filename, content, "utf-8"); } From 6efce8f5ab3c09a838345697839a0e4a8c28fe0b Mon Sep 17 00:00:00 2001 From: Jason Rodriguez Date: Thu, 17 Apr 2025 08:25:57 -0700 Subject: [PATCH 12/52] Add module type support in i18next framework output file (#119) * Refactor output directory handling and enhance module type support in JSON formatters - Updated `JSONFormatter` and `BaseFramework` to use `outDir` instead of `outputDir` for improved consistency. - Introduced module type handling in `I18NextFramework` to support both CommonJS and ES module formats. - Added methods for generating require statements and module exports to streamline output generation. - Enhanced Zod schema for JSON output to include optional module type specification. * Suggestions from review: - Add docstring comments to codegen methods - Add type to some additional docstring comments - Rename one codegen method - Add named require method --------- Co-authored-by: Marla Hoggard --- lib/src/formatters/frameworks/json/base.ts | 2 +- lib/src/formatters/frameworks/json/i18next.ts | 49 ++++++++++++--- lib/src/formatters/json.ts | 4 +- .../mixins/javascriptCodegenMixin.ts | 63 ++++++++++++++++++- lib/src/formatters/shared/base.ts | 4 +- lib/src/outputs/json.ts | 1 + 6 files changed, 108 insertions(+), 15 deletions(-) diff --git a/lib/src/formatters/frameworks/json/base.ts b/lib/src/formatters/frameworks/json/base.ts index 4430b56..df62aa8 100644 --- a/lib/src/formatters/frameworks/json/base.ts +++ b/lib/src/formatters/frameworks/json/base.ts @@ -8,7 +8,7 @@ export default class BaseFramework { constructor(output: Output) { this.output = output; - this.outDir = output.outDir ?? appContext.projectConfigDir; + this.outDir = output.outDir ?? appContext.outDir; } get framework() { diff --git a/lib/src/formatters/frameworks/json/i18next.ts b/lib/src/formatters/frameworks/json/i18next.ts index 54c7c57..8f785cd 100644 --- a/lib/src/formatters/frameworks/json/i18next.ts +++ b/lib/src/formatters/frameworks/json/i18next.ts @@ -12,6 +12,11 @@ export default class I18NextFramework extends applyMixins( process( outputJsonFiles: Record> ) { + let moduleType: "commonjs" | "module" = "commonjs"; + if ("type" in this.output && this.output.type) { + moduleType = this.output.type; + } + const driverFile = new JavascriptOutputFile({ filename: "index", path: this.outDir, @@ -27,19 +32,29 @@ export default class I18NextFramework extends applyMixins( {} as Record ); - driverFile.content += this.generateImportStatements(outputJsonFiles); + if (moduleType === "module") { + driverFile.content += this.generateImportStatements(outputJsonFiles); - driverFile.content += `\n`; + driverFile.content += `\n`; - driverFile.content += this.generateDefaultExportString( - filesGroupedByVariantId - ); + driverFile.content += this.codegenDefaultExport( + this.generateExportedObjectString(filesGroupedByVariantId) + ); + } else { + driverFile.content += this.generateRequireStatements(outputJsonFiles); + + driverFile.content += `\n`; + + driverFile.content += this.codegenCommonJSModuleExports( + this.generateExportedObjectString(filesGroupedByVariantId) + ); + } return [driverFile]; } /** - * Generates the import statements for the driver file. One import per generated json file. + * Generates the import statements for the driver file with type "module". One import per generated json file. * @param outputJsonFiles - The output json files. * @returns The import statements, stringified. */ @@ -56,12 +71,30 @@ export default class I18NextFramework extends applyMixins( return importStatements; } + /** + * Generates the require statements for the driver file with type "commonjs". One require per generated json file. + * @param outputJsonFiles - The output json files. + * @returns The require statements, stringified. + */ + private generateRequireStatements( + outputJsonFiles: Record> + ) { + let requireStatements = ""; + for (const file of Object.values(outputJsonFiles)) { + requireStatements += this.codegenDefaultRequire( + this.sanitizeStringForJSVariableName(file.filename), + `./${file.filenameWithExtension}` + ); + } + return requireStatements; + } + /** * Generates the default export for the driver file. By default this is an object with the json imports grouped by variant id. * @param filesGroupedByVariantId - The files grouped by variant id. * @returns The default export, stringified. */ - private generateDefaultExportString( + private generateExportedObjectString( filesGroupedByVariantId: Record ) { const variantIds = Object.keys(filesGroupedByVariantId); @@ -85,6 +118,6 @@ export default class I18NextFramework extends applyMixins( defaultExportObjectString += `}`; - return this.codegenDefaultExport(defaultExportObjectString); + return defaultExportObjectString; } } diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 14b3bfd..912b341 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -37,7 +37,7 @@ export default class JSONFormatter extends applyMixins( const variablesOutputFile = new JSONOutputFile({ filename: "variables", - path: this.outputDir, + path: this.outDir, }); for (let i = 0; i < data.textItems.length; i++) { @@ -47,7 +47,7 @@ export default class JSONFormatter extends applyMixins( outputJsonFiles[fileName] ??= new JSONOutputFile({ filename: fileName, - path: this.outputDir, + path: this.outDir, metadata: { variantId: textItem.variantId || "base" }, }); diff --git a/lib/src/formatters/mixins/javascriptCodegenMixin.ts b/lib/src/formatters/mixins/javascriptCodegenMixin.ts index f453c1e..f2028ef 100644 --- a/lib/src/formatters/mixins/javascriptCodegenMixin.ts +++ b/lib/src/formatters/mixins/javascriptCodegenMixin.ts @@ -15,8 +15,13 @@ export default function javascriptCodegenMixin( return str.replace(/[^a-zA-Z0-9]/g, "_"); } - protected codegenNamedImport(modules: NamedImport[], moduleName: string) { - const formattedModules = modules + /** + * Converts an array of modules into a string that can be used for a named import or require statement. + * @param modules array of { name: string, alias?: string }, each named import + * @returns a string of comma-separated module names/aliases, sorted + */ + protected formatNamedModules(modules: NamedImport[]) { + return modules .map((m) => { if (m.alias) { return `${m.name} as ${m.alias}`; @@ -25,18 +30,72 @@ export default function javascriptCodegenMixin( }) .sort() .join(", "); + } + + /** + * Creates a named import statement for one or more items from a module. + * Used for i18next type "module". + * @param modules array of { name: string, alias?: string }, each named import + * @param moduleName the name of the file or package to import from + * @returns i.e `import { foo, bar as name } from "./file";` + */ + protected codegenNamedImport(modules: NamedImport[], moduleName: string) { + const formattedModules = this.formatNamedModules(modules); return `import { ${formattedModules} } from "${moduleName}";\n`; } + /** + * Creates a named require statement for one or more items from a module. + * Used for i18next type "commonjs". + * @param modules array of { name: string, alias?: string }, each named import + * @param moduleName the name of the file or package to import from + * @returns i.e `const { foo, bar as name } = require("./file");` + */ + protected codegenNamedRequire(modules: NamedImport[], moduleName: string) { + const formattedModules = this.formatNamedModules(modules); + + return `const { ${formattedModules} } = require("${moduleName}");\n`; + } + + /** + * Creates a default import statement for i18next type "module". + * @param module the name of the module to import + * @param moduleName the name of the file or package to import from + * @returns i.e codegenDefaultImport("item", "./file") => `import item from "./file";` + */ protected codegenDefaultImport(module: string, moduleName: string) { return `import ${module} from "${moduleName}";\n`; } + /** + * Creates a default require statement for i18next type "commonjs". + * @param module the name of the module to import + * @param moduleName the name of the file or package to import from + * @returns i.e codegenDefaultRequire("item", "./file") => `const item = require("./file)";` + */ + protected codegenDefaultRequire(module: string, moduleName: string) { + return `const ${module} = require("${moduleName}");\n`; + } + + /** + * Creates a default export statement for i18next type "module". + * @param module the name of the module to export + * @returns i.e codegenDefaultExport("item") => "export default item;" + */ protected codegenDefaultExport(module: string) { return `export default ${module};`; } + /** + * Creates a module exports statement for i18next type "commonjs". + * @param module the name of the module to export + * @returns i.e codegenModuleExports("item") => "module.exports = item;" + */ + protected codegenCommonJSModuleExports(module: string) { + return `module.exports = ${module};`; + } + protected codegenPad(depth: number) { return " ".repeat(depth * this.indentSpaces); } diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index abb9cc1..09fc6d2 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -8,12 +8,12 @@ import appContext from "../../utils/appContext"; export default class BaseFormatter { protected output: Output; protected projectConfig: ProjectConfigYAML; - protected outputDir: string; + protected outDir: string; constructor(output: Output, projectConfig: ProjectConfigYAML) { this.output = output; this.projectConfig = projectConfig; - this.outputDir = output.outDir ?? appContext.outDir; + this.outDir = output.outDir ?? appContext.outDir; } protected async fetchAPIData(): Promise { diff --git a/lib/src/outputs/json.ts b/lib/src/outputs/json.ts index b26342f..21edefd 100644 --- a/lib/src/outputs/json.ts +++ b/lib/src/outputs/json.ts @@ -8,6 +8,7 @@ const ZBaseJSONOutput = ZBaseOutputFilters.extend({ const Zi18NextJSONOutput = ZBaseJSONOutput.extend({ framework: z.literal("i18next"), + type: z.literal("module").or(z.literal("commonjs")).optional(), }); export const ZJSONOutput = z.discriminatedUnion("framework", [ From 2a481706580defe52d8f788e7df8374242230543 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Thu, 17 Apr 2025 13:29:36 -0400 Subject: [PATCH 13/52] Version bump to beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c9f7663..a2f48a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.2", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From 608d28e2a20f25e8dbe8bbbc3e81a68d6c2a14d5 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Thu, 17 Apr 2025 17:57:32 -0400 Subject: [PATCH 14/52] [DIT-10128] Better error messaging for config errors (#120) * Better error messaging for config errors * Create DittoError * Use zods error flattening * Enhance Zod schemas for JSON outputs by enforcing strict mode in base output definitions * Use errorText in sentry --------- Co-authored-by: jason@dittowords.com --- lib/src/index.ts | 32 +++++++++--- lib/src/outputs/json.ts | 4 +- lib/src/services/projectConfig.ts | 27 +++++++++-- lib/src/utils/DittoError.ts | 81 +++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 lib/src/utils/DittoError.ts diff --git a/lib/src/index.ts b/lib/src/index.ts index 5e7c508..e934cc7 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -10,6 +10,7 @@ import initAPIToken from "./services/apiToken/initAPIToken"; import { initProjectConfig } from "./services/projectConfig"; import appContext from "./utils/appContext"; import type commander from "commander"; +import { ErrorType, isDittoError, isDittoErrorType } from "./utils/DittoError"; type Command = "pull"; @@ -87,18 +88,33 @@ const executeCommand = async ( } } } catch (error) { - const eventId = Sentry.captureException(error); - const eventStr = `\n\nError ID: ${logger.info(eventId)}`; - if (process.env.DEBUG === "true") { console.error(logger.info("Development stack trace:\n"), error); } - return await quit( - logger.errorText( - "Something went wrong. Please contact support or try again later." - ) + eventStr - ); + let sentryOptions = undefined; + let exitCode = undefined; + let errorText = + "Something went wrong. Please contact support or try again later."; + + if (isDittoError(error)) { + exitCode = error.exitCode; + + if (isDittoErrorType(error, ErrorType.ConfigYamlLoadError)) { + errorText = error.message; + } else if (isDittoErrorType(error, ErrorType.ConfigParseError)) { + errorText = `${error.data.messagePrefix}\n\n${error.data.formattedError}`; + } + + sentryOptions = { + extra: { message: errorText, ...(error.data || {}) }, + }; + } + + const eventId = Sentry.captureException(error, sentryOptions); + const eventStr = `\n\nError ID: ${logger.info(eventId)}`; + + return await quit(logger.errorText(errorText) + eventStr, exitCode); } }; diff --git a/lib/src/outputs/json.ts b/lib/src/outputs/json.ts index 21edefd..ab544ab 100644 --- a/lib/src/outputs/json.ts +++ b/lib/src/outputs/json.ts @@ -4,12 +4,12 @@ import { ZBaseOutputFilters } from "./shared"; const ZBaseJSONOutput = ZBaseOutputFilters.extend({ format: z.literal("json"), framework: z.undefined(), -}); +}).strict(); const Zi18NextJSONOutput = ZBaseJSONOutput.extend({ framework: z.literal("i18next"), type: z.literal("module").or(z.literal("commonjs")).optional(), -}); +}).strict(); export const ZJSONOutput = z.discriminatedUnion("framework", [ ZBaseJSONOutput, diff --git a/lib/src/services/projectConfig.ts b/lib/src/services/projectConfig.ts index 713b35b..d18fe9d 100644 --- a/lib/src/services/projectConfig.ts +++ b/lib/src/services/projectConfig.ts @@ -5,6 +5,7 @@ import appContext from "../utils/appContext"; import yaml from "js-yaml"; import { ZBaseOutputFilters } from "../outputs/shared"; import { ZOutput } from "../outputs"; +import DittoError, { ErrorType } from "../utils/DittoError"; const ZProjectConfigYAML = ZBaseOutputFilters.extend({ outputs: z.array(ZOutput), @@ -38,12 +39,30 @@ function readProjectConfigData( file = appContext.projectConfigFile, defaultData: ProjectConfigYAML = DEFAULT_PROJECT_CONFIG_JSON ): ProjectConfigYAML { - createFileIfMissingSync(file, yaml.dump(defaultData)); - const fileContents = fs.readFileSync(file, "utf8"); - const yamlData = yaml.load(fileContents); + let yamlData: unknown = defaultData; + + try { + createFileIfMissingSync(file, yaml.dump(defaultData)); + const fileContents = fs.readFileSync(file, "utf8"); + yamlData = yaml.load(fileContents); + } catch (err) { + throw new DittoError({ + type: ErrorType.ConfigYamlLoadError, + data: { rawErrorMessage: (err as any).message }, + message: + "Could not load the project config file. Please check the file path and that it is a valid YAML file.", + }); + } + const parsedYAML = ZProjectConfigYAML.safeParse(yamlData); if (!parsedYAML.success) { - throw new Error("Failed to parse project config file"); + throw new DittoError({ + type: ErrorType.ConfigParseError, + data: { + formattedError: JSON.stringify(parsedYAML.error.flatten(), null, 2), + messagePrefix: "There is an error in your project config file.", + }, + }); } return parsedYAML.data; } diff --git a/lib/src/utils/DittoError.ts b/lib/src/utils/DittoError.ts new file mode 100644 index 0000000..31efb44 --- /dev/null +++ b/lib/src/utils/DittoError.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +/** + * An Error extension designed to be thrown from anywhere in the CLI. + * The custom properties provide a reliable way to include additional data + * (what the error was and context around it) that can be leveraged + * to pass along data to the user, to Sentry, or to other services. + */ +export default class DittoError extends Error { + exitCode: number | undefined; + type: ErrorType; + // Note: if you see the type error "Type 'T' cannot be used to index type 'ErrorDataMap'", + // a value is missing from the ErrorDataMap defined below + data: ErrorDataMap[T]; + + /** + * Creates a new custom error with the following properties: + * @param type The type of error, from the ErrorType enum + * @param message Optional: error message to display to the user + * @param exitCode Optional: exit code to return to the shell. + * @param data Optional: additional data to pass along with the error + */ + constructor({ + type, + message, + exitCode, + data, + }: { + type: T; + message?: string; + exitCode?: number; + data: ErrorDataMap[T]; + }) { + const errorMessage = + message || + "Something went wrong. Please contact support or try again later."; + + super(errorMessage); + + this.exitCode = exitCode; + this.type = type; + this.data = data; + } +} + +/** + * Exhaustive list of DittoError types + * When adding to this list, you must also add a Data type to ErrorDataMap + */ +export enum ErrorType { + ConfigYamlLoadError = "ConfigYamlLoadError", + ConfigParseError = "ConfigParseError", +} + +/** + * Map of DittoError types to the data that is required for that type + * The keys of this must exhaustively match the keys of the ErrorType enum + */ +type ErrorDataMap = { + [ErrorType.ConfigYamlLoadError]: ConfigYamlLoadErrorData; + [ErrorType.ConfigParseError]: ConfigParseErrorData; +}; + +type ConfigYamlLoadErrorData = { + rawErrorMessage: string; +}; + +type ConfigParseErrorData = { + formattedError: string; + messagePrefix: string; +}; + +export function isDittoError(error: unknown): error is DittoError { + return error instanceof DittoError; +} +export function isDittoErrorType( + error: DittoError, + type: T +): error is DittoError { + return error.type === type; +} From 617fa39db2109c8f2843ad8bdd178222ceeb8af9 Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Fri, 18 Apr 2025 08:51:16 -0700 Subject: [PATCH 15/52] Update version to 5.0.0-beta.4 and enhance README with installation, authentication, and usage details for the Ditto CLI v5. --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++-- package.json | 2 +- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5fde594..638e0cb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,75 @@ -# Ditto CLI +# Ditto CLI v5 -The Ditto CLI enables developers to access the Ditto API directly from the command line. +The Ditto CLI enables developers to access the Ditto API directly from the command line. The v5 version of the CLI defaults to use the new Ditto Beta by default, but still fully supports legacy configurations and projects by passing in a `--legacy` flag to all invocations. [![NPM version](https://badge.fury.io/js/@dittowords%2Fcli.svg)](https://badge.fury.io/js/@dittowords%2Fcli) ## Documentation +The official documentation can be found [here](https://dittov3.notion.site/Beta-Developer-Integrations-1d8cc8865c7a800eb13dc54d10c3d231). + +## Support + +- [Bug Reports](https://github.com/dittowords/cli/issues/) +- [Support Chat](https://www.dittowords.com) +- [What is Ditto?](https://www.dittowords.com/docs/what-is-ditto) + +## Installation + +```sh +npm install --save-dev @dittowords/cli +``` + +It's recommended to install the CLI as a development dependency to ensure your whole team is on the same version. + +## Authentication + +The first time you run the CLI, you’ll be asked to provide an API key. You can generate an API key from your [developer integrations settings](https://app.dittowords.com/account/devtools). + +See the [Authentication](http://developer.dittowords.com/api-reference/authentication) page for more information on API keys. + +## Configuration + +By default, the CLI operates against a `ditto/` folder relative to the current working directory. + +The first time you run the CLI, a `ditto/` folder will be created if it doesn't already exist. The folder will also be populated with a default `config.yml` file, which is used to control the CLI's behavior. + +The default file looks like this: + +```yml +projects: [], +variants: [], +outputs: + - format: json, + framework: i18next +``` + +For more information on configuring the CLI, see [this documentation section](https://dittov3.notion.site/Beta-Developer-Integrations-1d8cc8865c7a800eb13dc54d10c3d231#1d8cc8865c7a80e68ebbeb083404d8ed). + +## Usage + +```bash +npx @dittowords/cli +``` + +Run the CLI to pull string data from Ditto and write it to disk. + +String files are written to the `ditto` folder in a format that corresponds to your configuration. After integrating these files into development, you can execute the CLI at any time to fetch the latest strings from Ditto and update them in your application. + +For more information on how files written to disk, see [this documentation section](https://dittov3.notion.site/Beta-Developer-Integrations-1d8cc8865c7a800eb13dc54d10c3d231#1d8cc8865c7a80c7bd4fe6f5f254f4d4). + +See our demo projects for examples of how to integrate the Ditto CLI in different environments: + +- [React web app](https://github.com/dittowords/ditto-react-demo) +- [iOS mobile app](https://github.com/dittowords/ditto-react-demo) +- [Android mobile app](https://github.com/dittowords/ditto-react-demo) + +# Legacy Setup + +v5 of the Ditto CLI points at the new Ditto beta experience by default. To run the CLI compatible with legacy Ditto, append the `--legacy` flag, and the CLI will work as it did in the `4.x` version. All existing legecy commands are depricated, but fully functional at this time. + +## Documentation + The official documentation can be found [here](http://developer.dittowords.com/cli-reference/authentication). ## Support diff --git a/package.json b/package.json index a2f48a4..32c899c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.2", + "version": "5.0.0-beta.4", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From 9d7b561ee628807a73b3388ca681afbbf0a2840a Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Fri, 18 Apr 2025 08:51:40 -0700 Subject: [PATCH 16/52] Update README to specify installation of the Ditto CLI beta version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 638e0cb..81f45d0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The official documentation can be found [here](https://dittov3.notion.site/Beta- ## Installation ```sh -npm install --save-dev @dittowords/cli +npm install --save-dev @dittowords/cli@beta ``` It's recommended to install the CLI as a development dependency to ensure your whole team is on the same version. From e1b334b942185cdda3985d791fa98f5515e1ca07 Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Tue, 22 Apr 2025 09:34:00 -0700 Subject: [PATCH 17/52] Update version to 5.0.0-beta.5 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 32c899c..79e2131 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.4", + "version": "5.0.0-beta.5", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From aacb52e458aa682c3c21a3b283d888d7e4fbd46b Mon Sep 17 00:00:00 2001 From: Jason Rodriguez Date: Tue, 22 Apr 2025 11:39:54 -0700 Subject: [PATCH 18/52] Update version to 5.0.0-beta.6 and add Vue I18n framework support in JSON output processing (#122) --- lib/src/formatters/frameworks/json/index.ts | 3 + .../formatters/frameworks/json/vue-i18n.ts | 135 ++++++++++++++++++ lib/src/outputs/json.ts | 6 + package.json | 2 +- 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 lib/src/formatters/frameworks/json/vue-i18n.ts diff --git a/lib/src/formatters/frameworks/json/index.ts b/lib/src/formatters/frameworks/json/index.ts index 7e99d0d..15b138e 100644 --- a/lib/src/formatters/frameworks/json/index.ts +++ b/lib/src/formatters/frameworks/json/index.ts @@ -1,5 +1,6 @@ import I18NextFramework from "./i18next"; import { Output } from "../../../outputs"; +import VueI18nFramework from "./vue-i18n"; export function getFrameworkProcessor(output: Output) { if (!output.framework) { @@ -9,6 +10,8 @@ export function getFrameworkProcessor(output: Output) { switch (frameworkType) { case "i18next": return new I18NextFramework(output); + case "vue-i18n": + return new VueI18nFramework(output); default: throw new Error(`Unsupported JSON framework: ${frameworkType}`); } diff --git a/lib/src/formatters/frameworks/json/vue-i18n.ts b/lib/src/formatters/frameworks/json/vue-i18n.ts new file mode 100644 index 0000000..b6d922d --- /dev/null +++ b/lib/src/formatters/frameworks/json/vue-i18n.ts @@ -0,0 +1,135 @@ +import JavascriptOutputFile from "../../shared/fileTypes/JavascriptOutputFile"; +import OutputFile from "../../shared/fileTypes/OutputFile"; +import { applyMixins } from "../../shared"; +import javascriptCodegenMixin from "../../mixins/javascriptCodegenMixin"; +import JSONOutputFile from "../../shared/fileTypes/JSONOutputFile"; +import BaseFramework from "./base"; + +export default class VueI18nFramework extends applyMixins( + BaseFramework, + javascriptCodegenMixin +) { + process( + outputJsonFiles: Record> + ) { + // vue-18n requires single mustaching for their json inputs, so we need to update the json + for (const file of Object.values(outputJsonFiles)) { + for (const key in file.content) { + const content = file.content[key]; + if (typeof content === "string") { + file.content[key] = content + .replaceAll(/{{/g, "{") + .replaceAll(/}}/g, "}"); + } + } + } + + let moduleType: "commonjs" | "module" = "commonjs"; + if ("type" in this.output && this.output.type) { + moduleType = this.output.type; + } + + const driverFile = new JavascriptOutputFile({ + filename: "index", + path: this.outDir, + }); + + const filesGroupedByVariantId = Object.values(outputJsonFiles).reduce( + (acc, file) => { + const variantId = file.metadata.variantId; + acc[variantId] ??= []; + acc[variantId].push(file); + return acc; + }, + {} as Record + ); + + if (moduleType === "module") { + driverFile.content += this.generateImportStatements(outputJsonFiles); + + driverFile.content += `\n`; + + driverFile.content += this.codegenDefaultExport( + this.generateExportedObjectString(filesGroupedByVariantId) + ); + } else { + driverFile.content += this.generateRequireStatements(outputJsonFiles); + + driverFile.content += `\n`; + + driverFile.content += this.codegenCommonJSModuleExports( + this.generateExportedObjectString(filesGroupedByVariantId) + ); + } + + return [driverFile]; + } + + /** + * Generates the import statements for the driver file with type "module". One import per generated json file. + * @param outputJsonFiles - The output json files. + * @returns The import statements, stringified. + */ + private generateImportStatements( + outputJsonFiles: Record> + ) { + let importStatements = ""; + for (const file of Object.values(outputJsonFiles)) { + importStatements += this.codegenDefaultImport( + this.sanitizeStringForJSVariableName(file.filename), + `./${file.filenameWithExtension}` + ); + } + return importStatements; + } + + /** + * Generates the require statements for the driver file with type "commonjs". One require per generated json file. + * @param outputJsonFiles - The output json files. + * @returns The require statements, stringified. + */ + private generateRequireStatements( + outputJsonFiles: Record> + ) { + let requireStatements = ""; + for (const file of Object.values(outputJsonFiles)) { + requireStatements += this.codegenDefaultRequire( + this.sanitizeStringForJSVariableName(file.filename), + `./${file.filenameWithExtension}` + ); + } + return requireStatements; + } + + /** + * Generates the default export for the driver file. By default this is an object with the json imports grouped by variant id. + * @param filesGroupedByVariantId - The files grouped by variant id. + * @returns The default export, stringified. + */ + private generateExportedObjectString( + filesGroupedByVariantId: Record + ) { + const variantIds = Object.keys(filesGroupedByVariantId); + + let defaultExportObjectString = "{\n"; + + for (let i = 0; i < variantIds.length; i++) { + const variantId = variantIds[i]; + const files = filesGroupedByVariantId[variantId]; + + defaultExportObjectString += `${this.codegenPad(1)}"${variantId}": {\n`; + for (const file of files) { + defaultExportObjectString += `${this.codegenPad( + 2 + )}...${this.sanitizeStringForJSVariableName(file.filename)},\n`; + } + defaultExportObjectString += `${this.codegenPad(1)}}${ + i < variantIds.length - 1 ? `,\n` : `\n` + }`; + } + + defaultExportObjectString += `}`; + + return defaultExportObjectString; + } +} diff --git a/lib/src/outputs/json.ts b/lib/src/outputs/json.ts index ab544ab..3bc061c 100644 --- a/lib/src/outputs/json.ts +++ b/lib/src/outputs/json.ts @@ -11,7 +11,13 @@ const Zi18NextJSONOutput = ZBaseJSONOutput.extend({ type: z.literal("module").or(z.literal("commonjs")).optional(), }).strict(); +const ZVueI18nJSONOutput = ZBaseJSONOutput.extend({ + framework: z.literal("vue-i18n"), + type: z.literal("module").or(z.literal("commonjs")).optional(), +}).strict(); + export const ZJSONOutput = z.discriminatedUnion("framework", [ ZBaseJSONOutput, Zi18NextJSONOutput, + ZVueI18nJSONOutput, ]); diff --git a/package.json b/package.json index 79e2131..72c1818 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.5", + "version": "5.0.0-beta.6", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From 572fdafe566363dff0f66522855866fad42ba23b Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Tue, 22 Apr 2025 11:41:07 -0700 Subject: [PATCH 19/52] Revert version to 5.0.0-beta.5 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 72c1818..79e2131 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.6", + "version": "5.0.0-beta.5", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From 39e4a43a4191dc4abbfa4654c70775701ea66013 Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Tue, 29 Apr 2025 08:49:26 -0700 Subject: [PATCH 20/52] Enhance legacy mode flag handling in Ditto CLI by adding support for the shorthand '-l' option alongside the '--legacy' flag. --- lib/ditto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ditto.ts b/lib/ditto.ts index 5f3cc54..c607575 100755 --- a/lib/ditto.ts +++ b/lib/ditto.ts @@ -12,7 +12,7 @@ Sentry.init({ dsn: process.env.SENTRY_DSN, environment, release }); const main = async () => { // Check for --legacy flag and run in legacy mode if present - if (process.argv.includes("--legacy")) { + if (process.argv.includes("--legacy") || process.argv.includes("-l")) { console.log( logger.warnText( "\nDitto CLI is running in legacy mode. This mode is deprecated and will be removed in a future release.\n" From d5ac2c0a517270bb2af8fdba0f69d34cdd702ad4 Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Tue, 29 Apr 2025 08:49:41 -0700 Subject: [PATCH 21/52] Update version to 5.0.0-beta.6 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 79e2131..72c1818 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.5", + "version": "5.0.0-beta.6", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From 3da6f586f2764d76eb066875afc91833cb28ccfe Mon Sep 17 00:00:00 2001 From: "jason@dittowords.com" Date: Tue, 29 Apr 2025 08:50:32 -0700 Subject: [PATCH 22/52] Remove shorthand '-l' option for legacy mode in Ditto CLI, updating to only use '--legacy' flag for clarity. --- lib/ditto.ts | 2 +- lib/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ditto.ts b/lib/ditto.ts index c607575..5f3cc54 100755 --- a/lib/ditto.ts +++ b/lib/ditto.ts @@ -12,7 +12,7 @@ Sentry.init({ dsn: process.env.SENTRY_DSN, environment, release }); const main = async () => { // Check for --legacy flag and run in legacy mode if present - if (process.argv.includes("--legacy") || process.argv.includes("-l")) { + if (process.argv.includes("--legacy")) { console.log( logger.warnText( "\nDitto CLI is running in legacy mode. This mode is deprecated and will be removed in a future release.\n" diff --git a/lib/src/index.ts b/lib/src/index.ts index e934cc7..7fa5282 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -62,7 +62,7 @@ const setupCommands = () => { }; const setupOptions = () => { - program.option("-l, --legacy", "Run in legacy mode"); + program.option("--legacy", "Run in legacy mode"); program.version(version, "-v, --version", "Output the current version"); }; From 80bcd1b3a14a307e2ed2f0d65d1c9f06fd86aa79 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:28:43 -0700 Subject: [PATCH 23/52] Add richText field to TextItemsResponse schema --- lib/src/http/textItems.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 18e86f9..726d8f4 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -11,6 +11,7 @@ const TextItemsResponse = z.array( z.object({ id: z.string(), text: z.string(), + richText: z.string().optional(), status: z.string(), notes: z.string(), tags: z.array(z.string()), From 2006fe5d2fd0a9b9559b3f20777a49a6a1146074 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:30:00 -0700 Subject: [PATCH 24/52] Add richText configuration option to base output filters --- lib/src/outputs/shared.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index 2ba0b05..bc4744c 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -8,4 +8,5 @@ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), + richText: z.literal("html").optional(), }); From 3d2eb38daa86a4fb2beec5e31834036839d32e8f Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:31:57 -0700 Subject: [PATCH 25/52] Update Text Items API request to support richText parameter --- lib/src/http/textItems.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 726d8f4..5b5f39c 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -5,6 +5,7 @@ import { z } from "zod"; export interface PullFilters { projects?: { id: string }[]; variants?: { id: string }[]; + richText?: "html"; } const TextItemsResponse = z.array( @@ -25,10 +26,19 @@ export type TextItemsResponse = z.infer; export default async function fetchText(filters?: PullFilters) { try { + const params: { filter: string; richText?: string } = { + filter: JSON.stringify({ + projects: filters?.projects, + variants: filters?.variants, + }), + }; + + if (filters?.richText) { + params.richText = filters.richText; + } + const response = await httpClient.get("/v2/textItems", { - params: { - filter: JSON.stringify(filters), - }, + params, }); return TextItemsResponse.parse(response.data); From 4200b0b674472dec623e6e17c750abc2f3b48fa3 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:32:56 -0700 Subject: [PATCH 26/52] Pass richText configuration to API filters in JSON formatter --- lib/src/formatters/json.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 912b341..0ca283a 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -85,6 +85,10 @@ export default class JSONFormatter extends applyMixins( filters.variants = this.output.variants; } + if (this.output.richText) { + filters.richText = this.output.richText; + } + return filters; } } From 411de910e0dcd8431448e80b6bdf1c29de7204bf Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:34:55 -0700 Subject: [PATCH 27/52] Use richText field in JSON output when configured and available --- lib/src/formatters/json.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 0ca283a..3b4c72a 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -51,8 +51,12 @@ export default class JSONFormatter extends applyMixins( metadata: { variantId: textItem.variantId || "base" }, }); + // Use richText if available and configured, otherwise use text + const textValue = (this.output.richText && textItem.richText) + ? textItem.richText + : textItem.text; - outputJsonFiles[fileName].content[textItem.id] = textItem.text; + outputJsonFiles[fileName].content[textItem.id] = textValue; for (const variableId of textItem.variableIds) { const variable = data.variablesById[variableId]; variablesOutputFile.content[variableId] = variable.data; From 8bc78d701600d17767cae63a247c605cb078796f Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:47:01 -0700 Subject: [PATCH 28/52] Add tests for richText field parsing in textItems --- lib/src/http/textItems.test.ts | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 lib/src/http/textItems.test.ts diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts new file mode 100644 index 0000000..aad3b53 --- /dev/null +++ b/lib/src/http/textItems.test.ts @@ -0,0 +1,89 @@ +import fetchText from "./textItems"; +import httpClient from "./client"; + +jest.mock("./client"); + +describe("fetchText", () => { + const mockHttpClient = httpClient as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("richText parameter", () => { + it("should parse response with richText field correctly", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text", + richText: "

Rich HTML text

", + status: "active", + notes: "Test note", + tags: ["tag1"], + variableIds: ["var1"], + projectId: "project1", + variantId: "variant1", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchText({ + richText: "html", + }); + + expect(result).toEqual([ + { + id: "text1", + text: "Plain text", + richText: "

Rich HTML text

", + status: "active", + notes: "Test note", + tags: ["tag1"], + variableIds: ["var1"], + projectId: "project1", + variantId: "variant1", + }, + ]); + }); + + it("should handle response without richText field", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text only", + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project1", + variantId: null, + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchText({ + richText: "html", + }); + + expect(result).toEqual([ + { + id: "text1", + text: "Plain text only", + richText: undefined, + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project1", + variantId: null, + }, + ]); + }); + }); +}); \ No newline at end of file From fd44d1616071aea94da9a25bf00ead173644de39 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 15:47:22 -0700 Subject: [PATCH 29/52] Use both top level and out filter --- lib/src/formatters/json.ts | 7 ++++--- lib/src/http/textItems.ts | 2 +- lib/src/outputs/shared.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 3b4c72a..82e94a7 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -52,7 +52,8 @@ export default class JSONFormatter extends applyMixins( }); // Use richText if available and configured, otherwise use text - const textValue = (this.output.richText && textItem.richText) + const richTextConfigured = this.output.richText === "html" || this.projectConfig.richText === "html" && this.output.richText !== "false" + const textValue = richTextConfigured && textItem.richText ? textItem.richText : textItem.text; @@ -89,8 +90,8 @@ export default class JSONFormatter extends applyMixins( filters.variants = this.output.variants; } - if (this.output.richText) { - filters.richText = this.output.richText; + if (this.projectConfig.richText === "html" || this.output.richText === "html") { + filters.richText = "html"; } return filters; diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 5b5f39c..4d0ad4e 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -5,7 +5,7 @@ import { z } from "zod"; export interface PullFilters { projects?: { id: string }[]; variants?: { id: string }[]; - richText?: "html"; + richText?: "html" | "false"; } const TextItemsResponse = z.array( diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index bc4744c..e78caca 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -8,5 +8,5 @@ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), - richText: z.literal("html").optional(), + richText: z.union([z.literal("html"), z.literal("false")]).optional(), }); From 9e6ccfc199dc4f32f07d957fe70314a1ba895cc7 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 15:48:53 -0700 Subject: [PATCH 30/52] formatting --- lib/src/formatters/json.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 82e94a7..84383a4 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -52,7 +52,8 @@ export default class JSONFormatter extends applyMixins( }); // Use richText if available and configured, otherwise use text - const richTextConfigured = this.output.richText === "html" || this.projectConfig.richText === "html" && this.output.richText !== "false" + const richTextConfigured = this.output.richText === "html" || + (this.projectConfig.richText === "html" && this.output.richText !== "false") const textValue = richTextConfigured && textItem.richText ? textItem.richText : textItem.text; From 907b84a64489d87d9790928631e9f30465fd58cc Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 15:51:40 -0700 Subject: [PATCH 31/52] remove unnecessary change --- lib/src/http/textItems.ts | 13 +--- specs/DIT-11112/design.md | 65 +++++++++++++++++ specs/DIT-11112/implementation.md | 115 ++++++++++++++++++++++++++++++ specs/DIT-11112/requirements.md | 31 ++++++++ 4 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 specs/DIT-11112/design.md create mode 100644 specs/DIT-11112/implementation.md create mode 100644 specs/DIT-11112/requirements.md diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 4d0ad4e..ebec073 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -26,19 +26,8 @@ export type TextItemsResponse = z.infer; export default async function fetchText(filters?: PullFilters) { try { - const params: { filter: string; richText?: string } = { - filter: JSON.stringify({ - projects: filters?.projects, - variants: filters?.variants, - }), - }; - - if (filters?.richText) { - params.richText = filters.richText; - } - const response = await httpClient.get("/v2/textItems", { - params, + params: JSON.stringify(filters) }); return TextItemsResponse.parse(response.data); diff --git a/specs/DIT-11112/design.md b/specs/DIT-11112/design.md new file mode 100644 index 0000000..e6448a2 --- /dev/null +++ b/specs/DIT-11112/design.md @@ -0,0 +1,65 @@ +# Design: Rich text for CLI + +## Architecture Changes +- Extend the text items API response schema to include an optional `richText` field +- Add a new configuration option `richText` to control rich text output behavior +- Modify the JSON formatter to conditionally use rich text values instead of plain text +- No changes to the overall architecture pattern - rich text is an additional data field + +## Components Modified + +### Core Components +- `lib/src/http/textItems.ts` - Update response schema to include richText field +- `lib/src/outputs/shared.ts` - Add richText configuration option +- `lib/src/outputs/json.ts` - Update output schema to support richText option +- `lib/src/formatters/json.ts` - Modify text value selection logic +- `lib/src/services/projectConfig.ts` - Update default config to include richText option + +### Framework Processors +- `lib/src/formatters/frameworks/json/i18next.ts` - Update to handle rich text values +- `lib/src/formatters/frameworks/json/vue-i18n.ts` - Update to handle rich text values + +### Legacy Support +- Legacy mode already has richText support implemented (found in `lib/legacy/types.ts` and `lib/legacy/pull.ts`) +- No changes needed for legacy mode + +## Database Changes +- None - this is a client-side change consuming existing API capabilities + +## API Changes +- No new endpoints required +- Existing `/v2/textItems` endpoint will be called with additional query parameter `richText=html` when rich text is enabled +- API response will include `richText` field alongside existing `text` field + +## UI Changes +- None - this is a CLI tool with JSON output +- Rich text data will be output as raw JSON values, not rendered + +## Implementation Notes + +### Configuration Approach +- Add `richText` as an optional string flag in the output configuration +- Valid values: `"html"` to enable rich text, or undefined/null to use plain text +- Default value should be undefined to maintain backward compatibility +- Can be set globally or per-output configuration + +### API Integration +- When `richText` is set to "html", pass query parameter to API: `richText=html` +- API response will include both `text` and `richText` fields +- Formatter will choose which field to use based on configuration + +### Output Format +- When rich text is enabled, the JSON value will be the `richText` content +- Rich text format is an HTML string +- Variables file remains unchanged as it doesn't contain rich text + +### Error Handling +- If rich text is requested but not available for a text item, fall back to plain text +- Log warnings for items missing rich text when it's expected +- Maintain robust parsing with Zod schema validation + +### Testing Considerations +- Test with both rich text enabled and disabled +- Verify backward compatibility +- Test framework processors with rich text content +- Ensure proper fallback behavior when rich text is unavailable \ No newline at end of file diff --git a/specs/DIT-11112/implementation.md b/specs/DIT-11112/implementation.md new file mode 100644 index 0000000..8532288 --- /dev/null +++ b/specs/DIT-11112/implementation.md @@ -0,0 +1,115 @@ +# Implementation: Rich text for CLI + +## Task Breakdown + +### 1. Update Text Items Response Schema +**Description**: Add richText field to the API response schema +- Files: `lib/src/http/textItems.ts` +- Dependencies: None +- Details: + - Add `richText: z.string().optional()` to TextItemsResponse schema + - Ensure backward compatibility with responses that don't include richText + +### 2. Add Rich Text Configuration to Base Output +**Description**: Add richText option to the base output configuration +- Files: `lib/src/outputs/shared.ts` +- Dependencies: None +- Details: + - Add `richText: z.literal("html").optional()` to ZBaseOutputFilters + - This allows all output formats to inherit the richText option + +### 3. Update Text Items API Request +**Description**: Pass richText parameter to API when configured +- Files: `lib/src/http/textItems.ts` +- Dependencies: Task 1, Task 2 +- Details: + - Modify fetchText function to accept richText parameter + - Add richText to query params when set: `params.richText = "html"` + - Update PullFilters interface to include richText option + +### 4. Update JSON Formatter Pull Filters +**Description**: Pass richText configuration to API fetch +- Files: `lib/src/formatters/json.ts` +- Dependencies: Task 3 +- Details: + - Update generatePullFilter() to include richText from output config + - Pass richText value to fetchText function + +### 5. Modify JSON Formatter Output Logic +**Description**: Use richText field when available and configured +- Files: `lib/src/formatters/json.ts` +- Dependencies: Task 1, Task 4 +- Details: + - In transformAPIData(), check if richText is configured + - Use `textItem.richText || textItem.text` when richText is enabled + - Use `textItem.text` when richText is not configured + +### 6. Update i18next Framework Processor +**Description**: Handle rich text in i18next output format +- Files: `lib/src/formatters/frameworks/json/i18next.ts` +- Dependencies: Task 5 +- Details: + - Ensure framework processor correctly handles HTML strings + - No special escaping needed as HTML should be preserved + +### 7. Update vue-i18n Framework Processor +**Description**: Handle rich text in vue-i18n output format +- Files: `lib/src/formatters/frameworks/json/vue-i18n.ts` +- Dependencies: Task 5 +- Details: + - Ensure framework processor correctly handles HTML strings + - No special escaping needed as HTML should be preserved + +### 8. Add Tests for Rich Text Feature +**Description**: Create tests to verify rich text functionality +- Files: New test files or updates to existing tests +- Dependencies: Tasks 1-7 +- Details: + - Test with richText="html" configuration + - Test without richText configuration (backward compatibility) + - Test fallback when richText field is missing + - Test framework processors with HTML content + +### 9. Update Documentation +**Description**: Document the new richText configuration option +- Files: `README.md`, configuration examples +- Dependencies: Tasks 1-7 +- Details: + - Add richText option to configuration documentation + - Provide example config with richText enabled + - Explain HTML output format + +## Implementation Order + +1. Update Text Items Response Schema (Task 1) +2. Add Rich Text Configuration to Base Output (Task 2) +3. Update Text Items API Request (Task 3) +4. Update JSON Formatter Pull Filters (Task 4) +5. Modify JSON Formatter Output Logic (Task 5) +6. Update i18next Framework Processor (Task 6) +7. Update vue-i18n Framework Processor (Task 7) +8. Add Tests for Rich Text Feature (Task 8) +9. Update Documentation (Task 9) + +## Example Configuration + +After implementation, users will be able to configure rich text like this: + +```yaml +projects: + - id: "project-id" +outputs: + - format: json + framework: i18next + richText: html # Enable rich text output +``` + +## Testing Checklist + +- [ ] Rich text parameter is correctly passed to API +- [ ] HTML content is properly returned in JSON output +- [ ] Plain text fallback works when richText field is missing +- [ ] Configuration validation accepts "html" value +- [ ] Framework processors handle HTML strings correctly +- [ ] Backward compatibility maintained when richText is not configured +- [ ] Legacy mode continues to work with its existing richText implementation \ No newline at end of file diff --git a/specs/DIT-11112/requirements.md b/specs/DIT-11112/requirements.md new file mode 100644 index 0000000..7204293 --- /dev/null +++ b/specs/DIT-11112/requirements.md @@ -0,0 +1,31 @@ +# Requirements: Rich text for CLI + +## Overview +The Ditto public API now supports rich text format, returning a `richText` field in addition to plain text values. The CLI needs to be extended to support outputting this rich text format when retrieving text items, providing users with access to formatted content including markup, links, and other rich text elements through the command-line interface. + +## User Stories +- As a developer using the Ditto CLI, I want to retrieve text items with rich text formatting so that I can access and work with formatted content in my development workflow +- As a team automating Ditto workflows, I want the CLI to optionally output rich text data so that we can preserve formatting information in our pipelines +- As a CLI user, I want to control whether I receive plain text or rich text output so that I can choose the appropriate format for my use case + +## Acceptance Criteria +- [ ] CLI can retrieve and output the `richText` field from the API's get text items endpoint +- [ ] JSON output includes the `richText` value as the value in key-value pairs when rich text mode is enabled +- [ ] Configuration file includes a sensible option to enable/disable rich text output +- [ ] Rich text mode can be toggled via CLI flag or configuration setting +- [ ] Existing plain text functionality remains unchanged when rich text is disabled +- [ ] Documentation is updated to explain the rich text feature and how to enable it +- [ ] Rich text output is properly formatted in JSON responses + +## Technical Requirements +- Integration with the existing API's `richText` field as documented in the Beta Developer Integrations guide +- Backward compatibility with existing CLI usage patterns +- Configuration management for rich text preference +- Proper JSON serialization of rich text content +- Support for all text item retrieval commands that currently exist in the CLI + +## Out of Scope +- Rich text rendering/display in the terminal (only raw rich text data output) +- Rich text editing capabilities through the CLI +- Conversion between rich text and plain text formats +- Support for rich text in other API endpoints beyond text items retrieval \ No newline at end of file From 35ebb5e0dce6effd244ed09a20d70851d6bb9900 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 15:53:01 -0700 Subject: [PATCH 32/52] remove spec files --- specs/DIT-11112/design.md | 65 ----------------- specs/DIT-11112/implementation.md | 115 ------------------------------ specs/DIT-11112/requirements.md | 31 -------- 3 files changed, 211 deletions(-) delete mode 100644 specs/DIT-11112/design.md delete mode 100644 specs/DIT-11112/implementation.md delete mode 100644 specs/DIT-11112/requirements.md diff --git a/specs/DIT-11112/design.md b/specs/DIT-11112/design.md deleted file mode 100644 index e6448a2..0000000 --- a/specs/DIT-11112/design.md +++ /dev/null @@ -1,65 +0,0 @@ -# Design: Rich text for CLI - -## Architecture Changes -- Extend the text items API response schema to include an optional `richText` field -- Add a new configuration option `richText` to control rich text output behavior -- Modify the JSON formatter to conditionally use rich text values instead of plain text -- No changes to the overall architecture pattern - rich text is an additional data field - -## Components Modified - -### Core Components -- `lib/src/http/textItems.ts` - Update response schema to include richText field -- `lib/src/outputs/shared.ts` - Add richText configuration option -- `lib/src/outputs/json.ts` - Update output schema to support richText option -- `lib/src/formatters/json.ts` - Modify text value selection logic -- `lib/src/services/projectConfig.ts` - Update default config to include richText option - -### Framework Processors -- `lib/src/formatters/frameworks/json/i18next.ts` - Update to handle rich text values -- `lib/src/formatters/frameworks/json/vue-i18n.ts` - Update to handle rich text values - -### Legacy Support -- Legacy mode already has richText support implemented (found in `lib/legacy/types.ts` and `lib/legacy/pull.ts`) -- No changes needed for legacy mode - -## Database Changes -- None - this is a client-side change consuming existing API capabilities - -## API Changes -- No new endpoints required -- Existing `/v2/textItems` endpoint will be called with additional query parameter `richText=html` when rich text is enabled -- API response will include `richText` field alongside existing `text` field - -## UI Changes -- None - this is a CLI tool with JSON output -- Rich text data will be output as raw JSON values, not rendered - -## Implementation Notes - -### Configuration Approach -- Add `richText` as an optional string flag in the output configuration -- Valid values: `"html"` to enable rich text, or undefined/null to use plain text -- Default value should be undefined to maintain backward compatibility -- Can be set globally or per-output configuration - -### API Integration -- When `richText` is set to "html", pass query parameter to API: `richText=html` -- API response will include both `text` and `richText` fields -- Formatter will choose which field to use based on configuration - -### Output Format -- When rich text is enabled, the JSON value will be the `richText` content -- Rich text format is an HTML string -- Variables file remains unchanged as it doesn't contain rich text - -### Error Handling -- If rich text is requested but not available for a text item, fall back to plain text -- Log warnings for items missing rich text when it's expected -- Maintain robust parsing with Zod schema validation - -### Testing Considerations -- Test with both rich text enabled and disabled -- Verify backward compatibility -- Test framework processors with rich text content -- Ensure proper fallback behavior when rich text is unavailable \ No newline at end of file diff --git a/specs/DIT-11112/implementation.md b/specs/DIT-11112/implementation.md deleted file mode 100644 index 8532288..0000000 --- a/specs/DIT-11112/implementation.md +++ /dev/null @@ -1,115 +0,0 @@ -# Implementation: Rich text for CLI - -## Task Breakdown - -### 1. Update Text Items Response Schema -**Description**: Add richText field to the API response schema -- Files: `lib/src/http/textItems.ts` -- Dependencies: None -- Details: - - Add `richText: z.string().optional()` to TextItemsResponse schema - - Ensure backward compatibility with responses that don't include richText - -### 2. Add Rich Text Configuration to Base Output -**Description**: Add richText option to the base output configuration -- Files: `lib/src/outputs/shared.ts` -- Dependencies: None -- Details: - - Add `richText: z.literal("html").optional()` to ZBaseOutputFilters - - This allows all output formats to inherit the richText option - -### 3. Update Text Items API Request -**Description**: Pass richText parameter to API when configured -- Files: `lib/src/http/textItems.ts` -- Dependencies: Task 1, Task 2 -- Details: - - Modify fetchText function to accept richText parameter - - Add richText to query params when set: `params.richText = "html"` - - Update PullFilters interface to include richText option - -### 4. Update JSON Formatter Pull Filters -**Description**: Pass richText configuration to API fetch -- Files: `lib/src/formatters/json.ts` -- Dependencies: Task 3 -- Details: - - Update generatePullFilter() to include richText from output config - - Pass richText value to fetchText function - -### 5. Modify JSON Formatter Output Logic -**Description**: Use richText field when available and configured -- Files: `lib/src/formatters/json.ts` -- Dependencies: Task 1, Task 4 -- Details: - - In transformAPIData(), check if richText is configured - - Use `textItem.richText || textItem.text` when richText is enabled - - Use `textItem.text` when richText is not configured - -### 6. Update i18next Framework Processor -**Description**: Handle rich text in i18next output format -- Files: `lib/src/formatters/frameworks/json/i18next.ts` -- Dependencies: Task 5 -- Details: - - Ensure framework processor correctly handles HTML strings - - No special escaping needed as HTML should be preserved - -### 7. Update vue-i18n Framework Processor -**Description**: Handle rich text in vue-i18n output format -- Files: `lib/src/formatters/frameworks/json/vue-i18n.ts` -- Dependencies: Task 5 -- Details: - - Ensure framework processor correctly handles HTML strings - - No special escaping needed as HTML should be preserved - -### 8. Add Tests for Rich Text Feature -**Description**: Create tests to verify rich text functionality -- Files: New test files or updates to existing tests -- Dependencies: Tasks 1-7 -- Details: - - Test with richText="html" configuration - - Test without richText configuration (backward compatibility) - - Test fallback when richText field is missing - - Test framework processors with HTML content - -### 9. Update Documentation -**Description**: Document the new richText configuration option -- Files: `README.md`, configuration examples -- Dependencies: Tasks 1-7 -- Details: - - Add richText option to configuration documentation - - Provide example config with richText enabled - - Explain HTML output format - -## Implementation Order - -1. Update Text Items Response Schema (Task 1) -2. Add Rich Text Configuration to Base Output (Task 2) -3. Update Text Items API Request (Task 3) -4. Update JSON Formatter Pull Filters (Task 4) -5. Modify JSON Formatter Output Logic (Task 5) -6. Update i18next Framework Processor (Task 6) -7. Update vue-i18n Framework Processor (Task 7) -8. Add Tests for Rich Text Feature (Task 8) -9. Update Documentation (Task 9) - -## Example Configuration - -After implementation, users will be able to configure rich text like this: - -```yaml -projects: - - id: "project-id" -outputs: - - format: json - framework: i18next - richText: html # Enable rich text output -``` - -## Testing Checklist - -- [ ] Rich text parameter is correctly passed to API -- [ ] HTML content is properly returned in JSON output -- [ ] Plain text fallback works when richText field is missing -- [ ] Configuration validation accepts "html" value -- [ ] Framework processors handle HTML strings correctly -- [ ] Backward compatibility maintained when richText is not configured -- [ ] Legacy mode continues to work with its existing richText implementation \ No newline at end of file diff --git a/specs/DIT-11112/requirements.md b/specs/DIT-11112/requirements.md deleted file mode 100644 index 7204293..0000000 --- a/specs/DIT-11112/requirements.md +++ /dev/null @@ -1,31 +0,0 @@ -# Requirements: Rich text for CLI - -## Overview -The Ditto public API now supports rich text format, returning a `richText` field in addition to plain text values. The CLI needs to be extended to support outputting this rich text format when retrieving text items, providing users with access to formatted content including markup, links, and other rich text elements through the command-line interface. - -## User Stories -- As a developer using the Ditto CLI, I want to retrieve text items with rich text formatting so that I can access and work with formatted content in my development workflow -- As a team automating Ditto workflows, I want the CLI to optionally output rich text data so that we can preserve formatting information in our pipelines -- As a CLI user, I want to control whether I receive plain text or rich text output so that I can choose the appropriate format for my use case - -## Acceptance Criteria -- [ ] CLI can retrieve and output the `richText` field from the API's get text items endpoint -- [ ] JSON output includes the `richText` value as the value in key-value pairs when rich text mode is enabled -- [ ] Configuration file includes a sensible option to enable/disable rich text output -- [ ] Rich text mode can be toggled via CLI flag or configuration setting -- [ ] Existing plain text functionality remains unchanged when rich text is disabled -- [ ] Documentation is updated to explain the rich text feature and how to enable it -- [ ] Rich text output is properly formatted in JSON responses - -## Technical Requirements -- Integration with the existing API's `richText` field as documented in the Beta Developer Integrations guide -- Backward compatibility with existing CLI usage patterns -- Configuration management for rich text preference -- Proper JSON serialization of rich text content -- Support for all text item retrieval commands that currently exist in the CLI - -## Out of Scope -- Rich text rendering/display in the terminal (only raw rich text data output) -- Rich text editing capabilities through the CLI -- Conversion between rich text and plain text formats -- Support for rich text in other API endpoints beyond text items retrieval \ No newline at end of file From 828f0e89f9558990b1d514798a5aefc37dc95476 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:04:42 -0700 Subject: [PATCH 33/52] clarify code --- lib/src/formatters/json.ts | 5 +++-- lib/src/http/textItems.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 84383a4..7a8401f 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -52,8 +52,9 @@ export default class JSONFormatter extends applyMixins( }); // Use richText if available and configured, otherwise use text - const richTextConfigured = this.output.richText === "html" || - (this.projectConfig.richText === "html" && this.output.richText !== "false") + const outputRichTextEnabled = this.output.richText === "html" + const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== "false" + const richTextConfigured = outputRichTextEnabled || baseRichTextEnabledAndNotOveridden const textValue = richTextConfigured && textItem.richText ? textItem.richText : textItem.text; diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index ebec073..e071c0d 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -27,7 +27,7 @@ export type TextItemsResponse = z.infer; export default async function fetchText(filters?: PullFilters) { try { const response = await httpClient.get("/v2/textItems", { - params: JSON.stringify(filters) + params: filters }); return TextItemsResponse.parse(response.data); From 6a86fae9ae645b7fef15e425e0148938f8237bc3 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:06:51 -0700 Subject: [PATCH 34/52] simplify code --- lib/src/formatters/json.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 7a8401f..4b15849 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -83,6 +83,7 @@ export default class JSONFormatter extends applyMixins( let filters: PullFilters = { projects: this.projectConfig.projects, variants: this.projectConfig.variants, + richText: this.projectConfig.richText }; if (this.output.projects) { filters.projects = this.output.projects; @@ -92,7 +93,7 @@ export default class JSONFormatter extends applyMixins( filters.variants = this.output.variants; } - if (this.projectConfig.richText === "html" || this.output.richText === "html") { + if (this.output.richText) { filters.richText = "html"; } From 45a4be03acb29ff5631667f9b368575cfc0991d7 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:10:07 -0700 Subject: [PATCH 35/52] remove incorrect richtext param --- lib/src/formatters/json.ts | 2 +- lib/src/http/textItems.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 4b15849..581b8c3 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -94,7 +94,7 @@ export default class JSONFormatter extends applyMixins( } if (this.output.richText) { - filters.richText = "html"; + filters.richText = this.output.richText; } return filters; diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index e071c0d..40446b3 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -26,8 +26,14 @@ export type TextItemsResponse = z.infer; export default async function fetchText(filters?: PullFilters) { try { + const params = filters; + + if (params?.richText === "false") { + delete params.richText + } + const response = await httpClient.get("/v2/textItems", { - params: filters + params }); return TextItemsResponse.parse(response.data); From c74c117d0595d37e7223ca9c5767fc65d3143a95 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:11:23 -0700 Subject: [PATCH 36/52] add clarifying comment --- lib/src/http/textItems.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 40446b3..bad2660 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -28,6 +28,7 @@ export default async function fetchText(filters?: PullFilters) { try { const params = filters; + // endpoint only takes "html" or undefined if (params?.richText === "false") { delete params.richText } From 5d98a9372313cab3aafdc5cfeda170bcf611f6a0 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:16:07 -0700 Subject: [PATCH 37/52] use boolean false instead of string --- lib/src/formatters/json.ts | 2 +- lib/src/http/textItems.ts | 4 ++-- lib/src/outputs/shared.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 581b8c3..a6a79cd 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -53,7 +53,7 @@ export default class JSONFormatter extends applyMixins( // Use richText if available and configured, otherwise use text const outputRichTextEnabled = this.output.richText === "html" - const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== "false" + const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== false const richTextConfigured = outputRichTextEnabled || baseRichTextEnabledAndNotOveridden const textValue = richTextConfigured && textItem.richText ? textItem.richText diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index bad2660..c0e4988 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -5,7 +5,7 @@ import { z } from "zod"; export interface PullFilters { projects?: { id: string }[]; variants?: { id: string }[]; - richText?: "html" | "false"; + richText?: "html" | false; } const TextItemsResponse = z.array( @@ -29,7 +29,7 @@ export default async function fetchText(filters?: PullFilters) { const params = filters; // endpoint only takes "html" or undefined - if (params?.richText === "false") { + if (params?.richText === false) { delete params.richText } diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index e78caca..c044d5a 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -8,5 +8,5 @@ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), - richText: z.union([z.literal("html"), z.literal("false")]).optional(), + richText: z.union([z.literal("html"), z.literal(false)]).optional(), }); From 26438dabf6cb93f36e9a328d418ef971f19b0e4a Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Thu, 14 Aug 2025 12:33:30 -0400 Subject: [PATCH 38/52] Update url for finding your api key --- lib/src/services/apiToken/collectToken.test.ts | 2 -- lib/src/services/apiToken/collectToken.ts | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/src/services/apiToken/collectToken.test.ts b/lib/src/services/apiToken/collectToken.test.ts index 4fa270c..54a5785 100644 --- a/lib/src/services/apiToken/collectToken.test.ts +++ b/lib/src/services/apiToken/collectToken.test.ts @@ -28,8 +28,6 @@ describe("collectToken", () => { const response = await collectToken(); expect(response).toBe(token); expect(logger.url).toHaveBeenCalled(); - expect(logger.bold).toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalled(); expect(logger.writeLine).toHaveBeenCalled(); expect(promptForApiTokenSpy).toHaveBeenCalled(); }); diff --git a/lib/src/services/apiToken/collectToken.ts b/lib/src/services/apiToken/collectToken.ts index eb7b717..045951f 100644 --- a/lib/src/services/apiToken/collectToken.ts +++ b/lib/src/services/apiToken/collectToken.ts @@ -6,9 +6,8 @@ import promptForApiToken from "./promptForApiToken"; * @returns The collected token */ export default async function collectToken() { - const apiUrl = logger.url("https://app.dittowords.com/account/devtools"); - const breadcrumbs = logger.bold(logger.info("API Keys")); - const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl} under "${breadcrumbs}".`; + const apiUrl = logger.url("https://app.dittowords.com/developers/api-keys"); + const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl}.`; logger.writeLine(tokenDescription); From 5cb330289b6519f09c8b82fe41a5943b36e4dc6f Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Thu, 14 Aug 2025 12:38:17 -0400 Subject: [PATCH 39/52] Fix fetch text query params in fetchText --- lib/src/http/textItems.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index c0e4988..250c5d5 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -24,18 +24,17 @@ const TextItemsResponse = z.array( export type TextItemsResponse = z.infer; -export default async function fetchText(filters?: PullFilters) { +export default async function fetchText(filters: PullFilters) { try { - const params = filters; + // richText applied as a separate parameter from the rest of the filters + const { richText, ...filter } = filters; - // endpoint only takes "html" or undefined - if (params?.richText === false) { - delete params.richText - } + const params = + richText === false + ? { filter: JSON.stringify(filter) } + : { filter: JSON.stringify(filter), richText }; - const response = await httpClient.get("/v2/textItems", { - params - }); + const response = await httpClient.get("/v2/textItems", { params }); return TextItemsResponse.parse(response.data); } catch (e: unknown) { From 3fc959811a0a0beba2805c25b3f380fb88cf503d Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Thu, 14 Aug 2025 12:50:14 -0400 Subject: [PATCH 40/52] Move the changes higher up --- lib/src/formatters/json.ts | 31 +++++++++++++++++++++++-------- lib/src/http/textItems.ts | 16 ++++++---------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index a6a79cd..e4145f8 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -1,9 +1,8 @@ -import fetchText, { PullFilters, TextItemsResponse } from "../http/textItems"; -import fetchVariables, { Variable, VariablesResponse } from "../http/variables"; +import fetchText, { TextItemsResponse, PullFilters, PullQueryParams } from "../http/textItems"; +import fetchVariables, { Variable } from "../http/variables"; import BaseFormatter from "./shared/base"; import OutputFile from "./shared/fileTypes/OutputFile"; import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; -import appContext from "../utils/appContext"; import { applyMixins } from "./shared"; import { getFrameworkProcessor } from "./frameworks/json"; @@ -16,8 +15,8 @@ export default class JSONFormatter extends applyMixins( BaseFormatter) { protected async fetchAPIData() { - const filters = this.generatePullFilter(); - const textItems = await fetchText(filters); + const queryParams = this.generateQueryParams(); + const textItems = await fetchText(queryParams); const variables = await fetchVariables(); const variablesById = variables.reduce((acc, variable) => { @@ -83,7 +82,6 @@ export default class JSONFormatter extends applyMixins( let filters: PullFilters = { projects: this.projectConfig.projects, variants: this.projectConfig.variants, - richText: this.projectConfig.richText }; if (this.output.projects) { filters.projects = this.output.projects; @@ -93,10 +91,27 @@ export default class JSONFormatter extends applyMixins( filters.variants = this.output.variants; } + return filters; + } + + /** + * Returns the query parameters for the fetchText API request + */ + private generateQueryParams() { + const filter = this.generatePullFilter(); + + let params: PullQueryParams = { + filter: JSON.stringify(filter), + }; + + if (this.projectConfig.richText) { + params.richText = this.projectConfig.richText; + } + if (this.output.richText) { - filters.richText = this.output.richText; + params.richText = this.output.richText; } - return filters; + return params; } } diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 250c5d5..f63cfbc 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -5,7 +5,11 @@ import { z } from "zod"; export interface PullFilters { projects?: { id: string }[]; variants?: { id: string }[]; - richText?: "html" | false; +} + +export interface PullQueryParams { + filter: string; // Stringified PullFilters + richText?: "html"; } const TextItemsResponse = z.array( @@ -24,16 +28,8 @@ const TextItemsResponse = z.array( export type TextItemsResponse = z.infer; -export default async function fetchText(filters: PullFilters) { +export default async function fetchText(params: PullQueryParams) { try { - // richText applied as a separate parameter from the rest of the filters - const { richText, ...filter } = filters; - - const params = - richText === false - ? { filter: JSON.stringify(filter) } - : { filter: JSON.stringify(filter), richText }; - const response = await httpClient.get("/v2/textItems", { params }); return TextItemsResponse.parse(response.data); From 26820d93a9bb7d5998cd7e105c69b6bc04ad871a Mon Sep 17 00:00:00 2001 From: asnewman Date: Thu, 14 Aug 2025 15:04:23 -0700 Subject: [PATCH 41/52] Add e2e tests --- lib/src/commands/pull.test.ts | 296 ++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 lib/src/commands/pull.test.ts diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts new file mode 100644 index 0000000..2ab4b1b --- /dev/null +++ b/lib/src/commands/pull.test.ts @@ -0,0 +1,296 @@ +import { pull } from "./pull"; +import httpClient from "../http/client"; +import appContext from "../utils/appContext"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +jest.mock("../http/client"); + +const mockHttpClient = httpClient as jest.Mocked; + +// Test data factories +const createMockTextItem = (overrides: any = {}) => ({ + id: "text-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project-1", + variantId: null, + ...overrides, +}); + +const createMockVariable = (overrides: any = {}) => ({ + id: "var-1", + name: "Variable 1", + type: "string", + data: { + example: "variable value", + fallback: undefined + }, + ...overrides, +}); + +// Create a temporary directory for tests +let testDir: string; +let outputDir: string; + +// Helper functions +const setupFilesystem = (configContent: string) => { + // Create config file + const configPath = path.join(testDir, "config.yml"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, configContent, "utf-8"); + + // Ensure output directory exists + fs.mkdirSync(outputDir, { recursive: true }); +}; + +const setupMocks = (textItems: any[] = [], variables: any[] = []) => { + mockHttpClient.get.mockImplementation((url: string) => { + if (url.includes("/v2/textItems")) { + return Promise.resolve({ data: textItems }); + } + if (url.includes("/v2/variables")) { + return Promise.resolve({ data: variables }); + } + return Promise.resolve({ data: [] }); + }); +}; + +const parseJsonFile = (filepath: string) => { + const content = fs.readFileSync(filepath, "utf-8"); + return JSON.parse(content); +}; + +const assertFileContainsText = ( + filepath: string, + textId: string, + expectedText: string +) => { + const content = parseJsonFile(filepath); + expect(content[textId]).toBe(expectedText); +}; + +const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { + const actualFiles = fs.readdirSync(outputDir).sort(); + expect(actualFiles).toEqual(expectedFiles.sort()); +}; + +// Reset appContext before each test +beforeEach(() => { + jest.clearAllMocks(); + + // Create a fresh temp directory for each test + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ditto-test-')); + outputDir = path.join(testDir, 'output'); + + // Reset appContext to a clean state + appContext.setProjectConfig({ + projects: [], + outputs: [], + }); +}); + +// Clean up temp directory after each test +afterEach(() => { + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } +}); + +describe("pull command - end-to-end tests", () => { + describe("Rich Text Feature", () => { + it("should use rich text when configured at base level", async () => { + const config = ` +projects: + - id: project-1 +richText: html +outputs: + - format: json + outDir: ${outputDir} +`; + setupFilesystem(config); + + const mockTextItem = createMockTextItem(); + setupMocks([mockTextItem], []); + + // Set up appContext + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + richText: "html", + outputs: [{ format: "json", outDir: outputDir }], + }); + + await pull(); + + // Verify rich text content was written + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "text-1", + "

Rich HTML content

" + ); + }); + + it("should use plain text when richText is disabled at output level", async () => { + const config = ` +projects: + - id: project-1 +richText: html +outputs: + - format: json + outDir: ${outputDir} + richText: false +`; + setupFilesystem(config); + + const mockTextItem = createMockTextItem(); + setupMocks([mockTextItem], []); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + richText: "html", + outputs: [{ format: "json", outDir: outputDir, richText: false }], + }); + + await pull(); + + // Verify plain text content was written despite base config + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "text-1", + "Plain text content" + ); + }); + + it("should use rich text when enabled only at output level", async () => { + const config = ` +projects: + - id: project-1 +outputs: + - format: json + outDir: ${outputDir} + richText: html +`; + setupFilesystem(config); + + const mockTextItem = createMockTextItem(); + setupMocks([mockTextItem], []); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + outputs: [{ format: "json", outDir: outputDir, richText: "html" }], + }); + + await pull(); + + // Verify rich text content was written + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "text-1", + "

Rich HTML content

" + ); + }); + }); + + describe("Filter Feature", () => { + it("should filter projects at output level", async () => { + const config = ` +projects: + - id: project-1 + - id: project-2 +outputs: + - format: json + outDir: ${outputDir} + projects: + - id: project-1 +`; + setupFilesystem(config); + + const textItem1 = createMockTextItem({ projectId: "project-1" }); + const textItem2 = createMockTextItem({ + id: "text-2", + projectId: "project-2" + }); + + // Mock should only return project-1 items when filtered + // The API only returns items for the requested projects + setupMocks([textItem1], []); + + appContext.setProjectConfig({ + projects: [ + { id: "project-1" }, + { id: "project-2" }, + ], + outputs: [{ + format: "json", + outDir: outputDir, + projects: [{ id: "project-1" }] + }], + }); + + await pull(); + + // Verify only project-1 files were created + assertFilesCreated(outputDir, [ + "project-1___base.json", + "variables.json", + ]); + }); + + it("should filter variants at output level", async () => { + const config = ` +projects: + - id: project-1 +variants: + - id: variant-a + - id: variant-b +outputs: + - format: json + outDir: ${outputDir} + variants: + - id: variant-a +`; + setupFilesystem(config); + + const textItemBase = createMockTextItem({ variantId: null }); + const textItemA = createMockTextItem({ + id: "text-2", + variantId: "variant-a" + }); + const textItemB = createMockTextItem({ + id: "text-3", + variantId: "variant-b" + }); + + // Mock should only return base and variant-a items when filtered + // The API only returns items for the requested variants (plus base) + setupMocks([textItemBase, textItemA], []); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + variants: [ + { id: "variant-a" }, + { id: "variant-b" }, + ], + outputs: [{ + format: "json", + outDir: outputDir, + variants: [{ id: "variant-a" }] + }], + }); + + await pull(); + + // Verify only base and variant-a files were created + assertFilesCreated(outputDir, [ + "project-1___base.json", + "project-1___variant-a.json", + "variables.json", + ]); + }); + }); +}); From ee8a8150365d406cea0b3da0f3bc58e36fb21cd8 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 14:35:12 -0700 Subject: [PATCH 42/52] Replace any types with proper TypeScript types in tests Updates createMockTextItem function to use Partial instead of any, providing better type safety for test mock overrides. --- lib/src/commands/pull.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 2ab4b1b..cc60b37 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -1,5 +1,6 @@ import { pull } from "./pull"; import httpClient from "../http/client"; +import { TextItemsResponse } from "../http/textItems"; import appContext from "../utils/appContext"; import * as path from "path"; import * as fs from "fs"; @@ -10,7 +11,7 @@ jest.mock("../http/client"); const mockHttpClient = httpClient as jest.Mocked; // Test data factories -const createMockTextItem = (overrides: any = {}) => ({ +const createMockTextItem = (overrides: Partial = {}) => ({ id: "text-1", text: "Plain text content", richText: "

Rich HTML content

", From 53479018447515a9f267ccdcd47dc8a74379bc94 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 14:41:58 -0700 Subject: [PATCH 43/52] Move test variables and hooks inside describe block Improves test organization by moving testDir, outputDir variables and beforeEach/afterEach hooks inside the main describe block for better scoping and isolation. --- lib/src/commands/pull.test.ts | 70 +++++++++++++++++------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index cc60b37..311e215 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -35,21 +35,7 @@ const createMockVariable = (overrides: any = {}) => ({ ...overrides, }); -// Create a temporary directory for tests -let testDir: string; -let outputDir: string; - // Helper functions -const setupFilesystem = (configContent: string) => { - // Create config file - const configPath = path.join(testDir, "config.yml"); - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, configContent, "utf-8"); - - // Ensure output directory exists - fs.mkdirSync(outputDir, { recursive: true }); -}; - const setupMocks = (textItems: any[] = [], variables: any[] = []) => { mockHttpClient.get.mockImplementation((url: string) => { if (url.includes("/v2/textItems")) { @@ -81,29 +67,43 @@ const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { expect(actualFiles).toEqual(expectedFiles.sort()); }; -// Reset appContext before each test -beforeEach(() => { - jest.clearAllMocks(); - - // Create a fresh temp directory for each test - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ditto-test-')); - outputDir = path.join(testDir, 'output'); - - // Reset appContext to a clean state - appContext.setProjectConfig({ - projects: [], - outputs: [], - }); -}); +describe("pull command - end-to-end tests", () => { + // Create a temporary directory for tests + let testDir: string; + let outputDir: string; -// Clean up temp directory after each test -afterEach(() => { - if (testDir && fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } -}); + // Helper function for this test suite + const setupFilesystem = (configContent: string) => { + // Create config file + const configPath = path.join(testDir, "config.yml"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, configContent, "utf-8"); + + // Ensure output directory exists + fs.mkdirSync(outputDir, { recursive: true }); + }; -describe("pull command - end-to-end tests", () => { + // Reset appContext before each test + beforeEach(() => { + jest.clearAllMocks(); + + // Create a fresh temp directory for each test + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ditto-test-')); + outputDir = path.join(testDir, 'output'); + + // Reset appContext to a clean state + appContext.setProjectConfig({ + projects: [], + outputs: [], + }); + }); + + // Clean up temp directory after each test + afterEach(() => { + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); describe("Rich Text Feature", () => { it("should use rich text when configured at base level", async () => { const config = ` From 0a13b282e65ab7c78a981684286f57d15573b51f Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 14:44:30 -0700 Subject: [PATCH 44/52] Use toSorted() instead of sort() in tests Replaces mutating sort() calls with non-mutating toSorted() to avoid potential side effects in test assertions. --- lib/src/commands/pull.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 311e215..a7016ba 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -63,8 +63,8 @@ const assertFileContainsText = ( }; const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { - const actualFiles = fs.readdirSync(outputDir).sort(); - expect(actualFiles).toEqual(expectedFiles.sort()); + const actualFiles = fs.readdirSync(outputDir).toSorted(); + expect(actualFiles).toEqual(expectedFiles.toSorted()); }; describe("pull command - end-to-end tests", () => { From 17085068fda635b3c5d6ce75f344db99562b7cd2 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 15:12:48 -0700 Subject: [PATCH 45/52] Simplify test setup by removing unused YAML config files These tests mock HTTP calls and set appContext directly, so the YAML config files were never actually read. Removed the duplication between YAML strings and setProjectConfig() calls - now tests only create what they need and use a single source of truth for configuration. This eliminates the risk of YAML/object mismatch bugs and makes the tests clearer about what they're actually testing. --- lib/src/commands/pull.test.ts | 70 ++++------------------------------- 1 file changed, 7 insertions(+), 63 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index a7016ba..af95cab 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -72,16 +72,6 @@ describe("pull command - end-to-end tests", () => { let testDir: string; let outputDir: string; - // Helper function for this test suite - const setupFilesystem = (configContent: string) => { - // Create config file - const configPath = path.join(testDir, "config.yml"); - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, configContent, "utf-8"); - - // Ensure output directory exists - fs.mkdirSync(outputDir, { recursive: true }); - }; // Reset appContext before each test beforeEach(() => { @@ -106,20 +96,13 @@ describe("pull command - end-to-end tests", () => { }); describe("Rich Text Feature", () => { it("should use rich text when configured at base level", async () => { - const config = ` -projects: - - id: project-1 -richText: html -outputs: - - format: json - outDir: ${outputDir} -`; - setupFilesystem(config); + // Only create output directory since we're mocking HTTP and setting appContext directly + fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); - // Set up appContext + // Set up appContext - this is what actually drives the test appContext.setProjectConfig({ projects: [{ id: "project-1" }], richText: "html", @@ -137,16 +120,7 @@ outputs: }); it("should use plain text when richText is disabled at output level", async () => { - const config = ` -projects: - - id: project-1 -richText: html -outputs: - - format: json - outDir: ${outputDir} - richText: false -`; - setupFilesystem(config); + fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); @@ -168,15 +142,7 @@ outputs: }); it("should use rich text when enabled only at output level", async () => { - const config = ` -projects: - - id: project-1 -outputs: - - format: json - outDir: ${outputDir} - richText: html -`; - setupFilesystem(config); + fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); @@ -199,17 +165,7 @@ outputs: describe("Filter Feature", () => { it("should filter projects at output level", async () => { - const config = ` -projects: - - id: project-1 - - id: project-2 -outputs: - - format: json - outDir: ${outputDir} - projects: - - id: project-1 -`; - setupFilesystem(config); + fs.mkdirSync(outputDir, { recursive: true }); const textItem1 = createMockTextItem({ projectId: "project-1" }); const textItem2 = createMockTextItem({ @@ -243,19 +199,7 @@ outputs: }); it("should filter variants at output level", async () => { - const config = ` -projects: - - id: project-1 -variants: - - id: variant-a - - id: variant-b -outputs: - - format: json - outDir: ${outputDir} - variants: - - id: variant-a -`; - setupFilesystem(config); + fs.mkdirSync(outputDir, { recursive: true }); const textItemBase = createMockTextItem({ variantId: null }); const textItemA = createMockTextItem({ From e47c9e259e7ab3fa8d64058cdb2ef5aee8b69b14 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 15:22:26 -0700 Subject: [PATCH 46/52] Add proper filter tests with API parameter verification Adds toHaveBeenCalledWith assertions to filter tests to verify that config filters are correctly converted to API query parameters. Tests now catch bugs in the config->API params conversion logic instead of just testing the mocked response->file output flow. This addresses the concern that filter tests weren't actually testing the filtering functionality due to complete API mocking. --- lib/src/commands/pull.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index af95cab..4f1ecfb 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -168,13 +168,6 @@ describe("pull command - end-to-end tests", () => { fs.mkdirSync(outputDir, { recursive: true }); const textItem1 = createMockTextItem({ projectId: "project-1" }); - const textItem2 = createMockTextItem({ - id: "text-2", - projectId: "project-2" - }); - - // Mock should only return project-1 items when filtered - // The API only returns items for the requested projects setupMocks([textItem1], []); appContext.setProjectConfig({ @@ -191,6 +184,13 @@ describe("pull command - end-to-end tests", () => { await pull(); + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith('/v2/textItems', { + params: { + filter: '{"projects":[{"id":"project-1"}]}' + } + }); + // Verify only project-1 files were created assertFilesCreated(outputDir, [ "project-1___base.json", @@ -206,13 +206,6 @@ describe("pull command - end-to-end tests", () => { id: "text-2", variantId: "variant-a" }); - const textItemB = createMockTextItem({ - id: "text-3", - variantId: "variant-b" - }); - - // Mock should only return base and variant-a items when filtered - // The API only returns items for the requested variants (plus base) setupMocks([textItemBase, textItemA], []); appContext.setProjectConfig({ @@ -230,6 +223,13 @@ describe("pull command - end-to-end tests", () => { await pull(); + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith('/v2/textItems', { + params: { + filter: '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"}]}' + } + }); + // Verify only base and variant-a files were created assertFilesCreated(outputDir, [ "project-1___base.json", From bb48ae21fed9b46ae4f85ac1ad6c14a8fb729ac4 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Mon, 18 Aug 2025 09:28:56 -0400 Subject: [PATCH 47/52] Split up filter/output tests, add more filter cases --- lib/src/commands/pull.test.ts | 261 +++++++++++++++++++++++++--------- lib/src/http/textItems.ts | 31 ++-- 2 files changed, 209 insertions(+), 83 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 4f1ecfb..99703d6 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -1,6 +1,6 @@ import { pull } from "./pull"; import httpClient from "../http/client"; -import { TextItemsResponse } from "../http/textItems"; +import { TextItem } from "../http/textItems"; import appContext from "../utils/appContext"; import * as path from "path"; import * as fs from "fs"; @@ -11,7 +11,7 @@ jest.mock("../http/client"); const mockHttpClient = httpClient as jest.Mocked; // Test data factories -const createMockTextItem = (overrides: Partial = {}) => ({ +const createMockTextItem = (overrides: Partial = {}) => ({ id: "text-1", text: "Plain text content", richText: "

Rich HTML content

", @@ -30,13 +30,13 @@ const createMockVariable = (overrides: any = {}) => ({ type: "string", data: { example: "variable value", - fallback: undefined + fallback: undefined, }, ...overrides, }); // Helper functions -const setupMocks = (textItems: any[] = [], variables: any[] = []) => { +const setupMocks = (textItems: TextItem[] = [], variables: any[] = []) => { mockHttpClient.get.mockImplementation((url: string) => { if (url.includes("/v2/textItems")) { return Promise.resolve({ data: textItems }); @@ -72,19 +72,23 @@ describe("pull command - end-to-end tests", () => { let testDir: string; let outputDir: string; - // Reset appContext before each test beforeEach(() => { jest.clearAllMocks(); - + // Create a fresh temp directory for each test - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ditto-test-')); - outputDir = path.join(testDir, 'output'); - + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "ditto-test-")); + outputDir = path.join(testDir, "output"); + // Reset appContext to a clean state appContext.setProjectConfig({ projects: [], - outputs: [], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], }); }); @@ -94,23 +98,24 @@ describe("pull command - end-to-end tests", () => { fs.rmSync(testDir, { recursive: true, force: true }); } }); + describe("Rich Text Feature", () => { it("should use rich text when configured at base level", async () => { // Only create output directory since we're mocking HTTP and setting appContext directly fs.mkdirSync(outputDir, { recursive: true }); - + const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); - + // Set up appContext - this is what actually drives the test appContext.setProjectConfig({ projects: [{ id: "project-1" }], richText: "html", outputs: [{ format: "json", outDir: outputDir }], }); - + await pull(); - + // Verify rich text content was written assertFileContainsText( path.join(outputDir, "project-1___base.json"), @@ -121,18 +126,18 @@ describe("pull command - end-to-end tests", () => { it("should use plain text when richText is disabled at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); - + const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); - + appContext.setProjectConfig({ projects: [{ id: "project-1" }], richText: "html", outputs: [{ format: "json", outDir: outputDir, richText: false }], }); - + await pull(); - + // Verify plain text content was written despite base config assertFileContainsText( path.join(outputDir, "project-1___base.json"), @@ -143,17 +148,17 @@ describe("pull command - end-to-end tests", () => { it("should use rich text when enabled only at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); - + const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); - + appContext.setProjectConfig({ projects: [{ id: "project-1" }], outputs: [{ format: "json", outDir: outputDir, richText: "html" }], }); - + await pull(); - + // Verify rich text content was written assertFileContainsText( path.join(outputDir, "project-1___base.json"), @@ -164,76 +169,192 @@ describe("pull command - end-to-end tests", () => { }); describe("Filter Feature", () => { + it("should filter projects when configured at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }, { id: "project-2" }], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: '{"projects":[{"id":"project-1"},{"id":"project-2"}]}', + }, + }); + }); + + it("should filter variants at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + variants: [{ id: "variant-a" }, { id: "variant-b" }], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: + '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"},{"id":"variant-b"}]}', + }, + }); + }); + it("should filter projects at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); - - const textItem1 = createMockTextItem({ projectId: "project-1" }); - setupMocks([textItem1], []); - + appContext.setProjectConfig({ - projects: [ - { id: "project-1" }, - { id: "project-2" }, + projects: [{ id: "project-1" }, { id: "project-2" }], + outputs: [ + { + format: "json", + outDir: outputDir, + projects: [{ id: "project-1" }], + }, ], - outputs: [{ - format: "json", - outDir: outputDir, - projects: [{ id: "project-1" }] - }], }); - + await pull(); - + // Verify correct API call with filtered params - expect(mockHttpClient.get).toHaveBeenCalledWith('/v2/textItems', { - params: { - filter: '{"projects":[{"id":"project-1"}]}' - } + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: '{"projects":[{"id":"project-1"}]}', + }, }); - - // Verify only project-1 files were created - assertFilesCreated(outputDir, [ - "project-1___base.json", - "variables.json", - ]); }); it("should filter variants at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); - - const textItemBase = createMockTextItem({ variantId: null }); - const textItemA = createMockTextItem({ - id: "text-2", - variantId: "variant-a" - }); - setupMocks([textItemBase, textItemA], []); - + appContext.setProjectConfig({ projects: [{ id: "project-1" }], - variants: [ - { id: "variant-a" }, - { id: "variant-b" }, + variants: [{ id: "variant-a" }, { id: "variant-b" }], + outputs: [ + { + format: "json", + outDir: outputDir, + variants: [{ id: "variant-a" }], + }, ], - outputs: [{ - format: "json", - outDir: outputDir, - variants: [{ id: "variant-a" }] - }], }); - + await pull(); - + // Verify correct API call with filtered params - expect(mockHttpClient.get).toHaveBeenCalledWith('/v2/textItems', { - params: { - filter: '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"}]}' - } + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: + '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"}]}', + }, }); - - // Verify only base and variant-a files were created + }); + + it("supports the default filter behavior", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: "{}", + }, + }); + }); + }); + + describe("Output files", () => { + it("should create output files for each project and variant returned from the API", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + // project-1 and project-2 each have at least one base text item + const baseTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-1", + }), + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-2", + }), + createMockTextItem({ + projectId: "project-2", + variantId: null, + id: "text-3", + }), + ]; + + // project-1 and project-2 each have a variant-a text item + const variantATextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-a", + id: "text-4", + }), + createMockTextItem({ + projectId: "project-2", + variantId: "variant-a", + id: "text-5", + }), + ]; + + // Only project-1 has variant-b, so only project-1 should get a variant-b file + const variantBTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-6", + }), + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-7", + }), + ]; + + setupMocks( + [...baseTextItems, ...variantATextItems, ...variantBTextItems], + [] + ); + + await pull(); + + // Verify a file was created for each project and variant present in the (mocked) API response assertFilesCreated(outputDir, [ "project-1___base.json", "project-1___variant-a.json", + "project-1___variant-b.json", + "project-2___base.json", + "project-2___variant-a.json", "variables.json", ]); }); diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index f63cfbc..173fc5b 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -12,19 +12,24 @@ export interface PullQueryParams { richText?: "html"; } -const TextItemsResponse = z.array( - z.object({ - id: z.string(), - text: z.string(), - richText: z.string().optional(), - status: z.string(), - notes: z.string(), - tags: z.array(z.string()), - variableIds: z.array(z.string()), - projectId: z.string(), - variantId: z.string().nullable(), - }) -); +const ZTextItem = z.object({ + id: z.string(), + text: z.string(), + richText: z.string().optional(), + status: z.string(), + notes: z.string(), + tags: z.array(z.string()), + variableIds: z.array(z.string()), + projectId: z.string(), + variantId: z.string().nullable(), +}); + +/** + * Represents a single text item, as returned from the /v2/textItems endpoint + */ +export type TextItem = z.infer; + +const TextItemsResponse = z.array(ZTextItem); export type TextItemsResponse = z.infer; From 88089ca6fff2252367d5a8ac637d6457bea5dca6 Mon Sep 17 00:00:00 2001 From: Jerome White Date: Mon, 29 Sep 2025 13:20:26 -0400 Subject: [PATCH 48/52] bump beta version to 7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 72c1818..5816c4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.6", + "version": "5.0.0-beta.7", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From 978b517ddd669ec2f0f3dcc1bc08012e1ea64616 Mon Sep 17 00:00:00 2001 From: Jerome White <40569622+JWhite30515@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:10:07 -0400 Subject: [PATCH 49/52] [DIT 11281] CLI support for components (#124) * Add support for fetching components from API for JSON output * clean up logs, basic test * make folders optional, add tests * split up filter rode, clean up error logging * support nested folder specification * upgrade to beta 8 version --- lib/src/commands/pull.test.ts | 250 ++++++++++++++++++++++++++++-- lib/src/formatters/json.ts | 139 +++++++++++------ lib/src/formatters/shared/base.ts | 11 ++ lib/src/http/components.test.ts | 67 ++++++++ lib/src/http/components.ts | 33 ++++ lib/src/http/textItems.test.ts | 2 + lib/src/http/textItems.ts | 44 ++---- lib/src/http/types.ts | 56 +++++++ lib/src/outputs/shared.ts | 10 +- lib/src/services/projectConfig.ts | 3 + package.json | 2 +- 11 files changed, 523 insertions(+), 94 deletions(-) create mode 100644 lib/src/http/components.test.ts create mode 100644 lib/src/http/components.ts create mode 100644 lib/src/http/types.ts diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 99703d6..c8a8589 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -1,6 +1,6 @@ import { pull } from "./pull"; import httpClient from "../http/client"; -import { TextItem } from "../http/textItems"; +import { Component, TextItem } from "../http/types"; import appContext from "../utils/appContext"; import * as path from "path"; import * as fs from "fs"; @@ -24,6 +24,19 @@ const createMockTextItem = (overrides: Partial = {}) => ({ ...overrides, }); +const createMockComponent = (overrides: Partial = {}) => ({ + id: "component-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "active", + notes: "", + tags: [], + variableIds: [], + folderId: null, + variantId: null, + ...overrides, +}) + const createMockVariable = (overrides: any = {}) => ({ id: "var-1", name: "Variable 1", @@ -36,7 +49,7 @@ const createMockVariable = (overrides: any = {}) => ({ }); // Helper functions -const setupMocks = (textItems: TextItem[] = [], variables: any[] = []) => { +const setupMocks = ({ textItems = [], components = [], variables = [] }: { textItems: TextItem[]; components?: Component[]; variables?: any[] }) => { mockHttpClient.get.mockImplementation((url: string) => { if (url.includes("/v2/textItems")) { return Promise.resolve({ data: textItems }); @@ -44,6 +57,9 @@ const setupMocks = (textItems: TextItem[] = [], variables: any[] = []) => { if (url.includes("/v2/variables")) { return Promise.resolve({ data: variables }); } + if (url.includes("/v2/components")) { + return Promise.resolve({ data: components }); + } return Promise.resolve({ data: [] }); }); }; @@ -55,11 +71,11 @@ const parseJsonFile = (filepath: string) => { const assertFileContainsText = ( filepath: string, - textId: string, + devId: string, expectedText: string ) => { const content = parseJsonFile(filepath); - expect(content[textId]).toBe(expectedText); + expect(content[devId]).toBe(expectedText); }; const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { @@ -105,11 +121,13 @@ describe("pull command - end-to-end tests", () => { fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); - setupMocks([mockTextItem], []); + const mockComponent = createMockComponent(); + setupMocks({ textItems: [mockTextItem], components: [mockComponent]}); // Set up appContext - this is what actually drives the test appContext.setProjectConfig({ projects: [{ id: "project-1" }], + components: {}, richText: "html", outputs: [{ format: "json", outDir: outputDir }], }); @@ -122,17 +140,25 @@ describe("pull command - end-to-end tests", () => { "text-1", "

Rich HTML content

" ); + + assertFileContainsText( + path.join(outputDir, "components___base.json"), + "component-1", + "

Rich HTML content

" + ) }); it("should use plain text when richText is disabled at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); - setupMocks([mockTextItem], []); + const mockComponent = createMockComponent(); + setupMocks({ textItems: [mockTextItem], components: [mockComponent] }); appContext.setProjectConfig({ projects: [{ id: "project-1" }], richText: "html", + components: {}, outputs: [{ format: "json", outDir: outputDir, richText: false }], }); @@ -144,13 +170,19 @@ describe("pull command - end-to-end tests", () => { "text-1", "Plain text content" ); + + assertFileContainsText( + path.join(outputDir, "components___base.json"), + "component-1", + "Plain text content" + ); }); it("should use rich text when enabled only at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); - setupMocks([mockTextItem], []); + setupMocks({ textItems: [mockTextItem] }); appContext.setProjectConfig({ projects: [{ id: "project-1" }], @@ -217,6 +249,137 @@ describe("pull command - end-to-end tests", () => { }); }); + it("should query components when source field is provided", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: {}, + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{}', + }, + }); + }) + + it("should filter components by folder at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: { + folders: [{ id: "folder-1" }], + }, + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{"folders":[{"id":"folder-1"}]}', + }, + }); + }) + + it("should filter components by folder and variants at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: { + folders: [{ id: "folder-1" }], + }, + variants: [{ id: "variant-a" }, { id: "variant-b" }], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{"folders":[{"id":"folder-1"}],"variants":[{"id":"variant-a"},{"id":"variant-b"}]}', + }, + }); + }) + + it("should filter components by folder at output level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: { + folders: [{ id: "folder-1" }], + }, + outputs: [ + { + format: "json", + outDir: outputDir, + components: { + folders: [{ id: "folder-3" }] + } + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{"folders":[{"id":"folder-3"}]}', + }, + }); + }) + + it("should filter components by folder and variants at output level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: { + folders: [{ id: "folder-1" }], + }, + outputs: [ + { + format: "json", + outDir: outputDir, + components: { + folders: [{ id: "folder-3" }] + }, + variants: [{ id: "variant-a" }, { id: "variant-b" }], + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{"folders":[{"id":"folder-3"}],"variants":[{"id":"variant-a"},{"id":"variant-b"}]}', + }, + }); + }) + it("should filter projects at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); @@ -271,6 +434,7 @@ describe("pull command - end-to-end tests", () => { fs.mkdirSync(outputDir, { recursive: true }); appContext.setProjectConfig({ + projects: [], outputs: [ { format: "json", @@ -284,9 +448,13 @@ describe("pull command - end-to-end tests", () => { // Verify correct API call with filtered params expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { params: { - filter: "{}", + filter: "{\"projects\":[]}", }, }); + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/variables") + + // Components endpoint should not be called if not provided as source field + expect(mockHttpClient.get).toHaveBeenCalledTimes(2) }); }); @@ -294,6 +462,17 @@ describe("pull command - end-to-end tests", () => { it("should create output files for each project and variant returned from the API", async () => { fs.mkdirSync(outputDir, { recursive: true }); + appContext.setProjectConfig({ + projects: [], + components: {}, + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + // project-1 and project-2 each have at least one base text item const baseTextItems = [ createMockTextItem({ @@ -341,10 +520,54 @@ describe("pull command - end-to-end tests", () => { }), ]; - setupMocks( - [...baseTextItems, ...variantATextItems, ...variantBTextItems], - [] - ); + const componentsBase = [ + createMockComponent({ + id: "comp-1", + variantId: null, + folderId: null, + }), + createMockComponent({ + id: "comp-2", + variantId: null, + folderId: "folder-1", + }), + createMockComponent({ + id: "comp-3", + variantId: null, + folderId: "folder-2", + }), + ] + + const componentsVariantA = [ + createMockComponent({ + id: "comp-4", + variantId: "variant-a", + folderId: null, + }), + createMockComponent({ + id: "comp-5", + variantId: "variant-a", + folderId: "folder-1", + }), + ] + + const componentsVariantB = [ + createMockComponent({ + id: "comp-6", + variantId: "variant-b", + folderId: null, + }), + createMockComponent({ + id: "comp-7", + variantId: "variant-b", + folderId: "folder-1", + }), + ] + + setupMocks({ + textItems: [...baseTextItems, ...variantATextItems, ...variantBTextItems], + components: [...componentsBase, ...componentsVariantA, ...componentsVariantB], + }); await pull(); @@ -355,6 +578,9 @@ describe("pull command - end-to-end tests", () => { "project-1___variant-b.json", "project-2___base.json", "project-2___variant-a.json", + "components___base.json", + "components___variant-a.json", + "components___variant-b.json", "variables.json", ]); }); diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index e4145f8..ec312bd 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -1,4 +1,6 @@ -import fetchText, { TextItemsResponse, PullFilters, PullQueryParams } from "../http/textItems"; +import fetchText from "../http/textItems"; +import { Component, ComponentsResponse, isTextItem, PullFilters, PullQueryParams, TextItem, TextItemsResponse } from "../http/types"; +import fetchComponents from "../http/components"; import fetchVariables, { Variable } from "../http/variables"; import BaseFormatter from "./shared/base"; import OutputFile from "./shared/fileTypes/OutputFile"; @@ -8,15 +10,18 @@ import { getFrameworkProcessor } from "./frameworks/json"; type JSONAPIData = { textItems: TextItemsResponse; + components: ComponentsResponse; variablesById: Record; }; +type RequestType = "textItem" | "component"; + export default class JSONFormatter extends applyMixins( BaseFormatter) { protected async fetchAPIData() { - const queryParams = this.generateQueryParams(); - const textItems = await fetchText(queryParams); + const textItems = await this.fetchTextItems(); + const components = await this.fetchComponents(); const variables = await fetchVariables(); const variablesById = variables.reduce((acc, variable) => { @@ -24,68 +29,88 @@ export default class JSONFormatter extends applyMixins( return acc; }, {} as Record); - return { textItems, variablesById }; + return { textItems, variablesById, components }; } protected async transformAPIData(data: JSONAPIData) { - - let outputJsonFiles: Record< - string, - JSONOutputFile<{ variantId: string }> - > = {}; - - const variablesOutputFile = new JSONOutputFile({ - filename: "variables", - path: this.outDir, - }); - for (let i = 0; i < data.textItems.length; i++) { const textItem = data.textItems[i]; - - const fileName = `${textItem.projectId}___${textItem.variantId || "base"}`; - - outputJsonFiles[fileName] ??= new JSONOutputFile({ - filename: fileName, - path: this.outDir, - metadata: { variantId: textItem.variantId || "base" }, - }); - - // Use richText if available and configured, otherwise use text - const outputRichTextEnabled = this.output.richText === "html" - const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== false - const richTextConfigured = outputRichTextEnabled || baseRichTextEnabledAndNotOveridden - const textValue = richTextConfigured && textItem.richText - ? textItem.richText - : textItem.text; - - outputJsonFiles[fileName].content[textItem.id] = textValue; - for (const variableId of textItem.variableIds) { - const variable = data.variablesById[variableId]; - variablesOutputFile.content[variableId] = variable.data; - } + this.transformAPITextEntity(textItem, data.variablesById); + } + + for (let i = 0; i < data.components.length; i++) { + const component = data.components[i]; + this.transformAPITextEntity(component, data.variablesById); } let results: OutputFile[] = [ - ...Object.values(outputJsonFiles), - variablesOutputFile, + ...Object.values(this.outputJsonFiles), + this.variablesOutputFile, ] if (this.output.framework) { // process framework - results.push(...getFrameworkProcessor(this.output).process(outputJsonFiles)); + results.push(...getFrameworkProcessor(this.output).process(this.outputJsonFiles)); } return results; } - private generatePullFilter() { + /** + * Transforms text entity returned from API response into JSON and inserts into corresponding output file. + * @param textEntity The text entity returned from API response. + * @param variablesById Mapping of devID <> variable data returned from API response. + */ + private transformAPITextEntity(textEntity: TextItem | Component, variablesById: Record) { + const fileName = isTextItem(textEntity) ? `${textEntity.projectId}___${textEntity.variantId || "base"}` : `components___${textEntity.variantId || "base"}`; + + this.outputJsonFiles[fileName] ??= new JSONOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: textEntity.variantId || "base" }, + }); + + // Use richText if available and configured, otherwise use text + const outputRichTextEnabled = this.output.richText === "html" + const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== false + const richTextConfigured = outputRichTextEnabled || baseRichTextEnabledAndNotOveridden + const textValue = richTextConfigured && textEntity.richText + ? textEntity.richText + : textEntity.text; + + this.outputJsonFiles[fileName].content[textEntity.id] = textValue; + for (const variableId of textEntity.variableIds) { + const variable = variablesById[variableId]; + this.variablesOutputFile.content[variableId] = variable.data; + } + } + + private generateTextItemPullFilter() { let filters: PullFilters = { projects: this.projectConfig.projects, variants: this.projectConfig.variants, }; + if (this.output.projects) { filters.projects = this.output.projects; - } + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } + + private generateComponentPullFilter() { + let filters: PullFilters = { + ...(this.projectConfig.components?.folders && { folders: this.projectConfig.components.folders }), + variants: this.projectConfig.variants, + }; + + if (this.output.components) { + filters.folders = this.output.components?.folders; + } if (this.output.variants) { filters.variants = this.output.variants; @@ -97,9 +122,9 @@ export default class JSONFormatter extends applyMixins( /** * Returns the query parameters for the fetchText API request */ - private generateQueryParams() { - const filter = this.generatePullFilter(); - + private generateQueryParams(requestType: RequestType) { + const filter = requestType === "textItem" ? this.generateTextItemPullFilter() : this.generateComponentPullFilter(); + let params: PullQueryParams = { filter: JSON.stringify(filter), }; @@ -114,4 +139,28 @@ export default class JSONFormatter extends applyMixins( return params; } + + /** + * Fetches text item data via API. + * Skips the fetch request if projects field is not specified in config. + * + * @returns text items data + */ + private async fetchTextItems() { + if (!this.projectConfig.projects && !this.output.projects) return []; + + return await fetchText(this.generateQueryParams("textItem")); + } + + /** + * Fetches component data via API. + * Skips the fetch request if components field is not specified in config. + * + * @returns components data + */ + private async fetchComponents() { + if (!this.projectConfig.components && !this.output.components) return []; + + return await fetchComponents(this.generateQueryParams("component")); + } } diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index 09fc6d2..b3d4484 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -4,16 +4,27 @@ import logger from "../../utils/logger"; import { ProjectConfigYAML } from "../../services/projectConfig"; import OutputFile from "./fileTypes/OutputFile"; import appContext from "../../utils/appContext"; +import JSONOutputFile from "./fileTypes/JSONOutputFile"; export default class BaseFormatter { protected output: Output; protected projectConfig: ProjectConfigYAML; protected outDir: string; + protected outputJsonFiles: Record< + string, + JSONOutputFile<{ variantId: string }> + >; + protected variablesOutputFile: JSONOutputFile; constructor(output: Output, projectConfig: ProjectConfigYAML) { this.output = output; this.projectConfig = projectConfig; this.outDir = output.outDir ?? appContext.outDir; + this.outputJsonFiles = {}; + this.variablesOutputFile = new JSONOutputFile({ + filename: "variables", + path: this.outDir, + }); } protected async fetchAPIData(): Promise { diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts new file mode 100644 index 0000000..25229e3 --- /dev/null +++ b/lib/src/http/components.test.ts @@ -0,0 +1,67 @@ +import httpClient from "./client"; +import fetchComponents from "./components"; + +jest.mock("./client"); + +describe("fetchComponents", () => { + const mockHttpClient = httpClient as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("richText parameter", () => { + it("should parse response with richText field correctly", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text", + richText: "

Rich HTML text

", + status: "active", + notes: "Test note", + tags: ["tag1"], + variableIds: ["var1"], + folderId: null, + variantId: "variant1", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchComponents({ + filter: "", + richText: "html", + }); + + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle response without richText field", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text only", + status: "active", + notes: "", + tags: [], + variableIds: [], + folderId: null, + variantId: null, + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchComponents({ + filter: "", + richText: "html", + }); + + expect(result).toEqual([...mockResponse.data]); + }); + }); +}); diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts new file mode 100644 index 0000000..c603b7c --- /dev/null +++ b/lib/src/http/components.ts @@ -0,0 +1,33 @@ +import { AxiosError } from "axios"; +import { ZComponentsResponse, PullQueryParams } from "./types"; +import httpClient from "./client"; + +export default async function fetchComponents(params: PullQueryParams) { + try { + const response = await httpClient.get("/v2/components", { params }); + + return ZComponentsResponse.parse(response.data); + } catch (e) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + + // Handle invalid filters + if (e.response?.status === 400) { + let errorMsgBase = "Invalid component filters"; + + if (e.response?.data?.message) errorMsgBase = e.response.data.message; + + throw new Error( + `${errorMsgBase}. Please check your component filters and try again.`, + { + cause: e.response?.data, + } + ); + } + + throw e; + } +} \ No newline at end of file diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts index aad3b53..37f19da 100644 --- a/lib/src/http/textItems.test.ts +++ b/lib/src/http/textItems.test.ts @@ -31,6 +31,7 @@ describe("fetchText", () => { mockHttpClient.get.mockResolvedValue(mockResponse); const result = await fetchText({ + filter: "", richText: "html", }); @@ -68,6 +69,7 @@ describe("fetchText", () => { mockHttpClient.get.mockResolvedValue(mockResponse); const result = await fetchText({ + filter: "", richText: "html", }); diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 173fc5b..d022922 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -1,43 +1,12 @@ import httpClient from "./client"; import { AxiosError } from "axios"; -import { z } from "zod"; - -export interface PullFilters { - projects?: { id: string }[]; - variants?: { id: string }[]; -} - -export interface PullQueryParams { - filter: string; // Stringified PullFilters - richText?: "html"; -} - -const ZTextItem = z.object({ - id: z.string(), - text: z.string(), - richText: z.string().optional(), - status: z.string(), - notes: z.string(), - tags: z.array(z.string()), - variableIds: z.array(z.string()), - projectId: z.string(), - variantId: z.string().nullable(), -}); - -/** - * Represents a single text item, as returned from the /v2/textItems endpoint - */ -export type TextItem = z.infer; - -const TextItemsResponse = z.array(ZTextItem); - -export type TextItemsResponse = z.infer; +import { PullQueryParams, ZTextItemsResponse } from "./types"; export default async function fetchText(params: PullQueryParams) { try { const response = await httpClient.get("/v2/textItems", { params }); - return TextItemsResponse.parse(response.data); + return ZTextItemsResponse.parse(response.data); } catch (e: unknown) { if (!(e instanceof AxiosError)) { throw new Error( @@ -47,8 +16,15 @@ export default async function fetchText(params: PullQueryParams) { // Handle invalid filters if (e.response?.status === 400) { + let errorMsgBase = "Invalid project filters"; + + if (e.response?.data?.message) errorMsgBase = e.response.data.message; + throw new Error( - "Invalid filters. Please check your filters and try again." + `${errorMsgBase}. Please check your project filters and try again.`, + { + cause: e.response?.data, + } ); } diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts new file mode 100644 index 0000000..78886e1 --- /dev/null +++ b/lib/src/http/types.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +export interface PullFilters { + projects?: { id: string }[] | false; + folders?: { + id: string; + excludeNestedFolders?: boolean; + }[]; + variants?: { id: string }[]; +} + +export interface PullQueryParams { + filter: string; // Stringified PullFilters + richText?: "html"; +} + +const ZBaseTextEntity = z.object({ + id: z.string(), + text: z.string(), + richText: z.string().optional(), + status: z.string(), + notes: z.string(), + tags: z.array(z.string()), + variableIds: z.array(z.string()), + variantId: z.string().nullable(), +}) + +const ZTextItem = ZBaseTextEntity.extend({ + projectId: z.string(), +}) + +export function isTextItem(item: TextItem | Component): item is TextItem { + return "projectId" in item; +} + +/** + * Represents a single text item, as returned from the /v2/textItems endpoint + */ +export type TextItem = z.infer; + +export const ZTextItemsResponse = z.array(ZTextItem); +export type TextItemsResponse = z.infer; + +// MARK - Components + +const ZComponent = ZBaseTextEntity.extend({ + folderId: z.string().nullable(), +}) + +/** + * Represents a single component, as returned from the /v2/components endpoint + */ +export type Component = z.infer; + +export const ZComponentsResponse = z.array(ZComponent); +export type ComponentsResponse = z.infer; diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index c044d5a..cd23afa 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -1,11 +1,17 @@ import { z } from "zod"; /** - * These filters that are common to all outputs, used to filter the text items that are fetched from the API. - * They are all optional by defualt unless otherwise specified in the output config. + * These filters that are common to all outputs, used to filter the text items and components that are fetched from the API. + * They are all optional by default unless otherwise specified in the output config. */ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), + components: z.object({ + folders: z.array(z.object({ + id: z.string(), + excludeNestedFolders: z.boolean().optional(), + })).optional(), + }).optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), richText: z.union([z.literal("html"), z.literal(false)]).optional(), diff --git a/lib/src/services/projectConfig.ts b/lib/src/services/projectConfig.ts index d18fe9d..eef4bec 100644 --- a/lib/src/services/projectConfig.ts +++ b/lib/src/services/projectConfig.ts @@ -16,6 +16,9 @@ export type ProjectConfigYAML = z.infer; export const DEFAULT_PROJECT_CONFIG_JSON: ProjectConfigYAML = { projects: [], variants: [], + components: { + folders: [], + }, outputs: [ { format: "json", diff --git a/package.json b/package.json index 5816c4d..168dbbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.7", + "version": "5.0.0-beta.8", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From b174397901a2c71b9bc3387e5546b7dc5d6d1041 Mon Sep 17 00:00:00 2001 From: Jerome White Date: Tue, 30 Sep 2025 02:16:32 -0400 Subject: [PATCH 50/52] update readme to show default components object --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 81f45d0..3c4e53f 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ The default file looks like this: ```yml projects: [], +components: { + folders: [] +}, variants: [], outputs: - format: json, From 664586e04d22f14a721a358a69c229e78a341ef6 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Wed, 1 Oct 2025 12:23:45 -0400 Subject: [PATCH 51/52] Update README --- .DS_Store | Bin 0 -> 8196 bytes README.md | 87 ++++++++++-------------------------------------------- 2 files changed, 15 insertions(+), 72 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c43b10d8a5c74df96c01addeff77bdbd71f454d8 GIT binary patch literal 8196 zcmeHM&ubGw6n@i0lc0w{5n@qTym|0PQM`x{OX^vu_m*UBBHd&Qu?KtFf1oFOvGmY? zK=39MJXQ2i5vv~R(R12Emv3Hn^EO1J7B<3pq6QIF zaj>nQLzmOo&qt=s*paKS3iB!I&M$T1{(1)K2fPAa0k42pz$@St_+J#jJ)2v#;J$CX zzV`}v1x}>`e0>OTux(nK7^^QG=yVAHTgI?%IIr^wY+!BE+Qe9R(3F`9Yo;n&Vkk2m zTk)o2;7 zKE>1llOexz+0dPQl;m`9W28^cdQzjwNSB|!x|XPoY#BJ5YHLT}WId9{hij#m`^S2p zM&4AFtk;QGORy-Np^&o^uQ(ibqIMidd>0VI4i#!J72%b1wjOQ&7%b}4br30~e1t=b z9x|;GMWE%TZRz&M)Fa~_9n=r6oLCw3Jh=r~uM@8laJ=(0KuU!4*d|AaRE_X%C_jCD zcJtyStmL%4`Qq0b^EED9TeAEcyyQLmc8_`2uyJ6CeieFU7(>XDe3axm_erF4Wr>xfqdmF842%H^|yV7_?;rlUZ~glTa7zgYbI ze>!A*6t93+;GZa<%B`i=0z&hzs=UUvc7S6A2RFtg#_B2PbU6;I%W>GzABH#&0OdJN ZYZGHJf;ROB0iq1?_kX Date: Wed, 1 Oct 2025 12:32:58 -0400 Subject: [PATCH 52/52] Set version to 5.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 168dbbc..f69cb84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.8", + "version": "5.0.0", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js",