From f9bcb28635a872e5d463c1fd04336a7d98348ffa Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:50:22 -0400 Subject: [PATCH 1/7] Retry connection failures and resolve config in one place - Retry when fetch itself throws (ECONNRESET, DNS, TLS), not only on 429/5xx. The final error names the attempt count and the cause. - Empty response bodies return { success: true }; the old fallback reported campaignId "delete" for the delete endpoint. - The client no longer reads process.env. loadConfig() is the single source of precedence (env, then file, then defaults) and now fills in region NA and sandbox false. - A config file that is not valid JSON prints a warning instead of being silently treated as missing. - Type the Login with Amazon token response. --- src/__tests__/amazon-ads-client.test.ts | 64 +++++++++++++++++++++++++ src/__tests__/config.test.ts | 32 +++++++++++++ src/amazon-ads-client.ts | 59 +++++++++++------------ src/config.ts | 20 ++++++-- 4 files changed, 140 insertions(+), 35 deletions(-) diff --git a/src/__tests__/amazon-ads-client.test.ts b/src/__tests__/amazon-ads-client.test.ts index 04f6e85..36e793b 100644 --- a/src/__tests__/amazon-ads-client.test.ts +++ b/src/__tests__/amazon-ads-client.test.ts @@ -232,3 +232,67 @@ describe("AmazonAdsClient list pagination", () => { expect(portfolios).toEqual({ portfolios: [{ portfolioId: 1 }, { portfolioId: 2 }] }); }); }); + +describe("AmazonAdsClient request hardening", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + delete process.env.AMAZON_ADS_REGION; + }); + + it("retries a request whose connection failed", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce(jsonResponse(200, [{ profileId: 1 }])); + + const pending = makeClient().getProfiles(); + await vi.runAllTimersAsync(); + + await expect(pending).resolves.toEqual([{ profileId: 1 }]); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("gives up after the retry budget and reports the connection error", async () => { + fetchMock.mockResolvedValueOnce(tokenResponse()); + fetchMock.mockRejectedValue(new TypeError("fetch failed")); + + const pending = makeClient().getProfiles(); + const outcome = pending.catch((e: Error) => e); + await vi.runAllTimersAsync(); + + await expect(outcome).resolves.toMatchObject({ message: expect.stringMatching(/fetch failed/) }); + expect(fetchMock).toHaveBeenCalledTimes(1 + 4); + }); + + it("reports a bare success for an empty response body", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect(makeClient().archiveCampaign("p", "c1", "sponsoredProducts")).resolves.toEqual({ + success: true, + }); + }); + + it("uses only the config it was given, not process.env", async () => { + process.env.AMAZON_ADS_REGION = "EU"; + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(jsonResponse(200, [])); + + await new AmazonAdsClient({ + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + }).getProfiles(); + + expect(fetchMock.mock.calls[1][0]).toBe("https://advertising-api.amazon.com/v2/profiles"); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index fced9c7..e8f4afa 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -100,3 +100,35 @@ describe("config", () => { expect(() => deleteConfig()).not.toThrow(); }); }); + +describe("config defaults", () => { + const testConfigDir = path.join(os.tmpdir(), "amazonads-cli-defaults-" + Date.now()); + + beforeEach(() => { + vi.spyOn(os, "homedir").mockReturnValue(testConfigDir); + delete process.env.AMAZON_ADS_REGION; + delete process.env.AMAZON_ADS_SANDBOX; + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(testConfigDir, { recursive: true, force: true }); + }); + + it("defaults region to NA and sandbox to false", () => { + const config = loadConfig(); + expect(config.region).toBe("NA"); + expect(config.sandbox).toBe(false); + }); + + it("warns and ignores a config file that is not valid JSON", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true }); + fs.writeFileSync(getConfigPath(), "{ not json"); + + const config = loadConfig(); + + expect(config.clientId).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/not valid JSON/)); + }); +}); diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index 28a9b25..01d7571 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -66,16 +66,9 @@ export class AmazonAdsClient { private accessToken?: string; private tokenExpiry?: number; - constructor(overrides?: Partial) { - // Load configuration from environment variables, then apply overrides - this.config = { - clientId: process.env.AMAZON_ADS_CLIENT_ID, - clientSecret: process.env.AMAZON_ADS_CLIENT_SECRET, - refreshToken: process.env.AMAZON_ADS_REFRESH_TOKEN, - region: process.env.AMAZON_ADS_REGION || "NA", - sandbox: process.env.AMAZON_ADS_SANDBOX === "true", - ...overrides, - }; + /** Config comes fully resolved from loadConfig(); the client never reads process.env itself. */ + constructor(config: AmazonAdsConfig = {}) { + this.config = { region: "NA", sandbox: false, ...config }; } private getEndpoint(): string { @@ -132,7 +125,7 @@ export class AmazonAdsClient { throw new Error(`Failed to refresh access token: ${error}`); } - const data = await response.json(); + const data = (await response.json()) as { access_token: string; expires_in: number }; this.accessToken = data.access_token; this.tokenExpiry = Date.now() + (data.expires_in - 60) * 1000; // Refresh 1 minute before expiry } @@ -141,6 +134,11 @@ export class AmazonAdsClient { return new Promise((resolve) => setTimeout(resolve, ms)); } + /** Exponential backoff: 1s, 2s, 4s, ... */ + private backoffMs(attempt: number): number { + return Math.pow(2, attempt) * 1000; + } + private isRetryableError(status: number): boolean { // Retry on rate limit (429) and server errors (5xx) return status === 429 || (status >= 500 && status < 600); @@ -183,19 +181,29 @@ export class AmazonAdsClient { options.body = JSON.stringify(body); } - // Retry logic with exponential backoff + // Retry on 429/5xx and on connection failures, with exponential backoff. const maxRetries = 3; - let lastError: Error | null = null; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const response = await fetch(url, options); + for (let attempt = 0; ; attempt++) { + let response: Response; + try { + response = await fetch(url, options); + } catch (err) { + if (attempt >= maxRetries) { + throw new Error( + `Amazon Ads API request failed after ${attempt + 1} attempts: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + await this.sleep(this.backoffMs(attempt)); + continue; + } if (response.ok) { - // Handle empty responses (like DELETE) + // Some endpoints answer with no body (e.g. 204). const text = await response.text(); - if (!text) { - return { success: true, campaignId: path.split("/").pop() }; - } + if (!text) return { success: true }; const parsed: unknown = JSON.parse(text); // Batch endpoints answer 207 Multi-Status and report per-item failures // in the body, so an OK status alone does not mean the write succeeded. @@ -205,24 +213,15 @@ export class AmazonAdsClient { const errorText = await response.text(); - // Check if we should retry if (this.isRetryableError(response.status) && attempt < maxRetries) { - // Exponential backoff: 1s, 2s, 4s - const delay = Math.pow(2, attempt) * 1000; - - // Check for Retry-After header const retryAfter = response.headers.get("Retry-After"); - const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : delay; - + const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : this.backoffMs(attempt); await this.sleep(Math.min(waitTime, 10000)); // Cap at 10 seconds continue; } - lastError = new Error(`Amazon Ads API error (${response.status}): ${errorText}`); - break; + throw new Error(`Amazon Ads API error (${response.status}): ${errorText}`); } - - throw lastError || new Error("Request failed after retries"); } /** diff --git a/src/config.ts b/src/config.ts index f362c42..ff1de12 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,8 @@ export interface CliConfig { defaultProfileId?: string; } +const DEFAULTS: CliConfig = { region: "NA", sandbox: false }; + export function getConfigPath(): string { return path.join(os.homedir(), ".config", "amazonads-cli", "config.json"); } @@ -19,15 +21,23 @@ export function loadConfig(): CliConfig { const configPath = getConfigPath(); let fileConfig: CliConfig = {}; + let raw: string | undefined; try { - const raw = fs.readFileSync(configPath, "utf-8"); - fileConfig = JSON.parse(raw) as CliConfig; + raw = fs.readFileSync(configPath, "utf-8"); } catch { - // File doesn't exist or is invalid — start with empty config + // No config file yet; env vars or `auth login` will supply credentials. + } + + if (raw !== undefined) { + try { + fileConfig = JSON.parse(raw) as CliConfig; + } catch { + console.warn(`Warning: ${configPath} is not valid JSON and was ignored.`); + } } - // Environment variables take precedence over file config - const config: CliConfig = { ...fileConfig }; + // Precedence: environment variables, then the config file, then defaults. + const config: CliConfig = { ...DEFAULTS, ...fileConfig }; if (process.env.AMAZON_ADS_CLIENT_ID) { config.clientId = process.env.AMAZON_ADS_CLIENT_ID; From 20b72235cc3b5f407e3a5d294222985b66648384 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:51:14 -0400 Subject: [PATCH 2/7] Build campaign performance on the shared report pipeline getCampaignPerformance had its own copy of the create/poll/download flow with a 5-second cap, so it almost always returned PENDING and asked the user to poll by hand. It also used 14-day attribution while the reports command uses 7-day, so ACoS differed between the two. Reuse createReport, the campaign preset, and waitForReport. The command waits up to --timeout seconds (default 600) and reads cost and sales7d. --- README.md | 2 +- src/__tests__/amazon-ads-client.test.ts | 31 +++++++ src/__tests__/commands/campaigns.test.ts | 21 +++++ src/amazon-ads-client.ts | 102 +++++------------------ src/commands/campaigns.ts | 26 +++--- 5 files changed, 82 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 7d36f07..12f40a2 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ amazonads campaigns list --profile --state enabled amazonads campaigns list --profile --type sponsoredBrands amazonads campaigns list --profile --json -# Campaign performance report (async — may return a report ID if still processing) +# Campaign performance (7-day attribution; waits for Amazon to build the report, --timeout caps it) amazonads campaigns performance --profile --campaign --start 2024-01-01 --end 2024-01-31 # Create a campaign (always created in PAUSED state) diff --git a/src/__tests__/amazon-ads-client.test.ts b/src/__tests__/amazon-ads-client.test.ts index 36e793b..a214514 100644 --- a/src/__tests__/amazon-ads-client.test.ts +++ b/src/__tests__/amazon-ads-client.test.ts @@ -158,6 +158,37 @@ describe("AmazonAdsClient.getCampaignPerformance", () => { expect(rows).toEqual([{ campaignId: 222, campaignName: "B" }]); }); + it("requests the campaign preset with 7-day attribution", async () => { + mockCompletedReport([]); + + await makeClient().getCampaignPerformance("p", "1", "2026-10-01", "2026-10-10"); + + const body = JSON.parse(fetchMock.mock.calls[1][1].body as string); + expect(body.configuration.reportTypeId).toBe("spCampaigns"); + expect(body.configuration.timeUnit).toBe("SUMMARY"); + expect(body.configuration.columns).toContain("sales7d"); + expect(body.configuration.columns).not.toContain("sales14d"); + }); + + it("keeps polling past five seconds until the report completes", async () => { + vi.useFakeTimers(); + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, { reportId: "r1" })); + for (let i = 0; i < 6; i++) { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { status: "PENDING" })); + } + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { status: "COMPLETED", url: "https://example.com/r1.gz" })) + .mockResolvedValueOnce(gzipResponse([{ campaignId: 1 }])); + + const pending = makeClient().getCampaignPerformance("p", "1", "2026-10-01", "2026-10-10"); + await vi.runAllTimersAsync(); + + await expect(pending).resolves.toEqual([{ campaignId: 1 }]); + vi.useRealTimers(); + }); + it("returns an empty list, not every campaign, when the campaign has no rows", async () => { mockCompletedReport([ { campaignId: 111, campaignName: "A" }, diff --git a/src/__tests__/commands/campaigns.test.ts b/src/__tests__/commands/campaigns.test.ts index f0a5cb4..fbf54bd 100644 --- a/src/__tests__/commands/campaigns.test.ts +++ b/src/__tests__/commands/campaigns.test.ts @@ -63,6 +63,27 @@ describe("campaigns commands", () => { expect(consoleSpy).toHaveBeenCalledWith("No campaigns found."); }); + it("campaigns performance computes ACoS from cost and 7-day sales", async () => { + const client = makeClient(); + (client.getCampaignPerformance as ReturnType).mockResolvedValue([ + { campaignId: "7", campaignName: "Perf", impressions: 1000, clicks: 50, cost: 25, sales7d: 100 }, + ]); + + const program = new Command(); + program.exitOverride(); + registerCampaignsCommands(program, client, resolveProfileId); + + await program.parseAsync([ + "node", "cli", "campaigns", "performance", + "--campaign", "7", "--start", "2026-10-01", "--end", "2026-10-10", + ]); + + const output = consoleSpy.mock.calls[0][0] as string; + expect(output).toContain("$25.00"); + expect(output).toContain("$100.00"); + expect(output).toContain("25.0%"); + }); + it("campaigns list renders table for campaign data", async () => { const client = makeClient(); (client.getCampaigns as ReturnType).mockResolvedValue({ diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index 01d7571..df41de5 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -1,6 +1,7 @@ import zlib from "zlib"; import { promisify } from "util"; import { getTokenUrl, normalizeRegion } from "./lwa.js"; +import { buildReportSpec } from "./report-presets.js"; const gunzip = promisify(zlib.gunzip); @@ -290,93 +291,28 @@ export class AmazonAdsClient { return this.listAll(path, profileId, body, contentType, "campaigns"); } + /** + * Performance rows for one campaign over a date range. Builds the standard + * campaign report (7-day attribution) and waits for Amazon to generate it. + */ async getCampaignPerformance( profileId: string, campaignId: string, startDate: string, - endDate: string - ): Promise { - // v3 reporting API - create async report - const contentType = "application/vnd.createasyncreportrequest.v3+json"; - - const reportBody = { - name: `Campaign ${campaignId} Performance Report`, - startDate, - endDate, - configuration: { - adProduct: "SPONSORED_PRODUCTS", - groupBy: ["campaign"], - columns: [ - "campaignId", - "campaignName", - "impressions", - "clicks", - "cost", - "spend", - "sales14d", - "purchases14d", - "unitsSoldClicks14d", - "clickThroughRate", - "costPerClick", - ], - reportTypeId: "spCampaigns", - timeUnit: "SUMMARY", - format: "GZIP_JSON", - }, - }; - - // Step 1: Create the report - const createResponse = await this.makeRequest( - "POST", - "/reporting/reports", - profileId, - reportBody, - contentType - ) as { reportId: string }; - - const reportId = createResponse.reportId; - - // Step 2: Poll until report is ready (max 5 seconds) - const maxAttempts = 5; - const pollInterval = 1000; // 1 second - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const statusResponse = await this.makeRequest( - "GET", - `/reporting/reports/${reportId}`, - profileId - ) as { status: string; url?: string }; - - if (statusResponse.status === "COMPLETED" && statusResponse.url) { - // Step 3: Download and decompress the report - const reportData = await this.downloadReport(statusResponse.url); - - // Filter to the requested campaign. An empty result is correct here; - // never fall back to the whole account's rows. - if (campaignId && Array.isArray(reportData)) { - return reportData.filter( - (row: Record) => String(row.campaignId) === campaignId - ); - } - - return reportData; - } - - if (statusResponse.status === "FAILURE") { - throw new Error(`Report generation failed for reportId: ${reportId}`); - } - - // Wait before next poll - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - } - - // Return pending status with reportId so user can check later - return { - status: "PENDING", - message: `Report still processing after ${maxAttempts * pollInterval / 1000} seconds`, - reportId, - checkUrl: `/reporting/reports/${reportId}`, - }; + endDate: string, + options: { timeoutMs?: number } = {} + ): Promise[]> { + const spec = buildReportSpec("campaign", startDate, endDate, false); + spec.name = `Campaign ${campaignId} Performance Report`; + + const { reportId } = await this.createReport(profileId, spec); + const rows = (await this.waitForReport(profileId, reportId, options)) as Record< + string, + unknown + >[]; + + // An empty result is correct here; never fall back to the whole account's rows. + return rows.filter((row) => String(row.campaignId) === campaignId); } /** diff --git a/src/commands/campaigns.ts b/src/commands/campaigns.ts index 43e112d..7073529 100644 --- a/src/commands/campaigns.ts +++ b/src/commands/campaigns.ts @@ -81,46 +81,40 @@ export function registerCampaignsCommands( campaigns .command("performance") - .description("Get campaign performance report") + .description("Get campaign performance (7-day attribution); waits for Amazon to build the report") .requiredOption("--campaign ", "Campaign ID") .requiredOption("--start ", "Start date (YYYY-MM-DD)") .requiredOption("--end ", "End date (YYYY-MM-DD)") + .option("--timeout ", "Max seconds to wait for the report", "600") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") .action(async (opts) => { try { const profileId = resolveProfileId(opts); + const timeoutMs = Math.max(1, parseInt(opts.timeout, 10) || 600) * 1000; + if (!opts.json) printWarning("Waiting for Amazon to generate the report..."); const data = await client.getCampaignPerformance( profileId, opts.campaign, opts.start, - opts.end + opts.end, + { timeoutMs } ); - const result = data as Record; - - if (result.status === "PENDING") { - printWarning(`Report still processing. Report ID: ${result.reportId}`); - console.log(`Check status with: amazonads reports get --report ${result.reportId} --profile ${profileId}`); - if (opts.json) printJson(data); - return; - } - if (opts.json) { printJson(data); return; } - const rows = Array.isArray(data) ? data as Record[] : []; - if (rows.length === 0) { + if (data.length === 0) { console.log("No performance data found."); return; } const headers = ["Campaign ID", "Name", "Impressions", "Clicks", "Spend", "Sales", "ACOS"]; - const tableRows = rows.map((r) => { - const spend = typeof r.spend === "number" ? r.spend : parseFloat(String(r.spend || 0)); - const sales = typeof r.sales14d === "number" ? r.sales14d : parseFloat(String(r.sales14d || 0)); + const tableRows = data.map((r) => { + const spend = typeof r.cost === "number" ? r.cost : parseFloat(String(r.cost || 0)); + const sales = typeof r.sales7d === "number" ? r.sales7d : parseFloat(String(r.sales7d || 0)); const acos = sales > 0 ? ((spend / sales) * 100).toFixed(1) + "%" : "N/A"; return [ String(r.campaignId || ""), From 5c92ebdd6c971381cf1ea8b02b95176d436050c5 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:52:09 -0400 Subject: [PATCH 3/7] Validate campaign option values up front --type, --state, and --targeting were free text. A typo fell through to the Sponsored Products code path silently, and campaigns create accepted sponsoredBrands/sponsoredDisplay even though only the SP request body is implemented, so those calls failed at the API with an opaque error. Add choiceOption(), a case-insensitive commander Option that lists the allowed values in --help and in the error, and restrict create to sponsoredProducts until the SB/SD payloads exist. --- README.md | 2 +- src/__tests__/commands/campaigns.test.ts | 61 ++++++++++++++++++++++++ src/commands/campaigns.ts | 18 ++++--- src/options.ts | 25 ++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 src/options.ts diff --git a/README.md b/README.md index 12f40a2..ca73ce9 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ amazonads campaigns list --profile --json # Campaign performance (7-day attribution; waits for Amazon to build the report, --timeout caps it) amazonads campaigns performance --profile --campaign --start 2024-01-01 --end 2024-01-31 -# Create a campaign (always created in PAUSED state) +# Create a Sponsored Products campaign (always created in PAUSED state; --type sponsoredProducts only) amazonads campaigns create \ --profile \ --name "My Campaign" \ diff --git a/src/__tests__/commands/campaigns.test.ts b/src/__tests__/commands/campaigns.test.ts index fbf54bd..088a081 100644 --- a/src/__tests__/commands/campaigns.test.ts +++ b/src/__tests__/commands/campaigns.test.ts @@ -149,3 +149,64 @@ describe("campaigns commands", () => { expect(processExitSpy).toHaveBeenCalledWith(1); }); }); + +describe("campaigns option validation", () => { + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeProgram(client: AmazonAdsClient): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeErr: () => {}, writeOut: () => {} }); + registerCampaignsCommands(program, client, resolveProfileId); + return program; + } + + it("rejects an unknown campaign type on list", async () => { + const program = makeProgram(makeClient()); + + await expect( + program.parseAsync(["node", "cli", "campaigns", "list", "--type", "sponsoredFoo"]) + ).rejects.toThrow(/sponsoredProducts, sponsoredBrands, sponsoredDisplay/); + }); + + it("accepts state in any case and passes the canonical value on", async () => { + const client = makeClient(); + (client.getCampaigns as ReturnType).mockResolvedValue({ campaigns: [] }); + + await makeProgram(client).parseAsync(["node", "cli", "campaigns", "list", "--state", "enabled"]); + + expect(client.getCampaigns).toHaveBeenCalledWith("test-profile-123", "ENABLED", undefined); + }); + + it("only allows sponsoredProducts on create", async () => { + const program = makeProgram(makeClient()); + + await expect( + program.parseAsync([ + "node", "cli", "campaigns", "create", + "--name", "x", "--type", "sponsoredBrands", "--targeting", "MANUAL", + "--budget", "5", "--start", "2026-10-13", + ]) + ).rejects.toThrow(/sponsoredProducts/); + }); + + it("rejects an unknown targeting type on create", async () => { + const program = makeProgram(makeClient()); + + await expect( + program.parseAsync([ + "node", "cli", "campaigns", "create", + "--name", "x", "--type", "sponsoredProducts", "--targeting", "broad", + "--budget", "5", "--start", "2026-10-13", + ]) + ).rejects.toThrow(/MANUAL, AUTO/); + }); +}); diff --git a/src/commands/campaigns.ts b/src/commands/campaigns.ts index 7073529..7cd397a 100644 --- a/src/commands/campaigns.ts +++ b/src/commands/campaigns.ts @@ -10,6 +10,7 @@ import { colorState, formatCurrency, } from "../output.js"; +import { choiceOption, CAMPAIGN_TYPES, CAMPAIGN_STATES, TARGETING_TYPES } from "../options.js"; interface Campaign { campaignId?: string | number; @@ -43,8 +44,8 @@ export function registerCampaignsCommands( .command("list") .description("List campaigns") .option("--profile ", "Profile ID") - .option("--state ", "Filter by state (ENABLED, PAUSED, ARCHIVED)") - .option("--type ", "Campaign type (sponsoredProducts, sponsoredBrands, sponsoredDisplay)") + .addOption(choiceOption("--state ", "Filter by state", CAMPAIGN_STATES)) + .addOption(choiceOption("--type ", "Campaign type", CAMPAIGN_TYPES)) .option("--json", "Output as JSON") .action(async (opts) => { try { @@ -138,8 +139,13 @@ export function registerCampaignsCommands( .command("create") .description("Create a new campaign (created in PAUSED state)") .requiredOption("--name ", "Campaign name") - .requiredOption("--type ", "Campaign type (sponsoredProducts, sponsoredBrands, sponsoredDisplay)") - .requiredOption("--targeting ", "Targeting type (MANUAL or AUTO)") + // Only the Sponsored Products create body is implemented; SB/SD need different payloads. + .addOption( + choiceOption("--type ", "Campaign type", ["sponsoredProducts"]).makeOptionMandatory() + ) + .addOption( + choiceOption("--targeting ", "Targeting type", TARGETING_TYPES).makeOptionMandatory() + ) .requiredOption("--budget ", "Daily budget amount") .requiredOption("--start ", "Start date (YYYY-MM-DD)") .option("--profile ", "Profile ID") @@ -159,7 +165,7 @@ export function registerCampaignsCommands( profileId, opts.name, opts.type, - opts.targeting.toUpperCase(), + opts.targeting, budget, opts.start ); @@ -182,7 +188,7 @@ export function registerCampaignsCommands( .command("archive") .description("Archive a campaign") .requiredOption("--campaign ", "Campaign ID") - .requiredOption("--type ", "Campaign type (sponsoredProducts, sponsoredBrands, sponsoredDisplay)") + .addOption(choiceOption("--type ", "Campaign type", CAMPAIGN_TYPES).makeOptionMandatory()) .option("--profile ", "Profile ID") .option("--confirm", "Skip confirmation prompt") .option("--json", "Output as JSON") diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..250b502 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,25 @@ +import { InvalidArgumentError, Option } from "commander"; + +export const CAMPAIGN_TYPES = ["sponsoredProducts", "sponsoredBrands", "sponsoredDisplay"] as const; +export const CAMPAIGN_STATES = ["ENABLED", "PAUSED", "ARCHIVED"] as const; +export const TARGETING_TYPES = ["MANUAL", "AUTO"] as const; + +/** + * An option restricted to a fixed set of values. Matching is case-insensitive + * and the canonical spelling is what the action receives, so `--state enabled` + * arrives as "ENABLED". + */ +export function choiceOption( + flags: string, + description: string, + choices: readonly string[] +): Option { + const list = choices.join(", "); + return new Option(flags, `${description} (${list})`).argParser((value: string) => { + const match = choices.find((c) => c.toLowerCase() === value.toLowerCase()); + if (!match) { + throw new InvalidArgumentError(`Allowed choices are ${list}.`); + } + return match; + }); +} From a6d843b483ac31bf1666510c9cb4802ef6bb87b9 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:53:21 -0400 Subject: [PATCH 4/7] Harden the OAuth login callback - Send a random state value on the consent URL and refuse callbacks whose state does not match, as Amazon's docs recommend, so a forged redirect cannot hand the CLI someone else's authorization code. - Escape everything interpolated into the callback HTML page; the provider's error string was previously reflected verbatim. - Explain EADDRINUSE on the callback port instead of printing the raw listen error, since port 3000 is a common dev-server port. - Move the config save out of the server close callback so the promise resolves with the refresh token and the flow reads top-down. --- src/__tests__/commands/auth.test.ts | 21 +++++ src/__tests__/lwa.test.ts | 36 +++++++- src/commands/auth.ts | 125 ++++++++++++++++------------ src/lwa.ts | 31 ++++++- 4 files changed, 159 insertions(+), 54 deletions(-) create mode 100644 src/__tests__/commands/auth.test.ts diff --git a/src/__tests__/commands/auth.test.ts b/src/__tests__/commands/auth.test.ts new file mode 100644 index 0000000..d5bc72c --- /dev/null +++ b/src/__tests__/commands/auth.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { escapeHtml, listenErrorMessage } from "../../commands/auth.js"; + +describe("escapeHtml", () => { + it("neutralises markup in values echoed to the callback page", () => { + expect(escapeHtml(` & 'q'`)).toBe( + "<script>alert("x")</script> & 'q'" + ); + }); +}); + +describe("listenErrorMessage", () => { + it("explains a port clash in terms the user can act on", () => { + const err = Object.assign(new Error("listen EADDRINUSE"), { code: "EADDRINUSE" }); + expect(listenErrorMessage(err, 3000)).toMatch(/port 3000.*in use/i); + }); + + it("passes other errors through", () => { + expect(listenErrorMessage(new Error("boom"), 3000)).toBe("boom"); + }); +}); diff --git a/src/__tests__/lwa.test.ts b/src/__tests__/lwa.test.ts index 89e6784..7ea5a27 100644 --- a/src/__tests__/lwa.test.ts +++ b/src/__tests__/lwa.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildConsentUrl, getTokenUrl, normalizeRegion } from "../lwa.js"; +import { buildConsentUrl, getTokenUrl, normalizeRegion, parseCallback } from "../lwa.js"; describe("normalizeRegion", () => { it("defaults to NA when empty", () => { @@ -48,3 +48,37 @@ describe("buildConsentUrl", () => { expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:3000/callback"); }); }); + +describe("buildConsentUrl state", () => { + it("carries the state value so the callback can be verified", () => { + const url = buildConsentUrl("NA", "cid", "http://localhost:3000/callback", "abc123"); + expect(url.searchParams.get("state")).toBe("abc123"); + }); +}); + +describe("parseCallback", () => { + it("returns the code when state matches", () => { + const params = new URLSearchParams({ code: "the-code", state: "s1" }); + expect(parseCallback(params, "s1")).toBe("the-code"); + }); + + it("rejects a callback whose state does not match", () => { + const params = new URLSearchParams({ code: "the-code", state: "other" }); + expect(() => parseCallback(params, "s1")).toThrow(/state/i); + }); + + it("rejects a callback with no state at all", () => { + const params = new URLSearchParams({ code: "the-code" }); + expect(() => parseCallback(params, "s1")).toThrow(/state/i); + }); + + it("surfaces the provider's error before checking anything else", () => { + const params = new URLSearchParams({ error: "access_denied", error_description: "User said no" }); + expect(() => parseCallback(params, "s1")).toThrow(/access_denied.*User said no/); + }); + + it("rejects a callback with no code", () => { + const params = new URLSearchParams({ state: "s1" }); + expect(() => parseCallback(params, "s1")).toThrow(/code/i); + }); +}); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index ef354bf..488dbc4 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,12 +1,20 @@ import http from "http"; +import { randomBytes } from "crypto"; import { URL } from "url"; import readline from "readline/promises"; import { Command } from "commander"; import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "../config.js"; import { printSuccess, printError, printWarning } from "../output.js"; -import { buildConsentUrl, getTokenUrl, normalizeRegion, REGIONS } from "../lwa.js"; +import { + buildConsentUrl, + getTokenUrl, + normalizeRegion, + parseCallback, + REGIONS, +} from "../lwa.js"; -const REDIRECT_URI = "http://localhost:3000/callback"; +const CALLBACK_PORT = 3000; +const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`; function maskSecret(value: string | undefined): string { if (!value) return "(not set)"; @@ -14,6 +22,35 @@ function maskSecret(value: string | undefined): string { return value.slice(0, 4) + "****" + value.slice(-4); } +/** Escape a value for interpolation into the callback HTML page. */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Turn a listen() failure into something the user can act on. */ +export function listenErrorMessage(err: unknown, port: number): string { + const code = (err as { code?: string })?.code; + if (code === "EADDRINUSE") { + return ( + `Port ${port} is already in use, so the OAuth callback server cannot start. ` + + `Stop whatever is listening on ${port} (often a dev server) and run auth login again.` + ); + } + return err instanceof Error ? err.message : String(err); +} + +function htmlPage(title: string, body: string): string { + return ( + `` + + `

${escapeHtml(title)}

${escapeHtml(body)}

` + ); +} + export function registerAuthCommands(program: Command): void { const auth = program.command("auth").description("Manage authentication"); @@ -30,10 +67,10 @@ export function registerAuthCommands(program: Command): void { console.log("\nAmazon Ads CLI — OAuth Login"); console.log("=============================\n"); - const clientId = await rl.question("Client ID: "); - const clientSecret = await rl.question("Client Secret: "); + const clientId = (await rl.question("Client ID: ")).trim(); + const clientSecret = (await rl.question("Client Secret: ")).trim(); - if (!clientId.trim() || !clientSecret.trim()) { + if (!clientId || !clientSecret) { printError("Client ID and Client Secret are required."); process.exit(1); } @@ -44,11 +81,13 @@ export function registerAuthCommands(program: Command): void { const sandboxAnswer = await rl.question("Sandbox mode? (y/N): "); const sandbox = sandboxAnswer.trim().toLowerCase() === "y"; - const authUrl = buildConsentUrl(region, clientId.trim(), REDIRECT_URI); + // Random state ties the callback to this login attempt (CSRF protection). + const state = randomBytes(16).toString("hex"); + const authUrl = buildConsentUrl(region, clientId, REDIRECT_URI, state); - console.log("\nStarting local callback server on http://localhost:3000..."); + console.log(`\nStarting local callback server on http://localhost:${CALLBACK_PORT}...`); - await new Promise((resolve, reject) => { + const refreshToken = await new Promise((resolve, reject) => { const server = http.createServer(async (req, res) => { const url = new URL(req.url || "", `http://${req.headers.host}`); @@ -58,23 +97,15 @@ export function registerAuthCommands(program: Command): void { return; } - const code = url.searchParams.get("code"); - const error = url.searchParams.get("error"); - - if (error) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(htmlPage("Authorization Failed", `Error: ${error}`)); - printError(`\nAuthorization failed: ${error}`); - server.close(); - reject(new Error(error)); - return; - } - - if (!code) { + let code: string; + try { + code = parseCallback(url.searchParams, state); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); res.writeHead(400, { "Content-Type": "text/html" }); - res.end(htmlPage("Missing Authorization Code", "No code received.")); + res.end(htmlPage("Authorization Failed", message)); server.close(); - reject(new Error("No authorization code received")); + reject(new Error(message)); return; } @@ -83,8 +114,8 @@ export function registerAuthCommands(program: Command): void { grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, - client_id: clientId.trim(), - client_secret: clientSecret.trim(), + client_id: clientId, + client_secret: clientSecret, }); const response = await fetch(getTokenUrl(region), { @@ -98,38 +129,20 @@ export function registerAuthCommands(program: Command): void { throw new Error(`Token exchange failed: ${errText}`); } - const tokens = await response.json() as { refresh_token: string }; + const tokens = (await response.json()) as { refresh_token: string }; res.writeHead(200, { "Content-Type": "text/html" }); res.end(htmlPage("Success!", "Your token has been saved. You can close this window.")); - - server.close(() => { - try { - const existing = loadConfig(); - saveConfig({ - ...existing, - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - refreshToken: tokens.refresh_token, - region, - sandbox, - }); - - printSuccess(`\nAuthentication successful! Config saved to ${getConfigPath()}`); - resolve(); - } catch (e) { - reject(e); - } - }); + server.close(() => resolve(tokens.refresh_token)); } catch (e) { res.writeHead(500, { "Content-Type": "text/html" }); - res.end(htmlPage("Token Exchange Failed", String(e))); + res.end(htmlPage("Token Exchange Failed", e instanceof Error ? e.message : String(e))); server.close(); reject(e); } }); - server.listen(3000, "127.0.0.1", async () => { + server.listen(CALLBACK_PORT, "127.0.0.1", async () => { console.log("Callback server started.\n"); console.log("Opening browser to Amazon OAuth..."); console.log(`\n ${authUrl.toString()}\n`); @@ -145,9 +158,21 @@ export function registerAuthCommands(program: Command): void { }); server.on("error", (err) => { - reject(err); + reject(new Error(listenErrorMessage(err, CALLBACK_PORT))); }); }); + + const existing = loadConfig(); + saveConfig({ + ...existing, + clientId, + clientSecret, + refreshToken, + region, + sandbox, + }); + + printSuccess(`\nAuthentication successful! Config saved to ${getConfigPath()}`); } catch (err) { printError(`Login failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); @@ -187,7 +212,3 @@ export function registerAuthCommands(program: Command): void { printSuccess("Logged out. Config file deleted."); }); } - -function htmlPage(title: string, body: string): string { - return `

${title}

${body}

`; -} diff --git a/src/lwa.ts b/src/lwa.ts index 08d3c57..f85d6e4 100644 --- a/src/lwa.ts +++ b/src/lwa.ts @@ -34,11 +34,40 @@ export function getTokenUrl(region: Region): string { return `${TOKEN_HOSTS[region]}/auth/o2/token`; } -export function buildConsentUrl(region: Region, clientId: string, redirectUri: string): URL { +export function buildConsentUrl( + region: Region, + clientId: string, + redirectUri: string, + state?: string +): URL { const url = new URL("/ap/oa", CONSENT_HOSTS[region]); url.searchParams.set("client_id", clientId); url.searchParams.set("scope", SCOPE); url.searchParams.set("response_type", "code"); url.searchParams.set("redirect_uri", redirectUri); + if (state) url.searchParams.set("state", state); return url; } + +/** + * Validate the query string Login with Amazon sends to the redirect URI and + * return the authorization code. The `state` echo must match what we sent so + * a request forged by another page cannot hand us its code. + */ +export function parseCallback(params: URLSearchParams, expectedState: string): string { + const error = params.get("error"); + if (error) { + const description = params.get("error_description"); + throw new Error(description ? `${error}: ${description}` : error); + } + + if (params.get("state") !== expectedState) { + throw new Error("Callback state did not match the login request; ignoring it."); + } + + const code = params.get("code"); + if (!code) { + throw new Error("No authorization code received."); + } + return code; +} From 2688ee4fe03e28e3b7c55abf00a88464cc6405bb Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:54:48 -0400 Subject: [PATCH 5/7] Route command errors through one runAction wrapper Every action repeated the same try/catch that printed the error and called process.exit(1). Replace it with runAction(), which prints once and sets process.exitCode so stdout is flushed before exit, and fail() for validation messages that are already user-facing. Also add a prettier config and format src/ with it, and build the CSV header set with a Set instead of repeated includes() scans. --- .prettierignore | 5 + .prettierrc | 4 + src/__tests__/amazon-ads-client.test.ts | 37 +++- src/__tests__/commands/campaigns.test.ts | 129 +++++++++--- src/__tests__/commands/keywords.test.ts | 43 +++- src/__tests__/commands/reports.test.ts | 53 ++++- src/__tests__/lwa.test.ts | 5 +- src/__tests__/output.test.ts | 14 +- src/__tests__/run-action.test.ts | 44 ++++ src/amazon-ads-client.ts | 45 ++--- src/commands/ads.ts | 14 +- src/commands/auth.ts | 243 +++++++++++------------ src/commands/campaigns.ts | 61 +++--- src/commands/keywords.ts | 46 ++--- src/commands/portfolios.ts | 14 +- src/commands/profiles.ts | 19 +- src/commands/reports.ts | 76 +++---- src/index.ts | 10 +- src/output.ts | 7 +- src/run-action.ts | 31 +++ 20 files changed, 536 insertions(+), 364 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 src/__tests__/run-action.test.ts create mode 100644 src/run-action.ts diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e08a214 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +build/ +coverage/ +node_modules/ +package-lock.json +.claude/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..12c91d0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 100, + "trailingComma": "es5" +} diff --git a/src/__tests__/amazon-ads-client.test.ts b/src/__tests__/amazon-ads-client.test.ts index a214514..b189f7d 100644 --- a/src/__tests__/amazon-ads-client.test.ts +++ b/src/__tests__/amazon-ads-client.test.ts @@ -36,9 +36,7 @@ describe("AmazonAdsClient token refresh", () => { }); it("refreshes against the regional Login with Amazon host", async () => { - fetchMock - .mockResolvedValueOnce(tokenResponse()) - .mockResolvedValueOnce(jsonResponse(200, [])); + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(jsonResponse(200, [])); await makeClient("EU").getProfiles(); @@ -101,7 +99,9 @@ describe("AmazonAdsClient multi-status handling", () => { error: [ { index: 0, - errors: [{ errorType: "DUPLICATE_VALUE", errorValue: { message: "Name already used" } }], + errors: [ + { errorType: "DUPLICATE_VALUE", errorValue: { message: "Name already used" } }, + ], }, ], }, @@ -109,7 +109,14 @@ describe("AmazonAdsClient multi-status handling", () => { ); await expect( - makeClient().createCampaign("profile-1", "Dup", "sponsoredProducts", "MANUAL", 10, "2026-10-13") + makeClient().createCampaign( + "profile-1", + "Dup", + "sponsoredProducts", + "MANUAL", + 10, + "2026-10-13" + ) ).rejects.toThrow(/DUPLICATE_VALUE/); }); @@ -179,7 +186,9 @@ describe("AmazonAdsClient.getCampaignPerformance", () => { fetchMock.mockResolvedValueOnce(jsonResponse(200, { status: "PENDING" })); } fetchMock - .mockResolvedValueOnce(jsonResponse(200, { status: "COMPLETED", url: "https://example.com/r1.gz" })) + .mockResolvedValueOnce( + jsonResponse(200, { status: "COMPLETED", url: "https://example.com/r1.gz" }) + ) .mockResolvedValueOnce(gzipResponse([{ campaignId: 1 }])); const pending = makeClient().getCampaignPerformance("p", "1", "2026-10-01", "2026-10-10"); @@ -223,7 +232,9 @@ describe("AmazonAdsClient list pagination", () => { .mockResolvedValueOnce( jsonResponse(200, { campaigns: [{ campaignId: "1" }], totalResults: 2, nextToken: "page2" }) ) - .mockResolvedValueOnce(jsonResponse(200, { campaigns: [{ campaignId: "2" }], totalResults: 2 })); + .mockResolvedValueOnce( + jsonResponse(200, { campaigns: [{ campaignId: "2" }], totalResults: 2 }) + ); const result = await makeClient().getCampaigns("p", "enabled"); @@ -238,7 +249,9 @@ describe("AmazonAdsClient list pagination", () => { it("makes a single request when the first page has no nextToken", async () => { fetchMock .mockResolvedValueOnce(tokenResponse()) - .mockResolvedValueOnce(jsonResponse(200, { keywords: [{ keywordId: "k1" }], totalResults: 1 })); + .mockResolvedValueOnce( + jsonResponse(200, { keywords: [{ keywordId: "k1" }], totalResults: 1 }) + ); const result = await makeClient().getKeywords("p", "c1"); @@ -252,7 +265,9 @@ describe("AmazonAdsClient list pagination", () => { .mockResolvedValueOnce(tokenResponse()) .mockResolvedValueOnce(jsonResponse(200, { productAds: [{ adId: "a1" }], nextToken: "n" })) .mockResolvedValueOnce(jsonResponse(200, { productAds: [{ adId: "a2" }] })) - .mockResolvedValueOnce(jsonResponse(200, { portfolios: [{ portfolioId: 1 }], nextToken: "n" })) + .mockResolvedValueOnce( + jsonResponse(200, { portfolios: [{ portfolioId: 1 }], nextToken: "n" }) + ) .mockResolvedValueOnce(jsonResponse(200, { portfolios: [{ portfolioId: 2 }] })); const client = makeClient(); @@ -300,7 +315,9 @@ describe("AmazonAdsClient request hardening", () => { const outcome = pending.catch((e: Error) => e); await vi.runAllTimersAsync(); - await expect(outcome).resolves.toMatchObject({ message: expect.stringMatching(/fetch failed/) }); + await expect(outcome).resolves.toMatchObject({ + message: expect.stringMatching(/fetch failed/), + }); expect(fetchMock).toHaveBeenCalledTimes(1 + 4); }); diff --git a/src/__tests__/commands/campaigns.test.ts b/src/__tests__/commands/campaigns.test.ts index 088a081..40b46cc 100644 --- a/src/__tests__/commands/campaigns.test.ts +++ b/src/__tests__/commands/campaigns.test.ts @@ -30,13 +30,21 @@ describe("campaigns commands", () => { afterEach(() => { vi.restoreAllMocks(); + process.exitCode = undefined; }); it("campaigns list outputs JSON when --json flag is set", async () => { const client = makeClient(); const mockData = { campaigns: [ - { campaignId: "1", name: "Test Campaign", state: "ENABLED", budget: { budget: 10 }, startDate: "2024-01-01", targetingType: "MANUAL" }, + { + campaignId: "1", + name: "Test Campaign", + state: "ENABLED", + budget: { budget: 10 }, + startDate: "2024-01-01", + targetingType: "MANUAL", + }, ], }; (client.getCampaigns as ReturnType).mockResolvedValue(mockData); @@ -66,7 +74,14 @@ describe("campaigns commands", () => { it("campaigns performance computes ACoS from cost and 7-day sales", async () => { const client = makeClient(); (client.getCampaignPerformance as ReturnType).mockResolvedValue([ - { campaignId: "7", campaignName: "Perf", impressions: 1000, clicks: 50, cost: 25, sales7d: 100 }, + { + campaignId: "7", + campaignName: "Perf", + impressions: 1000, + clicks: 50, + cost: 25, + sales7d: 100, + }, ]); const program = new Command(); @@ -74,8 +89,16 @@ describe("campaigns commands", () => { registerCampaignsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "campaigns", "performance", - "--campaign", "7", "--start", "2026-10-01", "--end", "2026-10-10", + "node", + "cli", + "campaigns", + "performance", + "--campaign", + "7", + "--start", + "2026-10-01", + "--end", + "2026-10-10", ]); const output = consoleSpy.mock.calls[0][0] as string; @@ -88,7 +111,14 @@ describe("campaigns commands", () => { const client = makeClient(); (client.getCampaigns as ReturnType).mockResolvedValue({ campaigns: [ - { campaignId: "42", name: "My Campaign", state: "PAUSED", budget: { budget: 50.00 }, startDate: "2024-06-01", targetingType: "AUTO" }, + { + campaignId: "42", + name: "My Campaign", + state: "PAUSED", + budget: { budget: 50.0 }, + startDate: "2024-06-01", + targetingType: "AUTO", + }, ], }); @@ -105,19 +135,29 @@ describe("campaigns commands", () => { it("campaigns create calls createCampaign with correct args", async () => { const client = makeClient(); - (client.createCampaign as ReturnType).mockResolvedValue({ campaigns: [{ campaignId: "99" }] }); + (client.createCampaign as ReturnType).mockResolvedValue({ + campaigns: [{ campaignId: "99" }], + }); const program = new Command(); program.exitOverride(); registerCampaignsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "campaigns", "create", - "--name", "New Campaign", - "--type", "sponsoredProducts", - "--targeting", "manual", - "--budget", "25.00", - "--start", "2024-07-01", + "node", + "cli", + "campaigns", + "create", + "--name", + "New Campaign", + "--type", + "sponsoredProducts", + "--targeting", + "manual", + "--budget", + "25.00", + "--start", + "2024-07-01", ]); expect(client.createCampaign).toHaveBeenCalledWith( @@ -138,15 +178,23 @@ describe("campaigns commands", () => { registerCampaignsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "campaigns", "create", - "--name", "Bad Campaign", - "--type", "sponsoredProducts", - "--targeting", "MANUAL", - "--budget", "abc", - "--start", "2024-07-01", + "node", + "cli", + "campaigns", + "create", + "--name", + "Bad Campaign", + "--type", + "sponsoredProducts", + "--targeting", + "MANUAL", + "--budget", + "abc", + "--start", + "2024-07-01", ]); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); }); @@ -181,7 +229,14 @@ describe("campaigns option validation", () => { const client = makeClient(); (client.getCampaigns as ReturnType).mockResolvedValue({ campaigns: [] }); - await makeProgram(client).parseAsync(["node", "cli", "campaigns", "list", "--state", "enabled"]); + await makeProgram(client).parseAsync([ + "node", + "cli", + "campaigns", + "list", + "--state", + "enabled", + ]); expect(client.getCampaigns).toHaveBeenCalledWith("test-profile-123", "ENABLED", undefined); }); @@ -191,9 +246,20 @@ describe("campaigns option validation", () => { await expect( program.parseAsync([ - "node", "cli", "campaigns", "create", - "--name", "x", "--type", "sponsoredBrands", "--targeting", "MANUAL", - "--budget", "5", "--start", "2026-10-13", + "node", + "cli", + "campaigns", + "create", + "--name", + "x", + "--type", + "sponsoredBrands", + "--targeting", + "MANUAL", + "--budget", + "5", + "--start", + "2026-10-13", ]) ).rejects.toThrow(/sponsoredProducts/); }); @@ -203,9 +269,20 @@ describe("campaigns option validation", () => { await expect( program.parseAsync([ - "node", "cli", "campaigns", "create", - "--name", "x", "--type", "sponsoredProducts", "--targeting", "broad", - "--budget", "5", "--start", "2026-10-13", + "node", + "cli", + "campaigns", + "create", + "--name", + "x", + "--type", + "sponsoredProducts", + "--targeting", + "broad", + "--budget", + "5", + "--start", + "2026-10-13", ]) ).rejects.toThrow(/MANUAL, AUTO/); }); diff --git a/src/__tests__/commands/keywords.test.ts b/src/__tests__/commands/keywords.test.ts index 4a459ca..4d6d121 100644 --- a/src/__tests__/commands/keywords.test.ts +++ b/src/__tests__/commands/keywords.test.ts @@ -26,13 +26,20 @@ describe("keywords commands", () => { afterEach(() => { vi.restoreAllMocks(); + process.exitCode = undefined; }); it("keywords list outputs JSON when --json flag set", async () => { const client = makeClient(); const mockData = { keywords: [ - { keywordId: "1", keywordText: "running shoes", matchType: "EXACT", state: "ENABLED", bid: 1.25 }, + { + keywordId: "1", + keywordText: "running shoes", + matchType: "EXACT", + state: "ENABLED", + bid: 1.25, + }, ], }; (client.getKeywords as ReturnType).mockResolvedValue(mockData); @@ -63,7 +70,15 @@ describe("keywords commands", () => { const client = makeClient(); (client.getKeywords as ReturnType).mockResolvedValue({ keywords: [ - { keywordId: "55", keywordText: "blue widget", matchType: "BROAD", state: "PAUSED", bid: 0.75, campaignId: "10", adGroupId: "20" }, + { + keywordId: "55", + keywordText: "blue widget", + matchType: "BROAD", + state: "PAUSED", + bid: 0.75, + campaignId: "10", + adGroupId: "20", + }, ], }); @@ -87,9 +102,14 @@ describe("keywords commands", () => { registerKeywordsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "keywords", "update-bid", - "--keyword", "kw-123", - "--bid", "1.50", + "node", + "cli", + "keywords", + "update-bid", + "--keyword", + "kw-123", + "--bid", + "1.50", ]); expect(client.updateKeywordBid).toHaveBeenCalledWith("test-profile-123", "kw-123", 1.5); @@ -103,12 +123,17 @@ describe("keywords commands", () => { registerKeywordsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "keywords", "update-bid", - "--keyword", "kw-123", - "--bid", "-5", + "node", + "cli", + "keywords", + "update-bid", + "--keyword", + "kw-123", + "--bid", + "-5", ]); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); expect(client.updateKeywordBid).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/commands/reports.test.ts b/src/__tests__/commands/reports.test.ts index 2e94f24..c3a99e9 100644 --- a/src/__tests__/commands/reports.test.ts +++ b/src/__tests__/commands/reports.test.ts @@ -69,6 +69,7 @@ describe("reports commands", () => { afterEach(() => { vi.restoreAllMocks(); + process.exitCode = undefined; rmSync(dir, { recursive: true, force: true }); }); @@ -81,9 +82,18 @@ describe("reports commands", () => { registerReportsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "reports", "create", - "--type", "search-term", "--start", "2026-09-01", "--end", "2026-09-30", - "--no-wait", "--json", + "node", + "cli", + "reports", + "create", + "--type", + "search-term", + "--start", + "2026-09-01", + "--end", + "2026-09-30", + "--no-wait", + "--json", ]); expect(client.createReport).toHaveBeenCalledWith( @@ -91,7 +101,9 @@ describe("reports commands", () => { expect.objectContaining({ reportTypeId: "spSearchTerm", startDate: "2026-09-01" }) ); expect(client.waitForReport).not.toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify({ reportId: "r-1", status: "PENDING" }, null, 2)); + expect(consoleSpy).toHaveBeenCalledWith( + JSON.stringify({ reportId: "r-1", status: "PENDING" }, null, 2) + ); }); it("reports create waits and writes CSV to --out", async () => { @@ -108,9 +120,18 @@ describe("reports commands", () => { registerReportsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "reports", "create", - "--type", "campaign", "--start", "2026-09-01", "--end", "2026-09-30", - "--out", out, + "node", + "cli", + "reports", + "create", + "--type", + "campaign", + "--start", + "2026-09-01", + "--end", + "2026-09-30", + "--out", + out, ]); expect(readFileSync(out, "utf-8")).toBe("campaignName,clicks\nA,3\nB,5\n"); @@ -123,12 +144,20 @@ describe("reports commands", () => { registerReportsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "reports", "create", - "--type", "bogus", "--start", "2026-09-01", "--end", "2026-09-30", + "node", + "cli", + "reports", + "create", + "--type", + "bogus", + "--start", + "2026-09-01", + "--end", + "2026-09-30", ]); expect(client.createReport).not.toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); it("reports get --out writes completed rows as JSON when the name ends in .json", async () => { @@ -145,6 +174,8 @@ describe("reports commands", () => { await program.parseAsync(["node", "cli", "reports", "get", "--report", "r-3", "--out", out]); - expect(JSON.parse(readFileSync(out, "utf-8"))).toEqual([{ searchTerm: "ghost decor", clicks: 2 }]); + expect(JSON.parse(readFileSync(out, "utf-8"))).toEqual([ + { searchTerm: "ghost decor", clicks: 2 }, + ]); }); }); diff --git a/src/__tests__/lwa.test.ts b/src/__tests__/lwa.test.ts index 7ea5a27..56657ef 100644 --- a/src/__tests__/lwa.test.ts +++ b/src/__tests__/lwa.test.ts @@ -73,7 +73,10 @@ describe("parseCallback", () => { }); it("surfaces the provider's error before checking anything else", () => { - const params = new URLSearchParams({ error: "access_denied", error_description: "User said no" }); + const params = new URLSearchParams({ + error: "access_denied", + error_description: "User said no", + }); expect(() => parseCallback(params, "s1")).toThrow(/access_denied.*User said no/); }); diff --git a/src/__tests__/output.test.ts b/src/__tests__/output.test.ts index 709af76..85bf3d3 100644 --- a/src/__tests__/output.test.ts +++ b/src/__tests__/output.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { printJson, printSuccess, printError, printWarning, formatCurrency, colorState, printTable } from "../output.js"; +import { + printJson, + printSuccess, + printError, + printWarning, + formatCurrency, + colorState, + printTable, +} from "../output.js"; describe("output", () => { let consoleSpy: ReturnType; @@ -18,9 +26,7 @@ describe("output", () => { it("printJson outputs pretty JSON", () => { printJson({ key: "value", num: 42 }); - expect(consoleSpy).toHaveBeenCalledWith( - JSON.stringify({ key: "value", num: 42 }, null, 2) - ); + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify({ key: "value", num: 42 }, null, 2)); }); it("printSuccess calls console.log", () => { diff --git a/src/__tests__/run-action.test.ts b/src/__tests__/run-action.test.ts new file mode 100644 index 0000000..90b7e56 --- /dev/null +++ b/src/__tests__/run-action.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { runAction, fail } from "../run-action.js"; + +describe("runAction", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + it("passes the action's arguments through and leaves exitCode alone on success", async () => { + const seen: unknown[] = []; + await runAction(async (a: string, b: number) => { + seen.push(a, b); + })("x", 2); + + expect(seen).toEqual(["x", 2]); + expect(process.exitCode).toBeUndefined(); + }); + + it("reports an unexpected error with an Error: prefix and exit code 1", async () => { + await runAction(async () => { + throw new Error("boom"); + })(); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Error: boom")); + expect(process.exitCode).toBe(1); + }); + + it("prints a fail() message verbatim, since it is already user-facing", async () => { + await runAction(async () => fail("Budget must be a positive number."))(); + + const printed = errorSpy.mock.calls[0][0] as string; + expect(printed).toContain("Budget must be a positive number."); + expect(printed).not.toContain("Error:"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index df41de5..f095a89 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -56,9 +56,7 @@ function throwIfItemsRejected(body: unknown): void { }) .join("\n"); - throw new Error( - `Amazon Ads API rejected ${errors.length} ${entity} item(s):\n${details}` - ); + throw new Error(`Amazon Ads API rejected ${errors.length} ${entity} item(s):\n${details}`); } } @@ -157,7 +155,7 @@ export class AmazonAdsClient { const url = `${this.getEndpoint()}${path}`; const ct = contentType || "application/json"; const headers: Record = { - "Authorization": `Bearer ${this.accessToken}`, + Authorization: `Bearer ${this.accessToken}`, "Amazon-Advertising-API-ClientId": this.config.clientId!, }; @@ -265,11 +263,7 @@ export class AmazonAdsClient { return this.makeRequest("GET", "/v2/profiles"); } - async getCampaigns( - profileId: string, - state?: string, - campaignType?: string - ): Promise { + async getCampaigns(profileId: string, state?: string, campaignType?: string): Promise { // v3 API uses POST with filters in body let path = "/sp/campaigns/list"; let contentType = "application/vnd.spcampaign.v3+json"; @@ -319,10 +313,7 @@ export class AmazonAdsClient { * Create an async v3 report. Returns the reportId; poll with getReport() * or waitForReport(). */ - async createReport( - profileId: string, - spec: ReportSpec - ): Promise<{ reportId: string }> { + async createReport(profileId: string, spec: ReportSpec): Promise<{ reportId: string }> { const contentType = "application/vnd.createasyncreportrequest.v3+json"; const body = { name: spec.name, @@ -339,13 +330,9 @@ export class AmazonAdsClient { }, }; - return this.makeRequest( - "POST", - "/reporting/reports", - profileId, - body, - contentType - ) as Promise<{ reportId: string }>; + return this.makeRequest("POST", "/reporting/reports", profileId, body, contentType) as Promise<{ + reportId: string; + }>; } /** @@ -410,11 +397,11 @@ export class AmazonAdsClient { } async getReport(profileId: string, reportId: string): Promise { - const statusResponse = await this.makeRequest( + const statusResponse = (await this.makeRequest( "GET", `/reporting/reports/${reportId}`, profileId - ) as { status: string; url?: string; failureReason?: string }; + )) as { status: string; url?: string; failureReason?: string }; if (statusResponse.status === "COMPLETED" && statusResponse.url) { const reportData = await this.downloadReport(statusResponse.url); @@ -439,11 +426,7 @@ export class AmazonAdsClient { }; } - async getKeywords( - profileId: string, - campaignId?: string, - adGroupId?: string - ): Promise { + async getKeywords(profileId: string, campaignId?: string, adGroupId?: string): Promise { const path = "/sp/keywords/list"; const contentType = "application/vnd.spkeyword.v3+json"; @@ -460,11 +443,7 @@ export class AmazonAdsClient { return this.listAll(path, profileId, body, contentType, "keywords"); } - async updateKeywordBid( - profileId: string, - keywordId: string, - bid: number - ): Promise { + async updateKeywordBid(profileId: string, keywordId: string, bid: number): Promise { const contentType = "application/vnd.spkeyword.v3+json"; const body = { keywords: [ @@ -502,7 +481,7 @@ export class AmazonAdsClient { { name, targetingType, - state: "PAUSED", // Default to PAUSED for safety - won't spend money + state: "PAUSED", // Default to PAUSED for safety - won't spend money budget: { budgetType: "DAILY", budget: dailyBudget, diff --git a/src/commands/ads.ts b/src/commands/ads.ts index 676edb4..c1239b7 100644 --- a/src/commands/ads.ts +++ b/src/commands/ads.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { printTable, printJson, printError, colorState } from "../output.js"; +import { printTable, printJson, colorState } from "../output.js"; +import { runAction, fail } from "../run-action.js"; interface ProductAd { adId?: string | number; @@ -36,8 +37,8 @@ export function registerAdsCommands( .option("--campaign ", "Filter by campaign ID") .option("--ad-group ", "Filter by ad group ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getProductAds(profileId, opts.campaign, opts.adGroup); @@ -63,9 +64,6 @@ export function registerAdsCommands( ]); printTable(headers, rows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 488dbc4..9012dca 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -4,14 +4,9 @@ import { URL } from "url"; import readline from "readline/promises"; import { Command } from "commander"; import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "../config.js"; -import { printSuccess, printError, printWarning } from "../output.js"; -import { - buildConsentUrl, - getTokenUrl, - normalizeRegion, - parseCallback, - REGIONS, -} from "../lwa.js"; +import { printSuccess, printWarning } from "../output.js"; +import { runAction, fail } from "../run-action.js"; +import { buildConsentUrl, getTokenUrl, normalizeRegion, parseCallback, REGIONS } from "../lwa.js"; const CALLBACK_PORT = 3000; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`; @@ -57,129 +52,131 @@ export function registerAuthCommands(program: Command): void { auth .command("login") .description("Authenticate with Amazon Ads OAuth") - .action(async () => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - try { - console.log("\nAmazon Ads CLI — OAuth Login"); - console.log("=============================\n"); - - const clientId = (await rl.question("Client ID: ")).trim(); - const clientSecret = (await rl.question("Client Secret: ")).trim(); - - if (!clientId || !clientSecret) { - printError("Client ID and Client Secret are required."); - process.exit(1); - } + .action( + runAction(async () => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + console.log("\nAmazon Ads CLI — OAuth Login"); + console.log("=============================\n"); + + const clientId = (await rl.question("Client ID: ")).trim(); + const clientSecret = (await rl.question("Client Secret: ")).trim(); + + if (!clientId || !clientSecret) { + fail("Client ID and Client Secret are required."); + } + + // Region picks the Login with Amazon consent page, so ask before opening the browser. + const regionAnswer = await rl.question(`Region (${REGIONS.join("/")}) [NA]: `); + const region = normalizeRegion(regionAnswer); + const sandboxAnswer = await rl.question("Sandbox mode? (y/N): "); + const sandbox = sandboxAnswer.trim().toLowerCase() === "y"; - // Region picks the Login with Amazon consent page, so ask before opening the browser. - const regionAnswer = await rl.question(`Region (${REGIONS.join("/")}) [NA]: `); - const region = normalizeRegion(regionAnswer); - const sandboxAnswer = await rl.question("Sandbox mode? (y/N): "); - const sandbox = sandboxAnswer.trim().toLowerCase() === "y"; - - // Random state ties the callback to this login attempt (CSRF protection). - const state = randomBytes(16).toString("hex"); - const authUrl = buildConsentUrl(region, clientId, REDIRECT_URI, state); - - console.log(`\nStarting local callback server on http://localhost:${CALLBACK_PORT}...`); - - const refreshToken = await new Promise((resolve, reject) => { - const server = http.createServer(async (req, res) => { - const url = new URL(req.url || "", `http://${req.headers.host}`); - - if (url.pathname !== "/callback") { - res.writeHead(404); - res.end("Not found"); - return; - } - - let code: string; - try { - code = parseCallback(url.searchParams, state); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(htmlPage("Authorization Failed", message)); - server.close(); - reject(new Error(message)); - return; - } - - try { - const tokenData = new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: REDIRECT_URI, - client_id: clientId, - client_secret: clientSecret, - }); - - const response = await fetch(getTokenUrl(region), { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: tokenData.toString(), - }); - - if (!response.ok) { - const errText = await response.text(); - throw new Error(`Token exchange failed: ${errText}`); + // Random state ties the callback to this login attempt (CSRF protection). + const state = randomBytes(16).toString("hex"); + const authUrl = buildConsentUrl(region, clientId, REDIRECT_URI, state); + + console.log(`\nStarting local callback server on http://localhost:${CALLBACK_PORT}...`); + + const refreshToken = await new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url || "", `http://${req.headers.host}`); + + if (url.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; } - const tokens = (await response.json()) as { refresh_token: string }; - - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(htmlPage("Success!", "Your token has been saved. You can close this window.")); - server.close(() => resolve(tokens.refresh_token)); - } catch (e) { - res.writeHead(500, { "Content-Type": "text/html" }); - res.end(htmlPage("Token Exchange Failed", e instanceof Error ? e.message : String(e))); - server.close(); - reject(e); - } - }); + let code: string; + try { + code = parseCallback(url.searchParams, state); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(htmlPage("Authorization Failed", message)); + server.close(); + reject(new Error(message)); + return; + } - server.listen(CALLBACK_PORT, "127.0.0.1", async () => { - console.log("Callback server started.\n"); - console.log("Opening browser to Amazon OAuth..."); - console.log(`\n ${authUrl.toString()}\n`); - console.log("If the browser doesn't open, copy and paste the URL above.\n"); - console.log("Waiting for authorization...\n"); - - try { - const open = await import("open"); - await open.default(authUrl.toString()); - } catch { - // open not available; user will copy/paste - } - }); + try { + const tokenData = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + client_secret: clientSecret, + }); + + const response = await fetch(getTokenUrl(region), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenData.toString(), + }); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(`Token exchange failed: ${errText}`); + } + + const tokens = (await response.json()) as { refresh_token: string }; + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + htmlPage("Success!", "Your token has been saved. You can close this window.") + ); + server.close(() => resolve(tokens.refresh_token)); + } catch (e) { + res.writeHead(500, { "Content-Type": "text/html" }); + res.end( + htmlPage("Token Exchange Failed", e instanceof Error ? e.message : String(e)) + ); + server.close(); + reject(e); + } + }); + + server.listen(CALLBACK_PORT, "127.0.0.1", async () => { + console.log("Callback server started.\n"); + console.log("Opening browser to Amazon OAuth..."); + console.log(`\n ${authUrl.toString()}\n`); + console.log("If the browser doesn't open, copy and paste the URL above.\n"); + console.log("Waiting for authorization...\n"); + + try { + const open = await import("open"); + await open.default(authUrl.toString()); + } catch { + // open not available; user will copy/paste + } + }); - server.on("error", (err) => { - reject(new Error(listenErrorMessage(err, CALLBACK_PORT))); + server.on("error", (err) => { + reject(new Error(listenErrorMessage(err, CALLBACK_PORT))); + }); }); - }); - const existing = loadConfig(); - saveConfig({ - ...existing, - clientId, - clientSecret, - refreshToken, - region, - sandbox, - }); + const existing = loadConfig(); + saveConfig({ + ...existing, + clientId, + clientSecret, + refreshToken, + region, + sandbox, + }); - printSuccess(`\nAuthentication successful! Config saved to ${getConfigPath()}`); - } catch (err) { - printError(`Login failed: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } finally { - rl.close(); - } - }); + printSuccess(`\nAuthentication successful! Config saved to ${getConfigPath()}`); + } finally { + rl.close(); + } + }) + ); auth .command("status") diff --git a/src/commands/campaigns.ts b/src/commands/campaigns.ts index 7cd397a..27afa02 100644 --- a/src/commands/campaigns.ts +++ b/src/commands/campaigns.ts @@ -5,11 +5,11 @@ import { printTable, printJson, printSuccess, - printError, printWarning, colorState, formatCurrency, } from "../output.js"; +import { runAction, fail } from "../run-action.js"; import { choiceOption, CAMPAIGN_TYPES, CAMPAIGN_STATES, TARGETING_TYPES } from "../options.js"; interface Campaign { @@ -47,8 +47,8 @@ export function registerCampaignsCommands( .addOption(choiceOption("--state ", "Filter by state", CAMPAIGN_STATES)) .addOption(choiceOption("--type ", "Campaign type", CAMPAIGN_TYPES)) .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getCampaigns(profileId, opts.state, opts.type); @@ -74,23 +74,22 @@ export function registerCampaignsCommands( ]); printTable(headers, rows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); campaigns .command("performance") - .description("Get campaign performance (7-day attribution); waits for Amazon to build the report") + .description( + "Get campaign performance (7-day attribution); waits for Amazon to build the report" + ) .requiredOption("--campaign ", "Campaign ID") .requiredOption("--start ", "Start date (YYYY-MM-DD)") .requiredOption("--end ", "End date (YYYY-MM-DD)") .option("--timeout ", "Max seconds to wait for the report", "600") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const timeoutMs = Math.max(1, parseInt(opts.timeout, 10) || 600) * 1000; if (!opts.json) printWarning("Waiting for Amazon to generate the report..."); @@ -115,7 +114,8 @@ export function registerCampaignsCommands( const headers = ["Campaign ID", "Name", "Impressions", "Clicks", "Spend", "Sales", "ACOS"]; const tableRows = data.map((r) => { const spend = typeof r.cost === "number" ? r.cost : parseFloat(String(r.cost || 0)); - const sales = typeof r.sales7d === "number" ? r.sales7d : parseFloat(String(r.sales7d || 0)); + const sales = + typeof r.sales7d === "number" ? r.sales7d : parseFloat(String(r.sales7d || 0)); const acos = sales > 0 ? ((spend / sales) * 100).toFixed(1) + "%" : "N/A"; return [ String(r.campaignId || ""), @@ -129,11 +129,8 @@ export function registerCampaignsCommands( }); printTable(headers, tableRows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); campaigns .command("create") @@ -150,15 +147,13 @@ export function registerCampaignsCommands( .requiredOption("--start ", "Start date (YYYY-MM-DD)") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const budget = parseFloat(opts.budget); if (isNaN(budget) || budget <= 0) { - printError("Budget must be a positive number."); - process.exit(1); - return; + fail("Budget must be a positive number."); } const data = await client.createCampaign( @@ -170,7 +165,9 @@ export function registerCampaignsCommands( opts.start ); - printWarning("Campaign created in PAUSED state. Enable it in the Amazon Ads console when ready."); + printWarning( + "Campaign created in PAUSED state. Enable it in the Amazon Ads console when ready." + ); if (opts.json) { printJson(data); @@ -178,11 +175,8 @@ export function registerCampaignsCommands( printSuccess("Campaign created successfully."); printJson(data); } - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); campaigns .command("archive") @@ -192,8 +186,8 @@ export function registerCampaignsCommands( .option("--profile ", "Profile ID") .option("--confirm", "Skip confirmation prompt") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); if (!opts.confirm) { @@ -221,9 +215,6 @@ export function registerCampaignsCommands( } else { printSuccess(`Campaign ${opts.campaign} archived successfully.`); } - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/keywords.ts b/src/commands/keywords.ts index 9591611..140ecaa 100644 --- a/src/commands/keywords.ts +++ b/src/commands/keywords.ts @@ -1,13 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { - printTable, - printJson, - printSuccess, - printError, - colorState, - formatCurrency, -} from "../output.js"; +import { printTable, printJson, printSuccess, colorState, formatCurrency } from "../output.js"; +import { runAction, fail } from "../run-action.js"; interface Keyword { keywordId?: string | number; @@ -44,8 +38,8 @@ export function registerKeywordsCommands( .option("--campaign ", "Filter by campaign ID") .option("--ad-group ", "Filter by ad group ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getKeywords(profileId, opts.campaign, opts.adGroup); @@ -60,7 +54,15 @@ export function registerKeywordsCommands( return; } - const headers = ["Keyword ID", "Keyword", "Match Type", "State", "Bid", "Campaign ID", "Ad Group ID"]; + const headers = [ + "Keyword ID", + "Keyword", + "Match Type", + "State", + "Bid", + "Campaign ID", + "Ad Group ID", + ]; const rows = list.map((k) => [ String(k.keywordId || ""), k.keywordText || "", @@ -72,11 +74,8 @@ export function registerKeywordsCommands( ]); printTable(headers, rows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); keywords .command("update-bid") @@ -85,15 +84,13 @@ export function registerKeywordsCommands( .requiredOption("--bid ", "New bid amount") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const bid = parseFloat(opts.bid); if (isNaN(bid) || bid <= 0) { - printError("Bid must be a positive number."); - process.exit(1); - return; + fail("Bid must be a positive number."); } const data = await client.updateKeywordBid(profileId, opts.keyword, bid); @@ -103,9 +100,6 @@ export function registerKeywordsCommands( } else { printSuccess(`Keyword ${opts.keyword} bid updated to ${formatCurrency(bid)}.`); } - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/portfolios.ts b/src/commands/portfolios.ts index 7576fde..8c4f8c8 100644 --- a/src/commands/portfolios.ts +++ b/src/commands/portfolios.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { printTable, printJson, printError, colorState, formatCurrency } from "../output.js"; +import { printTable, printJson, colorState, formatCurrency } from "../output.js"; +import { runAction, fail } from "../run-action.js"; interface Portfolio { portfolioId: number | string; @@ -22,8 +23,8 @@ export function registerPortfoliosCommands( .description("List portfolios") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getPortfolios(profileId); @@ -50,9 +51,6 @@ export function registerPortfoliosCommands( p.budget?.policy || "", ]) ); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index bb644a6..bccd8ca 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { printTable, printJson, printError } from "../output.js"; +import { printTable, printJson } from "../output.js"; +import { runAction, fail } from "../run-action.js"; interface Profile { profileId: number | string; @@ -14,16 +15,13 @@ interface Profile { timezone?: string; } -export function registerProfilesCommands( - program: Command, - client: AmazonAdsClient -): void { +export function registerProfilesCommands(program: Command, client: AmazonAdsClient): void { program .command("profiles") .description("List Amazon Ads profiles") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const data = await client.getProfiles(); const profiles = data as Profile[]; @@ -43,9 +41,6 @@ export function registerProfilesCommands( ]); printTable(headers, rows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/reports.ts b/src/commands/reports.ts index e9fea70..6aef20a 100644 --- a/src/commands/reports.ts +++ b/src/commands/reports.ts @@ -1,21 +1,16 @@ import { writeFileSync } from "fs"; import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { printJson, printError, printWarning, printSuccess, toCsv } from "../output.js"; -import { - REPORT_PRESET_NAMES, - buildReportSpec, - isReportPresetName, -} from "../report-presets.js"; +import { printJson, printWarning, printSuccess, toCsv } from "../output.js"; +import { runAction, fail } from "../run-action.js"; +import { REPORT_PRESET_NAMES, buildReportSpec, isReportPresetName } from "../report-presets.js"; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; /** Write rows to `path` as CSV (default) or JSON when the path ends in .json. */ export function writeReportRows(path: string, rows: unknown[]): void { const asJson = path.toLowerCase().endsWith(".json"); - const body = asJson - ? JSON.stringify(rows, null, 2) - : toCsv(rows as Record[]); + const body = asJson ? JSON.stringify(rows, null, 2) : toCsv(rows as Record[]); writeFileSync(path, body, "utf-8"); } @@ -29,10 +24,7 @@ export function registerReportsCommands( reports .command("create") .description("Create a Sponsored Products report and (by default) wait for it") - .requiredOption( - "--type ", - `Report preset (${REPORT_PRESET_NAMES.join(", ")})` - ) + .requiredOption("--type ", `Report preset (${REPORT_PRESET_NAMES.join(", ")})`) .requiredOption("--start ", "Start date (YYYY-MM-DD)") .requiredOption("--end ", "End date (YYYY-MM-DD)") .option("--daily", "One row per day instead of a summary over the range") @@ -41,19 +33,13 @@ export function registerReportsCommands( .option("--out ", "Write rows to a file (.csv default, .json if the name ends in .json)") .option("--profile ", "Profile ID") .option("--json", "Output rows as JSON to stdout") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { if (!isReportPresetName(opts.type)) { - printError( - `Unknown report type "${opts.type}". Use one of: ${REPORT_PRESET_NAMES.join(", ")}` - ); - process.exit(1); - return; + fail(`Unknown report type "${opts.type}". Use one of: ${REPORT_PRESET_NAMES.join(", ")}`); } if (!DATE_RE.test(opts.start) || !DATE_RE.test(opts.end)) { - printError("Dates must be YYYY-MM-DD."); - process.exit(1); - return; + fail("Dates must be YYYY-MM-DD."); } const profileId = resolveProfileId(opts); @@ -65,13 +51,16 @@ export function registerReportsCommands( printJson({ reportId, status: "PENDING" }); } else { printSuccess(`Report requested. Report ID: ${reportId}`); - console.log(`Retrieve with: amazonads reports get --report ${reportId} --profile ${profileId}`); + console.log( + `Retrieve with: amazonads reports get --report ${reportId} --profile ${profileId}` + ); } return; } const timeoutMs = Math.max(1, parseInt(opts.timeout, 10) || 600) * 1000; - if (!opts.json) printWarning(`Report ${reportId} requested; waiting for Amazon to generate it...`); + if (!opts.json) + printWarning(`Report ${reportId} requested; waiting for Amazon to generate it...`); const rows = await client.waitForReport(profileId, reportId, { timeoutMs }); @@ -80,16 +69,10 @@ export function registerReportsCommands( if (!opts.json) printSuccess(`Wrote ${rows.length} rows to ${opts.out}`); } - if (opts.json) { - printJson(rows); - } else if (!opts.out) { - printJson(rows); - } - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + // Rows go to stdout unless they were written to a file and JSON was not asked for. + if (opts.json || !opts.out) printJson(rows); + }) + ); reports .command("get") @@ -98,13 +81,17 @@ export function registerReportsCommands( .option("--out ", "Write rows to a file (.csv default, .json if the name ends in .json)") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getReport(profileId, opts.report); const result = data as Record; - if (result.status === "PENDING" || result.status === "IN_PROGRESS" || result.status === "PROCESSING") { + if ( + result.status === "PENDING" || + result.status === "IN_PROGRESS" || + result.status === "PROCESSING" + ) { printWarning(`Report ${opts.report} is still processing (status: ${result.status}).`); console.log("Try again in a few seconds."); if (opts.json) printJson(data); @@ -112,10 +99,8 @@ export function registerReportsCommands( } if (result.status === "FAILURE") { - printError(`Report failed: ${result.reason || "Unknown error"}`); if (opts.json) printJson(data); - process.exit(1); - return; + fail(`Report failed: ${result.reason || "Unknown error"}`); } const rows = Array.isArray(result.data) ? result.data : []; @@ -125,10 +110,7 @@ export function registerReportsCommands( if (!opts.json) return; } - printJson(opts.json ? data : result.data ?? data); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + printJson(opts.json ? data : (result.data ?? data)); + }) + ); } diff --git a/src/index.ts b/src/index.ts index 3ab72fc..c6d9b88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { loadConfig } from "./config.js"; const require = createRequire(import.meta.url); const { version } = require("../package.json") as { version: string }; import { AmazonAdsClient } from "./amazon-ads-client.js"; -import { printError } from "./output.js"; +import { fail } from "./run-action.js"; import { registerAuthCommands } from "./commands/auth.js"; import { registerProfilesCommands } from "./commands/profiles.js"; import { registerCampaignsCommands } from "./commands/campaigns.js"; @@ -30,19 +30,15 @@ function resolveProfileId(opts: { profile?: string }): string { const profileId = opts.profile ?? config.defaultProfileId; if (!profileId) { - printError( + fail( "Profile ID is required. Use --profile , set AMAZON_ADS_PROFILE_ID, or set defaultProfileId in config." ); - process.exit(1); } return profileId; } -program - .name("amazonads") - .description("Amazon Ads CLI") - .version(version); +program.name("amazonads").description("Amazon Ads CLI").version(version); registerAuthCommands(program); registerProfilesCommands(program, client); diff --git a/src/output.ts b/src/output.ts index 4d8a1e4..8b2b5c7 100644 --- a/src/output.ts +++ b/src/output.ts @@ -58,12 +58,11 @@ function csvEscape(value: unknown): string { /** Flatten an array of objects to CSV. Header is the union of keys in first-seen order. */ export function toCsv(rows: Record[]): string { - const headers: string[] = []; + const seen = new Set(); for (const row of rows) { - for (const key of Object.keys(row)) { - if (!headers.includes(key)) headers.push(key); - } + for (const key of Object.keys(row)) seen.add(key); } + const headers = [...seen]; const lines = [headers.map(csvEscape).join(",")]; for (const row of rows) { lines.push(headers.map((h) => csvEscape(row[h])).join(",")); diff --git a/src/run-action.ts b/src/run-action.ts new file mode 100644 index 0000000..c438ef2 --- /dev/null +++ b/src/run-action.ts @@ -0,0 +1,31 @@ +import { printError } from "./output.js"; + +/** An error whose message is already written for the user; printed without a prefix. */ +export class CliError extends Error {} + +/** Abort the current command with a user-facing message. */ +export function fail(message: string): never { + throw new CliError(message); +} + +/** + * Wrap a commander action so any thrown error is printed once and the + * process exits non-zero after stdout has flushed. Using exitCode instead + * of process.exit() keeps the action testable and never truncates output. + */ +export function runAction( + fn: (...args: T) => Promise | void +): (...args: T) => Promise { + return async (...args: T) => { + try { + await fn(...args); + } catch (err) { + if (err instanceof CliError) { + printError(err.message); + } else { + printError(`Error: ${err instanceof Error ? err.message : String(err)}`); + } + process.exitCode = 1; + } + }; +} From d79c4aae3396b8ad215341ac391d4088f41f1c22 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:56:08 -0400 Subject: [PATCH 6/7] Add lint, format, and test typecheck gates to CI - eslint with typescript-eslint (flat config) and a lint script. Fixes the findings it raised: unused test spies, a dropped error cause, and unused imports. - prettier --check over the whole repo; README table reformatted. - tsconfig.test.json so tsc covers the test files, which the build config excludes and vitest does not typecheck. Fixes two spy typings. - npm run check runs all of the above plus tests; CI and the release workflow call it. - Release workflow refuses a tag that does not match package.json and pins npm to the 11 line instead of latest. - engines.node >=20 to match the CI matrix; README said 18+. --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 13 +- README.md | 20 +- eslint.config.js | 16 + package-lock.json | 1302 +++++++++++++++++++++- package.json | 13 +- src/__tests__/commands/campaigns.test.ts | 5 +- src/__tests__/commands/keywords.test.ts | 2 - src/amazon-ads-client.ts | 5 +- src/commands/ads.ts | 2 +- src/commands/portfolios.ts | 2 +- src/commands/profiles.ts | 2 +- tsconfig.test.json | 9 + 13 files changed, 1358 insertions(+), 36 deletions(-) create mode 100644 eslint.config.js create mode 100644 tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb65555..fb48216 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,5 +24,8 @@ jobs: cache: npm - run: npm ci + - run: npm run lint + - run: npm run format:check + - run: npm run typecheck - run: npm run build - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0b63fb..9cf8110 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,12 +23,21 @@ jobs: cache: npm # Trusted publishing (OIDC) needs npm 11.5.1 or newer; Node 22 ships an older npm. - - run: npm install -g npm@latest + - run: npm install -g npm@11 - run: npm --version + - name: Check that the tag matches package.json + run: | + tag="${GITHUB_REF_NAME#v}" + version="$(node -p "require('./package.json').version")" + if [ "$tag" != "$version" ]; then + echo "Tag v$tag does not match package.json version $version" >&2 + exit 1 + fi + - run: npm ci + - run: npm run check - run: npm run build - - run: npm test - name: Publish to npm run: npm publish --access public diff --git a/README.md b/README.md index ca73ce9..48392f4 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,14 @@ You can also create the config file manually if you already have a refresh token } ``` -| Field | Values | Notes | -|---|---|---| -| `clientId` | `amzn1.application-oa2-client.xxx` | From Amazon Ads API console | -| `clientSecret` | string | From Amazon Ads API console | -| `refreshToken` | `Atzr\|xxx` | Obtained via `auth login` or existing token | -| `region` | `NA`, `EU`, `FE` | North America, Europe, Far East | -| `sandbox` | `true` / `false` | Use the Amazon Ads test endpoint | -| `defaultProfileId` | numeric string | Avoids needing `--profile` on every command | +| Field | Values | Notes | +| ------------------ | ---------------------------------- | ------------------------------------------- | +| `clientId` | `amzn1.application-oa2-client.xxx` | From Amazon Ads API console | +| `clientSecret` | string | From Amazon Ads API console | +| `refreshToken` | `Atzr\|xxx` | Obtained via `auth login` or existing token | +| `region` | `NA`, `EU`, `FE` | North America, Europe, Far East | +| `sandbox` | `true` / `false` | Use the Amazon Ads test endpoint | +| `defaultProfileId` | numeric string | Avoids needing `--profile` on every command | Environment variables override the config file: `AMAZON_ADS_CLIENT_ID`, `AMAZON_ADS_CLIENT_SECRET`, `AMAZON_ADS_REFRESH_TOKEN`, `AMAZON_ADS_REGION`, `AMAZON_ADS_SANDBOX`, `AMAZON_ADS_PROFILE_ID`. @@ -166,6 +166,8 @@ npm run dev # Run without building (hot-reloads on save) npm run build # Compile TypeScript npm run watch # Watch mode (compile only) npm run test # Run tests +npm run check # Lint, format check, typecheck (including tests), and tests — what CI runs +npm run format # Format with prettier ``` Pass CLI arguments after `--` when using `dev`: @@ -174,7 +176,7 @@ Pass CLI arguments after `--` when using `dev`: npm run dev -- campaigns list --profile 1234567890 ``` -Requires Node.js 18+. +Requires Node.js 20+. ## Releasing diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7b4939c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,16 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["build/", "coverage/", "node_modules/", ".claude/"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrors: "none" }, + ], + }, + } +); diff --git a/package-lock.json b/package-lock.json index 77d13e9..cbefff6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,13 +19,40 @@ "amazonads": "build/index.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^20.14.0", + "eslint": "^10.12.0", "prettier": "^3.3.0", "tsx": "^4.18.0", "typescript": "^5.6.0", + "typescript-eslint": "^8.71.2", "vitest": "^2.1.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -478,6 +505,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -485,6 +706,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -852,6 +1097,13 @@ "win32" ] }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -859,6 +1111,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -869,6 +1128,236 @@ "undici-types": "~6.21.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -982,6 +1471,46 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1001,6 +1530,29 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1026,6 +1578,20 @@ "node": ">=8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1089,6 +1655,21 @@ "node": ">=18" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1117,6 +1698,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -1224,25 +1812,278 @@ "@esbuild/win32-x64": "0.27.4" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -1272,6 +2113,59 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1287,6 +2181,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1296,6 +2200,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -1329,6 +2246,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1346,6 +2324,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1372,6 +2366,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -1390,6 +2391,76 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1414,6 +2485,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", @@ -1443,6 +2527,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.9.10", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", @@ -1459,6 +2553,36 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1527,6 +2651,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1598,6 +2758,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -1628,6 +2805,19 @@ "node": ">=14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1648,6 +2838,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1662,6 +2865,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1669,6 +2896,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -2248,6 +3485,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2265,6 +3518,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -2279,6 +3542,19 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index fcae015..5c8876f 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "CLI for Amazon Ads API", "author": "Chris Price", "license": "MIT", + "engines": { + "node": ">=20" + }, "repository": { "type": "git", "url": "git+https://github.com/cprice70/amazonads-cli.git" @@ -31,8 +34,13 @@ "start": "node build/index.js", "test": "vitest run", "test:watch": "vitest", + "lint": "eslint .", + "typecheck": "tsc -p tsconfig.test.json", "format": "prettier --write .", - "clean": "rm -rf build coverage" + "format:check": "prettier --check .", + "check": "npm run lint && npm run format:check && npm run typecheck && npm test", + "clean": "rm -rf build coverage", + "prepublishOnly": "npm run build" }, "dependencies": { "chalk": "^5.4.1", @@ -42,10 +50,13 @@ "open": "^10.2.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^20.14.0", + "eslint": "^10.12.0", "prettier": "^3.3.0", "tsx": "^4.18.0", "typescript": "^5.6.0", + "typescript-eslint": "^8.71.2", "vitest": "^2.1.0" } } diff --git a/src/__tests__/commands/campaigns.test.ts b/src/__tests__/commands/campaigns.test.ts index 40b46cc..3a3ba3c 100644 --- a/src/__tests__/commands/campaigns.test.ts +++ b/src/__tests__/commands/campaigns.test.ts @@ -18,14 +18,11 @@ function resolveProfileId(_opts: { profile?: string }) { describe("campaigns commands", () => { let consoleSpy: ReturnType; - let consoleWarnSpy: ReturnType; - let processExitSpy: ReturnType; beforeEach(() => { consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); - processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never); }); afterEach(() => { diff --git a/src/__tests__/commands/keywords.test.ts b/src/__tests__/commands/keywords.test.ts index 4d6d121..bba1bb4 100644 --- a/src/__tests__/commands/keywords.test.ts +++ b/src/__tests__/commands/keywords.test.ts @@ -16,12 +16,10 @@ function resolveProfileId(_opts: { profile?: string }) { describe("keywords commands", () => { let consoleSpy: ReturnType; - let processExitSpy: ReturnType; beforeEach(() => { consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); - processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never); }); afterEach(() => { diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index f095a89..5180ad4 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -192,7 +192,8 @@ export class AmazonAdsClient { throw new Error( `Amazon Ads API request failed after ${attempt + 1} attempts: ${ err instanceof Error ? err.message : String(err) - }` + }`, + { cause: err } ); } await this.sleep(this.backoffMs(attempt)); @@ -235,7 +236,7 @@ export class AmazonAdsClient { key: string ): Promise { const items: unknown[] = []; - let merged: Record = {}; + let merged: Record; let nextToken: string | undefined; do { diff --git a/src/commands/ads.ts b/src/commands/ads.ts index c1239b7..1499de4 100644 --- a/src/commands/ads.ts +++ b/src/commands/ads.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; import { printTable, printJson, colorState } from "../output.js"; -import { runAction, fail } from "../run-action.js"; +import { runAction } from "../run-action.js"; interface ProductAd { adId?: string | number; diff --git a/src/commands/portfolios.ts b/src/commands/portfolios.ts index 8c4f8c8..5b792ef 100644 --- a/src/commands/portfolios.ts +++ b/src/commands/portfolios.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; import { printTable, printJson, colorState, formatCurrency } from "../output.js"; -import { runAction, fail } from "../run-action.js"; +import { runAction } from "../run-action.js"; interface Portfolio { portfolioId: number | string; diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index bccd8ca..27bc38b 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; import { printTable, printJson } from "../output.js"; -import { runAction, fail } from "../run-action.js"; +import { runAction } from "../run-action.js"; interface Profile { profileId: number | string; diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..1dac1b3 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*", "vitest.config.ts", "eslint.config.js"], + "exclude": ["node_modules", "build"] +} From e1d86f98364035529b53b055332c8aa8aaea8402 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:56:33 -0400 Subject: [PATCH 7/7] Run CI on every pull request Stacked PRs target a feature branch rather than main and were getting no checks. --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb48216..9e9166d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] jobs: build-and-test: