diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..c43b10d Binary files /dev/null and b/.DS_Store differ 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/README.md b/README.md index e63b542..c298013 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,21 @@ # Ditto CLI -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. You can use the CLI to import Ditto data directly into your own codebase. + +The CLI is configured to fetch text from the latest version of Ditto by default. However, all legacy features are still fully supported by passing a `--legacy` flag along with the legacy CLI command. See [Legacy Setup](#legacy-setup) for details on using the CLI with older Ditto projects and components. [![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](http://developer.dittowords.com/cli-reference/authentication). +- [Documentation](https://developer.dittowords.com/cli-reference/authentication) +- [Changelog](https://developer.dittowords.com/feedback-support/changelog) ## 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) +- [What is Ditto?](https://developer.dittowords.com/introduction) ## Installation @@ -24,7 +27,7 @@ It's recommended to install the CLI as a development dependency to ensure your w ## 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). +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/developers/api-keys). See the [Authentication](http://developer.dittowords.com/api-reference/authentication) page for more information on API keys. @@ -37,25 +40,29 @@ 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: [], +components: { + folders: [] +}, +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). +For more information on configuring the CLI, see [this documentation section](https://developer.dittowords.com/cli-reference/configuration). ## Usage ```bash -npx @dittowords/cli +npx @dittowords/cli pull ``` 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. +String files are written to the specified 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 [http://developer.dittowords.com/cli-reference/files](http://developer.dittowords.com/cli-reference/files). +For more information on how files are written to disk, see [this documentation section](https://developer.dittowords.com/cli-reference/files). See our demo projects for examples of how to integrate the Ditto CLI in different environments: @@ -63,6 +70,10 @@ See our demo projects for examples of how to integrate the Ditto CLI in differen - [iOS mobile app](https://github.com/dittowords/ditto-react-demo) - [Android mobile app](https://github.com/dittowords/ditto-react-demo) +## Legacy Setup + +Beginning with `v5.0.0`, the Ditto CLI points at the new Ditto experience by default. To run the CLI compatible with legacy Ditto, append the `--legacy` flag to any legacy command, and the CLI will work as it did in the `4.x` version. All existing legacy commands remain fully functional at this time. + ## Feedback Have feedback? We’d love to hear it! Message us at [support@dittowords.com](mailto:support@dittowords.com). diff --git a/esbuild.mjs b/esbuild.mjs new file mode 100644 index 0000000..383e680 --- /dev/null +++ b/esbuild.mjs @@ -0,0 +1,49 @@ +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 of KEYS_TO_DEFINE) { + define[`process.env.${k}`] = JSON.stringify(process.env[k]); + } +} + +/** + * @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", + define, +}; + +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/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/ditto.ts b/lib/ditto.ts index b64b0f9..5f3cc54 100755 --- a/lib/ditto.ts +++ b/lib/ditto.ts @@ -1,285 +1,28 @@ #!/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 logger from "./src/utils/logger"; +// 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( + logger.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..8291766 --- /dev/null +++ b/lib/legacy/index.ts @@ -0,0 +1,269 @@ +#!/usr/bin/env node +// This is the main entry point for the legacy ditto-cli command. +import { program } from "commander"; +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 { version } from "../../package.json"; + +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 99% rename from lib/init/token.ts rename to lib/legacy/init/token.ts index f996093..c5e1f9c 100644 --- a/lib/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/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 98% rename from lib/utils/promptForProject.ts rename to lib/legacy/utils/promptForProject.ts index 5c8a650..fc45e73 100644 --- a/lib/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/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.test.ts b/lib/src/commands/pull.test.ts new file mode 100644 index 0000000..c8a8589 --- /dev/null +++ b/lib/src/commands/pull.test.ts @@ -0,0 +1,588 @@ +import { pull } from "./pull"; +import httpClient from "../http/client"; +import { Component, TextItem } from "../http/types"; +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: Partial = {}) => ({ + id: "text-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project-1", + variantId: null, + ...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", + type: "string", + data: { + example: "variable value", + fallback: undefined, + }, + ...overrides, +}); + +// Helper functions +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 }); + } + if (url.includes("/v2/variables")) { + return Promise.resolve({ data: variables }); + } + if (url.includes("/v2/components")) { + return Promise.resolve({ data: components }); + } + return Promise.resolve({ data: [] }); + }); +}; + +const parseJsonFile = (filepath: string) => { + const content = fs.readFileSync(filepath, "utf-8"); + return JSON.parse(content); +}; + +const assertFileContainsText = ( + filepath: string, + devId: string, + expectedText: string +) => { + const content = parseJsonFile(filepath); + expect(content[devId]).toBe(expectedText); +}; + +const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { + const actualFiles = fs.readdirSync(outputDir).toSorted(); + expect(actualFiles).toEqual(expectedFiles.toSorted()); +}; + +describe("pull command - end-to-end tests", () => { + // Create a temporary directory for 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"); + + // Reset appContext to a clean state + appContext.setProjectConfig({ + projects: [], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + }); + + // 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 () => { + // Only create output directory since we're mocking HTTP and setting appContext directly + fs.mkdirSync(outputDir, { recursive: true }); + + const mockTextItem = createMockTextItem(); + 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 }], + }); + + await pull(); + + // Verify rich text content was written + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "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(); + const mockComponent = createMockComponent(); + setupMocks({ textItems: [mockTextItem], components: [mockComponent] }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + richText: "html", + components: {}, + 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" + ); + + 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({ textItems: [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 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 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 }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }, { id: "project-2" }], + 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"}]}', + }, + }); + }); + + it("should filter variants at output 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, + 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"}]}', + }, + }); + }); + + it("supports the default filter behavior", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + projects: [], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + 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) + }); + }); + + describe("Output files", () => { + 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({ + 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", + }), + ]; + + 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(); + + // 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", + "components___base.json", + "components___variant-a.json", + "components___variant-b.json", + "variables.json", + ]); + }); + }); +}); diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts new file mode 100644 index 0000000..031a40f --- /dev/null +++ b/lib/src/commands/pull.ts @@ -0,0 +1,8 @@ +import appContext from "../utils/appContext"; +import formatOutput from "../formatters"; + +export const pull = async () => { + for (const output of appContext.selectedProjectConfigOutputs) { + await formatOutput(output, appContext.projectConfig); + } +}; diff --git a/lib/src/formatters/frameworks/json/base.ts b/lib/src/formatters/frameworks/json/base.ts new file mode 100644 index 0000000..df62aa8 --- /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.outDir; + } + + get framework() { + return this.output.framework; + } + + process(...args: any[]): OutputFile[] { + throw new Error("Not implemented"); + } +} diff --git a/lib/src/formatters/frameworks/json/i18next.ts b/lib/src/formatters/frameworks/json/i18next.ts new file mode 100644 index 0000000..8f785cd --- /dev/null +++ b/lib/src/formatters/frameworks/json/i18next.ts @@ -0,0 +1,123 @@ +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> + ) { + 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/formatters/frameworks/json/index.ts b/lib/src/formatters/frameworks/json/index.ts new file mode 100644 index 0000000..15b138e --- /dev/null +++ b/lib/src/formatters/frameworks/json/index.ts @@ -0,0 +1,18 @@ +import I18NextFramework from "./i18next"; +import { Output } from "../../../outputs"; +import VueI18nFramework from "./vue-i18n"; + +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); + 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/formatters/index.ts b/lib/src/formatters/index.ts new file mode 100644 index 0000000..a2536fb --- /dev/null +++ b/lib/src/formatters/index.ts @@ -0,0 +1,15 @@ +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(); + 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..ec312bd --- /dev/null +++ b/lib/src/formatters/json.ts @@ -0,0 +1,166 @@ +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"; +import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; +import { applyMixins } from "./shared"; +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 textItems = await this.fetchTextItems(); + const components = await this.fetchComponents(); + const variables = await fetchVariables(); + + const variablesById = variables.reduce((acc, variable) => { + acc[variable.id] = variable; + return acc; + }, {} as Record); + + return { textItems, variablesById, components }; + } + + protected async transformAPIData(data: JSONAPIData) { + for (let i = 0; i < data.textItems.length; i++) { + const textItem = data.textItems[i]; + 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(this.outputJsonFiles), + this.variablesOutputFile, + ] + + if (this.output.framework) { + // process framework + results.push(...getFrameworkProcessor(this.output).process(this.outputJsonFiles)); + } + + return results; + } + + /** + * 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; + } + + return filters; + } + + /** + * Returns the query parameters for the fetchText API request + */ + private generateQueryParams(requestType: RequestType) { + const filter = requestType === "textItem" ? this.generateTextItemPullFilter() : this.generateComponentPullFilter(); + + let params: PullQueryParams = { + filter: JSON.stringify(filter), + }; + + if (this.projectConfig.richText) { + params.richText = this.projectConfig.richText; + } + + if (this.output.richText) { + params.richText = this.output.richText; + } + + 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/mixins/javascriptCodegenMixin.ts b/lib/src/formatters/mixins/javascriptCodegenMixin.ts new file mode 100644 index 0000000..f2028ef --- /dev/null +++ b/lib/src/formatters/mixins/javascriptCodegenMixin.ts @@ -0,0 +1,103 @@ +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, "_"); + } + + /** + * 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}`; + } + return m.name; + }) + .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 new file mode 100644 index 0000000..b3d4484 --- /dev/null +++ b/lib/src/formatters/shared/base.ts @@ -0,0 +1,55 @@ +import { Output } from "../../outputs"; +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"; +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 { + return {} as APIDataType; + } + + protected async transformAPIData(data: APIDataType): Promise { + return []; + } + + async format(): 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/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 new file mode 100644 index 0000000..37f19da --- /dev/null +++ b/lib/src/http/textItems.test.ts @@ -0,0 +1,91 @@ +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({ + filter: "", + 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({ + filter: "", + 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 diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts new file mode 100644 index 0000000..d022922 --- /dev/null +++ b/lib/src/http/textItems.ts @@ -0,0 +1,33 @@ +import httpClient from "./client"; +import { AxiosError } from "axios"; +import { PullQueryParams, ZTextItemsResponse } from "./types"; + +export default async function fetchText(params: PullQueryParams) { + try { + const response = await httpClient.get("/v2/textItems", { params }); + + return ZTextItemsResponse.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) { + let errorMsgBase = "Invalid project filters"; + + if (e.response?.data?.message) errorMsgBase = e.response.data.message; + + throw new Error( + `${errorMsgBase}. Please check your project filters and try again.`, + { + cause: e.response?.data, + } + ); + } + + throw e; + } +} 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/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 new file mode 100644 index 0000000..7fa5282 --- /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 * 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/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"; + +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", + 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.", + }, + }, + }, +]; + +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); + } + } + ); + } + }); +}; + +const setupOptions = () => { + program.option("--legacy", "Run in legacy mode"); + program.version(version, "-v, --version", "Output the current version"); +}; + +const executeCommand = async ( + commandName: Command | "none", + command: commander.Command +): Promise => { + try { + const options = command.opts(); + const token = await initAPIToken(); + appContext.setApiToken(token); + + await initProjectConfig(options); + + switch (commandName) { + case "none": + case "pull": { + return await pull(); + } + default: { + await quit(`Invalid command: ${commandName}. Exiting Ditto CLI...`); + return; + } + } + } catch (error) { + if (process.env.DEBUG === "true") { + console.error(logger.info("Development stack trace:\n"), error); + } + + 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); + } +}; + +const appEntry = async () => { + setupCommands(); + setupOptions(); + + program.parse(process.argv); +}; + +export default appEntry; 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..3bc061c --- /dev/null +++ b/lib/src/outputs/json.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +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(); + +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/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts new file mode 100644 index 0000000..cd23afa --- /dev/null +++ b/lib/src/outputs/shared.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +/** + * 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/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..54a5785 --- /dev/null +++ b/lib/src/services/apiToken/collectToken.test.ts @@ -0,0 +1,34 @@ +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.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..045951f --- /dev/null +++ b/lib/src/services/apiToken/collectToken.ts @@ -0,0 +1,16 @@ +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/developers/api-keys"); + const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl}.`; + + 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..57aaca0 --- /dev/null +++ b/lib/src/services/apiToken/initAPIToken.test.ts @@ -0,0 +1,121 @@ +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"; +import appContext from "../../utils/appContext"; + +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; + let priorToken: string | undefined; + + beforeEach(() => { + priorToken = appContext.apiToken; + appContext.setApiToken(""); + + 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(() => { + appContext.setApiToken(priorToken); + jest.restoreAllMocks(); + }); + + it("should validate and return the token if provided", async () => { + appContext.setApiToken("validToken"); + const response = await initAPIToken(); + 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(); + expect(response).toBe("newToken"); + expect(validateTokenSpy).not.toHaveBeenCalled(); + expect(collectAndSaveTokenSpy).toHaveBeenCalled(); + expect(existsSyncSpy).toHaveBeenCalledWith(appContext.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(appContext.configFile); + expect(readGlobalConfigDataSpy).toHaveBeenCalledWith( + appContext.configFile + ); + expect(getURLHostnameSpy).toHaveBeenCalledWith(appContext.apiHost); + expect(collectAndSaveTokenSpy).toHaveBeenCalledWith("urlHostname"); + }; + + it("config[host] does not exist", async () => { + existsSyncSpy.mockReturnValue(true); + const configData = {}; + readGlobalConfigDataSpy.mockReturnValue(configData); + const response = await initAPIToken(); + 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(); + 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(); + 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(); + expect(response).toBe("myToken"); + expect(validateTokenSpy).toHaveBeenCalledWith("myToken"); + 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 new file mode 100644 index 0000000..30a7638 --- /dev/null +++ b/lib/src/services/apiToken/initAPIToken.ts @@ -0,0 +1,33 @@ +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 based on the appContext and config file. + * @returns The initialized API token + */ +export default async function initAPIToken() { + if (appContext.apiToken) { + return await validateToken(appContext.apiToken); + } + + if (!fs.existsSync(appContext.configFile)) { + return await collectAndSaveToken(); + } + + const configData = configService.readGlobalConfigData(appContext.configFile); + const sanitizedHost = getURLHostname(appContext.apiHost); + + 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..7e6bedf --- /dev/null +++ b/lib/src/services/apiToken/promptForAPIToken.test.ts @@ -0,0 +1,66 @@ +import Enquirer from "enquirer"; +import * as CheckToken from "../../http/checkToken"; +import promptForApiToken, { validate } from "./promptForApiToken"; + +describe("promptForApiToken", () => { + const mockResponse = { token: "mockToken" }; + let promptSpy: jest.SpiedFunction; + + beforeEach(() => { + promptSpy = jest.spyOn(Enquirer, "prompt").mockResolvedValue(mockResponse); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should prompt for API token and return it", async () => { + const response = await promptForApiToken(); + expect(response).toEqual(mockResponse); + expect(promptSpy).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; +} 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..eef4bec --- /dev/null +++ b/lib/src/services/projectConfig.ts @@ -0,0 +1,71 @@ +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"; +import DittoError, { ErrorType } from "../utils/DittoError"; + +const ZProjectConfigYAML = ZBaseOutputFilters.extend({ + outputs: z.array(ZOutput), +}).strict(); + +export type ProjectConfigYAML = z.infer; + +export const DEFAULT_PROJECT_CONFIG_JSON: ProjectConfigYAML = { + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "json", + framework: "i18next", + }, + ], +}; + +export async function initProjectConfig(options: { config?: string }) { + const projectConfig = readProjectConfigData(options.config); + 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 { + 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 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; +} diff --git a/lib/src/utils/appContext.ts b/lib/src/utils/appContext.ts new file mode 100644 index 0000000..4093fef --- /dev/null +++ b/lib/src/utils/appContext.ts @@ -0,0 +1,97 @@ +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; + #outDir: string; + 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; + this.#outDir = process.env.DITTO_OUT_DIR || this.projectConfigDir; + } + + 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; + } + + get outDir() { + return this.projectConfig.outDir || this.#outDir; + } +} + +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..21e0e92 --- /dev/null +++ b/lib/src/utils/fileSystem.ts @@ -0,0 +1,70 @@ +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) { + 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"); +} + +export const ensureEndsWithNewLine = (str: string) => + str + (/[\r\n]$/.test(str) ? "" : "\n"); diff --git a/lib/src/utils/logger.ts b/lib/src/utils/logger.ts new file mode 100644 index 0000000..e75995b --- /dev/null +++ b/lib/src/utils/logger.ts @@ -0,0 +1,23 @@ +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 bold = (msg: string) => chalk.bold(msg); +export const writeLine = (msg: string) => console.log(msg); + +export default { + errorText, + warnText, + info, + success, + url, + subtle, + write, + 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 new file mode 100644 index 0000000..07d4d07 --- /dev/null +++ b/lib/src/utils/quit.ts @@ -0,0 +1,8 @@ +import * as Sentry from "@sentry/node"; +import logger from "./logger"; + +export async function quit(message: string | null, exitCode = 2) { + if (message) logger.writeLine(`\n${message}\n`); + await Sentry.flush(); + process.exit(exitCode); +} diff --git a/package.json b/package.json index 77290c1..f69cb84 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,14 @@ { "name": "@dittowords/cli", - "version": "4.5.2", + "version": "5.0.0", "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 --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", @@ -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", @@ -73,7 +79,7 @@ "js-yaml": "^4.1.0", "memfs": "^4.7.7", "ora": "^5.0.0", - "v8-compile-cache": "^2.1.1" + "zod": "^3.24.2" }, "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..7094618 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" @@ -4696,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==