From c737f35a8e4387ee5412ea395ca2e697698e5125 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Fri, 3 Oct 2025 13:38:36 -0400 Subject: [PATCH 1/5] Add support for -m metadata tags --- lib/src/commands/pull.test.ts | 126 ++++++++++++++++-------- lib/src/commands/pull.ts | 4 +- lib/src/formatters/index.ts | 7 +- lib/src/formatters/json.ts | 4 + lib/src/formatters/shared/base.ts | 8 +- lib/src/index.ts | 9 +- lib/src/utils/processMetaOption.test.ts | 27 +++++ lib/src/utils/processMetaOption.ts | 27 +++++ 8 files changed, 165 insertions(+), 47 deletions(-) create mode 100644 lib/src/utils/processMetaOption.test.ts create mode 100644 lib/src/utils/processMetaOption.ts diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index c8a8589..0c26fb5 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -35,7 +35,7 @@ const createMockComponent = (overrides: Partial = {}) => ({ folderId: null, variantId: null, ...overrides, -}) +}); const createMockVariable = (overrides: any = {}) => ({ id: "var-1", @@ -49,7 +49,15 @@ const createMockVariable = (overrides: any = {}) => ({ }); // Helper functions -const setupMocks = ({ textItems = [], components = [], variables = [] }: { textItems: TextItem[]; components?: Component[]; variables?: any[] }) => { +const setupMocks = ({ + textItems = [], + components = [], + variables = [], +}: { + textItems: TextItem[]; + components?: Component[]; + variables?: any[]; +}) => { mockHttpClient.get.mockImplementation((url: string) => { if (url.includes("/v2/textItems")) { return Promise.resolve({ data: textItems }); @@ -122,7 +130,7 @@ describe("pull command - end-to-end tests", () => { const mockTextItem = createMockTextItem(); const mockComponent = createMockComponent(); - setupMocks({ textItems: [mockTextItem], components: [mockComponent]}); + setupMocks({ textItems: [mockTextItem], components: [mockComponent] }); // Set up appContext - this is what actually drives the test appContext.setProjectConfig({ @@ -132,7 +140,7 @@ describe("pull command - end-to-end tests", () => { outputs: [{ format: "json", outDir: outputDir }], }); - await pull(); + await pull({}); // Verify rich text content was written assertFileContainsText( @@ -145,7 +153,7 @@ describe("pull command - end-to-end tests", () => { path.join(outputDir, "components___base.json"), "component-1", "

Rich HTML content

" - ) + ); }); it("should use plain text when richText is disabled at output level", async () => { @@ -162,7 +170,7 @@ describe("pull command - end-to-end tests", () => { outputs: [{ format: "json", outDir: outputDir, richText: false }], }); - await pull(); + await pull({}); // Verify plain text content was written despite base config assertFileContainsText( @@ -189,7 +197,7 @@ describe("pull command - end-to-end tests", () => { outputs: [{ format: "json", outDir: outputDir, richText: "html" }], }); - await pull(); + await pull({}); // Verify rich text content was written assertFileContainsText( @@ -214,7 +222,7 @@ describe("pull command - end-to-end tests", () => { ], }); - await pull(); + await pull({}); // Verify correct API call with filtered params expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { @@ -238,7 +246,7 @@ describe("pull command - end-to-end tests", () => { ], }); - await pull(); + await pull({}); // Verify correct API call with filtered params expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { @@ -262,15 +270,14 @@ describe("pull command - end-to-end tests", () => { ], }); - await pull(); + await pull({}); expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { params: { - filter: - '{}', + filter: "{}", }, }); - }) + }); it("should filter components by folder at base level", async () => { fs.mkdirSync(outputDir, { recursive: true }); @@ -287,15 +294,14 @@ describe("pull command - end-to-end tests", () => { ], }); - await pull(); + await pull({}); expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { params: { - filter: - '{"folders":[{"id":"folder-1"}]}', + filter: '{"folders":[{"id":"folder-1"}]}', }, }); - }) + }); it("should filter components by folder and variants at base level", async () => { fs.mkdirSync(outputDir, { recursive: true }); @@ -313,7 +319,7 @@ describe("pull command - end-to-end tests", () => { ], }); - await pull(); + await pull({}); expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { params: { @@ -321,7 +327,7 @@ describe("pull command - end-to-end tests", () => { '{"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 }); @@ -335,21 +341,20 @@ describe("pull command - end-to-end tests", () => { format: "json", outDir: outputDir, components: { - folders: [{ id: "folder-3" }] - } + folders: [{ id: "folder-3" }], + }, }, ], }); - await pull(); + await pull({}); expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { params: { - filter: - '{"folders":[{"id":"folder-3"}]}', + filter: '{"folders":[{"id":"folder-3"}]}', }, }); - }) + }); it("should filter components by folder and variants at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); @@ -363,14 +368,14 @@ describe("pull command - end-to-end tests", () => { format: "json", outDir: outputDir, components: { - folders: [{ id: "folder-3" }] + folders: [{ id: "folder-3" }], }, variants: [{ id: "variant-a" }, { id: "variant-b" }], }, ], }); - await pull(); + await pull({}); expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { params: { @@ -378,7 +383,7 @@ describe("pull command - end-to-end tests", () => { '{"folders":[{"id":"folder-3"}],"variants":[{"id":"variant-a"},{"id":"variant-b"}]}', }, }); - }) + }); it("should filter projects at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); @@ -394,7 +399,7 @@ describe("pull command - end-to-end tests", () => { ], }); - await pull(); + await pull({}); // Verify correct API call with filtered params expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { @@ -419,7 +424,7 @@ describe("pull command - end-to-end tests", () => { ], }); - await pull(); + await pull({}); // Verify correct API call with filtered params expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { @@ -443,18 +448,51 @@ describe("pull command - end-to-end tests", () => { ], }); - await pull(); + await pull({}); // Verify correct API call with filtered params expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { params: { - filter: "{\"projects\":[]}", + filter: '{"projects":[]}', }, }); - expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/variables") + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/variables"); // Components endpoint should not be called if not provided as source field - expect(mockHttpClient.get).toHaveBeenCalledTimes(2) + expect(mockHttpClient.get).toHaveBeenCalledTimes(2); + }); + }); + + describe("Custom metadata", () => { + it("should include custom metadata in query params", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + await pull({ + githubActionRequest: "true", + }); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: '{"projects":[]}', + githubActionRequest: "true", + }, + }); + }); + + it("should ignore custom metadata with field name collisions", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + await pull({ + filter: "malicious", + }); + + // Verify that the filter param was not overridden by the custom metadata + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: '{"projects":[]}', + }, + }); }); }); @@ -536,7 +574,7 @@ describe("pull command - end-to-end tests", () => { variantId: null, folderId: "folder-2", }), - ] + ]; const componentsVariantA = [ createMockComponent({ @@ -549,7 +587,7 @@ describe("pull command - end-to-end tests", () => { variantId: "variant-a", folderId: "folder-1", }), - ] + ]; const componentsVariantB = [ createMockComponent({ @@ -562,14 +600,22 @@ describe("pull command - end-to-end tests", () => { variantId: "variant-b", folderId: "folder-1", }), - ] + ]; setupMocks({ - textItems: [...baseTextItems, ...variantATextItems, ...variantBTextItems], - components: [...componentsBase, ...componentsVariantA, ...componentsVariantB], + textItems: [ + ...baseTextItems, + ...variantATextItems, + ...variantBTextItems, + ], + components: [ + ...componentsBase, + ...componentsVariantA, + ...componentsVariantB, + ], }); - await pull(); + await pull({}); // Verify a file was created for each project and variant present in the (mocked) API response assertFilesCreated(outputDir, [ diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts index 031a40f..cd09b0c 100644 --- a/lib/src/commands/pull.ts +++ b/lib/src/commands/pull.ts @@ -1,8 +1,8 @@ import appContext from "../utils/appContext"; import formatOutput from "../formatters"; -export const pull = async () => { +export const pull = async (meta: Record) => { for (const output of appContext.selectedProjectConfigOutputs) { - await formatOutput(output, appContext.projectConfig); + await formatOutput(output, appContext.projectConfig, meta); } }; diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index a2536fb..e3febf7 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -2,13 +2,14 @@ import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; import JSONFormatter from "./json"; -export default function handleOutput( +export default function formatOutput( output: Output, - projectConfig: ProjectConfigYAML + projectConfig: ProjectConfigYAML, + meta: Record ) { switch (output.format) { case "json": - return new JSONFormatter(output, projectConfig).format(); + return new JSONFormatter(output, projectConfig, meta).format(); default: throw new Error(`Unsupported output format: ${output}`); } diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index ec312bd..4a52f9b 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -137,6 +137,10 @@ export default class JSONFormatter extends applyMixins( params.richText = this.output.richText; } + if (this.meta) { + params = { ...this.meta, ...params }; + } + return params; } diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index b3d4484..e9584a7 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -15,8 +15,13 @@ export default class BaseFormatter { JSONOutputFile<{ variantId: string }> >; protected variablesOutputFile: JSONOutputFile; + protected meta: Record; - constructor(output: Output, projectConfig: ProjectConfigYAML) { + constructor( + output: Output, + projectConfig: ProjectConfigYAML, + meta: Record + ) { this.output = output; this.projectConfig = projectConfig; this.outDir = output.outDir ?? appContext.outDir; @@ -25,6 +30,7 @@ export default class BaseFormatter { filename: "variables", path: this.outDir, }); + this.meta = meta; } protected async fetchAPIData(): Promise { diff --git a/lib/src/index.ts b/lib/src/index.ts index 7fa5282..9ca3113 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -11,6 +11,7 @@ import { initProjectConfig } from "./services/projectConfig"; import appContext from "./utils/appContext"; import type commander from "commander"; import { ErrorType, isDittoError, isDittoErrorType } from "./utils/DittoError"; +import processMetaOption from "./utils/processMetaOption"; type Command = "pull"; @@ -63,6 +64,10 @@ const setupCommands = () => { const setupOptions = () => { program.option("--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"); }; @@ -77,10 +82,12 @@ const executeCommand = async ( await initProjectConfig(options); + const { meta } = program.opts(); + switch (commandName) { case "none": case "pull": { - return await pull(); + return await pull(processMetaOption(meta)); } default: { await quit(`Invalid command: ${commandName}. Exiting Ditto CLI...`); diff --git a/lib/src/utils/processMetaOption.test.ts b/lib/src/utils/processMetaOption.test.ts new file mode 100644 index 0000000..2612dd4 --- /dev/null +++ b/lib/src/utils/processMetaOption.test.ts @@ -0,0 +1,27 @@ +import processMetaOption from "./processMetaOption"; + +describe("processMetaOption tests", () => { + it("It parses correctly", () => { + expect( + processMetaOption(["githubActionRequest:true", "trigger:manual"]) + ).toEqual({ + githubActionRequest: "true", + trigger: "manual", + }); + }); + + it("Successfully parses entries without : as key with undefined value", () => { + expect(processMetaOption(["context:github-action", "trigger"])).toEqual({ + context: "github-action", + trigger: undefined, + }); + }); + + it("Ignores entries with multiple : in them", () => { + expect( + processMetaOption(["context:github-action", "trigger:manual:ci"]) + ).toEqual({ + context: "github-action", + }); + }); +}); diff --git a/lib/src/utils/processMetaOption.ts b/lib/src/utils/processMetaOption.ts new file mode 100644 index 0000000..815b4b7 --- /dev/null +++ b/lib/src/utils/processMetaOption.ts @@ -0,0 +1,27 @@ +/** + * Processes an array of strings in the format "key:value" and returns an object mapping keys to values. + * @param inputArr Array of strings in the format "key:value" + * @returns An object mapping keys to values + */ +const processMetaOption = (inputArr: string[] | null) => { + const res: Record = {}; + + if (!Array.isArray(inputArr)) { + return res; + } + + inputArr.forEach((element) => { + const parts = element.split(":"); + if (parts.length > 2) { + // skip entries with multiple : characters + // Note: entries with no : will result in key with undefined value, which is ok + return; + } + const [key, value] = parts; + res[key] = value; + }); + + return res; +}; + +export default processMetaOption; From 9593f63391dc9154e5643462682a3b0961bf7dcd Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Fri, 3 Oct 2025 16:16:32 -0400 Subject: [PATCH 2/5] Rename + create type --- lib/src/commands/pull.ts | 3 ++- lib/src/formatters/index.ts | 3 ++- lib/src/formatters/shared/base.ts | 5 +++-- lib/src/http/types.ts | 11 ++++++++--- lib/src/index.ts | 4 ++-- ...Option.test.ts => processCommandMetaFlag.test.ts} | 12 +++++++----- ...rocessMetaOption.ts => processCommandMetaFlag.ts} | 12 +++++++----- 7 files changed, 31 insertions(+), 19 deletions(-) rename lib/src/utils/{processMetaOption.test.ts => processCommandMetaFlag.test.ts} (54%) rename lib/src/utils/{processMetaOption.ts => processCommandMetaFlag.ts} (59%) diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts index cd09b0c..56daea2 100644 --- a/lib/src/commands/pull.ts +++ b/lib/src/commands/pull.ts @@ -1,7 +1,8 @@ import appContext from "../utils/appContext"; import formatOutput from "../formatters"; +import { CommandMetaFlags } from "../http/types"; -export const pull = async (meta: Record) => { +export const pull = async (meta: CommandMetaFlags) => { for (const output of appContext.selectedProjectConfigOutputs) { await formatOutput(output, appContext.projectConfig, meta); } diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index e3febf7..d02e8d8 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -1,3 +1,4 @@ +import { CommandMetaFlags } from "../http/types"; import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; import JSONFormatter from "./json"; @@ -5,7 +6,7 @@ import JSONFormatter from "./json"; export default function formatOutput( output: Output, projectConfig: ProjectConfigYAML, - meta: Record + meta: CommandMetaFlags ) { switch (output.format) { case "json": diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index e9584a7..797ff0c 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -5,6 +5,7 @@ import { ProjectConfigYAML } from "../../services/projectConfig"; import OutputFile from "./fileTypes/OutputFile"; import appContext from "../../utils/appContext"; import JSONOutputFile from "./fileTypes/JSONOutputFile"; +import { CommandMetaFlags } from "../../http/types"; export default class BaseFormatter { protected output: Output; @@ -15,12 +16,12 @@ export default class BaseFormatter { JSONOutputFile<{ variantId: string }> >; protected variablesOutputFile: JSONOutputFile; - protected meta: Record; + protected meta: CommandMetaFlags; constructor( output: Output, projectConfig: ProjectConfigYAML, - meta: Record + meta: CommandMetaFlags ) { this.output = output; this.projectConfig = projectConfig; diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 78886e1..c746f3c 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -23,11 +23,11 @@ const ZBaseTextEntity = z.object({ 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; @@ -45,7 +45,7 @@ export type TextItemsResponse = z.infer; const ZComponent = ZBaseTextEntity.extend({ folderId: z.string().nullable(), -}) +}); /** * Represents a single component, as returned from the /v2/components endpoint @@ -54,3 +54,8 @@ export type Component = z.infer; export const ZComponentsResponse = z.array(ZComponent); export type ComponentsResponse = z.infer; + +/** + * Contains metadata attached to CLI commands via -m or --meta flag + */ +export type CommandMetaFlags = Record; diff --git a/lib/src/index.ts b/lib/src/index.ts index 9ca3113..b197503 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -11,7 +11,7 @@ import { initProjectConfig } from "./services/projectConfig"; import appContext from "./utils/appContext"; import type commander from "commander"; import { ErrorType, isDittoError, isDittoErrorType } from "./utils/DittoError"; -import processMetaOption from "./utils/processMetaOption"; +import processCommandMetaFlag from "./utils/processCommandMetaFlag"; type Command = "pull"; @@ -87,7 +87,7 @@ const executeCommand = async ( switch (commandName) { case "none": case "pull": { - return await pull(processMetaOption(meta)); + return await pull(processCommandMetaFlag(meta)); } default: { await quit(`Invalid command: ${commandName}. Exiting Ditto CLI...`); diff --git a/lib/src/utils/processMetaOption.test.ts b/lib/src/utils/processCommandMetaFlag.test.ts similarity index 54% rename from lib/src/utils/processMetaOption.test.ts rename to lib/src/utils/processCommandMetaFlag.test.ts index 2612dd4..776a7f5 100644 --- a/lib/src/utils/processMetaOption.test.ts +++ b/lib/src/utils/processCommandMetaFlag.test.ts @@ -1,9 +1,9 @@ -import processMetaOption from "./processMetaOption"; +import processCommandMetaFlag from "./processCommandMetaFlag"; -describe("processMetaOption tests", () => { +describe("processCommandMetaFlag tests", () => { it("It parses correctly", () => { expect( - processMetaOption(["githubActionRequest:true", "trigger:manual"]) + processCommandMetaFlag(["githubActionRequest:true", "trigger:manual"]) ).toEqual({ githubActionRequest: "true", trigger: "manual", @@ -11,7 +11,9 @@ describe("processMetaOption tests", () => { }); it("Successfully parses entries without : as key with undefined value", () => { - expect(processMetaOption(["context:github-action", "trigger"])).toEqual({ + expect( + processCommandMetaFlag(["context:github-action", "trigger"]) + ).toEqual({ context: "github-action", trigger: undefined, }); @@ -19,7 +21,7 @@ describe("processMetaOption tests", () => { it("Ignores entries with multiple : in them", () => { expect( - processMetaOption(["context:github-action", "trigger:manual:ci"]) + processCommandMetaFlag(["context:github-action", "trigger:manual:ci"]) ).toEqual({ context: "github-action", }); diff --git a/lib/src/utils/processMetaOption.ts b/lib/src/utils/processCommandMetaFlag.ts similarity index 59% rename from lib/src/utils/processMetaOption.ts rename to lib/src/utils/processCommandMetaFlag.ts index 815b4b7..8081ce2 100644 --- a/lib/src/utils/processMetaOption.ts +++ b/lib/src/utils/processCommandMetaFlag.ts @@ -1,10 +1,12 @@ +import { CommandMetaFlags } from "../http/types"; + /** * Processes an array of strings in the format "key:value" and returns an object mapping keys to values. * @param inputArr Array of strings in the format "key:value" * @returns An object mapping keys to values */ -const processMetaOption = (inputArr: string[] | null) => { - const res: Record = {}; +const processCommandMetaFlag = (inputArr: string[] | null) => { + const res: CommandMetaFlags = {}; if (!Array.isArray(inputArr)) { return res; @@ -12,9 +14,9 @@ const processMetaOption = (inputArr: string[] | null) => { inputArr.forEach((element) => { const parts = element.split(":"); + // Skip entries with multiple ":" characters + // Entries with no ":" will result in key with undefined value, which is ok if (parts.length > 2) { - // skip entries with multiple : characters - // Note: entries with no : will result in key with undefined value, which is ok return; } const [key, value] = parts; @@ -24,4 +26,4 @@ const processMetaOption = (inputArr: string[] | null) => { return res; }; -export default processMetaOption; +export default processCommandMetaFlag; From 13c51ac92a4928bce320bce861bad5567224a0cf Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Fri, 3 Oct 2025 17:09:15 -0400 Subject: [PATCH 3/5] Use headers instead of query params --- lib/src/commands/pull.test.ts | 45 ++++---------------- lib/src/formatters/json.ts | 13 +++--- lib/src/http/checkToken.ts | 8 ++-- lib/src/http/client.test.ts | 75 +++++++++++++++++++++++++++++++++ lib/src/http/client.ts | 18 +++++--- lib/src/http/components.test.ts | 32 +++++++++----- lib/src/http/components.ts | 20 ++++++--- lib/src/http/textItems.test.ts | 34 ++++++++++----- lib/src/http/textItems.ts | 9 +++- lib/src/http/variables.ts | 5 ++- lib/src/utils/appContext.ts | 4 ++ 11 files changed, 181 insertions(+), 82 deletions(-) create mode 100644 lib/src/http/client.test.ts diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 0c26fb5..c45f698 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -1,5 +1,5 @@ import { pull } from "./pull"; -import httpClient from "../http/client"; +import getHttpClient from "../http/client"; import { Component, TextItem } from "../http/types"; import appContext from "../utils/appContext"; import * as path from "path"; @@ -8,7 +8,13 @@ import * as os from "os"; jest.mock("../http/client"); -const mockHttpClient = httpClient as jest.Mocked; +// Create a mock client with a mock 'get' method +const mockHttpClient = { + get: jest.fn(), +}; + +// Make getHttpClient return your mock client +(getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); // Test data factories const createMockTextItem = (overrides: Partial = {}) => ({ @@ -58,7 +64,7 @@ const setupMocks = ({ components?: Component[]; variables?: any[]; }) => { - mockHttpClient.get.mockImplementation((url: string) => { + mockHttpClient.get.mockImplementation((url: string, config?: any) => { if (url.includes("/v2/textItems")) { return Promise.resolve({ data: textItems }); } @@ -463,39 +469,6 @@ describe("pull command - end-to-end tests", () => { }); }); - describe("Custom metadata", () => { - it("should include custom metadata in query params", async () => { - fs.mkdirSync(outputDir, { recursive: true }); - - await pull({ - githubActionRequest: "true", - }); - - // Verify correct API call with filtered params - expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { - params: { - filter: '{"projects":[]}', - githubActionRequest: "true", - }, - }); - }); - - it("should ignore custom metadata with field name collisions", async () => { - fs.mkdirSync(outputDir, { recursive: true }); - - await pull({ - filter: "malicious", - }); - - // Verify that the filter param was not overridden by the custom metadata - expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { - params: { - filter: '{"projects":[]}', - }, - }); - }); - }); - describe("Output files", () => { it("should create output files for each project and variant returned from the API", async () => { fs.mkdirSync(outputDir, { recursive: true }); diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 4a52f9b..8c189f3 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -22,7 +22,7 @@ export default class JSONFormatter extends applyMixins( protected async fetchAPIData() { const textItems = await this.fetchTextItems(); const components = await this.fetchComponents(); - const variables = await fetchVariables(); + const variables = await this.fetchVariables(); const variablesById = variables.reduce((acc, variable) => { acc[variable.id] = variable; @@ -137,9 +137,6 @@ export default class JSONFormatter extends applyMixins( params.richText = this.output.richText; } - if (this.meta) { - params = { ...this.meta, ...params }; - } return params; } @@ -153,7 +150,7 @@ export default class JSONFormatter extends applyMixins( private async fetchTextItems() { if (!this.projectConfig.projects && !this.output.projects) return []; - return await fetchText(this.generateQueryParams("textItem")); + return await fetchText(this.generateQueryParams("textItem"), this.meta); } /** @@ -165,6 +162,10 @@ export default class JSONFormatter extends applyMixins( private async fetchComponents() { if (!this.projectConfig.components && !this.output.components) return []; - return await fetchComponents(this.generateQueryParams("component")); + return await fetchComponents(this.generateQueryParams("component"), this.meta); + } + + private async fetchVariables() { + return await fetchVariables(this.meta); } } diff --git a/lib/src/http/checkToken.ts b/lib/src/http/checkToken.ts index f3bf38f..cc01224 100644 --- a/lib/src/http/checkToken.ts +++ b/lib/src/http/checkToken.ts @@ -1,13 +1,11 @@ -import { defaultInterceptor } from "./client"; +import getHttpClient from "./client"; import logger from "../utils/logger"; -import axios, { AxiosError } from "axios"; +import { 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 httpClient = getHttpClient({ token }); const response = await httpClient.get("/token-check"); diff --git a/lib/src/http/client.test.ts b/lib/src/http/client.test.ts new file mode 100644 index 0000000..8a8df10 --- /dev/null +++ b/lib/src/http/client.test.ts @@ -0,0 +1,75 @@ +import { defaultInterceptor } from "./client"; +import appContext from "../utils/appContext"; +import { InternalAxiosRequestConfig } from "axios"; + +describe("defaultInterceptor", () => { + const HOST = "https://api.example.com"; + const CLIENT_ID = "test-client-id"; + const API_TOKEN = "test-token"; + const INTERCEPTOR_CONFIG = { headers: {} } as InternalAxiosRequestConfig; + + beforeEach(() => { + appContext.apiHost = HOST; + appContext.setClientId(CLIENT_ID); + appContext.setApiToken(API_TOKEN); + }); + + it("sets baseURL to appContext.apiHost", () => { + appContext.apiHost = HOST; + + const interceptor = defaultInterceptor(); + const result = interceptor(INTERCEPTOR_CONFIG); + + expect(result.baseURL).toBe(HOST); + }); + + it("sets x-ditto-client-id to appContext.clientId", () => { + appContext.setClientId(CLIENT_ID); + + const interceptor = defaultInterceptor(); + const result = interceptor(INTERCEPTOR_CONFIG); + + expect(result.headers["x-ditto-client-id"]).toBe(CLIENT_ID); + }); + + it("sets Authorization header to appContext.apiToken when no token is provided", () => { + const interceptor = defaultInterceptor(); + const result = interceptor(INTERCEPTOR_CONFIG); + + expect(result.headers.Authorization).toBe(API_TOKEN); + }); + + it("sets Authorization header to provided token", () => { + const CUSTOM_TOKEN = "custom-token"; + + const interceptor = defaultInterceptor({ token: CUSTOM_TOKEN }); + const result = interceptor(INTERCEPTOR_CONFIG); + + expect(result.headers.Authorization).toBe(CUSTOM_TOKEN); + }); + + it("sets x-ditto-app to github_action when githubActionRequest is true", () => { + const interceptor = defaultInterceptor({ + meta: { githubActionRequest: "true" }, + }); + const result = interceptor(INTERCEPTOR_CONFIG); + + expect(result.headers["x-ditto-app"]).toBe("github_action"); + }); + + it("sets x-ditto-app to cli when githubActionRequest is false", () => { + const interceptor = defaultInterceptor({ + meta: { githubActionRequest: "false" }, + }); + const result = interceptor(INTERCEPTOR_CONFIG); + + expect(result.headers["x-ditto-app"]).toBe("cli"); + }); + + it("sets x-ditto-app to cli when githubActionRequest is not present", () => { + const interceptor = defaultInterceptor({ meta: {} }); + const result = interceptor(INTERCEPTOR_CONFIG); + + expect(result.headers["x-ditto-app"]).toBe("cli"); + }); +}); diff --git a/lib/src/http/client.ts b/lib/src/http/client.ts index e3df3cf..ae149c8 100644 --- a/lib/src/http/client.ts +++ b/lib/src/http/client.ts @@ -1,18 +1,24 @@ import axios, { InternalAxiosRequestConfig } from "axios"; import appContext from "../utils/appContext"; +import { CommandMetaFlags } from "./types"; -export function defaultInterceptor(token?: string) { +type InterceptorParams = { token?: string; meta?: CommandMetaFlags }; + +export function defaultInterceptor({ token, meta }: InterceptorParams = {}) { return function (config: InternalAxiosRequestConfig) { config.baseURL = appContext.apiHost; config.headers["x-ditto-client-id"] = appContext.clientId; - config.headers["x-ditto-app"] = "cli"; + config.headers["x-ditto-app"] = + meta?.githubActionRequest === "true" ? "github_action" : "cli"; config.headers.Authorization = token || appContext.apiToken; return config; }; } -const httpClient = axios.create({}); - -httpClient.interceptors.request.use(defaultInterceptor()); +const getHttpClient = (params: InterceptorParams) => { + const httpClient = axios.create({}); + httpClient.interceptors.request.use(defaultInterceptor(params)); + return httpClient; +}; -export default httpClient; +export default getHttpClient; diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts index 25229e3..85db3f4 100644 --- a/lib/src/http/components.test.ts +++ b/lib/src/http/components.test.ts @@ -1,10 +1,16 @@ -import httpClient from "./client"; +import getHttpClient from "./client"; import fetchComponents from "./components"; jest.mock("./client"); describe("fetchComponents", () => { - const mockHttpClient = httpClient as jest.Mocked; + // Create a mock client with a mock 'get' method + const mockHttpClient = { + get: jest.fn(), + }; + + // Make getHttpClient return your mock client + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); beforeEach(() => { jest.clearAllMocks(); @@ -30,10 +36,13 @@ describe("fetchComponents", () => { mockHttpClient.get.mockResolvedValue(mockResponse); - const result = await fetchComponents({ - filter: "", - richText: "html", - }); + const result = await fetchComponents( + { + filter: "", + richText: "html", + }, + {} + ); expect(result).toEqual([...mockResponse.data]); }); @@ -56,10 +65,13 @@ describe("fetchComponents", () => { mockHttpClient.get.mockResolvedValue(mockResponse); - const result = await fetchComponents({ - filter: "", - richText: "html", - }); + 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 index c603b7c..70c26cf 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -1,10 +1,20 @@ import { AxiosError } from "axios"; -import { ZComponentsResponse, PullQueryParams } from "./types"; -import httpClient from "./client"; +import { + ZComponentsResponse, + PullQueryParams, + CommandMetaFlags, +} from "./types"; +import getHttpClient from "./client"; -export default async function fetchComponents(params: PullQueryParams) { +export default async function fetchComponents( + params: PullQueryParams, + meta: CommandMetaFlags +) { try { - const response = await httpClient.get("/v2/components", { params }); + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/components", { + params, + }); return ZComponentsResponse.parse(response.data); } catch (e) { @@ -30,4 +40,4 @@ export default async function fetchComponents(params: PullQueryParams) { throw e; } -} \ No newline at end of file +} diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts index 37f19da..ecc82c1 100644 --- a/lib/src/http/textItems.test.ts +++ b/lib/src/http/textItems.test.ts @@ -1,10 +1,16 @@ import fetchText from "./textItems"; -import httpClient from "./client"; +import getHttpClient from "./client"; jest.mock("./client"); describe("fetchText", () => { - const mockHttpClient = httpClient as jest.Mocked; + // Create a mock client with a mock 'get' method + const mockHttpClient = { + get: jest.fn(), + }; + + // Make getHttpClient return your mock client + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); beforeEach(() => { jest.clearAllMocks(); @@ -30,10 +36,13 @@ describe("fetchText", () => { mockHttpClient.get.mockResolvedValue(mockResponse); - const result = await fetchText({ - filter: "", - richText: "html", - }); + const result = await fetchText( + { + filter: "", + richText: "html", + }, + {} + ); expect(result).toEqual([ { @@ -68,10 +77,13 @@ describe("fetchText", () => { mockHttpClient.get.mockResolvedValue(mockResponse); - const result = await fetchText({ - filter: "", - richText: "html", - }); + const result = await fetchText( + { + filter: "", + richText: "html", + }, + {} + ); expect(result).toEqual([ { @@ -88,4 +100,4 @@ describe("fetchText", () => { ]); }); }); -}); \ No newline at end of file +}); diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index d022922..57d4618 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -1,9 +1,14 @@ import httpClient from "./client"; import { AxiosError } from "axios"; -import { PullQueryParams, ZTextItemsResponse } from "./types"; +import { CommandMetaFlags, PullQueryParams, ZTextItemsResponse } from "./types"; +import getHttpClient from "./client"; -export default async function fetchText(params: PullQueryParams) { +export default async function fetchText( + params: PullQueryParams, + meta: CommandMetaFlags +) { try { + const httpClient = getHttpClient({ meta }); const response = await httpClient.get("/v2/textItems", { params }); return ZTextItemsResponse.parse(response.data); diff --git a/lib/src/http/variables.ts b/lib/src/http/variables.ts index da89480..ce9c2d4 100644 --- a/lib/src/http/variables.ts +++ b/lib/src/http/variables.ts @@ -1,6 +1,8 @@ import httpClient from "./client"; import { AxiosError } from "axios"; import { z } from "zod"; +import getHttpClient from "./client"; +import { CommandMetaFlags } from "./types"; const ZBaseVariable = z.object({ id: z.string(), @@ -65,8 +67,9 @@ const ZVariablesResponse = z.array(ZVariable); export type VariablesResponse = z.infer; -export default async function fetchVariables() { +export default async function fetchVariables(meta: CommandMetaFlags) { try { + const httpClient = getHttpClient({ meta }); const response = await httpClient.get("/v2/variables"); return ZVariablesResponse.parse(response.data); diff --git a/lib/src/utils/appContext.ts b/lib/src/utils/appContext.ts index 4093fef..05ee567 100644 --- a/lib/src/utils/appContext.ts +++ b/lib/src/utils/appContext.ts @@ -66,6 +66,10 @@ class AppContext { return this.#clientId; } + setClientId(value: string) { + this.#clientId = value; + } + setApiToken(value: string | undefined) { this.#apiToken = value; } From 3bba185223c12fc2ddd398e831eeacc4ef149863 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Mon, 6 Oct 2025 10:37:49 -0400 Subject: [PATCH 4/5] Cleanup --- lib/src/commands/pull.test.ts | 2 +- lib/src/http/components.test.ts | 2 +- lib/src/http/textItems.test.ts | 2 +- lib/src/http/types.ts | 6 +++++- lib/src/utils/processCommandMetaFlag.ts | 4 +++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index c45f698..1970675 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -13,7 +13,7 @@ const mockHttpClient = { get: jest.fn(), }; -// Make getHttpClient return your mock client +// Make getHttpClient return the mock client (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); // Test data factories diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts index 85db3f4..acd3698 100644 --- a/lib/src/http/components.test.ts +++ b/lib/src/http/components.test.ts @@ -9,7 +9,7 @@ describe("fetchComponents", () => { get: jest.fn(), }; - // Make getHttpClient return your mock client + // Make getHttpClient return the mock client (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); beforeEach(() => { diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts index ecc82c1..d2a7e22 100644 --- a/lib/src/http/textItems.test.ts +++ b/lib/src/http/textItems.test.ts @@ -9,7 +9,7 @@ describe("fetchText", () => { get: jest.fn(), }; - // Make getHttpClient return your mock client + // Make getHttpClient return the mock client (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); beforeEach(() => { diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index c746f3c..bd11c07 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -57,5 +57,9 @@ export type ComponentsResponse = z.infer; /** * Contains metadata attached to CLI commands via -m or --meta flag + * Currently only used internally to identify requests from our GitHub Action */ -export type CommandMetaFlags = Record; +export type CommandMetaFlags = { + githubActionRequest?: string; // Set to "true" if the request is from our GitHub Action + [key: string]: string | undefined; // Allow other arbitrary key-value pairs, but none of these values are used for anything at the moment +}; diff --git a/lib/src/utils/processCommandMetaFlag.ts b/lib/src/utils/processCommandMetaFlag.ts index 8081ce2..9abad35 100644 --- a/lib/src/utils/processCommandMetaFlag.ts +++ b/lib/src/utils/processCommandMetaFlag.ts @@ -5,7 +5,9 @@ import { CommandMetaFlags } from "../http/types"; * @param inputArr Array of strings in the format "key:value" * @returns An object mapping keys to values */ -const processCommandMetaFlag = (inputArr: string[] | null) => { +const processCommandMetaFlag = ( + inputArr: string[] | null +): CommandMetaFlags => { const res: CommandMetaFlags = {}; if (!Array.isArray(inputArr)) { From 1ac9fa88f3226962a0a5e303c5dc6ae46273a9f5 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Mon, 6 Oct 2025 10:59:45 -0400 Subject: [PATCH 5/5] Bump version to 5.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f69cb84..6a210bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0", + "version": "5.1.0", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js",