diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 52b5c8a..65b7e5b 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -10,7 +10,7 @@ inputs: runs: using: "composite" steps: - - uses: actions/cache@v3 + - uses: actions/cache@v4 env: cache-name: node_modules-cache with: diff --git a/.github/workflows/required-checks.yml b/.github/workflows/required-checks.yml index 0c1fe59..b827de6 100644 --- a/.github/workflows/required-checks.yml +++ b/.github/workflows/required-checks.yml @@ -1,23 +1,23 @@ name: "Required Checks" -on: - pull_request: - branches: - - master +on: pull_request jobs: jest-tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 timeout-minutes: 5 strategy: fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 + - uses: ./.github/actions/install-node-dependencies + - name: Jest Tests run: | npx jest --ci --silent --maxWorkers=1 diff --git a/lib/ditto.ts b/lib/ditto.ts index c1d72cf..5f3cc54 100755 --- a/lib/ditto.ts +++ b/lib/ditto.ts @@ -4,7 +4,7 @@ import * as Sentry from "@sentry/node"; import { version as release } from "../package.json"; import legacyAppEntry from "./legacy"; import appEntry from "./src"; -import output from "./src/output"; +import logger from "./src/utils/logger"; // Initialize Sentry const environment = process.env.ENV || "development"; @@ -14,7 +14,7 @@ const main = async () => { // Check for --legacy flag and run in legacy mode if present if (process.argv.includes("--legacy")) { console.log( - output.warnText( + logger.warnText( "\nDitto CLI is running in legacy mode. This mode is deprecated and will be removed in a future release.\n" ) ); diff --git a/lib/legacy/init/token.ts b/lib/legacy/init/token.ts index f996093..c5e1f9c 100644 --- a/lib/legacy/init/token.ts +++ b/lib/legacy/init/token.ts @@ -126,9 +126,8 @@ export const collectAndSaveToken = async (message: string | null = null) => { console.log( `Thanks for authenticating. We'll save the key to: ${output.info( consts.CONFIG_FILE - )}` + )}\n` ); - output.nl(); config.saveToken(consts.CONFIG_FILE, consts.API_HOST, token); return token; diff --git a/lib/legacy/utils/promptForProject.ts b/lib/legacy/utils/promptForProject.ts index 5c8a650..fc45e73 100644 --- a/lib/legacy/utils/promptForProject.ts +++ b/lib/legacy/utils/promptForProject.ts @@ -35,7 +35,7 @@ const promptForProject = async ({ projects, limit = 10, }: ProjectPromptParams) => { - output.nl(); + output.write("\n"); const choices = projects.map(formatProjectChoice); const prompt = new AutoComplete({ diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts index 7bee53f..031a40f 100644 --- a/lib/src/commands/pull.ts +++ b/lib/src/commands/pull.ts @@ -1,3 +1,8 @@ +import appContext from "../utils/appContext"; +import formatOutput from "../formatters"; + export const pull = async () => { - console.log("pull"); + for (const output of appContext.selectedProjectConfigOutputs) { + await formatOutput(output, appContext.projectConfig); + } }; diff --git a/lib/src/formatters/frameworks/base.ts b/lib/src/formatters/frameworks/base.ts new file mode 100644 index 0000000..c8c13ce --- /dev/null +++ b/lib/src/formatters/frameworks/base.ts @@ -0,0 +1,13 @@ +import OutputFile from "../shared/fileTypes/OutputFile"; + +export default class BaseFramework { + protected format: string; + + constructor(format: string) { + this.format = format; + } + + process(...args: any[]): OutputFile[] { + throw new Error("Not implemented"); + } +} diff --git a/lib/src/formatters/frameworks/i18next.ts b/lib/src/formatters/frameworks/i18next.ts new file mode 100644 index 0000000..749ae6a --- /dev/null +++ b/lib/src/formatters/frameworks/i18next.ts @@ -0,0 +1,94 @@ +import appContext from "../../utils/appContext"; +import JavascriptOutputFile from "../shared/fileTypes/JavascriptOutputFile"; +import OutputFile from "../shared/fileTypes/OutputFile"; +import { applyMixins } from "../shared"; +import javascriptCodegenMixin from "../mixins/javascriptCodegenMixin"; +import JSONOutputFile from "../shared/fileTypes/JSONOutputFile"; +import BaseFramework from "./base"; + +export default class I18NextFramework extends applyMixins( + BaseFramework, + javascriptCodegenMixin +) { + process( + outputJsonFiles: Record> + ) { + const outputDir = appContext.projectConfigDir; + // Generate Driver file + + const driverFile = new JavascriptOutputFile({ + filename: "index", + path: outputDir, + }); + + const filesGroupedByVariantId = Object.values(outputJsonFiles).reduce( + (acc, file) => { + const variantId = file.metadata.variantId; + acc[variantId] ??= []; + acc[variantId].push(file); + return acc; + }, + {} as Record + ); + + driverFile.content += this.generateImportStatements(outputJsonFiles); + + driverFile.content += `\n`; + + driverFile.content += this.generateDefaultExportString( + filesGroupedByVariantId + ); + + return [driverFile]; + } + + /** + * Generates the import statements for the driver file. One import per generated json file. + * @param outputJsonFiles - The output json files. + * @returns The import statements, stringified. + */ + private generateImportStatements( + outputJsonFiles: Record> + ) { + let importStatements = ""; + for (const file of Object.values(outputJsonFiles)) { + importStatements += this.codegenDefaultImport( + this.sanitizeStringForJSVariableName(file.filename), + `./${file.filenameWithExtension}` + ); + } + return importStatements; + } + + /** + * Generates the default export for the driver file. By default this is an object with the json imports grouped by variant id. + * @param filesGroupedByVariantId - The files grouped by variant id. + * @returns The default export, stringified. + */ + private generateDefaultExportString( + filesGroupedByVariantId: Record + ) { + const variantIds = Object.keys(filesGroupedByVariantId); + + let defaultExportObjectString = "{\n"; + + for (let i = 0; i < variantIds.length; i++) { + const variantId = variantIds[i]; + const files = filesGroupedByVariantId[variantId]; + + defaultExportObjectString += `${this.codegenPad(1)}"${variantId}": {\n`; + for (const file of files) { + defaultExportObjectString += `${this.codegenPad( + 2 + )}...${this.sanitizeStringForJSVariableName(file.filename)},\n`; + } + defaultExportObjectString += `${this.codegenPad(1)}}${ + i < variantIds.length - 1 ? `,\n` : `\n` + }`; + } + + defaultExportObjectString += `}`; + + return this.codegenDefaultExport(defaultExportObjectString); + } +} diff --git a/lib/src/formatters/frameworks/index.ts b/lib/src/formatters/frameworks/index.ts new file mode 100644 index 0000000..d85bad8 --- /dev/null +++ b/lib/src/formatters/frameworks/index.ts @@ -0,0 +1,10 @@ +import I18NextFramework from "./i18next"; + +export function getFrameworkProcessor(framework: string) { + switch (framework) { + case "i18next": + return new I18NextFramework(framework); + default: + throw new Error(`Unsupported framework: ${framework}`); + } +} diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts new file mode 100644 index 0000000..72e8438 --- /dev/null +++ b/lib/src/formatters/index.ts @@ -0,0 +1,18 @@ +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import JSONFormatter from "./json"; + +export default function handleOutput( + output: Output, + projectConfig: ProjectConfigYAML +) { + switch (output.format) { + case "json": + return new JSONFormatter(output, projectConfig).format( + output, + projectConfig + ); + default: + throw new Error(`Unsupported output format: ${output}`); + } +} diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts new file mode 100644 index 0000000..aa8f38b --- /dev/null +++ b/lib/src/formatters/json.ts @@ -0,0 +1,91 @@ +import fetchText, { PullFilters, TextItemsResponse } from "../http/textItems"; +import fetchVariables, { Variable, VariablesResponse } from "../http/variables"; +import BaseFormatter from "./shared/base"; +import OutputFile from "./shared/fileTypes/OutputFile"; +import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; +import appContext from "../utils/appContext"; +import { applyMixins } from "./shared"; +import { getFrameworkProcessor } from "./frameworks"; + +type JSONAPIData = { + textItems: TextItemsResponse; + variablesById: Record; +}; + +export default class JSONFormatter extends applyMixins( + BaseFormatter) { + + protected async fetchAPIData() { + const filters = this.generatePullFilter(); + const textItems = await fetchText(filters); + const variables = await fetchVariables(); + + const variablesById = variables.reduce((acc, variable) => { + acc[variable.id] = variable; + return acc; + }, {} as Record); + + return { textItems, variablesById }; + } + + protected async transformAPIData(data: JSONAPIData) { + const outputDir = appContext.projectConfigDir; + + let outputJsonFiles: Record< + string, + JSONOutputFile<{ variantId: string }> + > = {}; + + const variablesOutputFile = new JSONOutputFile({ + filename: "variables", + path: appContext.projectConfigDir, + }); + + for (let i = 0; i < data.textItems.length; i++) { + const textItem = data.textItems[i]; + + const fileName = `${textItem.projectId}___${textItem.variantId || "base"}`; + + outputJsonFiles[fileName] ??= new JSONOutputFile({ + filename: fileName, + path: outputDir, + metadata: { variantId: textItem.variantId || "base" }, + }); + + + outputJsonFiles[fileName].content[textItem.id] = textItem.text; + for (const variableId of textItem.variableIds) { + const variable = data.variablesById[variableId]; + variablesOutputFile.content[variableId] = variable.data; + } + } + + let results: OutputFile[] = [ + ...Object.values(outputJsonFiles), + variablesOutputFile, + ] + + if (this.output.framework) { + // process framework + results.push(...getFrameworkProcessor(this.output.framework).process(outputJsonFiles)); + } + + return results; + } + + private generatePullFilter() { + let filters: PullFilters = { + projects: this.projectConfig.projects, + variants: this.projectConfig.variants, + }; + if (this.output.projects) { + filters.projects = this.output.projects; + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } +} diff --git a/lib/src/formatters/mixins/javascriptCodegenMixin.ts b/lib/src/formatters/mixins/javascriptCodegenMixin.ts new file mode 100644 index 0000000..f453c1e --- /dev/null +++ b/lib/src/formatters/mixins/javascriptCodegenMixin.ts @@ -0,0 +1,44 @@ +import { Constructor } from "../shared"; + +interface NamedImport { + name: string; + alias?: string; +} + +export default function javascriptCodegenMixin( + Base: TBase +) { + return class JavascriptCodegenHelpers extends Base { + protected indentSpaces: number = 2; + + protected sanitizeStringForJSVariableName(str: string) { + return str.replace(/[^a-zA-Z0-9]/g, "_"); + } + + protected codegenNamedImport(modules: NamedImport[], moduleName: string) { + const formattedModules = modules + .map((m) => { + if (m.alias) { + return `${m.name} as ${m.alias}`; + } + return m.name; + }) + .sort() + .join(", "); + + return `import { ${formattedModules} } from "${moduleName}";\n`; + } + + protected codegenDefaultImport(module: string, moduleName: string) { + return `import ${module} from "${moduleName}";\n`; + } + + protected codegenDefaultExport(module: string) { + return `export default ${module};`; + } + + protected codegenPad(depth: number) { + return " ".repeat(depth * this.indentSpaces); + } + }; +} diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts new file mode 100644 index 0000000..264a75b --- /dev/null +++ b/lib/src/formatters/shared/base.ts @@ -0,0 +1,44 @@ +import { Output } from "../../outputs"; +import { writeFile } from "../../utils/fileSystem"; +import logger from "../../utils/logger"; +import { ProjectConfigYAML } from "../../services/projectConfig"; +import OutputFile from "./fileTypes/OutputFile"; + +export default class BaseFormatter { + protected output: Output; + protected projectConfig: ProjectConfigYAML; + + constructor(output: Output, projectConfig: ProjectConfigYAML) { + this.output = output; + this.projectConfig = projectConfig; + } + + protected async fetchAPIData(): Promise { + return {} as APIDataType; + } + + protected async transformAPIData(data: APIDataType): Promise { + return []; + } + + async format( + output: Output, + projectConfig: ProjectConfigYAML + ): Promise { + const data = await this.fetchAPIData(); + const files = await this.transformAPIData(data); + await this.writeFiles(files); + } + + private async writeFiles(files: OutputFile[]): Promise { + await Promise.all( + files.map((file) => + writeFile(file.fullPath, file.formattedContent).then(() => { + logger.writeLine( + `Successfully saved to ${logger.info(file.fullPath)}` + ); + }) + ) + ); + } +} diff --git a/lib/src/formatters/shared/fileTypes/JSONOutputFile.ts b/lib/src/formatters/shared/fileTypes/JSONOutputFile.ts new file mode 100644 index 0000000..64cae1e --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/JSONOutputFile.ts @@ -0,0 +1,25 @@ +import OutputFile from "./OutputFile"; + +export default class JSONOutputFile extends OutputFile< + Record, + MetadataType +> { + constructor(config: { + filename: string; + path: string; + content?: Record; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "json", + content: config.content ?? {}, + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return JSON.stringify(this.content, null, 2); + } +} diff --git a/lib/src/formatters/shared/fileTypes/JavascriptOutputFile.ts b/lib/src/formatters/shared/fileTypes/JavascriptOutputFile.ts new file mode 100644 index 0000000..fda195a --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/JavascriptOutputFile.ts @@ -0,0 +1,28 @@ +import { ensureEndsWithNewLine } from "../../../utils/fileSystem"; +import OutputFile from "./OutputFile"; + +export default class JavascriptOutputFile extends OutputFile< + string, + MetadataType +> { + indentSpaces: number = 2; + + constructor(config: { + filename: string; + path: string; + content?: string; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "js", + content: config.content ?? "", + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return ensureEndsWithNewLine(this.content ?? ""); + } +} diff --git a/lib/src/formatters/shared/fileTypes/OutputFile.ts b/lib/src/formatters/shared/fileTypes/OutputFile.ts new file mode 100644 index 0000000..664f26d --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/OutputFile.ts @@ -0,0 +1,33 @@ +export default class OutputFile { + filename: string; + path: string; + extension: string; + content: ContentType; + metadata: MetadataType; + + constructor(config: { + filename: string; + path: string; + extension: string; + content: ContentType; + metadata?: MetadataType; + }) { + this.filename = config.filename; + this.path = config.path; + this.extension = config.extension; + this.content = config.content; + this.metadata = config.metadata ?? ({} as MetadataType); + } + + get fullPath() { + return `${this.path}/${this.filename}.${this.extension}`; + } + + get filenameWithExtension() { + return `${this.filename}.${this.extension}`; + } + + get formattedContent(): string { + throw new Error("Not implemented"); + } +} diff --git a/lib/src/formatters/shared/index.test.ts b/lib/src/formatters/shared/index.test.ts new file mode 100644 index 0000000..817e516 --- /dev/null +++ b/lib/src/formatters/shared/index.test.ts @@ -0,0 +1,72 @@ +import { kMaxLength } from "buffer"; +import { applyMixins, Constructor } from "./index"; + +afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); +}); + +describe("applyMixins", () => { + // Base class + class Base { + baseMethod() { + return "base"; + } + } + + // First mixin + const TimestampMixin = (base: TBase) => { + return class extends base { + timestamp = Date.now(); + getTimestamp() { + return this.timestamp; + } + }; + }; + + // Second mixin + const LoggingMixin = (base: TBase) => { + return class extends base { + log(message: string) { + console.log(message); + return message; + } + }; + }; + + it("should apply a single mixin", () => { + const MixedClass = applyMixins(Base, TimestampMixin); + const instance = new MixedClass(); + + expect(instance.baseMethod()).toBe("base"); + expect(instance.getTimestamp()).toBeDefined(); + expect(typeof instance.getTimestamp()).toBe("number"); + }); + + it("should apply multiple mixins", () => { + const MixedClass = applyMixins(Base, TimestampMixin, LoggingMixin); + const instance = new MixedClass(); + + // Base class methods + expect(instance.baseMethod()).toBe("base"); + + // First mixin methods + expect(instance.getTimestamp()).toBeDefined(); + expect(typeof instance.getTimestamp()).toBe("number"); + + // Second mixin methods + const message = "test message"; + const consoleSpy = jest.spyOn(console, "log").mockImplementation(); + expect(instance.log(message)).toBe(message); + expect(consoleSpy).toHaveBeenCalledWith(message); + consoleSpy.mockRestore(); + }); + + it("should maintain the prototype chain", () => { + const MixedClass = applyMixins(Base, TimestampMixin, LoggingMixin); + const instance = new MixedClass(); + + expect(instance instanceof Base).toBe(true); + expect(instance instanceof MixedClass).toBe(true); + }); +}); diff --git a/lib/src/formatters/shared/index.ts b/lib/src/formatters/shared/index.ts new file mode 100644 index 0000000..c6a5323 --- /dev/null +++ b/lib/src/formatters/shared/index.ts @@ -0,0 +1,62 @@ +export type Constructor = new (...args: any[]) => T; + +type Mixin = ( + base: TBase +) => TReturn; + +// Overloads for up to 5 mixins +export function applyMixins(base: TBase): TBase; +export function applyMixins( + base: TBase, + m1: Mixin +): T1; +export function applyMixins< + TBase extends Constructor, + T1 extends Constructor, + T2 extends Constructor +>(base: TBase, m1: Mixin, m2: Mixin): T2; +export function applyMixins< + TBase extends Constructor, + T1 extends Constructor, + T2 extends Constructor, + T3 extends Constructor +>(base: TBase, m1: Mixin, m2: Mixin, m3: Mixin): T3; +export function applyMixins< + TBase extends Constructor, + T1 extends Constructor, + T2 extends Constructor, + T3 extends Constructor, + T4 extends Constructor +>( + base: TBase, + m1: Mixin, + m2: Mixin, + m3: Mixin, + m4: Mixin +): T4; +export function applyMixins< + TBase extends Constructor, + T1 extends Constructor, + T2 extends Constructor, + T3 extends Constructor, + T4 extends Constructor, + T5 extends Constructor +>( + base: TBase, + m1: Mixin, + m2: Mixin, + m3: Mixin, + m4: Mixin, + m5: Mixin +): T5; + +// Implementation +export function applyMixins( + base: Constructor, + ...mixins: Mixin[] +): Constructor { + if (mixins.length > 5) { + throw new Error("Maximum of 5 mixins supported"); + } + return mixins.reduce((acc, mixin) => mixin(acc), base); +} diff --git a/lib/src/http/checkToken.ts b/lib/src/http/checkToken.ts new file mode 100644 index 0000000..f3bf38f --- /dev/null +++ b/lib/src/http/checkToken.ts @@ -0,0 +1,65 @@ +import { defaultInterceptor } from "./client"; +import logger from "../utils/logger"; +import axios, { AxiosError } from "axios"; +import appContext from "../utils/appContext"; + +export default async function checkToken(token: string) { + try { + const httpClient = axios.create({}); + + httpClient.interceptors.request.use(defaultInterceptor(token)); + + const response = await httpClient.get("/token-check"); + + if (response.status === 200) { + return { success: true }; + } + + return { + success: false, + output: [ + logger.errorText("This API key isn't valid. Please try another."), + ], + }; + } catch (e: unknown) { + if (!(e instanceof AxiosError)) { + return { + success: false, + output: [ + logger.warnText( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ), + ], + }; + } + + if (e.code === "ENOTFOUND") { + return { + success: false, + output: [ + logger.errorText( + `Can't connect to API: ${logger.url(appContext.apiHost)}` + ), + ], + }; + } + + if (e.response?.status === 401 || e.response?.status === 404) { + return { + success: false, + output: [ + logger.errorText("This API key isn't valid. Please try another."), + ], + }; + } + + return { + success: false, + output: [ + logger.errorText( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ), + ], + }; + } +} diff --git a/lib/src/http/client.ts b/lib/src/http/client.ts new file mode 100644 index 0000000..e3df3cf --- /dev/null +++ b/lib/src/http/client.ts @@ -0,0 +1,18 @@ +import axios, { InternalAxiosRequestConfig } from "axios"; +import appContext from "../utils/appContext"; + +export function defaultInterceptor(token?: string) { + return function (config: InternalAxiosRequestConfig) { + config.baseURL = appContext.apiHost; + config.headers["x-ditto-client-id"] = appContext.clientId; + config.headers["x-ditto-app"] = "cli"; + config.headers.Authorization = token || appContext.apiToken; + return config; + }; +} + +const httpClient = axios.create({}); + +httpClient.interceptors.request.use(defaultInterceptor()); + +export default httpClient; diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts new file mode 100644 index 0000000..18e86f9 --- /dev/null +++ b/lib/src/http/textItems.ts @@ -0,0 +1,50 @@ +import httpClient from "./client"; +import { AxiosError } from "axios"; +import { z } from "zod"; + +export interface PullFilters { + projects?: { id: string }[]; + variants?: { id: string }[]; +} + +const TextItemsResponse = z.array( + z.object({ + id: z.string(), + text: z.string(), + status: z.string(), + notes: z.string(), + tags: z.array(z.string()), + variableIds: z.array(z.string()), + projectId: z.string(), + variantId: z.string().nullable(), + }) +); + +export type TextItemsResponse = z.infer; + +export default async function fetchText(filters?: PullFilters) { + try { + const response = await httpClient.get("/v2/textItems", { + params: { + filter: JSON.stringify(filters), + }, + }); + + return TextItemsResponse.parse(response.data); + } catch (e: unknown) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + + // Handle invalid filters + if (e.response?.status === 400) { + throw new Error( + "Invalid filters. Please check your filters and try again." + ); + } + + throw e; + } +} diff --git a/lib/src/http/variables.ts b/lib/src/http/variables.ts new file mode 100644 index 0000000..da89480 --- /dev/null +++ b/lib/src/http/variables.ts @@ -0,0 +1,81 @@ +import httpClient from "./client"; +import { AxiosError } from "axios"; +import { z } from "zod"; + +const ZBaseVariable = z.object({ + id: z.string(), + name: z.string(), +}); + +const ZVariableNumber = ZBaseVariable.merge( + z.object({ + type: z.literal("number"), + data: z.object({ + example: z.union([z.number(), z.string()]), + fallback: z.union([z.number(), z.string()]).optional(), + }), + }) +); + +const ZVariableString = ZBaseVariable.merge( + z.object({ + type: z.literal("string"), + data: z.object({ + example: z.string(), + fallback: z.string().optional(), + }), + }) +); + +const ZVariableHyperlink = ZBaseVariable.merge( + z.object({ + type: z.literal("hyperlink"), + data: z.object({ + text: z.string(), + url: z.string(), + }), + }) +); + +const ZVariableList = ZBaseVariable.merge( + z.object({ + type: z.literal("list"), + data: z.array(z.string()), + }) +); + +const ZVariableMap = ZBaseVariable.merge( + z.object({ + type: z.literal("map"), + data: z.record(z.string()), + }) +); + +const ZVariable = z.discriminatedUnion("type", [ + ZVariableString, + ZVariableNumber, + ZVariableHyperlink, + ZVariableList, + ZVariableMap, +]); + +export type Variable = z.infer; + +const ZVariablesResponse = z.array(ZVariable); + +export type VariablesResponse = z.infer; + +export default async function fetchVariables() { + try { + const response = await httpClient.get("/v2/variables"); + + return ZVariablesResponse.parse(response.data); + } catch (e: unknown) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + throw e; + } +} diff --git a/lib/src/index.ts b/lib/src/index.ts index 6cb10cc..450de62 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -1,27 +1,16 @@ #!/usr/bin/env node // This is the main entry point for the ditto-cli command. +import * as Sentry from "@sentry/node"; import { program } from "commander"; import { pull } from "./commands/pull"; import { quit } from "./utils/quit"; import { version } from "../../package.json"; +import logger from "./utils/logger"; +import { initAPIToken } from "./services/apiToken"; +import { initProjectConfig } from "./services/projectConfig"; +import appContext from "./utils/appContext"; -const CONFIG_FILE_RELIANT_COMMANDS = [ - "pull", - "none", - "project", - "project add", - "project remove", -]; - -type Command = - | "pull" - | "project" - | "project add" - | "project remove" - | "component-folders" - | "generate-suggestions" - | "replace" - | "import-components"; +type Command = "pull"; interface CommandConfig { name: T; @@ -61,22 +50,6 @@ const setupCommands = () => { } ); } - - if ("commands" in commandConfig && commandConfig.commands) { - commandConfig.commands.forEach((nestedCommand) => { - cmd - .command(nestedCommand.name) - .description(nestedCommand.description) - .action((str, options) => { - if (commandConfig.name === "project") { - const command = - `${commandConfig.name} ${nestedCommand.name}` as Command; - - return executeCommand(command, options); - } - }); - }); - } }); }; @@ -89,15 +62,35 @@ const executeCommand = async ( command: Command | "none", options: any ): Promise => { - switch (command) { - case "none": - case "pull": { - return pull(); + try { + const token = await initAPIToken(); + appContext.setApiToken(token); + + await initProjectConfig(); + + switch (command) { + case "none": + case "pull": { + return await pull(); + } + default: { + await quit(`Invalid command: ${command}. Exiting Ditto CLI...`); + return; + } } - default: { - await quit("Exiting Ditto CLI..."); - return; + } catch (error) { + const eventId = Sentry.captureException(error); + const eventStr = `\n\nError ID: ${logger.info(eventId)}`; + + if (process.env.IS_LOCAL === "true") { + console.error(logger.info("Development stack trace:\n"), error); } + + return await quit( + logger.errorText( + "Something went wrong. Please contact support or try again later." + ) + eventStr + ); } }; diff --git a/lib/src/outputs/index.ts b/lib/src/outputs/index.ts new file mode 100644 index 0000000..303b95b --- /dev/null +++ b/lib/src/outputs/index.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { ZJSONOutput } from "./json"; + +/** + * The output config is a discriminated union of all the possible output formats. + */ +export const ZOutput = z.union([...ZJSONOutput.options]); + +export type Output = z.infer; diff --git a/lib/src/outputs/json.ts b/lib/src/outputs/json.ts new file mode 100644 index 0000000..b26342f --- /dev/null +++ b/lib/src/outputs/json.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { ZBaseOutputFilters } from "./shared"; + +const ZBaseJSONOutput = ZBaseOutputFilters.extend({ + format: z.literal("json"), + framework: z.undefined(), +}); + +const Zi18NextJSONOutput = ZBaseJSONOutput.extend({ + framework: z.literal("i18next"), +}); + +export const ZJSONOutput = z.discriminatedUnion("framework", [ + ZBaseJSONOutput, + Zi18NextJSONOutput, +]); diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts new file mode 100644 index 0000000..3c00cf1 --- /dev/null +++ b/lib/src/outputs/shared.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +/** + * These filters that are common to all outputs, used to filter the text items that are fetched from the API. + * They are all optional by defualt unless otherwise specified in the output config. + */ +export const ZBaseOutputFilters = z.object({ + projects: z.array(z.object({ id: z.string() })).optional(), + variants: z.array(z.object({ id: z.string() })).optional(), +}); diff --git a/lib/src/services/apiToken.test.ts b/lib/src/services/apiToken.test.ts new file mode 100644 index 0000000..0e01f3e --- /dev/null +++ b/lib/src/services/apiToken.test.ts @@ -0,0 +1,27 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; +import { _test as apiTokenTest } from "./apiToken"; + +const { getURLHostname } = apiTokenTest; + +describe("apiToken", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getURLHostname", () => { + it("should return hostname when URL contains protocol", () => { + const expectedHostName = "example.com"; + + const result = getURLHostname("https://example.com"); + + expect(result).toBe(expectedHostName); + }); + + it("should return host as is when no protocol present", () => { + const expectedHostName = "example.com"; + + const result = getURLHostname("example.com"); + expect(result).toBe("example.com"); + }); + }); +}); diff --git a/lib/src/services/apiToken.ts b/lib/src/services/apiToken.ts new file mode 100644 index 0000000..2a79370 --- /dev/null +++ b/lib/src/services/apiToken.ts @@ -0,0 +1,149 @@ +import appContext from "../utils/appContext"; +import fs from "fs"; +import URL from "url"; +import * as configService from "./globalConfig"; +import checkToken from "../http/checkToken"; +import logger from "../utils/logger"; +import { quit } from "../utils/quit"; +import * as Sentry from "@sentry/node"; +import { prompt } from "enquirer"; + +/** + * Initializes the API token + * @param token The token to initialize the API token with. If not provided, the token will be fetched from the global config file. + * @param configFile The path to the global config file + * @param host The host to initialize the API token for + * @returns The initialized API token + */ +export async function initAPIToken( + token: string | undefined = appContext.apiToken, + configFile: string = appContext.configFile, + host: string = appContext.apiHost +) { + if (token) { + return await validateToken(token); + } + + if (!fs.existsSync(configFile)) { + return await collectAndSaveToken(); + } + + const configData = configService.readGlobalConfigData(configFile); + const sanitizedHost = getURLHostname(host); + + if ( + !configData[sanitizedHost] || + !configData[sanitizedHost][0] || + configData[sanitizedHost][0].token === "" + ) { + return await collectAndSaveToken(sanitizedHost); + } + + return await validateToken(configData[sanitizedHost][0].token); +} + +/** + * Collects a token from the user and saves it to the global config file + * @param host The host to save the token for + * @returns The collected token + */ +async function collectAndSaveToken(host: string = appContext.apiHost) { + try { + const token = await collectToken(); + logger.writeLine( + `Thanks for authenticating. We'll save the key to: ${logger.info( + appContext.configFile + )}\n` + ); + const sanitizedHost = getURLHostname(host); + configService.saveToken(appContext.configFile, sanitizedHost, token); + appContext.setApiToken(token); + return token; + } catch (error) { + // https://github.com/enquirer/enquirer/issues/225#issue-516043136 + // Empty string corresponds to the user hitting Ctrl + C + if (error === "") { + await quit("", 0); + return ""; + } + + const eventId = Sentry.captureException(error); + const eventStr = `\n\nError ID: ${logger.info(eventId)}`; + + await quit( + logger.errorText( + "Something went wrong. Please contact support or try again later." + ) + eventStr + ); + return ""; + } +} + +/** + * Outputs instructions to the user and collects an API token + * @returns The collected token + */ +async function collectToken() { + const apiUrl = logger.url("https://app.dittowords.com/account/devtools"); + const breadcrumbs = logger.bold(logger.info("API Keys")); + const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl} under "${breadcrumbs}".`; + + logger.writeLine(tokenDescription); + + const response = await promptForApiToken(); + return response.token; +} + +/** + * Prompt the user for an API token + * @returns The collected token + */ +async function promptForApiToken() { + const response = await prompt<{ token: string }>({ + type: "input", + name: "token", + message: "What is your API key?", + // @ts-expect-error - Enquirer types are not updated for the validate function + validate: async (token) => { + console.log("token", token); + const result = await checkToken(token); + if (!result.success) { + return result.output?.join("\n") || "Invalid API key"; + } + return true; + }, + }); + + return response; +} + +/** + * Get the hostname from a URL string + * @param hostString + * @returns + */ +function getURLHostname(hostString: string) { + if (!hostString.includes("://")) return hostString; + return URL.parse(hostString).hostname || ""; +} + +/** + * Validate a token + * @param token The token to validate + * @returns The newly validated token + */ +async function validateToken(token: string) { + const response = await checkToken(token); + if (!response.success) { + return await collectAndSaveToken(); + } + + return token; +} + +export const _test = { + collectToken, + validateToken, + getURLHostname, + promptForApiToken, +}; diff --git a/lib/src/services/globalConfig.ts b/lib/src/services/globalConfig.ts new file mode 100644 index 0000000..b7d22e4 --- /dev/null +++ b/lib/src/services/globalConfig.ts @@ -0,0 +1,58 @@ +import appContext from "../utils/appContext"; +import fs from "fs"; +import yaml from "js-yaml"; +import { z } from "zod"; +import { createFileIfMissingSync } from "../utils/fileSystem"; + +const ZGlobalConfigYAML = z.record( + z.string(), + z.array( + z.object({ + token: z.string(), + }) + ) +); + +type GlobalConfigYAML = z.infer; + +/** + * Read data from a global config file + * @param file The path to the global config file + * @returns + */ +export function readGlobalConfigData( + file = appContext.configFile +): GlobalConfigYAML { + createFileIfMissingSync(file); + const fileContents = fs.readFileSync(file, "utf8"); + const yamlData = yaml.load(fileContents); + const parsedYAML = ZGlobalConfigYAML.safeParse(yamlData); + if (parsedYAML.success) { + return parsedYAML.data; + } + return {}; +} + +/** + * Write data to a global config file + * @param file The path to the global config file + * @param data The data to write to the file + */ +function writeGlobalConfigData(file: string, data: object) { + createFileIfMissingSync(file); + const existingData = readGlobalConfigData(file); + const yamlStr = yaml.dump({ ...existingData, ...data }); + fs.writeFileSync(file, yamlStr, "utf8"); +} + +/** + * Save a token to the global config file + * @param file The path to the global config file + * @param hostname The hostname to save the token for + * @param token The token to save + */ +export function saveToken(file: string, hostname: string, token: string) { + const data = readGlobalConfigData(file); + data[hostname] = [{ token }]; // only allow one token per host + writeGlobalConfigData(file, data); +} diff --git a/lib/src/services/projectConfig.ts b/lib/src/services/projectConfig.ts new file mode 100644 index 0000000..b788c38 --- /dev/null +++ b/lib/src/services/projectConfig.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import fs from "fs"; +import { createFileIfMissingSync } from "../utils/fileSystem"; +import appContext from "../utils/appContext"; +import yaml from "js-yaml"; +import { ZBaseOutputFilters } from "../outputs/shared"; +import { ZOutput } from "../outputs"; + +const ZProjectConfigYAML = ZBaseOutputFilters.extend({ + outputs: z.array(ZOutput), +}).strict(); + +export type ProjectConfigYAML = z.infer; + +export const DEFAULT_PROJECT_CONFIG_JSON: ProjectConfigYAML = { + projects: [], + variants: [], + outputs: [ + { + format: "json", + }, + ], +}; + +export async function initProjectConfig() { + const projectConfig = readProjectConfigData(); + appContext.setProjectConfig(projectConfig); +} + +/** + * Read data from a global config file + * @param file defaults to the projectConfigFile defined in appContext + * @param defaultData defaults to `{}` + * @returns + */ +function readProjectConfigData( + file = appContext.projectConfigFile, + defaultData: ProjectConfigYAML = DEFAULT_PROJECT_CONFIG_JSON +): ProjectConfigYAML { + createFileIfMissingSync(file, yaml.dump(defaultData)); + const fileContents = fs.readFileSync(file, "utf8"); + const yamlData = yaml.load(fileContents); + const parsedYAML = ZProjectConfigYAML.safeParse(yamlData); + if (!parsedYAML.success) { + throw new Error("Failed to parse project config file"); + } + return parsedYAML.data; +} diff --git a/lib/src/utils/appContext.ts b/lib/src/utils/appContext.ts new file mode 100644 index 0000000..0536e8e --- /dev/null +++ b/lib/src/utils/appContext.ts @@ -0,0 +1,92 @@ +import { homedir } from "os"; +import path from "path"; +import crypto from "crypto"; +import { + DEFAULT_PROJECT_CONFIG_JSON, + ProjectConfigYAML, +} from "../services/projectConfig"; + +/** + * This class is used to store the global CLI context. It is preserved across all methods + * and is used to store the running state of the CLI. + */ +class AppContext { + #apiHost: string; + #apiToken: string | undefined; + #configFile: string; + #projectConfigDir: string; + #projectConfigFile: string; + #clientId: string; + #projectConfig: ProjectConfigYAML; + + constructor() { + this.#apiHost = process.env.DITTO_API_HOST || "https://api.dittowords.com"; + this.#apiToken = process.env.DITTO_TOKEN; + this.#configFile = + process.env.DITTO_CONFIG_FILE || path.join(homedir(), ".config", "ditto"); + this.#projectConfigFile = + process.env.DITTO_PROJECT_CONFIG_FILE || + path.normalize(path.join("ditto", "config.yml")); + this.#projectConfigDir = path.normalize( + path.dirname(this.#projectConfigFile) + ); + this.#clientId = crypto.randomUUID(); + this.#projectConfig = DEFAULT_PROJECT_CONFIG_JSON; + } + + get apiHost() { + return this.#apiHost; + } + + set apiHost(value: string) { + this.#apiHost = value; + } + + get apiToken() { + return this.#apiToken; + } + + get apiTokenOrThrow() { + if (!this.#apiToken) { + throw new Error("No API Token found."); + } + return this.#apiToken; + } + + get configFile() { + return this.#configFile; + } + + get projectConfigFile() { + return this.#projectConfigFile; + } + + get clientId() { + return this.#clientId; + } + + setApiToken(value: string | undefined) { + this.#apiToken = value; + } + + get projectConfig() { + return this.#projectConfig; + } + + setProjectConfig(value: ProjectConfigYAML) { + this.#projectConfig = value; + } + + get selectedProjectConfigOutputs() { + // TODO: Filter out based on flags. + return this.#projectConfig.outputs; + } + + get projectConfigDir() { + return this.#projectConfigDir; + } +} + +const appContext = new AppContext(); + +export default appContext; diff --git a/lib/src/utils/fileSystem.ts b/lib/src/utils/fileSystem.ts new file mode 100644 index 0000000..be5843d --- /dev/null +++ b/lib/src/utils/fileSystem.ts @@ -0,0 +1,63 @@ +import path from "path"; +import fs from "fs"; +import fsPromises from "fs/promises"; +/** + * Creates a file with the given filename if it doesn't already exist. + * @param filename The path to the file to create. + * @param defaultContents The contents to write to the file if it doesn't already exist. Defaults to an empty string. + * @returns `true` if the file was created, `false` if it already exists. + */ +export function createFileIfMissingSync( + filename: string, + defaultContents: string = "" +) { + const dir = path.dirname(filename); + + // create the directory if it doesn't already exist + if (!fs.existsSync(dir)) fs.mkdirSync(dir); + + // create the file if it doesn't already exist + if (!fs.existsSync(filename)) { + // create the file, writing the `defaultContents` if provided + writeFileSync(filename, defaultContents); + return true; + } else { + return false; + } +} + +/** + * Creates a file with the given filename if it doesn't already exist. + * @param filename The path to the file to create. + * @param defaultContents The contents to write to the file if it doesn't already exist. Defaults to an empty string. + * @returns `true` if the file was created, `false` if it already exists. + */ +export async function createFileIfMissing( + filename: string, + defaultContents: string = "" +) { + const dir = path.dirname(filename); + + // create the directory if it doesn't already exist + if (!fs.existsSync(dir)) fs.mkdirSync(dir); + + // create the file if it doesn't already exist + if (!fs.existsSync(filename)) { + // create the file, writing the `defaultContents` if provided + await writeFile(filename, defaultContents); + return true; + } else { + return false; + } +} + +export function writeFileSync(filename: string, content: string) { + fs.writeFileSync(filename, content, "utf-8"); +} + +export async function writeFile(filename: string, content: string) { + await fsPromises.writeFile(filename, content, "utf-8"); +} + +export const ensureEndsWithNewLine = (str: string) => + str + (/[\r\n]$/.test(str) ? "" : "\n"); diff --git a/lib/src/output.ts b/lib/src/utils/logger.ts similarity index 80% rename from lib/src/output.ts rename to lib/src/utils/logger.ts index 74499d0..e75995b 100644 --- a/lib/src/output.ts +++ b/lib/src/utils/logger.ts @@ -7,7 +7,8 @@ export const success = (msg: string) => chalk.green(msg); export const url = (msg: string) => chalk.blueBright.underline(msg); export const subtle = (msg: string) => chalk.grey(msg); export const write = (msg: string) => chalk.white(msg); -export const nl = () => console.log("\n"); +export const bold = (msg: string) => chalk.bold(msg); +export const writeLine = (msg: string) => console.log(msg); export default { errorText, @@ -17,5 +18,6 @@ export default { url, subtle, write, - nl, + writeLine, + bold, }; diff --git a/lib/src/utils/messages.ts b/lib/src/utils/messages.ts new file mode 100644 index 0000000..0e5bf92 --- /dev/null +++ b/lib/src/utils/messages.ts @@ -0,0 +1,13 @@ +import boxen from "boxen"; +import chalk from "chalk"; +import logger from "./logger"; + +export function welcome() { + const msg = chalk.white(`${chalk.bold( + "Welcome to the", + chalk.magentaBright("Ditto CLI") + )}. + +We're glad to have you here.`); + logger.writeLine(boxen(msg, { padding: 1 })); +} diff --git a/lib/src/utils/quit.ts b/lib/src/utils/quit.ts index cdb6a91..07d4d07 100644 --- a/lib/src/utils/quit.ts +++ b/lib/src/utils/quit.ts @@ -1,7 +1,8 @@ import * as Sentry from "@sentry/node"; +import logger from "./logger"; export async function quit(message: string | null, exitCode = 2) { - if (message) console.log(`\n${message}\n`); + if (message) logger.writeLine(`\n${message}\n`); await Sentry.flush(); process.exit(exitCode); } diff --git a/package.json b/package.json index 7ce14c5..b3d5fb0 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "scripts": { "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", - "start": "node esbuild.mjs && node bin/ditto.js", - "sync": "node esbuild.mjs && node bin/ditto.js pull" + "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", + "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull" }, "repository": { "type": "git", @@ -78,7 +78,8 @@ "glob": "^9.3.4", "js-yaml": "^4.1.0", "memfs": "^4.7.7", - "ora": "^5.0.0" + "ora": "^5.0.0", + "zod": "^3.24.2" }, "lint-staged": { "src/**/*.{js,jsx,ts,tsx,css,json}": "prettier --write" diff --git a/yarn.lock b/yarn.lock index 9ef48a9..7094618 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4694,3 +4694,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.24.2: + version "3.24.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3" + integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==