From a03455c14d0bd1c56740d933a2f95d36ecee8d76 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:42:23 -0400 Subject: [PATCH 1/6] Fail on rejected items in 207 Multi-Status responses Amazon's v3/v4 batch endpoints (create/update/delete) return 207 with per-item success and error arrays in the body. The client treated any OK status as success, so keyword bid updates, campaign creates, and archives printed a success message even when Amazon rejected the item. makeRequest now scans OK response bodies for a non-empty error array and throws with the errorType and errorValue for each rejected item. --- src/__tests__/amazon-ads-client.test.ts | 97 +++++++++++++++++++++++++ src/amazon-ads-client.ts | 43 ++++++++++- 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/amazon-ads-client.test.ts diff --git a/src/__tests__/amazon-ads-client.test.ts b/src/__tests__/amazon-ads-client.test.ts new file mode 100644 index 0000000..e7026a7 --- /dev/null +++ b/src/__tests__/amazon-ads-client.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { AmazonAdsClient } from "../amazon-ads-client.js"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function tokenResponse(): Response { + return jsonResponse(200, { access_token: "access-123", expires_in: 3600 }); +} + +function makeClient(): AmazonAdsClient { + return new AmazonAdsClient({ + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + region: "NA", + sandbox: false, + }); +} + +describe("AmazonAdsClient multi-status handling", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("throws when a 207 response reports a rejected item", async () => { + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce( + jsonResponse(207, { + keywords: { + success: [], + error: [ + { + index: 0, + errors: [ + { + errorType: "ENTITY_NOT_FOUND", + errorValue: { message: "Keyword 999 does not exist" }, + }, + ], + }, + ], + }, + }) + ); + + await expect(makeClient().updateKeywordBid("profile-1", "999", 1.25)).rejects.toThrow( + /ENTITY_NOT_FOUND/ + ); + }); + + it("resolves with the body when a 207 response has no rejected items", async () => { + const body = { + keywords: { success: [{ index: 0, keywordId: "123" }], error: [] }, + }; + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(jsonResponse(207, body)); + + await expect(makeClient().updateKeywordBid("profile-1", "123", 1.25)).resolves.toEqual(body); + }); + + it("throws when a campaign create is rejected", async () => { + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce( + jsonResponse(207, { + campaigns: { + success: [], + error: [ + { + index: 0, + errors: [{ errorType: "DUPLICATE_VALUE", errorValue: { message: "Name already used" } }], + }, + ], + }, + }) + ); + + await expect( + makeClient().createCampaign("profile-1", "Dup", "sponsoredProducts", "MANUAL", 10, "2026-10-13") + ).rejects.toThrow(/DUPLICATE_VALUE/); + }); + + it("leaves list responses untouched", async () => { + const body = { campaigns: [{ campaignId: "1" }], totalResults: 1 }; + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(jsonResponse(200, body)); + + await expect(makeClient().getCampaigns("profile-1")).resolves.toEqual(body); + }); +}); diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index 84a6154..11a6ac1 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -23,6 +23,43 @@ export interface ReportSpec { filters?: unknown[]; } +interface RejectedItem { + index?: number; + errors?: { errorType?: string; errorValue?: unknown }[]; +} + +/** + * Amazon's v3/v4 batch endpoints (create/update/delete) return 207 with + * `{ : { success: [...], error: [...] } }`. Throw when any item was + * rejected so callers never report success for a failed write. + */ +function throwIfItemsRejected(body: unknown): void { + if (!body || typeof body !== "object" || Array.isArray(body)) return; + + for (const [entity, value] of Object.entries(body as Record)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const errors = (value as { error?: unknown }).error; + if (!Array.isArray(errors) || errors.length === 0) continue; + + const details = (errors as RejectedItem[]) + .map((item) => { + const reasons = (item.errors ?? []) + .map((e) => { + const type = e.errorType ?? "UNKNOWN_ERROR"; + const value = e.errorValue === undefined ? "" : ` ${JSON.stringify(e.errorValue)}`; + return `${type}${value}`; + }) + .join("; "); + return `item ${item.index ?? "?"}: ${reasons || "no details"}`; + }) + .join("\n"); + + throw new Error( + `Amazon Ads API rejected ${errors.length} ${entity} item(s):\n${details}` + ); + } +} + export class AmazonAdsClient { private config: AmazonAdsConfig; private accessToken?: string; @@ -158,7 +195,11 @@ export class AmazonAdsClient { if (!text) { return { success: true, campaignId: path.split("/").pop() }; } - return JSON.parse(text); + 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. + throwIfItemsRejected(parsed); + return parsed; } const errorText = await response.text(); From 4cbb92221527ddfb46a97d2d93ac41698a602dc1 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:44:09 -0400 Subject: [PATCH 2/6] Use regional Login with Amazon endpoints for OAuth The consent page and token service were hardcoded to the North America hosts, so EU and FE advertisers could not complete auth login. Region was also only asked for after the browser flow, too late to pick the right consent host. Add src/lwa.ts with the per-region consent and token URLs from Amazon's authorization-grants docs, ask for region (and sandbox) before opening the browser, and refresh access tokens against the regional token host. Region input is now validated; an unknown value fails instead of silently falling back to NA. --- README.md | 2 +- src/__tests__/amazon-ads-client.test.ts | 28 +++++++++++++- src/__tests__/lwa.test.ts | 50 +++++++++++++++++++++++++ src/amazon-ads-client.ts | 3 +- src/commands/auth.ts | 27 +++++-------- src/lwa.ts | 44 ++++++++++++++++++++++ 6 files changed, 133 insertions(+), 21 deletions(-) create mode 100644 src/__tests__/lwa.test.ts create mode 100644 src/lwa.ts diff --git a/README.md b/README.md index 704d4b4..7d36f07 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ npm install -g amazonads-cli amazonads auth login ``` -Prompts for your Client ID and Client Secret, opens a browser to complete Amazon OAuth, and saves credentials to `~/.config/amazonads-cli/config.json`. +Prompts for your Client ID, Client Secret, and region (NA, EU, or FE), opens the regional Login with Amazon consent page, and saves credentials to `~/.config/amazonads-cli/config.json`. You can also create the config file manually if you already have a refresh token: diff --git a/src/__tests__/amazon-ads-client.test.ts b/src/__tests__/amazon-ads-client.test.ts index e7026a7..128c69f 100644 --- a/src/__tests__/amazon-ads-client.test.ts +++ b/src/__tests__/amazon-ads-client.test.ts @@ -12,16 +12,40 @@ function tokenResponse(): Response { return jsonResponse(200, { access_token: "access-123", expires_in: 3600 }); } -function makeClient(): AmazonAdsClient { +function makeClient(region = "NA"): AmazonAdsClient { return new AmazonAdsClient({ clientId: "client-id", clientSecret: "client-secret", refreshToken: "refresh-token", - region: "NA", + region, sandbox: false, }); } +describe("AmazonAdsClient token refresh", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("refreshes against the regional Login with Amazon host", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, [])); + + await makeClient("EU").getProfiles(); + + expect(fetchMock.mock.calls[0][0]).toBe("https://api.amazon.co.uk/auth/o2/token"); + expect(fetchMock.mock.calls[1][0]).toBe("https://advertising-api-eu.amazon.com/v2/profiles"); + }); +}); + describe("AmazonAdsClient multi-status handling", () => { let fetchMock: ReturnType; diff --git a/src/__tests__/lwa.test.ts b/src/__tests__/lwa.test.ts new file mode 100644 index 0000000..89e6784 --- /dev/null +++ b/src/__tests__/lwa.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { buildConsentUrl, getTokenUrl, normalizeRegion } from "../lwa.js"; + +describe("normalizeRegion", () => { + it("defaults to NA when empty", () => { + expect(normalizeRegion(undefined)).toBe("NA"); + expect(normalizeRegion("")).toBe("NA"); + expect(normalizeRegion(" ")).toBe("NA"); + }); + + it("accepts any case and surrounding whitespace", () => { + expect(normalizeRegion("eu")).toBe("EU"); + expect(normalizeRegion(" fe ")).toBe("FE"); + }); + + it("rejects unknown regions", () => { + expect(() => normalizeRegion("UK")).toThrow(/NA, EU, FE/); + }); +}); + +describe("getTokenUrl", () => { + it("returns the regional Login with Amazon token host", () => { + expect(getTokenUrl("NA")).toBe("https://api.amazon.com/auth/o2/token"); + expect(getTokenUrl("EU")).toBe("https://api.amazon.co.uk/auth/o2/token"); + expect(getTokenUrl("FE")).toBe("https://api.amazon.co.jp/auth/o2/token"); + }); +}); + +describe("buildConsentUrl", () => { + it("uses the regional consent host", () => { + expect(buildConsentUrl("NA", "cid", "http://localhost:3000/callback").origin).toBe( + "https://www.amazon.com" + ); + expect(buildConsentUrl("EU", "cid", "http://localhost:3000/callback").origin).toBe( + "https://eu.account.amazon.com" + ); + expect(buildConsentUrl("FE", "cid", "http://localhost:3000/callback").origin).toBe( + "https://apac.account.amazon.com" + ); + }); + + it("sets the OAuth query parameters", () => { + const url = buildConsentUrl("NA", "my-client", "http://localhost:3000/callback"); + expect(url.pathname).toBe("/ap/oa"); + expect(url.searchParams.get("client_id")).toBe("my-client"); + expect(url.searchParams.get("scope")).toBe("advertising::campaign_management"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:3000/callback"); + }); +}); diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index 11a6ac1..9f60b70 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -1,5 +1,6 @@ import zlib from "zlib"; import { promisify } from "util"; +import { getTokenUrl, normalizeRegion } from "./lwa.js"; const gunzip = promisify(zlib.gunzip); @@ -118,7 +119,7 @@ export class AmazonAdsClient { client_secret: clientSecret, }); - const response = await fetch("https://api.amazon.com/auth/o2/token", { + const response = await fetch(getTokenUrl(normalizeRegion(this.config.region)), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", diff --git a/src/commands/auth.ts b/src/commands/auth.ts index d6b6252..ef354bf 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -4,9 +4,9 @@ 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"; const REDIRECT_URI = "http://localhost:3000/callback"; -const SCOPES = "advertising::campaign_management"; function maskSecret(value: string | undefined): string { if (!value) return "(not set)"; @@ -38,12 +38,13 @@ export function registerAuthCommands(program: Command): void { process.exit(1); } - // Build the authorization URL - const authUrl = new URL("https://www.amazon.com/ap/oa"); - authUrl.searchParams.set("client_id", clientId.trim()); - authUrl.searchParams.set("scope", SCOPES); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("redirect_uri", REDIRECT_URI); + // 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"; + + const authUrl = buildConsentUrl(region, clientId.trim(), REDIRECT_URI); console.log("\nStarting local callback server on http://localhost:3000..."); @@ -86,7 +87,7 @@ export function registerAuthCommands(program: Command): void { client_secret: clientSecret.trim(), }); - const response = await fetch("https://api.amazon.com/auth/o2/token", { + const response = await fetch(getTokenUrl(region), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: tokenData.toString(), @@ -102,16 +103,8 @@ export function registerAuthCommands(program: Command): void { res.writeHead(200, { "Content-Type": "text/html" }); res.end(htmlPage("Success!", "Your token has been saved. You can close this window.")); - server.close(async () => { + server.close(() => { try { - const regionAnswer = await rl.question( - "\nRegion (NA/EU/FE) [NA]: " - ); - const sandboxAnswer = await rl.question("Sandbox mode? (y/N): "); - - const region = regionAnswer.trim().toUpperCase() || "NA"; - const sandbox = sandboxAnswer.trim().toLowerCase() === "y"; - const existing = loadConfig(); saveConfig({ ...existing, diff --git a/src/lwa.ts b/src/lwa.ts new file mode 100644 index 0000000..08d3c57 --- /dev/null +++ b/src/lwa.ts @@ -0,0 +1,44 @@ +/** + * Login with Amazon (LwA) endpoints. The consent page and token service are + * served from regional hosts; tokens issued by any region are valid globally. + * https://advertising.amazon.com/API/docs/en-us/guides/account-management/authorization/authorization-grants + */ + +export type Region = "NA" | "EU" | "FE"; + +export const REGIONS: Region[] = ["NA", "EU", "FE"]; + +const CONSENT_HOSTS: Record = { + NA: "https://www.amazon.com", + EU: "https://eu.account.amazon.com", + FE: "https://apac.account.amazon.com", +}; + +const TOKEN_HOSTS: Record = { + NA: "https://api.amazon.com", + EU: "https://api.amazon.co.uk", + FE: "https://api.amazon.co.jp", +}; + +export const SCOPE = "advertising::campaign_management"; + +/** Upper-cases and validates a region string; empty input means NA. */ +export function normalizeRegion(input: string | undefined): Region { + const value = (input ?? "").trim().toUpperCase(); + if (!value) return "NA"; + if ((REGIONS as string[]).includes(value)) return value as Region; + throw new Error(`Unknown region "${input}". Use one of: ${REGIONS.join(", ")}`); +} + +export function getTokenUrl(region: Region): string { + return `${TOKEN_HOSTS[region]}/auth/o2/token`; +} + +export function buildConsentUrl(region: Region, clientId: string, redirectUri: 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); + return url; +} From b8d038e6d44963ae2f11caecc89ed2497e3dfd31 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:45:00 -0400 Subject: [PATCH 3/6] Return no rows when a campaign has no performance data getCampaignPerformance filtered the report to the requested campaign but fell back to the unfiltered report when nothing matched, so a typo'd or inactive campaign ID silently showed every campaign in the account. Return the filtered rows as-is; the command already prints "No performance data found." for an empty result. --- src/__tests__/amazon-ads-client.test.ts | 50 +++++++++++++++++++++++++ src/amazon-ads-client.ts | 6 +-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/__tests__/amazon-ads-client.test.ts b/src/__tests__/amazon-ads-client.test.ts index 128c69f..23f5f57 100644 --- a/src/__tests__/amazon-ads-client.test.ts +++ b/src/__tests__/amazon-ads-client.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { gzipSync } from "zlib"; import { AmazonAdsClient } from "../amazon-ads-client.js"; function jsonResponse(status: number, body: unknown): Response { @@ -119,3 +120,52 @@ describe("AmazonAdsClient multi-status handling", () => { await expect(makeClient().getCampaigns("profile-1")).resolves.toEqual(body); }); }); + +describe("AmazonAdsClient.getCampaignPerformance", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function gzipResponse(rows: unknown[]): Response { + return new Response(gzipSync(JSON.stringify(rows)), { status: 200 }); + } + + function mockCompletedReport(rows: unknown[]): void { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, { reportId: "r1" })) + .mockResolvedValueOnce( + jsonResponse(200, { status: "COMPLETED", url: "https://example.com/r1.gz" }) + ) + .mockResolvedValueOnce(gzipResponse(rows)); + } + + it("returns only the requested campaign's rows", async () => { + mockCompletedReport([ + { campaignId: 111, campaignName: "A" }, + { campaignId: 222, campaignName: "B" }, + ]); + + const rows = await makeClient().getCampaignPerformance("p", "222", "2026-10-01", "2026-10-10"); + + expect(rows).toEqual([{ campaignId: 222, campaignName: "B" }]); + }); + + it("returns an empty list, not every campaign, when the campaign has no rows", async () => { + mockCompletedReport([ + { campaignId: 111, campaignName: "A" }, + { campaignId: 222, campaignName: "B" }, + ]); + + const rows = await makeClient().getCampaignPerformance("p", "999", "2026-10-01", "2026-10-10"); + + expect(rows).toEqual([]); + }); +}); diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index 9f60b70..e18e776 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -316,12 +316,12 @@ export class AmazonAdsClient { // Step 3: Download and decompress the report const reportData = await this.downloadReport(statusResponse.url); - // Filter to requested campaign if specified + // 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)) { - const filtered = reportData.filter( + return reportData.filter( (row: Record) => String(row.campaignId) === campaignId ); - return filtered.length > 0 ? filtered : reportData; } return reportData; From 7e9a357eabe54e8d675ff5dfc8f451ddb3fe23ba Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:46:20 -0400 Subject: [PATCH 4/6] Follow nextToken when listing campaigns, keywords, ads, and portfolios The v3/v4 list endpoints page their results and return a nextToken when more remain. The client only ever requested the first page, so accounts with more entities than one page holds got silently truncated lists. Add a listAll helper that re-posts the same filters with each nextToken and merges the pages under the entity key, so --json output keeps the same shape as a single-page response. --- src/__tests__/amazon-ads-client.test.ts | 63 +++++++++++++++++++++++++ src/amazon-ads-client.ts | 44 +++++++++++++++-- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/__tests__/amazon-ads-client.test.ts b/src/__tests__/amazon-ads-client.test.ts index 23f5f57..04f6e85 100644 --- a/src/__tests__/amazon-ads-client.test.ts +++ b/src/__tests__/amazon-ads-client.test.ts @@ -169,3 +169,66 @@ describe("AmazonAdsClient.getCampaignPerformance", () => { expect(rows).toEqual([]); }); }); + +describe("AmazonAdsClient list pagination", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function requestBody(callIndex: number): Record { + return JSON.parse(fetchMock.mock.calls[callIndex][1].body as string); + } + + it("follows nextToken until every campaign page is fetched", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + jsonResponse(200, { campaigns: [{ campaignId: "1" }], totalResults: 2, nextToken: "page2" }) + ) + .mockResolvedValueOnce(jsonResponse(200, { campaigns: [{ campaignId: "2" }], totalResults: 2 })); + + const result = await makeClient().getCampaigns("p", "enabled"); + + expect(result).toEqual({ + campaigns: [{ campaignId: "1" }, { campaignId: "2" }], + totalResults: 2, + }); + expect(requestBody(1)).toEqual({ stateFilter: { include: ["ENABLED"] } }); + expect(requestBody(2)).toEqual({ stateFilter: { include: ["ENABLED"] }, nextToken: "page2" }); + }); + + it("makes a single request when the first page has no nextToken", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, { keywords: [{ keywordId: "k1" }], totalResults: 1 })); + + const result = await makeClient().getKeywords("p", "c1"); + + expect(result).toEqual({ keywords: [{ keywordId: "k1" }], totalResults: 1 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(requestBody(1)).toEqual({ campaignIdFilter: { include: ["c1"] } }); + }); + + it("paginates product ads and portfolios too", async () => { + fetchMock + .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: 2 }] })); + + const client = makeClient(); + const ads = await client.getProductAds("p"); + const portfolios = await client.getPortfolios("p"); + + expect(ads).toEqual({ productAds: [{ adId: "a1" }, { adId: "a2" }] }); + expect(portfolios).toEqual({ portfolios: [{ portfolioId: 1 }, { portfolioId: 2 }] }); + }); +}); diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index e18e776..28a9b25 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -225,6 +225,42 @@ export class AmazonAdsClient { throw lastError || new Error("Request failed after retries"); } + /** + * POST a v3/v4 list endpoint and follow `nextToken` until every page has + * been fetched. Pages are merged under `key` so callers see one response. + */ + private async listAll( + path: string, + profileId: string, + body: Record, + contentType: string, + key: string + ): Promise { + const items: unknown[] = []; + let merged: Record = {}; + let nextToken: string | undefined; + + do { + const page = (await this.makeRequest( + "POST", + path, + profileId, + nextToken ? { ...body, nextToken } : body, + contentType + )) as Record | unknown[]; + + // Older endpoints answer with a bare array and no paging. + if (Array.isArray(page)) return page; + + const { nextToken: token, ...rest } = page; + merged = rest; + if (Array.isArray(page[key])) items.push(...(page[key] as unknown[])); + nextToken = typeof token === "string" && token ? token : undefined; + } while (nextToken); + + return { ...merged, [key]: items }; + } + async getProfiles(): Promise { return this.makeRequest("GET", "/v2/profiles"); } @@ -252,7 +288,7 @@ export class AmazonAdsClient { body.stateFilter = { include: [state.toUpperCase()] }; } - return this.makeRequest("POST", path, profileId, body, contentType); + return this.listAll(path, profileId, body, contentType, "campaigns"); } async getCampaignPerformance( @@ -423,7 +459,7 @@ export class AmazonAdsClient { async getPortfolios(profileId: string): Promise { const contentType = "application/vnd.spPortfolio.v3+json"; - return this.makeRequest("POST", "/portfolios/list", profileId, {}, contentType); + return this.listAll("/portfolios/list", profileId, {}, contentType, "portfolios"); } private async downloadReport(url: string): Promise { @@ -486,7 +522,7 @@ export class AmazonAdsClient { body.adGroupIdFilter = { include: [adGroupId] }; } - return this.makeRequest("POST", path, profileId, body, contentType); + return this.listAll(path, profileId, body, contentType, "keywords"); } async updateKeywordBid( @@ -562,7 +598,7 @@ export class AmazonAdsClient { body.adGroupIdFilter = { include: [adGroupId] }; } - return this.makeRequest("POST", path, profileId, body, contentType); + return this.listAll(path, profileId, body, contentType, "productAds"); } async archiveCampaign( From 1bf85d192b4c8d05bca70f61e45d5b5c4698e7f1 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:47:59 -0400 Subject: [PATCH 5/6] Restrict config file and directory to the owner config.json stores the client secret and refresh token but was written with the default umask, typically world-readable. Create the directory 0700 and the file 0600, and chmod both on every save so an existing file left over from an older version is tightened as well. --- src/__tests__/config.test.ts | 18 ++++++++++++++++++ src/config.ts | 8 ++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index c8186f3..fced9c7 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -70,6 +70,24 @@ describe("config", () => { expect(config.region).toBe("EU"); }); + it("saveConfig writes the file and its directory readable only by the owner", () => { + saveConfig({ clientSecret: "secret" }); + + const fileMode = fs.statSync(getConfigPath()).mode & 0o777; + const dirMode = fs.statSync(path.dirname(getConfigPath())).mode & 0o777; + expect(fileMode).toBe(0o600); + expect(dirMode).toBe(0o700); + }); + + it("saveConfig tightens permissions on an existing world-readable file", () => { + saveConfig({ clientSecret: "secret" }); + fs.chmodSync(getConfigPath(), 0o644); + + saveConfig({ clientSecret: "rotated" }); + + expect(fs.statSync(getConfigPath()).mode & 0o777).toBe(0o600); + }); + it("deleteConfig removes the config file", () => { saveConfig({ clientId: "test" }); expect(fs.existsSync(getConfigPath())).toBe(true); diff --git a/src/config.ts b/src/config.ts index 25fe157..f362c42 100644 --- a/src/config.ts +++ b/src/config.ts @@ -55,8 +55,12 @@ export function saveConfig(config: CliConfig): void { const configPath = getConfigPath(); const dir = path.dirname(configPath); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); + // The file holds the client secret and refresh token: owner-only access. + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.chmodSync(dir, 0o700); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 0o600 }); + // writeFileSync only applies mode on create; tighten an existing file too. + fs.chmodSync(configPath, 0o600); } export function deleteConfig(): void { From bcf4978c6035bbe3eaa5110f2e729482935ee474 Mon Sep 17 00:00:00 2001 From: Chris Price Date: Tue, 13 Oct 2026 18:47:59 -0400 Subject: [PATCH 6/6] Tidy project config - Remove axios; nothing imports it, the client uses fetch. - Point the clean script at build/, the actual tsc outDir, not dist/. - Ignore .claude/worktrees/ so session worktrees never get committed. - Fix .env.example: drop AMAZON_ADS_API_ENDPOINT, which the CLI never reads, and document REGION, SANDBOX, and PROFILE_ID, which it does. --- .env.example | 16 ++- .gitignore | 1 + package-lock.json | 311 +--------------------------------------------- package.json | 3 +- 4 files changed, 16 insertions(+), 315 deletions(-) diff --git a/.env.example b/.env.example index 95678b1..ae03fdb 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,18 @@ -# Example environment variables for the Amazon Ads CLI -# Copy this file to .env and fill in your values +# Example environment variables for the Amazon Ads CLI. +# Copy this file to .env and fill in your values, or run `amazonads auth login` +# to save credentials to ~/.config/amazonads-cli/config.json instead. +# Environment variables override the config file. # Amazon Ads API credentials AMAZON_ADS_CLIENT_ID=your_client_id_here AMAZON_ADS_CLIENT_SECRET=your_client_secret_here AMAZON_ADS_REFRESH_TOKEN=your_refresh_token_here -# API endpoint (optional) -AMAZON_ADS_API_ENDPOINT=https://advertising-api.amazon.com +# Region: NA, EU, or FE (default NA) +AMAZON_ADS_REGION=NA + +# Use the Amazon Ads sandbox endpoint (default false) +AMAZON_ADS_SANDBOX=false + +# Default profile ID, so --profile is not needed on every command +AMAZON_ADS_PROFILE_ID= diff --git a/.gitignore b/.gitignore index 99da9e1..c967d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ .env *.js.map *.d.ts.map +.claude/worktrees/ diff --git a/package-lock.json b/package-lock.json index 85d061e..77d13e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "2.0.0", "license": "MIT", "dependencies": { - "axios": "^1.7.7", "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -983,18 +982,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1014,24 +1001,6 @@ "node": ">=12" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1057,19 +1026,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1124,18 +1080,6 @@ "@colors/colors": "1.5.0" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -1149,6 +1093,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1212,15 +1157,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1233,44 +1169,12 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1278,33 +1182,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -1367,42 +1244,6 @@ "node": ">=12.0.0" } }, - "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1418,52 +1259,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -1477,70 +1272,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1615,40 +1346,11 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1757,15 +1459,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", diff --git a/package.json b/package.json index 7962515..fcae015 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,9 @@ "test": "vitest run", "test:watch": "vitest", "format": "prettier --write .", - "clean": "rm -rf dist coverage" + "clean": "rm -rf build coverage" }, "dependencies": { - "axios": "^1.7.7", "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^12.1.0",