diff --git a/.mocharc.json b/.mocharc.json index fa25f20..91236a3 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -1,6 +1,7 @@ { "require": [ - "ts-node/register" + "ts-node/register", + "test/helpers/isolate-config.ts" ], "watch-extensions": [ "ts" diff --git a/CLAUDE.md b/CLAUDE.md index ffde58a..4ed74bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,9 @@ src/ placements/ # list, get, create, update (audiences[] or deprecated --paywall-id) segments/ # list, get access-levels/ # list, get, create, update + asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords, + # negative-keywords, search-terms, ads, product-pages, creatives, automations, metrics, + # competitors lib/ api-client.ts # HTTP client (fetch-based, bearer auth) config.ts # ~/.config/adapty/config.json read/write @@ -32,23 +35,35 @@ src/ errors.ts # ApiError, NetworkError, AuthRequiredError flags.ts # shared flags: --app (UUID), pagination output.ts # printResponse(), printList() helpers (auto-formats snake_case keys) + asa-client.ts # factory: ApiClient against the ASA service (errorFormat 'asa') + asa-flags.ts # shared asa flags: scope filters, period, money, batch caps + asa-confirm.ts # mutation preview + confirmation prompt (--yes; refuses when piped or --json) + asa-schemas.ts # response typings for asa entities ``` ## Conventions - oclif topic separator is space (e.g. `adapty apps list`, not `adapty apps:list`) -- All resource commands scoped under `--app APP_ID` (UUID, validated) +- All resource commands scoped under `--app APP_ID` (UUID, validated) — except `asa`, which is scoped by the + token's company (`--app` there is only a list filter) - `list` commands use shared pagination flags (--page, --page-size) - Commands support `--json` flag via oclif's `enableJsonFlag = true` - Auth token stored at `~/.config/adapty/config.json` (mode 0o600) - `ADAPTY_TOKEN` env overrides stored token - `ADAPTY_API_URL` env overrides default API base URL - API base: `https://api-admin.adapty.io/api/v1/developer` +- `asa` topic talks to its own service: base `https://api-asa-admin.adapty.io/api/v1/cli`, overridden by + `ADAPTY_ASA_API_URL`; same bearer token, but errors follow the ASA shape (per-item `errors[]`, FastAPI + `detail`, `Retry-After` on 429) ## Key Patterns - Each command: single class extending `Command` in its own file - `createAuthenticatedClient(config)` — factory for token-aware ApiClient +- `createAsaClient(config)` — same, against the ASA service; asa writes print the request body and ask for + confirmation before sending (`asa-confirm.ts`) +- All asa POST/PUT go through `asaWrite()` (`asa-client.ts`): auto `Idempotency-Key` (pin with + `--idempotency-key`), one retry on NetworkError with the same key, replayed responses get a printed note - `PaginatedResponse` — standard list response wrapper - Human output via `printResponse()`/`printList()` (auto-formats snake_case → labels); JSON output via oclif flag - All entities use `title` field (not `name`) in API requests and responses diff --git a/README.md b/README.md index f8fca84..c4193b9 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,131 @@ adapty access-levels create --app UUID [flags] adapty access-levels update --app UUID ACCESS_LEVEL_ID [flags] ``` +### Apple Search Ads + +Apple Search Ads commands live under `adapty asa` and talk to the ASA service rather than the Developer +API. They take **no `--app`**: the scope is the company behind your token. A connected Apple Ads account and +an active Ads Manager subscription are required — `adapty asa whoami` tells you where you stand. + +```sh +adapty asa whoami # company, how access was granted, Apple connection state +adapty asa connect [--no-wait] # link an Apple Search Ads account +adapty asa apps list # apps promoted in Apple Search Ads +adapty asa orgs list # Apple Search Ads organizations +``` + +Every list takes scope filters, and they narrow the query rather than the printed page — `asa keywords list` +unfiltered pages through the whole account, while one ad group is a handful of rows, so scope the read: + +```sh +adapty asa campaigns list --app APP_UUID --status PAUSED +adapty asa ad-groups list --campaign CAMPAIGN_UUID +adapty asa keywords list --ad-group AD_GROUP_UUID --status ACTIVE +adapty asa keywords list --ad-group AD_GROUP_UUID --ad-group OTHER_UUID # repeatable +adapty asa creatives list --app APP_UUID +``` + +`--campaign-group`, `--app`, `--campaign`, `--ad-group` are repeatable and take the UUIDs printed by the +matching list command; `--search` matches names case-insensitively. Each list accepts only the filters that +make sense for it: `--ad-group` starts at keywords, negative keywords, search terms and ads, `--status` is +`ENABLED`/`PAUSED` everywhere except keywords, which are `ACTIVE`/`PAUSED`, and `asa ads list` has no `--app` +because ads hang off ad groups. An id belonging to another company simply matches nothing. + +Campaign structure. These lists return metadata only — numbers come from `asa metrics`, and only +`asa search-terms list` takes `--date-from` / `--date-to` (default: today): + +```sh +adapty asa campaigns list +adapty asa campaigns get CAMPAIGN_ID +adapty asa campaigns create --org UUID --name "Winter push" --adam-id 123456 --country US --daily-budget 50 +adapty asa campaigns update CAMPAIGN_ID [--status PAUSED] [--daily-budget 80] [--country US] + +adapty asa ad-groups list +adapty asa ad-groups get AD_GROUP_ID +adapty asa ad-groups create --campaign UUID --name "Brand terms" --default-bid 1.20 +adapty asa ad-groups update AD_GROUP_ID [--default-bid 1.50] [--status PAUSED] + +adapty asa ads list +adapty asa ads get AD_ID +adapty asa ads create --ad-group UUID --creative-id 4321 --name "Summer ad" +adapty asa ads update AD_ID [--name "..."] [--status PAUSED] +``` + +Keywords are always applied as a batch, at most 100 per call, and a partial rejection is reported per item: + +```sh +adapty asa keywords list +adapty asa keywords add --ad-group UUID --text "running shoes" --text "trail shoes" [--bid 1.20] [--match-type EXACT] +adapty asa keywords add --ad-group UUID --from-file keywords.txt +adapty asa keywords update KEYWORD_ID [KEYWORD_ID...] [--bid 2.00] [--status PAUSED] + +adapty asa negative-keywords list +adapty asa negative-keywords add --ad-group UUID --text free +adapty asa negative-keywords add --campaign UUID [--all-ad-groups] --text free + +adapty asa search-terms list [--date-from ... --date-to ...] +``` + +Product pages and rule-based automations: + +```sh +adapty asa product-pages list +adapty asa product-pages sync [--adam-id 123456] + +adapty asa automations list +adapty asa automations get AUTOMATION_ID +adapty asa automations create --file rule.json [--run-now] +adapty asa automations update AUTOMATION_ID [--stop] [--start] [--name "..."] [--file rule.json] +adapty asa automations run AUTOMATION_ID [--dry-run] +adapty asa automations runs AUTOMATION_ID +``` + +Metrics take an entity level, a period and an optional metric selection. Rows come back one per entity, +aggregated and sorted server-side, so a top-N or a breakdown is a single call — use `--order-by` with a small +`--page-size` for rankings, `metrics overview` for account totals and time series, and one big page (up to +1000 rows) when you genuinely need every row; never sum pages client-side: + +```sh +adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 +adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --order-by spend --page-size 5 +adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --group-by country --page-size 1000 +adapty asa metrics --entity keyword --date-from 2026-07-01 --date-to 2026-07-31 --metric spend --metric roas +adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric roas --by-days 7 --by-days 90 +adapty asa metrics overview --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 [--period-unit week] +``` + +There is no `ltv` metric: lifetime value is a cohort metric read at a renewal window, so `--by-days` is how you +ask for day-7 or day-90 values — on either route, up to 16 windows per call. `--order-by-day` ranks the rows by +one of those windows, which is how you get the top campaigns by day-90 ROAS in a single call. + +Competitor summary takes 1–5 Apple App Store IDs and covers the last full month across every country — there +are no period or country flags on purpose. The first call on a cold cache can take tens of seconds: + +```sh +adapty asa competitors summary --app-ids 111111111,2222222 +``` + +Writes go straight to Apple and take seconds, so every writing command first prints the exact request body and +asks for a yes. `--yes` skips the question for scripts; in a pipe or under `--json` the command refuses instead +of waiting for input that will never come. There is no undo — the CLI has no delete. + +Every write also carries an idempotency key. The CLI generates one per invocation and retries once on a +network error, so a request that died on the wire is never applied twice. Pass `--idempotency-key` to pin the +key yourself: re-running a script with the same key within 24 hours replays the stored result — the CLI prints +"Already applied earlier — showing the stored result." — instead of creating a second entity. The same key +with a different body is rejected (`422 cli_idempotency_key_reuse`), and a concurrent duplicate answers +`409 cli_idempotency_in_progress`. + +Analytics is rate limited per company: the metrics routes get 5 calls a minute (at most 2 in any 10 seconds) +and share a pool of two concurrent queries with the search-terms list — a busy pool answers +`429 cli_analytics_busy`, an exhausted window `429 cli_rate_limit_exceeded`, both with the exact wait in +`Retry-After`. The CLI absorbs a single 429 on its own — it waits the announced `Retry-After` (up to 60 +seconds; cool-downs are never waited out) and retries once — so a 429 that reaches you means the retry failed +too. A burst of 429s puts the token into an escalating cool-down (`cli_cooldown_active`, 5 minutes → +30 minutes → 3 hours); retries during the pause don't extend it, but the cure is fixing the failing request, +not waiting out the pause in a loop. An automation run is queued rather than awaited: `run` prints a run ID +and the outcome shows up in `adapty asa automations runs`. + ### Global Flags | Flag | Description | @@ -93,14 +218,18 @@ adapty access-levels update --app UUID ACCESS_LEVEL_ID [flags] | `--json` | Output as JSON | | `--help` | Show help | | `--page` | Page number (default: 1) | -| `--page-size` | Items per page (default: 20, max: 100) | +| `--page-size` | Items per page (default: 20, max: 100; `asa` commands: default 100, max 1000) | ## Environment Variables -| Variable | Description | -| ---------------- | ------------------------------------------------------------------------------- | -| `ADAPTY_TOKEN` | Override stored auth token | -| `ADAPTY_API_URL` | Override API base URL (default: `https://api-admin.adapty.io/api/v1/developer`) | +| Variable | Description | +| -------------------- | --------------------------------------------------------------------------------------- | +| `ADAPTY_TOKEN` | Override stored auth token | +| `ADAPTY_API_URL` | Override Developer API base URL (default: `https://api-admin.adapty.io/api/v1/developer`) | +| `ADAPTY_ASA_API_URL` | Override Apple Search Ads base URL (default: `https://api-asa-admin.adapty.io/api/v1/cli`) | + +The two API URLs are independent: pointing `ADAPTY_API_URL` at a staging host leaves `adapty asa` on the ASA +default, and the other way round. ## Claude Code Skill diff --git a/package.json b/package.json index 85cd208..eecef78 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "adapty", "description": "Adapty command line interface", - "version": "0.3.0", + "version": "0.4.0", "author": "Adapty team ", "bin": { "adapty": "./bin/run.js" @@ -82,6 +82,45 @@ }, "segments": { "description": "List segments" + }, + "asa": { + "description": "Apple Search Ads: campaigns, keywords, metrics and automations (scoped by the token's company, no --app)" + }, + "asa:apps": { + "description": "Apps promoted in Apple Search Ads" + }, + "asa:orgs": { + "description": "Apple Search Ads organizations" + }, + "asa:campaigns": { + "description": "Manage Apple Search Ads campaigns" + }, + "asa:ad-groups": { + "description": "Manage ad groups" + }, + "asa:keywords": { + "description": "Manage targeting keywords" + }, + "asa:negative-keywords": { + "description": "Manage negative keywords" + }, + "asa:search-terms": { + "description": "Search terms your ads matched" + }, + "asa:ads": { + "description": "Manage ads" + }, + "asa:product-pages": { + "description": "Custom product pages" + }, + "asa:creatives": { + "description": "Creatives that back ads" + }, + "asa:automations": { + "description": "Rule-based automations" + }, + "asa:metrics": { + "description": "Metrics and overviews" } } }, diff --git a/skills/adapty-cli/SKILL.md b/skills/adapty-cli/SKILL.md index 82a1bd7..d19ea4b 100644 --- a/skills/adapty-cli/SKILL.md +++ b/skills/adapty-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: adapty-cli -description: Use when setting up or managing Adapty in-app subscriptions, paywalls, or placements via CLI. +description: Use when setting up or managing Adapty in-app subscriptions, paywalls, placements, or Apple Search Ads campaigns via CLI. --- # Adapty CLI Skill @@ -19,7 +19,7 @@ npx adapty@latest --- -Two modes: **Setup** (new users, quiz-driven) and **Manage** (existing users, direct commands). +Three modes: **Setup** (new users, quiz-driven), **Manage** (existing users, direct commands) and **Apple Search Ads** (`adapty asa`, ad spend). ## Mode: Setup (New to Adapty) @@ -158,6 +158,58 @@ Key notes: --- +## Mode: Apple Search Ads (`adapty asa`) + +Ad spend, not subscriptions. The `asa` topic manages Apple Search Ads campaigns, keywords, ads and +automations, and reads their performance. Full reference in `references/cli-commands.md`. + +**Before answering any performance question, read `references/asa-agent-playbook.md`** — it maps the +common questions (spend, trends, top-N, geo, wasted keywords, LTV, search terms, competitors) to the +single command that answers each, lists every valid metric name, and gives the request budgets. The +short version: + +- One question → one call. Totals and trends = `asa metrics overview`; per-entity ranking = + `asa metrics --order-by ... --page-size N`. The server aggregates and sorts — never loop pages to + sum things yourself; a page holds up to 1000 rows if you really need them all. +- Metrics budget is 5 calls/min (max 2 per 10s, 2 concurrent). Plan inside it; the CLI absorbs one + 429 by itself (waits `Retry-After`, retries once), so a surfaced 429 means back off for real. + Don't add comparisons the user didn't ask for. +- Metric names are fixed and listed in the playbook; a wrong name fails with the full valid list, so + never spend calls probing. +- Date window caps: 90 days at day grain, 180 by week, 365 by month — widen by coarsening + `--group-by`/`--period-unit`, not by splitting into several calls. + +**These commands spend money and change a live ad account.** Treat every write as irreversible: + +- **Confirm before any write.** State plainly what will change — which campaign, which budget, how many + keywords — and get an explicit yes. The command asks too: it prints the request body it is about to send and + waits. Pass `--yes` only after the user has agreed; there is no undo and no delete. +- **Never invent IDs or budgets.** Read them first (`asa orgs list`, `asa campaigns list`) or ask. +- **Re-runs are safe when the key is pinned.** Every write sends an auto-generated `Idempotency-Key`, and one + network error is retried with the same key, so a call is never applied twice by accident. In scripts pass + `--idempotency-key` so the whole pipeline can be re-run: a repeat replays the stored result (the CLI prints + "Already applied earlier") instead of applying again. +- **Prefer the smallest step.** Add a handful of keywords, check the result, then continue. A 100-item batch + that Apple partially rejects is harder to reason about than three small ones. +- **A dry run is available for automations only**: `asa automations run --dry-run` evaluates a rule and + logs what it would do without touching Apple. Use it before enabling a rule that changes bids. +- **Metrics are cheap, writes are not.** Reads and `--dry-run` are safe to run freely; anything else is not. + +Key notes that differ from the rest of the CLI: + +- No required `--app`: scope comes from the token's company. `--app` exists on lists only, as a filter +- **Filter every list you can.** `--campaign-group`, `--app`, `--campaign`, `--ad-group`, `--status`, `--search` + narrow the query itself, so a scoped read is cheap and an unscoped one pages the whole account. `asa keywords + list` without `--ad-group` is still the widest read in the surface +- `asa whoami` first — it reports whether Apple Ads is connected and whether the company may use the CLI +- A 402 means the company has no Ads Manager subscription; a 404 means the entity is not theirs or absent +- A 429 carries the wait in `Retry-After`. Metrics and the search-terms list share one + analytics pool (2 concurrent queries per company, `cli_analytics_busy`); a burst of 429s triggers an + escalating token cool-down (`cli_cooldown_active`, 5m → 30m → 3h) — fix the request, don't hammer +- Keywords are always batches, capped at 100 per call, and a partial rejection is reported per item + +--- + ## Adapty Concepts - **Product** — subscription or one-time purchase mapped to store product IDs. Has a period, grants an access level. diff --git a/skills/adapty-cli/references/asa-agent-playbook.md b/skills/adapty-cli/references/asa-agent-playbook.md new file mode 100644 index 0000000..e10129a --- /dev/null +++ b/skills/adapty-cli/references/asa-agent-playbook.md @@ -0,0 +1,176 @@ +# ASA Agent Playbook — answering questions without wasting requests + +How to turn a user's question about Apple Search Ads into the **minimum** number of CLI calls. +Read this before answering any performance question. The budgets here are hard server limits: +an agent that ignores them gets 429s, then a token cool-down, and then it cannot answer at all. + +## How the system is built (mental model) + +- **Catalog reads** (`campaigns/ad-groups/keywords/negative-keywords/ads/creatives/product-pages list|get`) + return synced **metadata only** — names, ids, statuses, budgets, bids. No numbers. Cheap (Postgres). +- **Numbers** come from exactly two commands, both ClickHouse-backed and rate-limited hard: + - `asa metrics` — one row **per entity** (campaign / ad-group / keyword / ad), already aggregated over + the period and **already sorted** by `--order-by`. Pagination walks entities, not raw data. + - `asa metrics overview` — **account-level totals** for one entity level + a per-period time series. +- `asa search-terms list` is the only catalog-style list that still carries metrics and a period. +- Numbers equal the dashboard: both commands proxy the same v5 layer the dashboard reads. +- Money and ratio values arrive as **strings** (decimal precision), statuses as enums. + +## Iron rules + +1. **One question → one call.** Decide the single command that answers the question before running + anything. Do not add period comparisons, trends or extra breakdowns the user did not ask for — + propose them in the answer instead, as a follow-up the user can accept. +2. **Never sum pages yourself.** The server aggregates. Totals = `metrics overview` (one call). + Top-N = `metrics --order-by X --page-size N` (one call). If a breakdown genuinely needs every row, + ask for one big page: `--page-size` goes up to **1000** on every asa command. +3. **Trend and comparison questions are still one call.** A per-day/week/month series from + `metrics overview` contains both "today" and "yesterday", both "this week" and "last week". + Never make one call per period. +4. **Never guess or probe metric names.** The full vocabulary is below. A wrong name is a 422 whose + message lists every valid name — one failed call is the most discovery ever costs, so never spend + calls "checking what works". +5. **Respect the metrics budget: 5 calls/minute, at most 2 per any 10 seconds, 2 concurrent.** + Plan the whole answer inside that. Sequential calls only. The CLI absorbs a single 429 by itself — + it waits the exact `Retry-After` (up to 60s, cool-downs excluded) and retries once — so if a command + still fails with 429, the budget is genuinely gone: do not loop, reduce the number of calls or tell + the user when to retry. 20 rejections within 5 minutes put the token into an escalating cool-down + (5m → 30m → 3h) that blocks everything. +6. **A too-wide date window is fixed by coarsening, not splitting.** Caps: 90 days at day grain (or no + period grouping), 180 by week, 365 by month/quarter/year. Need a year of data? `--group-by month` + (or `--period-unit month`) in one call — never a series of 90-day calls. +7. **Counting entities needs no data.** Every list response carries `meta.pagination.count` — use + `--page-size 1` and read the count. +8. **Keyword metadata list is the heaviest read.** `asa keywords list` has its own budget (30/min, + 5 per 10s, 2 concurrent, 60s server timeout). Always filter it with `--ad-group`/`--campaign`. + Note this is the *metadata* list; keyword *numbers* come from `metrics --entity keyword`, which + ranks the whole account in one call. +9. **Writes change a live ad account.** Preview + explicit user confirmation, `--yes` only after the + user agreed, pin `--idempotency-key` in scripts. 20 writes/minute. + +## Request budgets (per company, not per token) + +| Commands | Budget | +|---|---| +| `metrics`, `metrics overview` | 5/min, burst 2 per 10s, 2 concurrent (shared with search-terms) | +| `search-terms list`, `competitors summary` | 30/min, same 2-concurrent analytics pool (search-terms) | +| `keywords list` | 30/min, burst 5 per 10s, own 2-concurrent pool, 60s timeout | +| catalog lists and gets, automation reads | 120/min | +| all writes | 20/min | +| `whoami` | 60/min | + +Every refusal is a `429` with the exact wait in `Retry-After`; `cli_analytics_busy` means the +2-concurrent pool is full (wait ~5s), `cli_rate_limit_exceeded` means the window is full, +`cli_cooldown_active` means stop entirely and tell the user when to retry. The CLI already waits out +and retries the first 429 of a command on its own — a surfaced 429 means the second attempt failed too. + +## Metric vocabulary + +`--metric` and `--order-by` take the dashboard's own names. Cohort roots — `revenue`, `arpu`, `arppu`, +`arpas` (alias `cohort_arpas`), `roas`, `roi` — expand to their `gross_` / `proceeds_` / `net_` variants; +to *rank* by a cohort metric use the expanded name (e.g. `--order-by gross_roas`). There is **no `ltv` +metric**: lifetime value = cohort metrics read at renewal windows via `--by-days` (up to 16 per call). + +**Apple spend metrics:** `spend`, `local_spend`, `impressions`, `taps`, `ttr`, `avg_cpt`, `avg_cpm`, +`ipm`, `total_installs`, `total_new_downloads`, `total_redownloads`, `tap_installs`, +`tap_new_downloads`, `tap_redownloads`, `view_installs`, `view_new_downloads`, `view_redownloads`, +`total_avg_cpi`, `total_install_rate`, `tap_install_cpi`, `tap_install_rate`. + +**Adapty attribution metrics:** `adapty_installs`, `trials_started`, `trials_converted`, +`subscriptions_started`, `non_subscriptions`, `paid`, `conversion`, `paid_subscribers`, `subscribers`, +`adapty_install_cr`, `trial_cr`, `trials_converted_cr`, `subscriptions_started_cr`, +`non_subscriptions_cr`, `paid_cr`, `conversion_cr`, `cost_per_adapty_install`, `cost_per_trial`, +`cost_per_trials_converted`, `cost_per_subscriptions_started`, `cost_per_non_subscriptions`, +`cost_per_paid`, `cost_per_conversion`. + +**Cohort (revenue) metrics**, per gross/proceeds/net: `gross_revenue`, `proceeds_revenue`, +`net_revenue`, and the same triple for `arpu`, `arppu`, `arpas`, `roas`, `roi`. + +**Keyword-only:** `rank`, `search_popularity`, `impression_midpoint`. + +`asa metrics overview` accepts the **root names only** (`revenue`, `roas`, `spend`, `taps`, …) — no +`gross_`/`proceeds_`/`net_` variants and no keyword-only names there. + +## Recipes: question → command + +Each recipe is the whole answer — if it says one call, a second call is a mistake. +Substitute the user's period; default to the current month when they don't name one. + +**"How much did I spend today / this week / this month?"** — one call: +```sh +adapty asa metrics overview --entity campaign --date-from 2026-08-01 --date-to 2026-08-11 --metric spend +``` + +**"Did spend go up or down vs yesterday / last week?" (any trend)** — one call, the series covers +both periods; compare the buckets in the response: +```sh +adapty asa metrics overview --entity campaign --date-from 2026-08-04 --date-to 2026-08-11 --metric spend [--period-unit week] +``` + +**"Best / worst campaign by ROAS (or any metric)?"** — one call, the server ranks: +```sh +adapty asa metrics --entity campaign --date-from ... --date-to ... --order-by gross_roas --page-size 5 +adapty asa metrics --entity campaign --date-from ... --date-to ... --order-by gross_roas --order asc --page-size 5 # worst +``` + +**"Top / bottom keywords by spend this week?"** — one call: +```sh +adapty asa metrics --entity keyword --date-from ... --date-to ... --metric spend --metric gross_roas --order-by spend --page-size 10 +``` + +**"Performance by country?"** — one call; every row carries its country breakdown, take the single +big page and aggregate in your answer (not by fetching more pages): +```sh +adapty asa metrics --entity campaign --date-from ... --date-to ... --group-by country --page-size 1000 +``` + +**"Is campaign X hitting its budget? / Is it spending?"** — two calls maximum: budget is metadata, +spend is metrics; match the campaign by id or name in the metrics rows: +```sh +adapty asa campaigns list --search "Brand US" +adapty asa metrics --entity campaign --date-from ... --date-to ... --metric spend +``` + +**"Keywords spending without converting (wasted spend)?"** — one call, scan rows where the +conversion columns are zero: +```sh +adapty asa metrics --entity keyword --date-from ... --date-to ... --metric spend --metric adapty_installs --metric trials_started --order-by spend --page-size 50 +``` + +**"What's my D7 / D30 ROAS? Which campaigns hit the D30 target?"** — one call; `--by-days` reads the +cohort at those windows, `--order-by-day` ranks by one of them: +```sh +adapty asa metrics --entity campaign --date-from ... --date-to ... --metric roas --by-days 7 --by-days 30 --order-by gross_roas --order-by-day 30 +``` + +**"How many active campaigns do I have?"** — one call, read `meta.pagination.count`: +```sh +adapty asa campaigns list --status ENABLED --page-size 1 --json +``` + +**"New search terms worth adding? Terms to negate?"** — one call, then propose the write and wait +for a yes: +```sh +adapty asa search-terms list --campaign CAMPAIGN_UUID --date-from ... --date-to ... +``` + +**"Raise/lower bids, pause things" (any mutation)** — read the target first if you don't have its +UUID, preview to the user, apply only after an explicit yes: +```sh +adapty asa keywords update KW_UUID [KW_UUID...] --bid 2.00 --yes +``` + +**"How do I compare to competitors?"** — one call, expect tens of seconds on a cold cache: +```sh +adapty asa competitors summary --app-ids 1668337467,6503873027 +``` + +## What the failed sessions did wrong (do not repeat) + +- Looped `--page 1..4` to build an account total → burned the 5/min budget, hit 429s, gave up. + Right: one `overview` call, or one `--page-size 1000` page. +- Queried once "to see valid metric names" → the vocabulary is above; a typo'd call already returns + the full list in its error. +- Added an unrequested previous-period comparison → doubled the calls; the user only asked for now. +- Retried with a guessed `sleep 25` instead of the `Retry-After` value → wasted the retry inside the + same window and struck the cool-down counter again. diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 268af16..883c092 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -94,6 +94,86 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v | `access-levels create` | `--app`, `--sdk-id`, `--title` | | `access-levels update ` | `--app`, `--title` | +## Apple Search Ads (`asa` topic) + +Different service behind the same token. **No `--app`**: every command is scoped to the company the token +belongs to. Requires a connected Apple Ads account plus an active Ads Manager subscription — without one +every `asa` command answers `402 ads_manager_subscription_required`. Start with `asa whoami`. + +| Command | Required flags / notes | +|-------------------------------------|----------------------------------------------------------------------------| +| `asa whoami` | company, how access was granted, Apple connection state | +| `asa connect` | prints the Apple authorization link and waits; `--no-wait` returns at once | +| `asa apps list` | (pagination only) | +| `asa orgs list` | ASA organizations; their ID is the `--org` of `campaigns create` | +| `asa campaigns list` | metadata only, no metrics; filters below | +| `asa campaigns get ` | positional UUID | +| `asa campaigns create` | `--org`, `--name`, `--adam-id`, `--country` (repeatable), `--daily-budget`; optional `--target-cpa`, `--bidding-strategy` | +| `asa campaigns update ` | at least one of `--name`, `--status`, `--country`, `--daily-budget`, `--budget`, `--target-cpa`, `--bidding-strategy` | +| `asa ad-groups list` / `get ` | metadata only, like campaigns; numbers come from `asa metrics` | +| `asa ad-groups create` | `--campaign`, `--name`, `--default-bid`; Apple also needs `--pricing-model` (default CPC) and `--start-time` (default today) | +| `asa ad-groups update ` | at least one field; the campaign is resolved server-side, never passed | +| `asa keywords list` | metadata only; **filter by `--ad-group`** — the heaviest read, own budget (30/min, 2 concurrent, 60s cap) | +| `asa keywords add` | `--ad-group` plus `--text` (repeatable) and/or `--from-file`; max 100 per call | +| `asa keywords update [...]` | one change applied to every id; `--text` only for a single keyword | +| `asa negative-keywords list` | `ad_group_id` is empty for campaign-level rows; `--campaign-level-only` keeps only those | +| `asa negative-keywords add` | exactly one of `--ad-group` / `--campaign`; `--all-ad-groups` needs `--campaign` | +| `asa search-terms list` | period flags; filter by `--ad-group` / `--campaign` to build the keyword pipeline | +| `asa ads list` / `get ` | `serving_state_reasons` explains a non-running ad; list has no `--app` filter | +| `asa ads create` | `--ad-group`, `--creative-id`, `--name`; the creative id comes from `asa creatives list` | +| `asa ads update ` | `--name` and/or `--status`; creative and parent are fixed at creation | +| `asa product-pages list` | read-only; filter by `--app` | +| `asa creatives list` | the Apple `creative_id` an ad is created against; filter by `--app` | +| `asa product-pages sync` | `--adam-id` optional; queued, 200 means already running or nothing to sync | +| `asa automations list` / `get ` | `status` is 1 for active, 0 for stopped | +| `asa automations create` | `--file rule.json` (or `-` for stdin); `--run-now` queues the first run | +| `asa automations update ` | `--stop` / `--start` / `--name` / `--file`; the file must not carry `internal_id` | +| `asa automations run ` | queued, prints a run ID; `--dry-run` evaluates without touching Apple | +| `asa automations runs ` | past runs, including dry runs | +| `asa metrics` | `--entity`, `--date-from`, `--date-to`; `--metric` repeatable, `--group-by`, `--order-by`, `--by-days` (max 16), `--order-by-day`; one server-sorted row per entity — top-N is one call | +| `asa metrics overview` | same, plus `--period-unit` (day/week/month/quarter/year); account totals + per-period series in one call | +| `asa competitors summary` | `--app-ids` (1–5 Apple App Store IDs, comma-separated); last full month, all countries, no period/country flags; slow on a cold cache | + +Filters on list commands — they narrow the query, not the printed page, so always scope a read: + +| Filter | Lists that accept it | +|--------------------|------------------------------------------------------------------------| +| `--campaign-group` | every list below | +| `--app` | campaigns, ad groups, keywords, negative keywords, search terms, product pages, creatives | +| `--campaign` | ad groups, keywords, negative keywords, search terms, ads | +| `--ad-group` | keywords, negative keywords, search terms, ads | +| `--status` | campaigns, ad groups (`ENABLED`/`PAUSED`), keywords (`ACTIVE`/`PAUSED`), ads | +| `--search` | every list except product pages and creatives | +| `--campaign-level-only` | negative keywords: only campaign-level rows (`ad_group_id` is null) | + +Id filters are repeatable and take the UUIDs from the matching list command; an id owned by another company +matches nothing, so the page comes back empty rather than erroring. + +Before running any of these: + +- **Writes reach Apple directly** and take seconds. Each writing command prints the body it will send and asks + for confirmation; `--yes` skips the question, and in a pipe or under `--json` the command refuses rather than + hanging. There is no server-side preview, but every write sends an `Idempotency-Key` header — auto-generated + per invocation, or pinned with `--idempotency-key ` on any mutating command. A repeat with the same key + and body within 24 hours replays the stored result (the CLI prints "Already applied earlier") instead of + creating a second entity; the same key with a different body fails with `422 cli_idempotency_key_reuse`, and + a concurrent duplicate with `409 cli_idempotency_in_progress`. One network error is retried automatically + with the same key. +- **Keyword and negative-keyword calls are batches.** One bad ID fails the whole batch before Apple is + called; Apple may still reject individual items, and each rejection comes back with its reason. +- **Analytics budgets are tight and per company**: `metrics`/`metrics overview` get 5 calls/min (max 2 per + 10s) and share a 2-concurrent pool with the search-terms list (`429 cli_analytics_busy`); search terms and + competitors get 30/min; keyword lists 30/min on their own 2-concurrent pool; catalog reads 120/min; writes + 20/min. Every 429 carries the exact wait in `Retry-After`; the CLI waits it out and retries once by itself + (up to 60s, cool-downs excluded), so a surfaced 429 means the retry failed too. A burst of 429s (20 within 5 minutes) puts the + token into a cool-down (`429 cli_cooldown_active`, escalating 5m → 30m → 3h); retrying during the pause does + not extend it, but the cure is fixing the request, not hammering. Answer questions with the fewest calls — + recipes in `asa-agent-playbook.md`. +- **`--page-size` goes up to 1000 on asa commands** — one big page always beats a pagination loop, and + `meta.pagination.count` answers "how many" without reading the rows. +- **Money flags take a bare amount** (`--daily-budget 50`); `--currency` defaults to USD. +- Anything owned by another company reads as missing, so a 404 means "not yours, or not there". + ## Validation Rules - `--app` must be a valid UUID diff --git a/src/commands/asa/ad-groups/create.ts b/src/commands/asa/ad-groups/create.ts new file mode 100644 index 0000000..d34f2ec --- /dev/null +++ b/src/commands/asa/ad-groups/create.ts @@ -0,0 +1,74 @@ +import {Command, Flags} from '@oclif/core' + +import type {AsaAdGroupMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import { + currencyFlag, + idempotencyFlags, + money, + moneyFlag, + pricingModelFlag, + scheduleFlags, + startOfDayUtc, + todayUtc, +} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaAdGroupsCreate extends Command { + static description = 'Create an ad group inside a campaign' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa ad-groups create --campaign UUID --name "Brand terms" --default-bid 1.20', + '<%= config.bin %> asa ad-groups create --campaign UUID --name "Brand terms" --default-bid 1.20 --start-time 2026-09-01 --pricing-model CPM', + ] + static flags = { + ...currencyFlag, + ...scheduleFlags, + ...pricingModelFlag, + ...confirmFlags, + ...idempotencyFlags, + 'automated-keywords': Flags.boolean({allowNo: true, description: 'Let Apple add keywords automatically'}), + campaign: Flags.string({description: 'Campaign ID (UUID)', required: true}), + 'cpa-goal': moneyFlag('CPA goal'), + 'default-bid': moneyFlag('Default bid', {required: true}), + name: Flags.string({description: 'Ad group name', required: true}), + status: Flags.string({description: 'Initial status', options: ['ENABLED', 'PAUSED']}), + } + + async run(): Promise { + const {flags} = await this.parse(AsaAdGroupsCreate) + if (!isValidUuid(flags.campaign)) this.error('Invalid campaign ID format.', {exit: 2}) + + const body = { + automated_keywords_opt_in: flags['automated-keywords'], + campaign_id: flags.campaign, + cpa_goal: money(flags['cpa-goal'], flags.currency), + default_bid_amount: money(flags['default-bid'], flags.currency), + end_time: startOfDayUtc(flags['end-time']), + name: flags.name, + pricing_model: flags['pricing-model'], + start_time: startOfDayUtc(flags['start-time'] ?? todayUtc()), + status: flags.status, + } + await confirmMutation( + this, + {body, method: 'POST', path: '/ad-groups/', summary: `Create ad group ${flags.name}`}, + flags.yes, + ) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'post', '/ad-groups', { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + if (result.ad_group && !replayed) this.log('Ad group created!') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/ad-groups/get.ts b/src/commands/asa/ad-groups/get.ts new file mode 100644 index 0000000..b7756d2 --- /dev/null +++ b/src/commands/asa/ad-groups/get.ts @@ -0,0 +1,28 @@ +import {Args, Command} from '@oclif/core' + +import type {AsaAdGroupDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaAdGroupsGet extends Command { + static args = { + ad_group_id: Args.string({description: 'Ad group ID (UUID)', required: true}), + } + static description = 'Show one ad group; read numbers with asa metrics' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa ad-groups get 550e8400-e29b-41d4-a716-446655440000'] + + async run(): Promise { + const {args} = await this.parse(AsaAdGroupsGet) + if (!isValidUuid(args.ad_group_id)) this.error('Invalid ad group ID format.', {exit: 2}) + + const client = await createAsaClient(this.config) + const result = await client.get(`/ad-groups/${args.ad_group_id}`) + + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/ad-groups/list.ts b/src/commands/asa/ad-groups/list.ts new file mode 100644 index 0000000..38954b2 --- /dev/null +++ b/src/commands/asa/ad-groups/list.ts @@ -0,0 +1,31 @@ +import {Command} from '@oclif/core' + +import type {AsaAdGroupDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {asaPaginationFlags, campaignScopeFlags, scopeParams, statusFilter} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaAdGroupsList extends Command { + static description = 'List ad groups; read numbers with asa metrics' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa ad-groups list', + '<%= config.bin %> asa ad-groups list --campaign CAMPAIGN_UUID', + ] + static flags = {...asaPaginationFlags, ...campaignScopeFlags, ...statusFilter(['ENABLED', 'PAUSED'])} + + async run(): Promise> { + const {flags} = await this.parse(AsaAdGroupsList) + const client = await createAsaClient(this.config) + const result = await client.get>('/ad-groups', { + ...paginationParams(flags), + ...scopeParams(flags), + }) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/ad-groups/update.ts b/src/commands/asa/ad-groups/update.ts new file mode 100644 index 0000000..7fa02f7 --- /dev/null +++ b/src/commands/asa/ad-groups/update.ts @@ -0,0 +1,68 @@ +import {Args, Command, Flags} from '@oclif/core' + +import type {AsaAdGroupMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {currencyFlag, idempotencyFlags, money, moneyFlag, scheduleFlags, startOfDayUtc} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaAdGroupsUpdate extends Command { + static args = { + ad_group_id: Args.string({description: 'Ad group ID (UUID)', required: true}), + } + static description = 'Change an ad group: bid, CPA goal, status or schedule' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa ad-groups update UUID --default-bid 1.50', + '<%= config.bin %> asa ad-groups update UUID --status PAUSED', + ] + static flags = { + ...currencyFlag, + ...scheduleFlags, + ...confirmFlags, + ...idempotencyFlags, + 'automated-keywords': Flags.boolean({allowNo: true, description: 'Let Apple add keywords automatically'}), + 'cpa-goal': moneyFlag('CPA goal'), + 'default-bid': moneyFlag('Default bid'), + name: Flags.string({description: 'Ad group name'}), + status: Flags.string({description: 'Ad group status', options: ['ENABLED', 'PAUSED']}), + } + + async run(): Promise { + const {args, flags} = await this.parse(AsaAdGroupsUpdate) + if (!isValidUuid(args.ad_group_id)) this.error('Invalid ad group ID format.', {exit: 2}) + + const body: Record = {} + if (flags.name !== undefined) body.name = flags.name + if (flags.status !== undefined) body.status = flags.status + if (flags['default-bid'] !== undefined) body.default_bid_amount = money(flags['default-bid'], flags.currency) + if (flags['cpa-goal'] !== undefined) body.cpa_goal = money(flags['cpa-goal'], flags.currency) + if (flags['automated-keywords'] !== undefined) body.automated_keywords_opt_in = flags['automated-keywords'] + if (flags['start-time'] !== undefined) body.start_time = startOfDayUtc(flags['start-time']) + if (flags['end-time'] !== undefined) body.end_time = startOfDayUtc(flags['end-time']) + + if (Object.keys(body).length === 0) { + this.error('Nothing to change. Pass at least one field, e.g. --status PAUSED.', {exit: 2}) + } + + await confirmMutation( + this, + {body, method: 'PUT', path: `/ad-groups/${args.ad_group_id}/`, summary: 'Update ad group'}, + flags.yes, + ) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'put', `/ad-groups/${args.ad_group_id}`, { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + if (result.ad_group && !replayed) this.log('Ad group updated!') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/ads/create.ts b/src/commands/asa/ads/create.ts new file mode 100644 index 0000000..9bdc727 --- /dev/null +++ b/src/commands/asa/ads/create.ts @@ -0,0 +1,48 @@ +import {Command, Flags} from '@oclif/core' + +import type {AsaAdMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {idempotencyFlags} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaAdsCreate extends Command { + static description = 'Create an ad from a creative in an ad group' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa ads create --ad-group UUID --creative-id 4321 --name "Summer ad"'] + static flags = { + ...confirmFlags, + ...idempotencyFlags, + 'ad-group': Flags.string({description: 'Ad group ID (UUID) — the campaign is resolved from it', required: true}), + 'creative-id': Flags.integer({description: 'Apple creative ID from a product page or the default set', required: true}), + name: Flags.string({description: 'Ad name', required: true}), + status: Flags.string({description: 'Initial status', options: ['ENABLED', 'PAUSED']}), + } + + async run(): Promise { + const {flags} = await this.parse(AsaAdsCreate) + if (!isValidUuid(flags['ad-group'])) this.error('Invalid ad group ID format.', {exit: 2}) + + const body = { + ad_group_id: flags['ad-group'], + creative_id: flags['creative-id'], + name: flags.name, + ...(flags.status === undefined ? {} : {status: flags.status}), + } + await confirmMutation(this, {body, method: 'POST', path: '/ads/', summary: `Create ad ${flags.name}`}, flags.yes) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'post', '/ads', { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + if (result.ad && !replayed) this.log('Ad created!') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/ads/get.ts b/src/commands/asa/ads/get.ts new file mode 100644 index 0000000..99f0a0c --- /dev/null +++ b/src/commands/asa/ads/get.ts @@ -0,0 +1,28 @@ +import {Args, Command} from '@oclif/core' + +import type {AsaAdDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaAdsGet extends Command { + static args = { + ad_id: Args.string({description: 'Ad ID (UUID)', required: true}), + } + static description = 'Show one ad and its serving state' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa ads get 550e8400-e29b-41d4-a716-446655440000'] + + async run(): Promise { + const {args} = await this.parse(AsaAdsGet) + if (!isValidUuid(args.ad_id)) this.error('Invalid ad ID format.', {exit: 2}) + + const client = await createAsaClient(this.config) + const result = await client.get(`/ads/${args.ad_id}`) + + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/ads/list.ts b/src/commands/asa/ads/list.ts new file mode 100644 index 0000000..f806fc4 --- /dev/null +++ b/src/commands/asa/ads/list.ts @@ -0,0 +1,31 @@ +import {Command} from '@oclif/core' + +import type {AsaAdDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {adScopeFlags, asaPaginationFlags, scopeParams, statusFilter} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaAdsList extends Command { + static description = 'List ads; serving_state_reasons explains why an enabled ad is not running' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa ads list', + '<%= config.bin %> asa ads list --ad-group AD_GROUP_UUID --status ENABLED', + ] + static flags = {...asaPaginationFlags, ...adScopeFlags, ...statusFilter(['ENABLED', 'PAUSED'])} + + async run(): Promise> { + const {flags} = await this.parse(AsaAdsList) + const client = await createAsaClient(this.config) + const result = await client.get>('/ads', { + ...paginationParams(flags), + ...scopeParams(flags), + }) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/ads/update.ts b/src/commands/asa/ads/update.ts new file mode 100644 index 0000000..56a2b91 --- /dev/null +++ b/src/commands/asa/ads/update.ts @@ -0,0 +1,51 @@ +import {Args, Command, Flags} from '@oclif/core' + +import type {AsaAdMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {idempotencyFlags} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaAdsUpdate extends Command { + static args = { + ad_id: Args.string({description: 'Ad ID (UUID)', required: true}), + } + static description = 'Rename an ad or pause it; the creative and ad group are fixed at creation' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa ads update UUID --status PAUSED'] + static flags = { + ...confirmFlags, + ...idempotencyFlags, + name: Flags.string({description: 'Ad name'}), + status: Flags.string({description: 'Ad status', options: ['ENABLED', 'PAUSED']}), + } + + async run(): Promise { + const {args, flags} = await this.parse(AsaAdsUpdate) + if (!isValidUuid(args.ad_id)) this.error('Invalid ad ID format.', {exit: 2}) + + const body: Record = {} + if (flags.name !== undefined) body.name = flags.name + if (flags.status !== undefined) body.status = flags.status + + if (Object.keys(body).length === 0) { + this.error('Nothing to change. Pass --name or --status.', {exit: 2}) + } + + await confirmMutation(this, {body, method: 'PUT', path: `/ads/${args.ad_id}/`, summary: 'Update ad'}, flags.yes) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'put', `/ads/${args.ad_id}`, { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + if (result.ad && !replayed) this.log('Ad updated!') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/apps/list.ts b/src/commands/asa/apps/list.ts new file mode 100644 index 0000000..8cddfc9 --- /dev/null +++ b/src/commands/asa/apps/list.ts @@ -0,0 +1,25 @@ +import {Command} from '@oclif/core' + +import type {AsaAppDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {asaPaginationFlags} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaAppsList extends Command { + static description = 'List the apps promoted by this company in Apple Search Ads' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa apps list', '<%= config.bin %> asa apps list --page 2 --page-size 50'] + static flags = {...asaPaginationFlags} + + async run(): Promise> { + const {flags} = await this.parse(AsaAppsList) + const client = await createAsaClient(this.config) + const result = await client.get>('/apps', paginationParams(flags)) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/automations/create.ts b/src/commands/asa/automations/create.ts new file mode 100644 index 0000000..611da57 --- /dev/null +++ b/src/commands/asa/automations/create.ts @@ -0,0 +1,70 @@ +import {Command, Flags} from '@oclif/core' +import {readFile} from 'node:fs/promises' + +import type {AsaAutomationMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {idempotencyFlags} from '../../../lib/asa-flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaAutomationsCreate extends Command { + static description = 'Create an automation rule from a JSON rule file' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa automations create --file rule.json', + '<%= config.bin %> asa automations create --file rule.json --run-now', + ] + static flags = { + ...confirmFlags, + ...idempotencyFlags, + file: Flags.string({description: 'JSON file with the rule body, or - to read stdin', required: true}), + 'run-now': Flags.boolean({description: 'Queue the first run right after the rule is stored'}), + } + + async run(): Promise { + const {flags} = await this.parse(AsaAutomationsCreate) + + const body = await this.readRule(flags.file) + if (flags['run-now']) body.run_immediately = true + + const summary = flags['run-now'] ? 'Create automation rule and run it immediately' : 'Create automation rule' + await confirmMutation(this, {body, method: 'POST', path: '/automations/', summary}, flags.yes) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'post', '/automations', { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + if (result.automation && !replayed) { + this.log(flags['run-now'] ? 'Automation created and the first run queued!' : 'Automation created!') + } + + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } + + private async readRule(path: string): Promise> { + let raw: string + try { + raw = path === '-' ? await this.readStdin() : await readFile(path, 'utf8') + } catch { + this.error(`Could not read ${path}.`, {exit: 2}) + } + + try { + return JSON.parse(raw) as Record + } catch { + this.error(`${path} is not valid JSON.`, {exit: 2}) + } + } + + private async readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) chunks.push(chunk as Buffer) + return Buffer.concat(chunks).toString('utf8') + } +} diff --git a/src/commands/asa/automations/get.ts b/src/commands/asa/automations/get.ts new file mode 100644 index 0000000..9b81ad1 --- /dev/null +++ b/src/commands/asa/automations/get.ts @@ -0,0 +1,28 @@ +import {Args, Command} from '@oclif/core' + +import type {AsaAutomationDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaAutomationsGet extends Command { + static args = { + automation_id: Args.string({description: 'Automation rule ID (UUID)', required: true}), + } + static description = 'Show one automation rule with its conditions and actions' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa automations get 550e8400-e29b-41d4-a716-446655440000'] + + async run(): Promise { + const {args} = await this.parse(AsaAutomationsGet) + if (!isValidUuid(args.automation_id)) this.error('Invalid automation ID format.', {exit: 2}) + + const client = await createAsaClient(this.config) + const result = await client.get(`/automations/${args.automation_id}`) + + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/automations/list.ts b/src/commands/asa/automations/list.ts new file mode 100644 index 0000000..98585f0 --- /dev/null +++ b/src/commands/asa/automations/list.ts @@ -0,0 +1,25 @@ +import {Command} from '@oclif/core' + +import type {AsaAutomationDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {asaPaginationFlags} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaAutomationsList extends Command { + static description = 'List automation rules; status 1 is active, 0 is stopped' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa automations list'] + static flags = {...asaPaginationFlags} + + async run(): Promise> { + const {flags} = await this.parse(AsaAutomationsList) + const client = await createAsaClient(this.config) + const result = await client.get>('/automations', paginationParams(flags)) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/automations/run.ts b/src/commands/asa/automations/run.ts new file mode 100644 index 0000000..243f1c2 --- /dev/null +++ b/src/commands/asa/automations/run.ts @@ -0,0 +1,57 @@ +import {Args, Command, Flags} from '@oclif/core' + +import type {AsaAutomationRunEnqueuedDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {idempotencyFlags} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' + +export default class AsaAutomationsRun extends Command { + static args = { + automation_id: Args.string({description: 'Automation rule ID (UUID)', required: true}), + } + static description = 'Run an automation rule now; the run is queued, not awaited' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa automations run UUID', + '<%= config.bin %> asa automations run UUID --dry-run', + ] + static flags = { + ...confirmFlags, + ...idempotencyFlags, + 'dry-run': Flags.boolean({description: 'Evaluate and log the rule without touching Apple'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(AsaAutomationsRun) + if (!isValidUuid(args.automation_id)) this.error('Invalid automation ID format.', {exit: 2}) + + if (!flags['dry-run']) { + await confirmMutation( + this, + { + method: 'POST', + path: `/automations/${args.automation_id}/run/`, + summary: 'Run the rule for real — it applies its actions to Apple', + }, + flags.yes, + ) + } + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite( + client, + 'post', + `/automations/${args.automation_id}/run`, + {idempotencyKey: flags['idempotency-key'], params: flags['dry-run'] ? {dry_run: 'true'} : undefined}, + ) + + noteReplay(replayed, this.log.bind(this)) + if (!replayed) this.log(flags['dry-run'] ? 'Dry run queued.' : 'Run queued.') + this.log(`Run ID: ${result.run_id ?? 'unknown'}`) + this.log(`Follow it with: ${this.config.bin} asa automations runs ${args.automation_id}`) + + return result + } +} diff --git a/src/commands/asa/automations/runs.ts b/src/commands/asa/automations/runs.ts new file mode 100644 index 0000000..9bcc9eb --- /dev/null +++ b/src/commands/asa/automations/runs.ts @@ -0,0 +1,34 @@ +import {Args, Command} from '@oclif/core' + +import type {AsaAutomationRunDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {asaPaginationFlags} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaAutomationsRuns extends Command { + static args = { + automation_id: Args.string({description: 'Automation rule ID (UUID)', required: true}), + } + static description = 'List past runs of an automation rule, including dry runs' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa automations runs 550e8400-e29b-41d4-a716-446655440000'] + static flags = {...asaPaginationFlags} + + async run(): Promise> { + const {args, flags} = await this.parse(AsaAutomationsRuns) + if (!isValidUuid(args.automation_id)) this.error('Invalid automation ID format.', {exit: 2}) + + const client = await createAsaClient(this.config) + const result = await client.get>( + `/automations/${args.automation_id}/runs`, + paginationParams(flags), + ) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/automations/update.ts b/src/commands/asa/automations/update.ts new file mode 100644 index 0000000..8b47c67 --- /dev/null +++ b/src/commands/asa/automations/update.ts @@ -0,0 +1,89 @@ +import {Args, Command, Flags} from '@oclif/core' +import {readFile} from 'node:fs/promises' + +import type {AsaAutomationMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {idempotencyFlags} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaAutomationsUpdate extends Command { + static args = { + automation_id: Args.string({description: 'Automation rule ID (UUID)', required: true}), + } + static description = 'Change an automation rule: stop it, rename it, or replace parts of the rule' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa automations update UUID --stop', + '<%= config.bin %> asa automations update UUID --file rule.json', + ] + static flags = { + ...confirmFlags, + ...idempotencyFlags, + file: Flags.string({description: 'JSON file with the parts to change, or - to read stdin'}), + name: Flags.string({description: 'Rule name'}), + start: Flags.boolean({description: 'Activate the rule', exclusive: ['stop']}), + stop: Flags.boolean({description: 'Stop the rule and clear its next run', exclusive: ['start']}), + } + + async run(): Promise { + const {args, flags} = await this.parse(AsaAutomationsUpdate) + if (!isValidUuid(args.automation_id)) this.error('Invalid automation ID format.', {exit: 2}) + + const body: Record = flags.file ? await this.readRule(flags.file) : {} + if (flags.name !== undefined) body.name = flags.name + if (flags.start) body.status = 1 + if (flags.stop) body.status = 0 + + if (Object.keys(body).length === 0) { + this.error('Nothing to change. Pass --stop, --start, --name or --file.', {exit: 2}) + } + + if ('internal_id' in body) { + this.error('Remove internal_id from the file: the rule ID comes from the command line.', {exit: 2}) + } + + await confirmMutation( + this, + {body, method: 'PUT', path: `/automations/${args.automation_id}/`, summary: 'Update automation rule'}, + flags.yes, + ) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite( + client, + 'put', + `/automations/${args.automation_id}`, + {body, idempotencyKey: flags['idempotency-key']}, + ) + + noteReplay(replayed, this.log.bind(this)) + if (result.automation && !replayed) this.log('Automation updated!') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } + + private async readRule(path: string): Promise> { + let raw: string + try { + raw = path === '-' ? await this.readStdin() : await readFile(path, 'utf8') + } catch { + this.error(`Could not read ${path}.`, {exit: 2}) + } + + try { + return JSON.parse(raw) as Record + } catch { + this.error(`${path} is not valid JSON.`, {exit: 2}) + } + } + + private async readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) chunks.push(chunk as Buffer) + return Buffer.concat(chunks).toString('utf8') + } +} diff --git a/src/commands/asa/campaigns/create.ts b/src/commands/asa/campaigns/create.ts new file mode 100644 index 0000000..cfe24c9 --- /dev/null +++ b/src/commands/asa/campaigns/create.ts @@ -0,0 +1,74 @@ +import {Command, Flags} from '@oclif/core' + +import type {AsaCampaignMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {currencyFlag, idempotencyFlags, money, moneyFlag} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaCampaignsCreate extends Command { + static description = 'Create a campaign in Apple Search Ads' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa campaigns create --org UUID --name "Winter push" --adam-id 123456 --country US --daily-budget 50', + ] + static flags = { + ...currencyFlag, + ...confirmFlags, + ...idempotencyFlags, + 'ad-channel-type': Flags.string({default: 'SEARCH', description: 'Ad channel type', options: ['DISPLAY', 'SEARCH']}), + 'adam-id': Flags.integer({description: 'App Store app ID (adam_id)', required: true}), + 'bidding-strategy': Flags.string({ + description: 'Bidding strategy; Apple defaults to MANUAL_CPT when omitted', + options: ['MANUAL_CPT', 'MAX_CONVERSIONS'], + }), + 'billing-event': Flags.string({default: 'TAPS', description: 'Billing event', options: ['IMPRESSIONS', 'TAPS']}), + budget: moneyFlag('Lifetime budget'), + country: Flags.string({description: 'Country or region code, repeatable', multiple: true, required: true}), + 'daily-budget': moneyFlag('Daily budget', {required: true}), + name: Flags.string({description: 'Campaign name', required: true}), + org: Flags.string({description: 'Campaign group ID (UUID) — see `adapty asa orgs list`', required: true}), + status: Flags.string({description: 'Initial status', options: ['ENABLED', 'PAUSED']}), + 'supply-source': Flags.string({ + default: ['APPSTORE_SEARCH_RESULTS'], + description: 'Supply source, repeatable', + multiple: true, + }), + 'target-cpa': moneyFlag('Target CPA'), + } + + async run(): Promise { + const {flags} = await this.parse(AsaCampaignsCreate) + if (!isValidUuid(flags.org)) this.error('Invalid org ID format.', {exit: 2}) + + const body = { + ad_channel_type: flags['ad-channel-type'], + adam_id: flags['adam-id'], + bidding_strategy: flags['bidding-strategy'], + billing_event: flags['billing-event'], + budget_amount: money(flags.budget, flags.currency), + campaign_group_id: flags.org, + countries_or_regions: flags.country, + daily_budget_amount: money(flags['daily-budget'], flags.currency), + name: flags.name, + status: flags.status, + supply_sources: flags['supply-source'], + target_cpa: money(flags['target-cpa'], flags.currency), + } + await confirmMutation(this, {body, method: 'POST', path: '/campaigns/', summary: `Create campaign ${flags.name}`}, flags.yes) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'post', '/campaigns', { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + if (result.campaign && !replayed) this.log('Campaign created!') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/campaigns/get.ts b/src/commands/asa/campaigns/get.ts new file mode 100644 index 0000000..839d461 --- /dev/null +++ b/src/commands/asa/campaigns/get.ts @@ -0,0 +1,28 @@ +import {Args, Command} from '@oclif/core' + +import type {AsaCampaignDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaCampaignsGet extends Command { + static args = { + campaign_id: Args.string({description: 'Campaign ID (UUID)', required: true}), + } + static description = 'Show one campaign; read numbers with asa metrics' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa campaigns get 550e8400-e29b-41d4-a716-446655440000'] + + async run(): Promise { + const {args} = await this.parse(AsaCampaignsGet) + if (!isValidUuid(args.campaign_id)) this.error('Invalid campaign ID format.', {exit: 2}) + + const client = await createAsaClient(this.config) + const result = await client.get(`/campaigns/${args.campaign_id}`) + + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/campaigns/list.ts b/src/commands/asa/campaigns/list.ts new file mode 100644 index 0000000..ae6f215 --- /dev/null +++ b/src/commands/asa/campaigns/list.ts @@ -0,0 +1,31 @@ +import {Command} from '@oclif/core' + +import type {AsaCampaignDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {asaPaginationFlags, orgScopeFlags, scopeParams, statusFilter} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaCampaignsList extends Command { + static description = 'List Apple Search Ads campaigns; read numbers with asa metrics' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa campaigns list', + '<%= config.bin %> asa campaigns list --app APP_UUID --status PAUSED', + ] + static flags = {...asaPaginationFlags, ...orgScopeFlags, ...statusFilter(['ENABLED', 'PAUSED'])} + + async run(): Promise> { + const {flags} = await this.parse(AsaCampaignsList) + const client = await createAsaClient(this.config) + const result = await client.get>('/campaigns', { + ...paginationParams(flags), + ...scopeParams(flags), + }) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/campaigns/update.ts b/src/commands/asa/campaigns/update.ts new file mode 100644 index 0000000..7e59c1a --- /dev/null +++ b/src/commands/asa/campaigns/update.ts @@ -0,0 +1,72 @@ +import {Args, Command, Flags} from '@oclif/core' + +import type {AsaCampaignMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {currencyFlag, idempotencyFlags, money, moneyFlag} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaCampaignsUpdate extends Command { + static args = { + campaign_id: Args.string({description: 'Campaign ID (UUID)', required: true}), + } + static description = 'Change a campaign: budgets, countries, status or schedule' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa campaigns update UUID --status PAUSED', + '<%= config.bin %> asa campaigns update UUID --daily-budget 80', + ] + static flags = { + ...currencyFlag, + ...confirmFlags, + ...idempotencyFlags, + 'bidding-strategy': Flags.string({ + description: 'Bidding strategy', + options: ['MANUAL_CPT', 'MAX_CONVERSIONS'], + }), + budget: moneyFlag('Lifetime budget'), + country: Flags.string({description: 'Replace the country list, repeatable', multiple: true}), + 'daily-budget': moneyFlag('Daily budget'), + name: Flags.string({description: 'Campaign name'}), + status: Flags.string({description: 'Campaign status', options: ['ENABLED', 'PAUSED']}), + 'target-cpa': moneyFlag('Target CPA'), + } + + async run(): Promise { + const {args, flags} = await this.parse(AsaCampaignsUpdate) + if (!isValidUuid(args.campaign_id)) this.error('Invalid campaign ID format.', {exit: 2}) + + const body: Record = {} + if (flags.name !== undefined) body.name = flags.name + if (flags.status !== undefined) body.status = flags.status + if (flags.country !== undefined) body.countries_or_regions = flags.country + if (flags['daily-budget'] !== undefined) body.daily_budget_amount = money(flags['daily-budget'], flags.currency) + if (flags.budget !== undefined) body.budget_amount = money(flags.budget, flags.currency) + if (flags['target-cpa'] !== undefined) body.target_cpa = money(flags['target-cpa'], flags.currency) + if (flags['bidding-strategy'] !== undefined) body.bidding_strategy = flags['bidding-strategy'] + + if (Object.keys(body).length === 0) { + this.error('Nothing to change. Pass at least one field, e.g. --status PAUSED.', {exit: 2}) + } + + await confirmMutation( + this, + {body, method: 'PUT', path: `/campaigns/${args.campaign_id}/`, summary: 'Update campaign'}, + flags.yes, + ) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'put', `/campaigns/${args.campaign_id}`, { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + if (result.campaign && !replayed) this.log('Campaign updated!') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/competitors/summary.ts b/src/commands/asa/competitors/summary.ts new file mode 100644 index 0000000..a8abfb6 --- /dev/null +++ b/src/commands/asa/competitors/summary.ts @@ -0,0 +1,78 @@ +import {Command, Flags} from '@oclif/core' + +import type {AsaCompetitorsSummaryDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient} from '../../../lib/asa-client.js' +import {printList, printResponse} from '../../../lib/output.js' + +const MAX_APP_IDS = 5 + +export default class AsaCompetitorsSummary extends Command { + static description = + 'Competitor summary for up to five App Store apps; the server covers the last full month and every country' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa competitors summary --app-ids 1668337467,6503873027'] + static flags = { + 'app-ids': Flags.string({ + description: `Apple App Store IDs (adam_id), comma-separated, 1-${MAX_APP_IDS} values`, + required: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(AsaCompetitorsSummary) + const appIds = flags['app-ids'] + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + if (appIds.length === 0 || appIds.length > MAX_APP_IDS) { + this.error(`Pass 1 to ${MAX_APP_IDS} App Store IDs, got ${appIds.length}.`, {exit: 2}) + } + + if (appIds.some((id) => !/^\d+$/.test(id))) { + this.error('App Store IDs are numbers, e.g. --app-ids 1668337467,6503873027.', {exit: 2}) + } + + const client = await createAsaClient(this.config) + const {result} = await asaWrite(client, 'post', '/competitors/summary', { + body: {app_ids: appIds.map(Number)}, + }) + + const {total} = result + printResponse( + { + competitors_count: total.competitorsCount, + countries_asa_count: total.countriesAsaCount, + countries_with_asa_terms: total.countriesWithAsaTerms, + total_unique_terms: total.totalUniqueTerms, + }, + this.log.bind(this), + ) + + this.log('') + this.log('Top apps by performance:') + printList( + total.topAppsByPerformance.map((app) => ({ + adam_id: app.adamId, + avg_sov: app.avgSov, + countries: app.countries, + name: app.name, + terms_count: app.termsCount, + })), + this.log.bind(this), + ) + + this.log('') + this.log('Most contested terms:') + printList( + total.mostContestedTerms.map((term) => ({ + competitor_count: term.competitorCount, + max_sov: term.maxSov, + term: term.term, + })), + this.log.bind(this), + ) + + return result + } +} diff --git a/src/commands/asa/connect.ts b/src/commands/asa/connect.ts new file mode 100644 index 0000000..7206e39 --- /dev/null +++ b/src/commands/asa/connect.ts @@ -0,0 +1,51 @@ +import {Command, Flags} from '@oclif/core' +import open from 'open' + +import type {AsaAppleOAuthDTO, AsaMeDTO} from '../../lib/asa-schemas.js' + +import {createAsaClient} from '../../lib/asa-client.js' + +const POLL_INTERVAL_MS = 3000 + +export default class AsaConnect extends Command { + static description = 'Connect an Apple Search Ads account to this company' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa connect', '<%= config.bin %> asa connect --no-wait'] + static flags = { + timeout: Flags.integer({default: 300, description: 'Seconds to wait for the browser step'}), + wait: Flags.boolean({allowNo: true, default: true, description: 'Wait until Apple Ads reports as connected'}), + } + + async run(): Promise { + const {flags} = await this.parse(AsaConnect) + const client = await createAsaClient(this.config) + + const {auth_url: authUrl} = await client.get('/apple/oauth') + this.log(`If the browser doesn't open, visit: ${authUrl}\n`) + this.log('The link is valid for one hour. Sign in to the Adapty dashboard in that browser first — the last') + this.log('step is authorized by the dashboard session, not by this CLI.') + + await open(authUrl).catch(() => false) + + if (!flags.wait) return {auth_url: authUrl} + + const deadline = Date.now() + flags.timeout * 1000 + let status: AsaMeDTO | undefined + while (Date.now() < deadline) { + status = await client.get('/me') + if (status.apple_credentials_status === 'active') { + this.log('Apple Ads connected. The first metadata import starts automatically.') + return status + } + + await new Promise((resolve) => { + setTimeout(resolve, POLL_INTERVAL_MS) + }) + } + + this.log(`Still not connected after ${flags.timeout}s. The usual cause is a browser that is not signed in to`) + this.log(`the Adapty dashboard. Finish the browser step, then check with: ${this.config.bin} asa whoami`) + + return status ?? {auth_url: authUrl} + } +} diff --git a/src/commands/asa/creatives/list.ts b/src/commands/asa/creatives/list.ts new file mode 100644 index 0000000..ca2223a --- /dev/null +++ b/src/commands/asa/creatives/list.ts @@ -0,0 +1,31 @@ +import {Command} from '@oclif/core' + +import type {AsaCreativeDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {asaPaginationFlags, assetScopeFlags, scopeParams} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaCreativesList extends Command { + static description = 'List creatives; creative_id is what `asa ads create` needs' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa creatives list', + '<%= config.bin %> asa creatives list --app APP_UUID', + ] + static flags = {...asaPaginationFlags, ...assetScopeFlags} + + async run(): Promise> { + const {flags} = await this.parse(AsaCreativesList) + const client = await createAsaClient(this.config) + const result = await client.get>('/creatives', { + ...paginationParams(flags), + ...scopeParams(flags), + }) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/keywords/add.ts b/src/commands/asa/keywords/add.ts new file mode 100644 index 0000000..b2f1fca --- /dev/null +++ b/src/commands/asa/keywords/add.ts @@ -0,0 +1,88 @@ +import {Command, Flags} from '@oclif/core' +import {readFile} from 'node:fs/promises' + +import type {AsaKeywordMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {currencyFlag, idempotencyFlags, MAX_BULK_ITEMS, money, moneyFlag, reportBulkOutcome} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' + +export default class AsaKeywordsAdd extends Command { + static description = 'Add targeting keywords to an ad group' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa keywords add --ad-group UUID --text "running shoes" --text "trail shoes" --bid 1.20', + '<%= config.bin %> asa keywords add --ad-group UUID --from-file keywords.txt --match-type EXACT', + ] + static flags = { + ...currencyFlag, + ...confirmFlags, + ...idempotencyFlags, + 'ad-group': Flags.string({description: 'Ad group ID (UUID) — the campaign is resolved from it', required: true}), + bid: moneyFlag('Bid per keyword'), + 'from-file': Flags.string({description: 'File with one keyword per line, combined with any --text values'}), + 'match-type': Flags.string({default: 'BROAD', description: 'Match type', options: ['BROAD', 'EXACT']}), + status: Flags.string({default: 'ACTIVE', description: 'Keyword status', options: ['ACTIVE', 'PAUSED']}), + text: Flags.string({description: 'Keyword text, repeatable', multiple: true}), + } + + async run(): Promise { + const {flags} = await this.parse(AsaKeywordsAdd) + if (!isValidUuid(flags['ad-group'])) this.error('Invalid ad group ID format.', {exit: 2}) + + const texts = [...(flags.text ?? []), ...(await this.readTexts(flags['from-file']))] + if (texts.length === 0) this.error('Pass at least one --text or a --from-file with keywords.', {exit: 2}) + if (texts.length > MAX_BULK_ITEMS) { + this.error(`A single call takes at most ${MAX_BULK_ITEMS} keywords, got ${texts.length}.`, {exit: 2}) + } + + const body = { + keywords: texts.map((text) => ({ + ad_group_id: flags['ad-group'], + bid_amount: money(flags.bid, flags.currency), + match_type: flags['match-type'], + status: flags.status, + text, + })), + } + await confirmMutation( + this, + {body, method: 'POST', path: '/keywords/', summary: `Add ${texts.length} keyword(s)`}, + flags.yes, + ) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'post', '/keywords', { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + reportBulkOutcome( + { + applied: result.keywords, + errors: result.errors, + isValidationFailure: result.is_validation_failure, + kind: 'keywords', + }, + this.log.bind(this), + ) + + return result + } + + private async readTexts(path: string | undefined): Promise { + if (!path) return [] + + try { + const raw = await readFile(path, 'utf8') + return raw + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + this.error(`Could not read ${path}.`, {exit: 2}) + } + } +} diff --git a/src/commands/asa/keywords/list.ts b/src/commands/asa/keywords/list.ts new file mode 100644 index 0000000..60cd832 --- /dev/null +++ b/src/commands/asa/keywords/list.ts @@ -0,0 +1,31 @@ +import {Command} from '@oclif/core' + +import type {AsaKeywordDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {adGroupScopeFlags, asaPaginationFlags, scopeParams, statusFilter} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaKeywordsList extends Command { + static description = 'List targeting keywords; read numbers with asa metrics' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa keywords list', + '<%= config.bin %> asa keywords list --ad-group AD_GROUP_UUID --status ACTIVE', + ] + static flags = {...asaPaginationFlags, ...adGroupScopeFlags, ...statusFilter(['ACTIVE', 'PAUSED'])} + + async run(): Promise> { + const {flags} = await this.parse(AsaKeywordsList) + const client = await createAsaClient(this.config) + const result = await client.get>('/keywords', { + ...paginationParams(flags), + ...scopeParams(flags), + }) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/keywords/update.ts b/src/commands/asa/keywords/update.ts new file mode 100644 index 0000000..bf01a57 --- /dev/null +++ b/src/commands/asa/keywords/update.ts @@ -0,0 +1,78 @@ +import {Args, Command, Flags} from '@oclif/core' + +import type {AsaKeywordMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {currencyFlag, idempotencyFlags, money, moneyFlag, reportBulkOutcome} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' + +export default class AsaKeywordsUpdate extends Command { + static args = { + keyword_id: Args.string({description: 'Keyword ID (UUID), repeatable as extra arguments', required: true}), + } + static description = 'Change bid, status, text or match type of keywords' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa keywords update UUID --bid 2.00', + '<%= config.bin %> asa keywords update UUID_A UUID_B --status PAUSED', + ] + static flags = { + ...currencyFlag, + ...confirmFlags, + ...idempotencyFlags, + bid: moneyFlag('Bid'), + 'match-type': Flags.string({description: 'Match type', options: ['BROAD', 'EXACT']}), + status: Flags.string({description: 'Keyword status', options: ['ACTIVE', 'PAUSED']}), + text: Flags.string({description: 'Keyword text (only meaningful for a single keyword)'}), + } + static strict = false + + async run(): Promise { + const {argv, flags} = await this.parse(AsaKeywordsUpdate) + const keywordIds = argv as string[] + for (const id of keywordIds) { + if (!isValidUuid(id)) this.error(`Invalid keyword ID format: ${id}`, {exit: 2}) + } + + const change: Record = {} + if (flags.bid !== undefined) change.bid_amount = money(flags.bid, flags.currency) + if (flags.status !== undefined) change.status = flags.status + if (flags['match-type'] !== undefined) change.match_type = flags['match-type'] + if (flags.text !== undefined) change.text = flags.text + + if (Object.keys(change).length === 0) { + this.error('Nothing to change. Pass at least one field, e.g. --bid 2.00.', {exit: 2}) + } + + if (flags.text !== undefined && keywordIds.length > 1) { + this.error('--text would give every keyword the same text. Update them one at a time.', {exit: 2}) + } + + const body = {keywords: keywordIds.map((id) => ({internal_id: id, ...change}))} + await confirmMutation( + this, + {body, method: 'PUT', path: '/keywords/', summary: `Update ${keywordIds.length} keyword(s)`}, + flags.yes, + ) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'put', '/keywords', { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + reportBulkOutcome( + { + applied: result.keywords, + errors: result.errors, + isValidationFailure: result.is_validation_failure, + kind: 'keywords', + }, + this.log.bind(this), + ) + + return result + } +} diff --git a/src/commands/asa/metrics/index.ts b/src/commands/asa/metrics/index.ts new file mode 100644 index 0000000..0fbf726 --- /dev/null +++ b/src/commands/asa/metrics/index.ts @@ -0,0 +1,76 @@ +import {Command, Flags} from '@oclif/core' + +import {asaWrite, createAsaClient} from '../../../lib/asa-client.js' +import {ASA_GROUP_BY_DIMENSIONS, ASA_METRIC_ENTITIES, asaPaginationFlags, byDaysFlag, MAX_BY_DAYS} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaMetrics extends Command { + static description = `Query metrics for any level of the account over a date range + +One row per entity, already aggregated server-side and sorted by --order-by, so a top-N question is one +call with --order-by and --page-size N — never sum pages yourself. Account-level totals are one call to +asa metrics overview instead. The date window is capped by the coarsest --group-by period: 90 days for +day or no period grouping, 180 by week, 365 by month and coarser — widen the window by coarsening the +grouping, not by splitting into more calls. Budget: 5 metrics calls per minute, at most 2 per 10 seconds.` + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31', + '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --order-by spend --page-size 5', + '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --group-by country --page-size 1000', + '<%= config.bin %> asa metrics --entity keyword --date-from 2026-07-01 --date-to 2026-07-31 --metric spend --metric roas', + '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric roas --by-days 7 --by-days 90', + ] + static flags = { + ...asaPaginationFlags, + ...byDaysFlag, + 'date-from': Flags.string({description: 'Start of the period (YYYY-MM-DD)', required: true}), + 'date-to': Flags.string({description: 'End of the period (YYYY-MM-DD)', required: true}), + entity: Flags.string({description: 'What to report on', options: ASA_METRIC_ENTITIES, required: true}), + 'group-by': Flags.string({ + description: 'Break the rows down by a dimension, repeatable', + multiple: true, + options: ASA_GROUP_BY_DIMENSIONS, + }), + metric: Flags.string({ + description: + 'Metric name (dashboard nomenclature, e.g. spend, taps, gross_roas), repeatable; omit for every metric; a wrong name fails listing all valid ones', + multiple: true, + }), + order: Flags.string({default: 'desc', description: 'Sort direction', options: ['asc', 'desc']}), + 'order-by': Flags.string({ + description: 'Metric or field to sort by; cohort metrics rank via their gross_/proceeds_/net_ names', + }), + 'order-by-day': Flags.integer({ + description: 'Rank by a cohort metric at this renewal window; must be one of the --by-days values', + }), + } + + async run(): Promise>> { + const {flags} = await this.parse(AsaMetrics) + if (flags['by-days'] && flags['by-days'].length > MAX_BY_DAYS) { + this.error(`At most ${MAX_BY_DAYS} renewal windows per call, got ${flags['by-days'].length}.`, {exit: 2}) + } + + const client = await createAsaClient(this.config) + + const {result} = await asaWrite>>(client, 'post', '/metrics', { + body: { + date_from: flags['date-from'], + date_to: flags['date-to'], + entity: flags.entity, + order: flags.order, + ...(flags.metric === undefined ? {} : {metrics: flags.metric}), + ...(flags['by-days'] === undefined ? {} : {by_days: flags['by-days']}), + ...(flags['group-by'] === undefined ? {} : {group_by: flags['group-by']}), + ...(flags['order-by'] === undefined ? {} : {order_by: flags['order-by']}), + ...(flags['order-by-day'] === undefined ? {} : {order_by_day: flags['order-by-day']}), + }, + params: paginationParams(flags), + }) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta?.pagination) + + return result + } +} diff --git a/src/commands/asa/metrics/overview.ts b/src/commands/asa/metrics/overview.ts new file mode 100644 index 0000000..eb13694 --- /dev/null +++ b/src/commands/asa/metrics/overview.ts @@ -0,0 +1,58 @@ +import {Command, Flags} from '@oclif/core' + +import {asaWrite, createAsaClient} from '../../../lib/asa-client.js' +import {ASA_METRIC_ENTITIES, byDaysFlag, MAX_BY_DAYS} from '../../../lib/asa-flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaMetricsOverview extends Command { + static description = `Totals for a period, optionally split into day, week or month buckets + +The one-call answer to "how much did I spend / earn overall" and to any single-period trend question: +totals for the whole entity level plus a per-period series, no pagination, no client-side summing. The +date window is capped by --period-unit: 90 days at day, 180 by week, 365 by month and coarser. Shares +the 5-per-minute metrics budget with asa metrics.` + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa metrics overview --entity campaign --date-from 2026-07-01 --date-to 2026-07-31', + '<%= config.bin %> asa metrics overview --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --period-unit WEEK', + ] + static flags = { + ...byDaysFlag, + 'date-from': Flags.string({description: 'Start of the period (YYYY-MM-DD)', required: true}), + 'date-to': Flags.string({description: 'End of the period (YYYY-MM-DD)', required: true}), + entity: Flags.string({description: 'What to report on', options: ASA_METRIC_ENTITIES, required: true}), + metric: Flags.string({ + description: + 'Metric name, repeatable; cohort roots only here (revenue, roas, arpu), not their gross_/proceeds_/net_ variants; omit for every metric', + multiple: true, + }), + 'period-unit': Flags.string({ + default: 'day', + description: 'Bucket size', + options: ['day', 'month', 'quarter', 'week', 'year'], + }), + } + + async run(): Promise> { + const {flags} = await this.parse(AsaMetricsOverview) + if (flags['by-days'] && flags['by-days'].length > MAX_BY_DAYS) { + this.error(`At most ${MAX_BY_DAYS} renewal windows per call, got ${flags['by-days'].length}.`, {exit: 2}) + } + + const client = await createAsaClient(this.config) + const {result} = await asaWrite>(client, 'post', '/metrics/overview', { + body: { + date_from: flags['date-from'], + date_to: flags['date-to'], + entity: flags.entity, + period_unit: flags['period-unit'], + ...(flags.metric === undefined ? {} : {metrics: flags.metric}), + ...(flags['by-days'] === undefined ? {} : {by_days: flags['by-days']}), + }, + }) + + printResponse(result, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/negative-keywords/add.ts b/src/commands/asa/negative-keywords/add.ts new file mode 100644 index 0000000..2aa3098 --- /dev/null +++ b/src/commands/asa/negative-keywords/add.ts @@ -0,0 +1,86 @@ +import {Command, Flags} from '@oclif/core' + +import type {AsaNegativeKeywordMutationDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {idempotencyFlags, MAX_BULK_ITEMS, reportBulkOutcome} from '../../../lib/asa-flags.js' +import {isValidUuid} from '../../../lib/flags.js' + +export default class AsaNegativeKeywordsAdd extends Command { + static description = 'Add negative keywords to an ad group or across a campaign' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa negative-keywords add --ad-group UUID --text "free"', + '<%= config.bin %> asa negative-keywords add --campaign UUID --text "free" --text "cheap"', + '<%= config.bin %> asa negative-keywords add --campaign UUID --all-ad-groups --text "free"', + ] + static flags = { + ...confirmFlags, + ...idempotencyFlags, + 'ad-group': Flags.string({description: 'Ad group ID (UUID) — the campaign is resolved from it', exclusive: ['campaign']}), + 'all-ad-groups': Flags.boolean({ + dependsOn: ['campaign'], + description: 'Apply to every ad group of the campaign instead of the campaign itself', + }), + campaign: Flags.string({description: 'Campaign ID (UUID)', exclusive: ['ad-group']}), + 'match-type': Flags.string({default: 'EXACT', description: 'Match type', options: ['BROAD', 'EXACT']}), + status: Flags.string({default: 'ACTIVE', description: 'Keyword status', options: ['ACTIVE', 'PAUSED']}), + text: Flags.string({description: 'Keyword text, repeatable', multiple: true, required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(AsaNegativeKeywordsAdd) + const target = flags['ad-group'] ?? flags.campaign + if (!target) this.error('Pass either --ad-group or --campaign.', {exit: 2}) + if (!isValidUuid(target)) this.error('Invalid ID format.', {exit: 2}) + if (flags.text.length > MAX_BULK_ITEMS) { + this.error(`A single call takes at most ${MAX_BULK_ITEMS} keywords, got ${flags.text.length}.`, {exit: 2}) + } + + const scope = flags['ad-group'] ? 'AD_GROUP' : flags['all-ad-groups'] ? 'ALL_CAMPAIGN_AD_GROUPS' : 'CAMPAIGN' + const parent = flags['ad-group'] ? {ad_group_id: flags['ad-group']} : {campaign_id: flags.campaign} + + const body = { + negative_keywords: flags.text.map((text) => ({ + ...parent, + match_type: flags['match-type'], + status: flags.status, + text, + })), + scope, + } + await confirmMutation( + this, + { + body, + method: 'POST', + path: '/negative-keywords/', + summary: + scope === 'ALL_CAMPAIGN_AD_GROUPS' + ? `Add ${flags.text.length} negative keyword(s) to every ad group of the campaign` + : `Add ${flags.text.length} negative keyword(s) in ${scope} scope`, + }, + flags.yes, + ) + + const client = await createAsaClient(this.config) + const {replayed, result} = await asaWrite(client, 'post', '/negative-keywords', { + body, + idempotencyKey: flags['idempotency-key'], + }) + + noteReplay(replayed, this.log.bind(this)) + reportBulkOutcome( + { + applied: result.negative_keywords, + errors: result.errors, + isValidationFailure: result.is_validation_failure, + kind: 'negative keywords', + }, + this.log.bind(this), + ) + + return result + } +} diff --git a/src/commands/asa/negative-keywords/list.ts b/src/commands/asa/negative-keywords/list.ts new file mode 100644 index 0000000..3be9d60 --- /dev/null +++ b/src/commands/asa/negative-keywords/list.ts @@ -0,0 +1,36 @@ +import {Command, Flags} from '@oclif/core' + +import type {AsaNegativeKeywordDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {adGroupScopeFlags, asaPaginationFlags, scopeParams} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaNegativeKeywordsList extends Command { + static description = 'List negative keywords; ad_group_id is empty for campaign-level ones' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa negative-keywords list', + '<%= config.bin %> asa negative-keywords list --campaign CAMPAIGN_UUID', + ] + static flags = { + ...asaPaginationFlags, + ...adGroupScopeFlags, + 'campaign-level-only': Flags.boolean({description: 'Keep only campaign-level rows (ad_group_id is null)'}), + } + + async run(): Promise> { + const {flags} = await this.parse(AsaNegativeKeywordsList) + const client = await createAsaClient(this.config) + const result = await client.get>('/negative-keywords', { + ...paginationParams(flags), + ...scopeParams(flags), + ...(flags['campaign-level-only'] ? {campaign_level_only: 'true'} : {}), + }) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/orgs/list.ts b/src/commands/asa/orgs/list.ts new file mode 100644 index 0000000..41dc6d5 --- /dev/null +++ b/src/commands/asa/orgs/list.ts @@ -0,0 +1,25 @@ +import {Command} from '@oclif/core' + +import type {AsaCampaignGroupDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {asaPaginationFlags} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaOrgsList extends Command { + static description = 'List the Apple Search Ads organizations this company can spend from' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa orgs list'] + static flags = {...asaPaginationFlags} + + async run(): Promise> { + const {flags} = await this.parse(AsaOrgsList) + const client = await createAsaClient(this.config) + const result = await client.get>('/campaign-groups', paginationParams(flags)) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/product-pages/list.ts b/src/commands/asa/product-pages/list.ts new file mode 100644 index 0000000..79dac5f --- /dev/null +++ b/src/commands/asa/product-pages/list.ts @@ -0,0 +1,31 @@ +import {Command} from '@oclif/core' + +import type {AsaProductPageDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {asaPaginationFlags, assetScopeFlags, scopeParams} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaProductPagesList extends Command { + static description = 'List custom product pages (read-only; authoring stays in App Store Connect)' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa product-pages list', + '<%= config.bin %> asa product-pages list --app APP_UUID', + ] + static flags = {...asaPaginationFlags, ...assetScopeFlags} + + async run(): Promise> { + const {flags} = await this.parse(AsaProductPagesList) + const client = await createAsaClient(this.config) + const result = await client.get>('/product-pages', { + ...paginationParams(flags), + ...scopeParams(flags), + }) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/product-pages/sync.ts b/src/commands/asa/product-pages/sync.ts new file mode 100644 index 0000000..7b2a3c0 --- /dev/null +++ b/src/commands/asa/product-pages/sync.ts @@ -0,0 +1,41 @@ +import {Command, Flags} from '@oclif/core' + +import type {AsaProductPageSyncDTO} from '../../../lib/asa-schemas.js' + +import {asaWrite, createAsaClient} from '../../../lib/asa-client.js' +import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' +import {idempotencyFlags} from '../../../lib/asa-flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class AsaProductPagesSync extends Command { + static description = 'Refresh custom product pages from Apple' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa product-pages sync', '<%= config.bin %> asa product-pages sync --adam-id 123456'] + static flags = { + ...confirmFlags, + ...idempotencyFlags, + 'adam-id': Flags.integer({description: 'Limit the refresh to one app; omit to cover every app'}), + } + + async run(): Promise { + const {flags} = await this.parse(AsaProductPagesSync) + + const body = {...(flags['adam-id'] === undefined ? {} : {adam_id: flags['adam-id']})} + await confirmMutation( + this, + {body, method: 'POST', path: '/product-pages/sync/', summary: 'Queue a product page refresh from Apple'}, + flags.yes, + ) + + const client = await createAsaClient(this.config) + const {result} = await asaWrite(client, 'post', '/product-pages/sync', { + body, + idempotencyKey: flags['idempotency-key'], + }) + + this.log(result.replayed ? 'Already running; nothing new was queued.' : 'Sync queued.') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/asa/search-terms/list.ts b/src/commands/asa/search-terms/list.ts new file mode 100644 index 0000000..6eee8fa --- /dev/null +++ b/src/commands/asa/search-terms/list.ts @@ -0,0 +1,32 @@ +import {Command} from '@oclif/core' + +import type {AsaSearchTermDTO} from '../../../lib/asa-schemas.js' + +import {createAsaClient} from '../../../lib/asa-client.js' +import {adGroupScopeFlags, asaPaginationFlags, periodFlags, periodParams, scopeParams} from '../../../lib/asa-flags.js' +import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' +import {printList} from '../../../lib/output.js' + +export default class AsaSearchTermsList extends Command { + static description = 'List the search terms your ads matched, with their metrics' + static enableJsonFlag = true + static examples = [ + '<%= config.bin %> asa search-terms list --date-from 2026-07-01 --date-to 2026-07-31', + '<%= config.bin %> asa search-terms list --ad-group AD_GROUP_UUID', + ] + static flags = {...asaPaginationFlags, ...periodFlags, ...adGroupScopeFlags} + + async run(): Promise> { + const {flags} = await this.parse(AsaSearchTermsList) + const client = await createAsaClient(this.config) + const result = await client.get>('/search-terms', { + ...paginationParams(flags), + ...periodParams(flags), + ...scopeParams(flags), + }) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/commands/asa/whoami.ts b/src/commands/asa/whoami.ts new file mode 100644 index 0000000..ca0ee83 --- /dev/null +++ b/src/commands/asa/whoami.ts @@ -0,0 +1,30 @@ +import {Command} from '@oclif/core' + +import type {AsaMeDTO} from '../../lib/asa-schemas.js' + +import {createAsaClient} from '../../lib/asa-client.js' +import {printResponse} from '../../lib/output.js' + +export default class AsaWhoami extends Command { + static description = 'Show which company the token unlocks and whether Apple Ads is connected' + static enableJsonFlag = true + static examples = ['<%= config.bin %> asa whoami'] + + async run(): Promise { + await this.parse(AsaWhoami) + const client = await createAsaClient(this.config) + const result = await client.get('/me') + + printResponse(result as unknown as Record, this.log.bind(this)) + if (result.apple_credentials_status !== 'active') { + this.log('\nApple Ads is not connected. Run `adapty asa connect` to link an account.') + } + + if (result.access_source === 'none') { + this.log('\nNo active Ads Manager subscription for this company: connecting an account works, but every') + this.log('data command answers 402 until the subscription is in place.') + } + + return result + } +} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index dcef4a6..8af543b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,58 +1,98 @@ -import {ApiError, NetworkError, parseApiError} from './errors.js' +import {ApiError, type ApiErrorFormat, NetworkError, parseApiError} from './errors.js' const DEFAULT_API_URL = 'https://api-admin.adapty.io/api/v1/developer' +const MAX_RETRY_AFTER_SECONDS = 60 function ensureTrailingSlash(path: string): string { return path.endsWith('/') ? path : `${path}/` } +export type QueryParams = Record + +export interface RequestOptions { + headers?: Record + onResponse?: (headers: Headers) => void +} + export interface ApiClientOptions { baseUrl?: string + defaultBaseUrl?: string + errorFormat?: ApiErrorFormat token?: null | string + urlEnvVar?: string userAgent?: string } export class ApiClient { private baseUrl: string + private errorFormat: ApiErrorFormat private token: null | string private userAgent: string constructor(opts: ApiClientOptions = {}) { - this.baseUrl = (opts.baseUrl ?? process.env.ADAPTY_API_URL ?? DEFAULT_API_URL).replace(/\/$/, '') - if (this.baseUrl !== DEFAULT_API_URL) { + const defaultBaseUrl = opts.defaultBaseUrl ?? DEFAULT_API_URL + const envBaseUrl = process.env[opts.urlEnvVar ?? 'ADAPTY_API_URL'] + this.baseUrl = (opts.baseUrl ?? envBaseUrl ?? defaultBaseUrl).replace(/\/$/, '') + if (this.baseUrl !== defaultBaseUrl) { process.stderr.write(`Warning: using non-default API URL: ${this.baseUrl}\n`) } + this.errorFormat = opts.errorFormat ?? 'developer' this.token = opts.token ?? null this.userAgent = opts.userAgent ?? 'adapty-cli' } - async get(path: string, params?: Record): Promise { - let url = `${this.baseUrl}${ensureTrailingSlash(path)}` - if (params) { - const qs = new URLSearchParams(params) - url += `?${qs.toString()}` - } + async get(path: string, params?: QueryParams): Promise { + return this.request(this.buildUrl(path, params), {method: 'GET'}) + } - return this.request(url, {method: 'GET'}) + async post(path: string, body?: unknown, params?: QueryParams, opts?: RequestOptions): Promise { + return this.request( + this.buildUrl(path, params), + { + body: body ? JSON.stringify(body) : undefined, + method: 'POST', + }, + opts, + ) } - async post(path: string, body?: unknown): Promise { - return this.request(`${this.baseUrl}${ensureTrailingSlash(path)}`, { - body: body ? JSON.stringify(body) : undefined, - method: 'POST', - }) + async put(path: string, body?: unknown, params?: QueryParams, opts?: RequestOptions): Promise { + return this.request( + this.buildUrl(path, params), + { + body: body ? JSON.stringify(body) : undefined, + method: 'PUT', + }, + opts, + ) } - async put(path: string, body?: unknown): Promise { - return this.request(`${this.baseUrl}${ensureTrailingSlash(path)}`, { - body: body ? JSON.stringify(body) : undefined, - method: 'PUT', - }) + private buildUrl(path: string, params?: QueryParams): string { + const url = `${this.baseUrl}${ensureTrailingSlash(path)}` + if (!params) return url + + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue + for (const item of Array.isArray(value) ? value : [value]) search.append(key, item) + } + + return search.size === 0 ? url : `${url}?${search.toString()}` + } + + private isRetryableRateLimit(error: ApiError): boolean { + return ( + this.errorFormat === 'asa' && + error.statusCode === 429 && + error.errorCode !== 'cli_cooldown_active' && + error.retryAfterSeconds !== undefined && + error.retryAfterSeconds <= MAX_RETRY_AFTER_SECONDS + ) } // eslint-disable-next-line no-undef - private async request(url: string, init: RequestInit): Promise { + private async request(url: string, init: RequestInit, opts: RequestOptions = {}, retried = false): Promise { const headers: Record = { 'User-Agent': this.userAgent, } @@ -65,6 +105,8 @@ export class ApiClient { headers.Authorization = `Bearer ${this.token}` } + Object.assign(headers, opts.headers) + let response: Response try { response = await fetch(url, {...init, headers}) @@ -72,27 +114,41 @@ export class ApiClient { throw new NetworkError(error instanceof Error ? error.message : 'Connection failed') } + opts.onResponse?.(response.headers) + if (response.status === 204) { return undefined as T } + const retryAfter = Number.parseInt(response.headers.get('Retry-After') ?? '', 10) + const errorOptions = this.errorFormat === 'asa' && !Number.isNaN(retryAfter) ? {retryAfterSeconds: retryAfter} : {} + let body: unknown try { body = await response.json() } catch { if (!response.ok) { - throw new ApiError(response.status, `http_${response.status}`, {}) + throw new ApiError(response.status, `http_${response.status}`, {}, errorOptions) } return undefined as T } if (!response.ok) { - const error = parseApiError(response.status, body) + const error = parseApiError(response.status, body, errorOptions, this.errorFormat) if (response.status === 401) { error.message = 'Token expired or invalid. Run `adapty auth login`.' } + if (!retried && this.isRetryableRateLimit(error)) { + const seconds = error.retryAfterSeconds ?? 0 + process.stderr.write(`Rate limited (${error.errorCode}); waiting ${seconds}s per Retry-After, then retrying once.\n`) + await new Promise((resolve) => { + setTimeout(resolve, seconds * 1000) + }) + return this.request(url, init, opts, true) + } + throw error } diff --git a/src/lib/asa-client.ts b/src/lib/asa-client.ts new file mode 100644 index 0000000..0051930 --- /dev/null +++ b/src/lib/asa-client.ts @@ -0,0 +1,68 @@ +import type {Config} from '@oclif/core' + +import {randomUUID} from 'node:crypto' + +import {ApiClient, type QueryParams} from './api-client.js' +import {resolveToken} from './auth.js' +import {buildUserAgent} from './client-from-config.js' +import {AuthRequiredError, NetworkError} from './errors.js' + +export const ASA_API_URL = 'https://api-asa-admin.adapty.io/api/v1/cli' +export const ASA_API_URL_ENV_VAR = 'ADAPTY_ASA_API_URL' + +export async function createAsaClient(config: Config): Promise { + const token = await resolveToken(config.configDir) + if (!token) throw new AuthRequiredError() + + return new ApiClient({ + defaultBaseUrl: ASA_API_URL, + errorFormat: 'asa', + token, + urlEnvVar: ASA_API_URL_ENV_VAR, + userAgent: buildUserAgent(config), + }) +} + +export interface AsaWriteOptions { + body?: unknown + idempotencyKey?: string + params?: QueryParams +} + +export interface AsaWriteOutcome { + replayed: boolean + result: T +} + +export async function asaWrite( + client: ApiClient, + method: 'post' | 'put', + path: string, + opts: AsaWriteOptions = {}, +): Promise> { + const key = opts.idempotencyKey ?? randomUUID() + let replayed = false + const requestOpts = { + headers: {'Idempotency-Key': key}, + onResponse(headers: Headers) { + replayed = headers.get('Idempotency-Replayed') === 'true' + }, + } + const send = (): Promise => + method === 'post' + ? client.post(path, opts.body, opts.params, requestOpts) + : client.put(path, opts.body, opts.params, requestOpts) + + try { + const result = await send() + return {replayed, result} + } catch (error) { + if (!(error instanceof NetworkError)) throw error + const result = await send() + return {replayed, result} + } +} + +export function noteReplay(replayed: boolean, log: (msg: string) => void): void { + if (replayed) log('Already applied earlier — showing the stored result.') +} diff --git a/src/lib/asa-confirm.ts b/src/lib/asa-confirm.ts new file mode 100644 index 0000000..3fc6b24 --- /dev/null +++ b/src/lib/asa-confirm.ts @@ -0,0 +1,50 @@ +import {Command, Flags} from '@oclif/core' +import {createInterface} from 'node:readline/promises' + +export const confirmFlags = { + yes: Flags.boolean({ + char: 'y', + description: 'Apply without asking; required when the output is piped or --json is used', + }), +} + +export type ConfirmDecision = 'ask' | 'proceed' | 'refuse' + +export function decideConfirmation(opts: {isTty: boolean; json: boolean; yes: boolean}): ConfirmDecision { + if (opts.yes) return 'proceed' + if (opts.json || !opts.isTty) return 'refuse' + return 'ask' +} + +export interface MutationPreview { + body?: unknown + method: string + path: string + summary: string +} + +export function renderPreview(preview: MutationPreview): string { + const lines = [preview.summary, `${preview.method} ${preview.path}`] + if (preview.body !== undefined) lines.push(JSON.stringify(preview.body, null, 2)) + return lines.join('\n') +} + +export async function confirmMutation(command: Command, preview: MutationPreview, yes: boolean): Promise { + const decision = decideConfirmation({isTty: process.stdin.isTTY === true, json: command.jsonEnabled(), yes}) + if (decision === 'proceed') return + + if (decision === 'refuse') { + const reason = `${preview.summary} changes your account. Re-run with --yes to apply it without a prompt.` + process.stderr.write(`${reason}\n${renderPreview(preview)}\n`) + command.error(reason, {exit: 2}) + } + + process.stderr.write(`${renderPreview(preview)}\n`) + const reader = createInterface({input: process.stdin, output: process.stderr}) + try { + const answer = await reader.question('Apply? [y/N] ') + if (!/^y(es)?$/i.test(answer.trim())) command.error('Cancelled, nothing was sent.', {exit: 1}) + } finally { + reader.close() + } +} diff --git a/src/lib/asa-flags.ts b/src/lib/asa-flags.ts new file mode 100644 index 0000000..0015725 --- /dev/null +++ b/src/lib/asa-flags.ts @@ -0,0 +1,183 @@ +import {Flags} from '@oclif/core' + +import type {QueryParams} from './api-client.js' +import type {AsaMoney, AsaMutationError} from './asa-schemas.js' + +import {describeListedError} from './errors.js' +import {isValidUuid} from './flags.js' + +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/ +const MONEY_REGEX = /^\d+(\.\d{1,6})?$/ + +export const MAX_BULK_ITEMS = 100 +export const MAX_BY_DAYS = 16 +export const ASA_METRIC_ENTITIES = ['ad', 'ad-group', 'campaign', 'keyword'] +export const ASA_GROUP_BY_DIMENSIONS = ['country', 'day', 'month', 'quarter', 'week', 'year'] + +export const asaPaginationFlags = { + page: Flags.integer({ + default: 1, + description: 'Page number', + min: 1, + }), + 'page-size': Flags.integer({ + default: 100, + description: 'Items per page (max 1000); prefer one big page over a pagination loop', + max: 1000, + min: 1, + }), +} + +export const byDaysFlag = { + 'by-days': Flags.integer({ + description: 'Renewal window in days for cohort metrics, repeatable; omit for the dashboard defaults', + multiple: true, + }), +} + +async function parseDate(input: string): Promise { + if (!DATE_REGEX.test(input)) throw new Error('Dates must be written as YYYY-MM-DD.') + return input +} + +export const periodFlags = { + 'date-from': Flags.string({ + description: 'Start of the reporting period (YYYY-MM-DD), defaults to today', + parse: parseDate, + }), + 'date-to': Flags.string({ + description: 'End of the reporting period (YYYY-MM-DD), defaults to today', + parse: parseDate, + }), +} + +async function parseId(input: string): Promise { + if (!isValidUuid(input)) throw new Error('Ids are the UUIDs printed by the matching list command.') + return input +} + +function idFilter(entity: string) { + return Flags.string({description: `Keep only rows in this ${entity}; repeatable`, multiple: true, parse: parseId}) +} + +const searchFilter = {search: Flags.string({description: 'Case-insensitive substring match on the name'})} + +export const assetScopeFlags = { + app: idFilter('app'), + 'campaign-group': idFilter('campaign group'), +} + +export const orgScopeFlags = {...assetScopeFlags, ...searchFilter} + +export const campaignScopeFlags = {...orgScopeFlags, campaign: idFilter('campaign')} + +export const adGroupScopeFlags = {...campaignScopeFlags, 'ad-group': idFilter('ad group')} + +export const adScopeFlags = { + 'ad-group': idFilter('ad group'), + campaign: idFilter('campaign'), + 'campaign-group': idFilter('campaign group'), + ...searchFilter, +} + +export const statusFilter = (options: string[]) => ({ + status: Flags.string({description: 'Keep only rows in this state', options}), +}) + +interface ScopeFlags { + 'ad-group'?: string[] + app?: string[] + campaign?: string[] + 'campaign-group'?: string[] + search?: string + status?: string +} + +export function scopeParams(flags: ScopeFlags): QueryParams { + return { + ad_group_id: flags['ad-group'], + app_id: flags.app, + campaign_group_id: flags['campaign-group'], + campaign_id: flags.campaign, + search: flags.search, + status: flags.status, + } +} + +export const scheduleFlags = { + 'end-time': Flags.string({description: 'Schedule end (YYYY-MM-DD)', parse: parseDate}), + 'start-time': Flags.string({description: 'Schedule start (YYYY-MM-DD), defaults to today', parse: parseDate}), +} + +export const pricingModelFlag = { + 'pricing-model': Flags.string({ + default: 'CPC', + description: 'Pricing model; Apple requires one on every ad group', + options: ['CPC', 'CPM'], + }), +} + +export function startOfDayUtc(date: string | undefined): string | undefined { + return date === undefined ? undefined : `${date}T00:00:00Z` +} + +export function todayUtc(): string { + return new Date().toISOString().slice(0, 10) +} + +export function periodParams(flags: {'date-from'?: string; 'date-to'?: string}): Record { + const params: Record = {} + if (flags['date-from']) params.date_from = flags['date-from'] + if (flags['date-to']) params.date_to = flags['date-to'] + return params +} + +async function parseMoney(input: string): Promise { + if (!MONEY_REGEX.test(input)) throw new Error('Amounts must be plain numbers, e.g. 50 or 12.50.') + return input +} + +export function moneyFlag(description: string, opts: {required?: boolean} = {}) { + return Flags.string({ + description: `${description} (amount, e.g. 50 or 12.50)`, + parse: parseMoney, + required: opts.required, + }) +} + +export const currencyFlag = { + currency: Flags.string({default: 'USD', description: 'Currency code for the amounts in this call'}), +} + +export const idempotencyFlags = { + 'idempotency-key': Flags.string({ + description: + 'Idempotency key for this write; re-running with the same key replays the stored result instead of applying twice', + }), +} + +export function money(amount: string | undefined, currency: string): AsaMoney | undefined { + return amount === undefined ? undefined : {amount, currency} +} + +interface BulkOutcome { + applied: unknown[] + errors: AsaMutationError[] + isValidationFailure: boolean + kind: string +} + +export function reportBulkOutcome( + {applied, errors, isValidationFailure, kind}: BulkOutcome, + log: (msg: string) => void, +): void { + if (isValidationFailure) { + log(`Nothing was applied: the batch failed validation before Apple was called.`) + } else { + log(`${applied.length} ${kind} applied, ${errors.length} rejected.`) + } + + for (const error of errors) { + log(` ${describeListedError(error).text}`) + } +} diff --git a/src/lib/asa-schemas.ts b/src/lib/asa-schemas.ts new file mode 100644 index 0000000..6ec928a --- /dev/null +++ b/src/lib/asa-schemas.ts @@ -0,0 +1,321 @@ + +export type AsaAccessSource = 'allowlist' | 'legacy' | 'none' | 'payg' + +export type AsaAppleCredentialsStatus = 'active' | 'expired' | 'invalid' | 'unset' + +export type AsaKeywordMatchType = 'BROAD' | 'EXACT' + +export type AsaKeywordStatus = 'ACTIVE' | 'PAUSED' + +export type AsaStatus = 'ENABLED' | 'PAUSED' + +export type AsaNegativeKeywordScope = 'AD_GROUP' | 'ALL_CAMPAIGN_AD_GROUPS' | 'CAMPAIGN' + +export interface AsaMoney { + amount: string + currency: string +} + +export interface AsaMetricsDTO { + avg_cpm: string + avg_cpt: string + impressions: number + ipm: string + local_spend: string + tap_install_cpi: string + tap_install_rate: string + tap_installs: number + tap_new_downloads: number + tap_redownloads: number + taps: number + total_avg_cpi: string + total_install_rate: string + total_installs: number + total_new_downloads: number + total_redownloads: number + ttr: string + view_installs: number + view_new_downloads: number + view_redownloads: number +} + +export interface AsaMeDTO { + access_source: AsaAccessSource + apple_credentials_status: AsaAppleCredentialsStatus + company_id: string +} + +export interface AsaAppleOAuthDTO { + auth_url: string +} + +export interface AsaAppDTO { + adam_id: number + bundle_id: null | string + campaign_group_ids: string[] + country_or_region_codes: string[] + developer_name: null | string + internal_id: string + last_synced_at: string + name: string +} + +export interface AsaCampaignGroupDTO { + currency: string + internal_id: string + last_synced_at: string + org_id: number + org_name: string + parent_org_id: number + time_zone: string +} + +export interface AsaCampaignDTO { + ad_channel_type: string + adam_id: number + app_id: string + bidding_strategy: null | string + billing_event: string + budget_amount: AsaMoney | null + campaign_group_id: string + campaign_id: number + countries_or_regions: string[] + daily_budget_amount: AsaMoney | null + end_time: null | string + internal_id: string + name: string + org_id: number + start_time: null | string + status: AsaStatus + supply_sources: string[] + target_cpa: AsaMoney | null +} + +export interface AsaAdGroupDTO { + ad_group_id: number + app_id: string + automated_keywords_opt_in: boolean | null + bidding_strategy: null | string + campaign_group_id: string + campaign_id: string + cpa_goal: AsaMoney | null + default_bid_amount: AsaMoney + end_time: null | string + internal_id: string + name: string + payment_model: null | string + pricing_model: null | string + start_time: null | string + status: AsaStatus | null + target_cpa: AsaMoney | null +} + +export interface AsaKeywordDTO { + ad_group_id: string + app_id: string + bid_amount: AsaMoney + campaign_group_id: string + campaign_id: string + creation_time: null | string + internal_id: string + keyword_id: number + match_type: AsaKeywordMatchType + status: AsaKeywordStatus | null + text: string +} + +export interface AsaSearchTermDTO { + ad_group_id: string + app_id: string + campaign_group_id: string + campaign_id: string + country_or_region: string + keyword_id: null | string + match_type: AsaKeywordMatchType | null + metrics: AsaMetricsDTO + rank: null | number + search_popularity: null | number + source: null | string + text: null | string +} + +export interface AsaNegativeKeywordDTO { + ad_group_id: null | string + ad_group_name: null | string + campaign_id: string + campaign_name: null | string + internal_id: string + last_synced_at: string + match_type: AsaKeywordMatchType + status: AsaKeywordStatus + text: string +} + +export interface AsaCreativeDTO { + adam_id: number + app_id: string + campaign_group_id: string + creative_id: number + internal_id: string + name: string + org_id: number + product_page_id: null | string + state: string + state_reasons: string[] + type: string +} + +export interface AsaProductPageDTO { + adam_id: number + app_id: string + deep_link: null | string + external_id: number + internal_id: string + language_codes: string[] + languages: string[] + last_synced_at: string + name: string + state: string +} + +export interface AsaAdDTO { + ad_group_id: string + ad_group_name: null | string + ad_id: number + app_id: null | string + campaign_group_id: string + campaign_id: string + creation_time: null | string + creative_id: null | string + creative_type: string + internal_id: string + last_synced_at: string + name: string + product_page_id: null | string + product_page_name: null | string + serving_state_reasons: string[] + serving_status: string + status: AsaStatus +} + +export interface AsaAutomationDTO { + actions: unknown[] + apply_to: unknown[] + conditions: unknown[] + created_at: string + date_last_run: null | string + date_next_run: null | string + id: string + name: string + operate_with: string + rba_type: string + run_frequency: Record + status: number + updated_at: string +} + +export interface AsaAutomationRunDTO { + [key: string]: unknown + id?: string +} + +export interface AsaAutomationRunEnqueuedDTO { + automation_id: string + dry_run: boolean + run_id: null | string +} + +export interface AsaProductPageSyncDTO { + accepted_at: string + message: string + org_targets: number + replayed: boolean + state: string + sync_id: string +} + +export interface AsaCompetitorTopAppDTO { + adamId: number + avgSov: string + countries: string[] + iconUrl: null | string + name: null | string + termsCount: number +} + +export interface AsaCompetitorContestedTermDTO { + competitorCount: number + maxSov: string + term: string +} + +export interface AsaCompetitorsSummaryTotalDTO { + competitorsCount: number + countriesAsaCount: number + countriesWithAsaTerms: number + mostContestedTerms: AsaCompetitorContestedTermDTO[] + topAppsByPerformance: AsaCompetitorTopAppDTO[] + totalUniqueTerms: number +} + +export interface AsaCompetitorAppTermsDTO { + adamId: number + countries: Record + iconUrl: null | string + name: null | string +} + +export interface AsaCompetitorsSummaryDTO { + byApps: AsaCompetitorAppTermsDTO[] + total: AsaCompetitorsSummaryTotalDTO +} + +export interface AsaMutationError { + apple_error_code?: null | string + apple_error_message?: null | string + entity_type?: string + input_ref?: null | number + operation?: string + retryable?: boolean + validation_error_code?: string + validation_error_message?: string +} + +export interface AsaCampaignMutationDTO { + campaign: AsaCampaignMutationEntity | null + errors: AsaMutationError[] +} + +export interface AsaCampaignMutationEntity { + campaign_group_id: string + campaign_id: number + internal_id: string + name: string + status: AsaStatus +} + +export interface AsaAdGroupMutationDTO { + ad_group: null | Record + errors: AsaMutationError[] +} + +export interface AsaAdMutationDTO { + ad: AsaAdDTO | null + errors: AsaMutationError[] +} + +export interface AsaKeywordMutationDTO { + errors: AsaMutationError[] + is_validation_failure: boolean + keywords: Record[] +} + +export interface AsaNegativeKeywordMutationDTO { + errors: AsaMutationError[] + is_validation_failure: boolean + negative_keywords: Record[] +} + +export interface AsaAutomationMutationDTO { + automation: AsaAutomationDTO | null +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 9531db0..8c94dce 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,18 +1,51 @@ export interface ApiErrorBody { + detail?: string error?: string error_code?: string errors?: Record + retry_after_seconds?: number status_code?: number } +export type ApiErrorFormat = 'asa' | 'developer' + +export interface ApiErrorOptions { + detail?: string + retryAfterSeconds?: number +} + +export interface ListedError { + apple_error_code?: null | string + apple_error_message?: null | string + error_code?: string + field_name?: null | string + input_ref?: null | number + message?: string + validation_error_code?: string + validation_error_message?: string +} + +export function describeListedError(error: ListedError): {code: string | undefined; text: string} { + const code = error.error_code ?? error.apple_error_code ?? error.validation_error_code ?? undefined + const reason = error.message ?? error.apple_error_message ?? error.validation_error_message ?? code ?? 'rejected' + const position = error.input_ref === null || error.input_ref === undefined ? '' : ` (item ${error.input_ref + 1})` + return {code, text: `${reason}${position}`} +} + export class ApiError extends Error { + detail?: string + retryAfterSeconds?: number + constructor( public statusCode: number, public errorCode: string, public fieldErrors: Record, + opts: ApiErrorOptions = {}, ) { - super(errorCode) + super(opts.detail ?? errorCode) this.name = 'ApiError' + this.detail = opts.detail + this.retryAfterSeconds = opts.retryAfterSeconds } toHuman(): string { @@ -32,8 +65,10 @@ export class ApiError extends Error { toJSON(): ApiErrorBody { return { + detail: this.detail, error_code: this.errorCode, errors: Object.keys(this.fieldErrors).length > 0 ? this.fieldErrors : undefined, + retry_after_seconds: this.retryAfterSeconds, status_code: this.statusCode, } } @@ -61,19 +96,58 @@ export class AuthRequiredError extends Error { } } -export function parseApiError(statusCode: number, body: unknown): ApiError { +function parseListedErrors(errors: ListedError[], statusCode: number, opts: ApiErrorOptions): ApiError { + const fieldErrors: Record = {} + for (const item of errors) { + if (item.field_name && item.message) { + fieldErrors[item.field_name] = [...(fieldErrors[item.field_name] ?? []), item.message] + } + } + + const described = errors.map((item) => describeListedError(item)) + const detail = described.map((item) => item.text).join('; ') + return new ApiError(statusCode, described[0]?.code ?? `http_${statusCode}`, fieldErrors, { + ...opts, + detail: detail || undefined, + }) +} + +export function parseApiError( + statusCode: number, + body: unknown, + opts: ApiErrorOptions = {}, + format: ApiErrorFormat = 'developer', +): ApiError { if (!body || typeof body !== 'object') { - return new ApiError(statusCode, `http_${statusCode}`, {}) + return new ApiError(statusCode, `http_${statusCode}`, {}, opts) + } + + const parsed = body as ApiErrorBody & {detail?: unknown; errors?: unknown} + if (format === 'asa' && Array.isArray(parsed.errors) && parsed.errors.length > 0) { + return parseListedErrors(parsed.errors as ListedError[], statusCode, opts) } - const parsed = body as ApiErrorBody if (parsed.error_code) { - return new ApiError(statusCode, parsed.error_code, parsed.errors ?? {}) + return new ApiError(statusCode, parsed.error_code, (parsed.errors as Record) ?? {}, opts) } if (parsed.error) { - return new ApiError(statusCode, parsed.error, {}) + return new ApiError(statusCode, parsed.error, {}, opts) + } + + if (format === 'asa' && Array.isArray(parsed.detail)) { + const fieldErrors: Record = {} + for (const item of parsed.detail as {loc?: unknown[]; msg?: string}[]) { + const field = (item.loc ?? []).slice(1).join('.') || 'body' + if (item.msg) fieldErrors[field] = [...(fieldErrors[field] ?? []), item.msg] + } + + return new ApiError(statusCode, `http_${statusCode}`, fieldErrors, opts) + } + + if (format === 'asa' && typeof parsed.detail === 'string') { + return new ApiError(statusCode, `http_${statusCode}`, {}, {...opts, detail: parsed.detail}) } - return new ApiError(statusCode, `http_${statusCode}`, {}) + return new ApiError(statusCode, `http_${statusCode}`, {}, opts) } diff --git a/src/lib/output.ts b/src/lib/output.ts index 8ddb0cb..5fede3a 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -11,31 +11,54 @@ function formatLabel(snakeKey: string): string { .join(' ') } -function formatValue(value: unknown, indent = ''): string { - if (Array.isArray(value)) { - return value.map((v) => (typeof v === 'object' && v !== null ? formatValue(v, indent) : String(v))).join(', ') - } +function isScalar(value: unknown): boolean { + return typeof value !== 'object' || value === null +} - if (typeof value === 'object' && value !== null) { - return Object.entries(value as Record) - .filter(([, v]) => v !== undefined && v !== null) - .map(([k, v]) => `${indent} ${formatLabel(k)}: ${formatValue(v, indent + ' ')}`) - .join('\n') +function renderArrayItems(items: unknown[], indent: string): string[] { + const childIndent = indent + ' ' + const lines: string[] = [] + for (const item of items) { + if (item === undefined || item === null) continue + if (Array.isArray(item)) { + lines.push(...renderArrayItems(item, childIndent)) + } else if (isScalar(item)) { + lines.push(`${indent}- ${String(item)}`) + } else { + const rendered = renderObject(item as Record, childIndent) + if (rendered.length === 0) continue + lines.push(`${indent}- ${rendered[0].slice(childIndent.length)}`, ...rendered.slice(1)) + } } - return String(value) + return lines } -export function printResponse(data: Record, log: (msg: string) => void): void { +function renderObject(data: Record, indent: string): string[] { + const lines: string[] = [] for (const [key, value] of Object.entries(data)) { if (value === undefined || value === null) continue - if (typeof value === 'object' && !Array.isArray(value)) { - log(`${formatLabel(key)}:`) - log(formatValue(value)) + const label = `${indent}${formatLabel(key)}:` + if (Array.isArray(value)) { + if (value.length === 0) continue + if (value.every((v) => isScalar(v))) { + lines.push(`${label} ${value.map(String).join(', ')}`) + } else { + lines.push(label, ...renderArrayItems(value, indent + ' ')) + } + } else if (isScalar(value)) { + lines.push(`${label} ${String(value)}`) } else { - log(`${formatLabel(key)}: ${formatValue(value)}`) + const nested = renderObject(value as Record, indent + ' ') + if (nested.length > 0) lines.push(label, ...nested) } } + + return lines +} + +export function printResponse(data: Record, log: (msg: string) => void): void { + for (const line of renderObject(data, '')) log(line) } export function printList( diff --git a/test/commands/asa-idempotency.test.ts b/test/commands/asa-idempotency.test.ts new file mode 100644 index 0000000..4620bdc --- /dev/null +++ b/test/commands/asa-idempotency.test.ts @@ -0,0 +1,124 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import * as sinon from 'sinon' + +import { + ASA_API_BASE, + assertFetch, + EMPTY_LIST_RESPONSE, + mockFetch, + mockFetchFailure, + restoreFetch, + TEST_RESOURCE_ID, +} from '../helpers/mock-fetch.js' + +const CAMPAIGN_OK = {campaign: {campaign_id: 777, internal_id: TEST_RESOURCE_ID, name: 'x', status: 'PAUSED'}, errors: []} +const KEYWORDS_OK = {errors: [], is_validation_failure: false, keywords: [{internal_id: TEST_RESOURCE_ID, text: 'shoes'}]} +const UUID_RE = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/ + +function keyOf(stub: sinon.SinonStub, callIndex: number): string | undefined { + const headers = stub.getCall(callIndex).args[1].headers as Record + return headers['Idempotency-Key'] +} + +function jsonResponse(body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), {headers: {'Content-Type': 'application/json', ...headers}, status: 200}) +} + +describe('asa idempotency', () => { + let fetchStub: sinon.SinonStub + + beforeEach(() => { + process.env.ADAPTY_TOKEN = 'dev_live_test' + delete process.env.ADAPTY_ASA_API_URL + }) + + afterEach(() => { + restoreFetch(fetchStub) + delete process.env.ADAPTY_TOKEN + }) + + it('every write sends a generated Idempotency-Key', async () => { + fetchStub = mockFetch([CAMPAIGN_OK]) + await runCommand(`asa campaigns update --yes ${TEST_RESOURCE_ID} --status PAUSED`) + expect(keyOf(fetchStub, 0)).to.match(UUID_RE) + }) + + it('a fresh key is generated per invocation', async () => { + fetchStub = mockFetch([CAMPAIGN_OK, CAMPAIGN_OK]) + await runCommand(`asa campaigns update --yes ${TEST_RESOURCE_ID} --status PAUSED`) + await runCommand(`asa campaigns update --yes ${TEST_RESOURCE_ID} --status PAUSED`) + expect(keyOf(fetchStub, 0)).to.match(UUID_RE) + expect(keyOf(fetchStub, 0)).to.not.equal(keyOf(fetchStub, 1)) + }) + + it('--idempotency-key overrides the generated key', async () => { + fetchStub = mockFetch([KEYWORDS_OK]) + await runCommand( + `asa keywords add --yes --ad-group ${TEST_RESOURCE_ID} --text shoes --idempotency-key deploy-2026-08-06`, + ) + assertFetch({ + base: ASA_API_BASE, + callIndex: 0, + headers: {'Idempotency-Key': 'deploy-2026-08-06'}, + method: 'POST', + path: '/keywords/', + stub: fetchStub, + }) + }) + + it('reads carry no idempotency key', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa campaigns list') + expect(keyOf(fetchStub, 0)).to.equal(undefined) + }) + + it('metrics posts carry a key too, so a network retry cannot double-submit', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31') + expect(keyOf(fetchStub, 0)).to.match(UUID_RE) + }) + + it('one network failure is retried with the same key', async () => { + fetchStub = sinon.stub(globalThis, 'fetch') + fetchStub.onFirstCall().rejects(new TypeError('fetch failed')) + fetchStub.onSecondCall().resolves(jsonResponse(CAMPAIGN_OK)) + const {error} = await runCommand(`asa campaigns update --yes ${TEST_RESOURCE_ID} --status PAUSED`) + expect(error).to.equal(undefined) + expect(fetchStub.callCount).to.equal(2) + expect(keyOf(fetchStub, 0)).to.match(UUID_RE) + expect(keyOf(fetchStub, 0)).to.equal(keyOf(fetchStub, 1)) + }) + + it('a second network failure surfaces instead of looping', async () => { + fetchStub = sinon.stub(globalThis, 'fetch').rejects(new TypeError('fetch failed')) + const {error} = await runCommand(`asa campaigns update --yes ${TEST_RESOURCE_ID} --status PAUSED`) + expect(error?.message).to.contain('fetch failed') + expect(fetchStub.callCount).to.equal(2) + }) + + it('an API error is not retried', async () => { + fetchStub = mockFetchFailure({errors: [{message: 'same key, different body'}]}, {status: 422}) + const {error} = await runCommand(`asa campaigns update --yes ${TEST_RESOURCE_ID} --status PAUSED`) + expect(error?.message).to.contain('same key, different body') + expect(fetchStub.callCount).to.equal(1) + }) + + it('a replayed response is announced instead of the success line', async () => { + fetchStub = mockFetchFailure(CAMPAIGN_OK, {headers: {'Idempotency-Replayed': 'true'}, status: 200}) + const {stdout} = await runCommand( + `asa campaigns update --yes ${TEST_RESOURCE_ID} --status PAUSED --idempotency-key deploy-2026-08-06`, + ) + expect(stdout).to.contain('Already applied earlier') + expect(stdout).to.not.contain('Campaign updated!') + }) + + it('the replay note stays out of --json output', async () => { + fetchStub = mockFetchFailure(CAMPAIGN_OK, {headers: {'Idempotency-Replayed': 'true'}, status: 200}) + const {stdout} = await runCommand( + `asa campaigns update --yes --json ${TEST_RESOURCE_ID} --status PAUSED --idempotency-key deploy-2026-08-06`, + ) + expect(stdout).to.not.contain('Already applied') + expect(JSON.parse(stdout)).to.deep.equal(CAMPAIGN_OK) + }) +}) diff --git a/test/commands/asa-reads.test.ts b/test/commands/asa-reads.test.ts new file mode 100644 index 0000000..fbc93ae --- /dev/null +++ b/test/commands/asa-reads.test.ts @@ -0,0 +1,245 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import * as sinon from 'sinon' + +import { + ASA_API_BASE, + assertFetch, + EMPTY_LIST_RESPONSE, + mockFetch, + restoreFetch, + TEST_APP_ID, + TEST_RESOURCE_ID, +} from '../helpers/mock-fetch.js' + +const PERIOD = {date_from: '2026-07-01', date_to: '2026-07-31'} + +describe('asa reads', () => { + let fetchStub: sinon.SinonStub + + beforeEach(() => { + process.env.ADAPTY_TOKEN = 'dev_live_test' + delete process.env.ADAPTY_ASA_API_URL + }) + + afterEach(() => { + restoreFetch(fetchStub) + delete process.env.ADAPTY_TOKEN + }) + + it('campaigns list asks for metadata only, with no reporting window', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa campaigns list') + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: '/campaigns/', stub: fetchStub}) + const {searchParams} = new URL(fetchStub.getCall(0).args[0] as string) + expect(searchParams.has('date_from')).to.be.false + expect(searchParams.has('date_to')).to.be.false + }) + + it('campaigns list no longer accepts the period flags', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + const {error} = await runCommand('asa campaigns list --date-from 2026-07-01') + expect(error?.message).to.contain('--date-from') + expect(fetchStub.callCount).to.equal(0) + }) + + it('search-terms list still forwards the reporting window', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa search-terms list --date-from 2026-07-01 --date-to 2026-07-31') + assertFetch({ + base: ASA_API_BASE, + callIndex: 0, + method: 'GET', + path: '/search-terms/', + query: PERIOD, + stub: fetchStub, + }) + }) + + it('keywords list scopes the read to one ad group and one status', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand(`asa keywords list --ad-group ${TEST_RESOURCE_ID} --campaign ${TEST_APP_ID} --status ACTIVE`) + assertFetch({ + base: ASA_API_BASE, + callIndex: 0, + method: 'GET', + path: '/keywords/', + query: {ad_group_id: TEST_RESOURCE_ID, campaign_id: TEST_APP_ID, status: 'ACTIVE'}, + stub: fetchStub, + }) + }) + + it('a repeated id filter travels as repeated query params', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand(`asa ads list --ad-group ${TEST_RESOURCE_ID} --ad-group ${TEST_APP_ID}`) + const url = fetchStub.getCall(0).args[0] as string + expect(new URL(url).searchParams.getAll('ad_group_id')).to.deep.equal([TEST_RESOURCE_ID, TEST_APP_ID]) + }) + + it('a filter id that is not a UUID is refused before the call', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + const {error} = await runCommand('asa creatives list --app not-a-uuid') + expect(error?.message).to.contain('UUIDs printed by the matching list command') + expect(fetchStub.callCount).to.equal(0) + }) + + it('search-terms list rejects a malformed date before calling anything', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + const {error} = await runCommand('asa search-terms list --date-from 01-07-2026') + expect(error?.message).to.contain('YYYY-MM-DD') + expect(fetchStub.callCount).to.equal(0) + }) + + it('campaigns get asks for one campaign', async () => { + fetchStub = mockFetch([{internal_id: TEST_RESOURCE_ID, name: 'Winter push'}]) + await runCommand(`asa campaigns get ${TEST_RESOURCE_ID}`) + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: `/campaigns/${TEST_RESOURCE_ID}/`, stub: fetchStub}) + }) + + it('campaigns get refuses a non-UUID id without a request', async () => { + fetchStub = mockFetch([{}]) + const {error} = await runCommand('asa campaigns get not-a-uuid') + expect(error?.message).to.contain('Invalid campaign ID') + expect(fetchStub.callCount).to.equal(0) + }) + + it('ad-groups list and get hit their paths', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE, {internal_id: TEST_RESOURCE_ID}]) + await runCommand('asa ad-groups list') + await runCommand(`asa ad-groups get ${TEST_RESOURCE_ID}`) + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: '/ad-groups/', stub: fetchStub}) + assertFetch({base: ASA_API_BASE, callIndex: 1, method: 'GET', path: `/ad-groups/${TEST_RESOURCE_ID}/`, stub: fetchStub}) + }) + + it('negative keywords list forwards the campaign-level-only filter', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa negative-keywords list --campaign-level-only') + assertFetch({ + base: ASA_API_BASE, + callIndex: 0, + method: 'GET', + path: '/negative-keywords/', + query: {campaign_level_only: 'true'}, + stub: fetchStub, + }) + }) + + it('keywords, search terms and negative keywords list from their own paths', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa keywords list') + await runCommand('asa search-terms list') + await runCommand('asa negative-keywords list') + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: '/keywords/', stub: fetchStub}) + assertFetch({base: ASA_API_BASE, callIndex: 1, method: 'GET', path: '/search-terms/', stub: fetchStub}) + assertFetch({base: ASA_API_BASE, callIndex: 2, method: 'GET', path: '/negative-keywords/', stub: fetchStub}) + }) + + it('ads list surfaces why an ad is not serving', async () => { + fetchStub = mockFetch([ + { + data: [{internal_id: TEST_RESOURCE_ID, name: 'Summer ad', serving_state_reasons: ['CREATIVE_PENDING_REVIEW'], serving_status: 'NOT_RUNNING'}], + meta: {pagination: {count: 1, page: 1, pages: 1}}, + }, + ]) + const {stdout} = await runCommand('asa ads list') + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: '/ads/', stub: fetchStub}) + expect(stdout).to.contain('CREATIVE_PENDING_REVIEW') + }) + + it('ads get, product-pages and creatives lists hit their paths', async () => { + fetchStub = mockFetch([{internal_id: TEST_RESOURCE_ID}, EMPTY_LIST_RESPONSE, EMPTY_LIST_RESPONSE]) + await runCommand(`asa ads get ${TEST_RESOURCE_ID}`) + await runCommand('asa product-pages list') + await runCommand('asa creatives list') + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: `/ads/${TEST_RESOURCE_ID}/`, stub: fetchStub}) + assertFetch({base: ASA_API_BASE, callIndex: 1, method: 'GET', path: '/product-pages/', stub: fetchStub}) + assertFetch({base: ASA_API_BASE, callIndex: 2, method: 'GET', path: '/creatives/', stub: fetchStub}) + }) + + it('automations list, get and runs hit their paths', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE, {id: TEST_RESOURCE_ID, name: 'rule'}, EMPTY_LIST_RESPONSE]) + await runCommand('asa automations list') + await runCommand(`asa automations get ${TEST_RESOURCE_ID}`) + await runCommand(`asa automations runs ${TEST_RESOURCE_ID}`) + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: '/automations/', stub: fetchStub}) + assertFetch({base: ASA_API_BASE, callIndex: 1, method: 'GET', path: `/automations/${TEST_RESOURCE_ID}/`, stub: fetchStub}) + assertFetch({ + base: ASA_API_BASE, + callIndex: 2, + method: 'GET', + path: `/automations/${TEST_RESOURCE_ID}/runs/`, + stub: fetchStub, + }) + }) + + it('paginates every list the same way', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa keywords list --page 3 --page-size 100') + assertFetch({ + base: ASA_API_BASE, + callIndex: 0, + method: 'GET', + path: '/keywords/', + query: {'page[number]': '3', 'page[size]': '100'}, + stub: fetchStub, + }) + }) + + it('waits out a rate-limited 429 once and then succeeds', async () => { + fetchStub = sinon.stub(globalThis, 'fetch') + fetchStub.onFirstCall().resolves( + new Response(JSON.stringify({errors: [{error_code: 'cli_rate_limit_exceeded', message: 'slow down'}]}), { + headers: {'Content-Type': 'application/json', 'Retry-After': '0'}, + status: 429, + }), + ) + fetchStub.onSecondCall().resolves( + new Response(JSON.stringify(EMPTY_LIST_RESPONSE), {headers: {'Content-Type': 'application/json'}, status: 200}), + ) + const {error, stderr} = await runCommand('asa campaigns list') + expect(error).to.equal(undefined) + expect(stderr).to.contain('retrying once') + expect(fetchStub.callCount).to.equal(2) + }) + + it('a second consecutive 429 surfaces instead of looping', async () => { + fetchStub = sinon.stub(globalThis, 'fetch').callsFake( + async () => + new Response(JSON.stringify({errors: [{error_code: 'cli_analytics_busy', message: 'busy'}]}), { + headers: {'Content-Type': 'application/json', 'Retry-After': '0'}, + status: 429, + }), + ) + const {error} = await runCommand('asa campaigns list') + expect(error?.message).to.contain('busy') + expect(fetchStub.callCount).to.equal(2) + }) + + it('a cool-down 429 is never retried', async () => { + fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({errors: [{error_code: 'cli_cooldown_active', message: 'cool down'}]}), { + headers: {'Content-Type': 'application/json', 'Retry-After': '300'}, + status: 429, + }), + ) + const {error} = await runCommand('asa campaigns list') + expect(error?.message).to.contain('cool down') + expect(fetchStub.callCount).to.equal(1) + }) + + it('accepts big pages up to the server cap and refuses above it', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa campaigns list --page-size 1000') + assertFetch({ + base: ASA_API_BASE, + callIndex: 0, + method: 'GET', + path: '/campaigns/', + query: {'page[number]': '1', 'page[size]': '1000'}, + stub: fetchStub, + }) + const {error} = await runCommand('asa keywords list --page-size 1001') + expect(error?.message).to.contain('1000') + expect(fetchStub.callCount).to.equal(1) + }) +}) diff --git a/test/commands/asa-writes.test.ts b/test/commands/asa-writes.test.ts new file mode 100644 index 0000000..19f61ba --- /dev/null +++ b/test/commands/asa-writes.test.ts @@ -0,0 +1,437 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import {mkdtemp, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import sinon from 'sinon' + +import { + ASA_API_BASE, + assertFetch, + mockFetch, + mockFetchFailure, + restoreFetch, + TEST_APP_ID, + TEST_RESOURCE_ID, +} from '../helpers/mock-fetch.js' + +const CAMPAIGN_OK = {campaign: {campaign_id: 777, internal_id: TEST_RESOURCE_ID, name: 'Winter push', status: 'ENABLED'}, errors: []} +const AD_GROUP_OK = {ad_group: {internal_id: TEST_RESOURCE_ID, name: 'Brand terms'}, errors: []} +const AD_OK = {ad: {internal_id: TEST_RESOURCE_ID, name: 'Summer ad'}, errors: []} +const KEYWORDS_OK = {errors: [], is_validation_failure: false, keywords: [{internal_id: TEST_RESOURCE_ID, text: 'running shoes'}]} +const NEGATIVES_OK = {errors: [], is_validation_failure: false, negative_keywords: [{internal_id: TEST_RESOURCE_ID, text: 'free'}]} + +describe('asa writes', () => { + let fetchStub: sinon.SinonStub + + beforeEach(() => { + process.env.ADAPTY_TOKEN = 'dev_live_test' + delete process.env.ADAPTY_ASA_API_URL + }) + + afterEach(() => { + restoreFetch(fetchStub) + delete process.env.ADAPTY_TOKEN + }) + + it('campaigns create sends the flat body with money objects', async () => { + fetchStub = mockFetch([CAMPAIGN_OK]) + await runCommand( + `asa campaigns create --yes --org ${TEST_APP_ID} --name "Winter push" --adam-id 123456 --country US --country GB --daily-budget 50`, + ) + assertFetch({ + base: ASA_API_BASE, + body: { + ad_channel_type: 'SEARCH', + adam_id: 123_456, + billing_event: 'TAPS', + campaign_group_id: TEST_APP_ID, + countries_or_regions: ['US', 'GB'], + daily_budget_amount: {amount: '50', currency: 'USD'}, + name: 'Winter push', + supply_sources: ['APPSTORE_SEARCH_RESULTS'], + }, + callIndex: 0, + method: 'POST', + path: '/campaigns/', + stub: fetchStub, + }) + }) + + it('refuses to write from a script unless --yes is passed', async () => { + fetchStub = mockFetch([CAMPAIGN_OK]) + const {stderr} = await runCommand( + `asa campaigns create --json --org ${TEST_APP_ID} --name x --adam-id 1 --country US --daily-budget 50`, + ) + expect(stderr).to.contain('--yes') + expect(stderr).to.contain('POST /campaigns/') + expect(fetchStub.callCount).to.equal(0) + }) + + it('campaigns create refuses an amount that is not a number', async () => { + fetchStub = mockFetch([CAMPAIGN_OK]) + const {error} = await runCommand( + `asa campaigns create --yes --org ${TEST_APP_ID} --name x --adam-id 1 --country US --daily-budget 50usd --budget abc`, + ) + expect(error?.message).to.contain('plain numbers') + expect(fetchStub.callCount).to.equal(0) + }) + + it('campaigns carry target CPA and bidding strategy when asked', async () => { + fetchStub = mockFetch([CAMPAIGN_OK, CAMPAIGN_OK]) + await runCommand( + `asa campaigns create --yes --org ${TEST_APP_ID} --name x --adam-id 1 --country US --daily-budget 50 --target-cpa 3.50 --bidding-strategy MAX_CONVERSIONS`, + ) + const created = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(created.target_cpa).to.deep.equal({amount: '3.50', currency: 'USD'}) + expect(created.bidding_strategy).to.equal('MAX_CONVERSIONS') + + await runCommand(`asa campaigns update --yes ${TEST_RESOURCE_ID} --target-cpa 2`) + const updated = JSON.parse(fetchStub.getCall(1).args[1].body as string) + expect(updated).to.deep.equal({target_cpa: {amount: '2', currency: 'USD'}}) + }) + + it('campaigns update sends only the mentioned fields', async () => { + fetchStub = mockFetch([CAMPAIGN_OK]) + await runCommand(`asa campaigns update --yes ${TEST_RESOURCE_ID} --status PAUSED`) + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body).to.deep.equal({status: 'PAUSED'}) + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'PUT', path: `/campaigns/${TEST_RESOURCE_ID}/`, stub: fetchStub}) + }) + + it('campaigns update refuses an empty change without a request', async () => { + fetchStub = mockFetch([CAMPAIGN_OK]) + const {error} = await runCommand(`asa campaigns update --yes ${TEST_RESOURCE_ID}`) + expect(error?.message).to.contain('Nothing to change') + expect(fetchStub.callCount).to.equal(0) + }) + + it('ad-groups create and update carry only their own fields', async () => { + fetchStub = mockFetch([AD_GROUP_OK, AD_GROUP_OK]) + await runCommand(`asa ad-groups create --yes --campaign ${TEST_RESOURCE_ID} --name "Brand terms" --default-bid 1.20`) + await runCommand(`asa ad-groups update --yes ${TEST_RESOURCE_ID} --default-bid 1.50`) + const createBody = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(createBody).to.deep.include({ + campaign_id: TEST_RESOURCE_ID, + default_bid_amount: {amount: '1.20', currency: 'USD'}, + name: 'Brand terms', + }) + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'POST', path: '/ad-groups/', stub: fetchStub}) + const updateBody = JSON.parse(fetchStub.getCall(1).args[1].body as string) + expect(updateBody).to.deep.equal({default_bid_amount: {amount: '1.50', currency: 'USD'}}) + }) + + it('ad-groups create supplies what Apple demands: a pricing model and a start time', async () => { + fetchStub = mockFetch([AD_GROUP_OK, AD_GROUP_OK]) + await runCommand(`asa ad-groups create --yes --campaign ${TEST_RESOURCE_ID} --name AG --default-bid 1`) + const defaults = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(defaults.pricing_model).to.equal('CPC') + expect(defaults.start_time).to.match(/^\d{4}-\d{2}-\d{2}T00:00:00Z$/) + expect(defaults.end_time).to.equal(undefined) + + await runCommand( + `asa ad-groups create --yes --campaign ${TEST_RESOURCE_ID} --name AG --default-bid 1 --pricing-model CPM --start-time 2026-09-01 --end-time 2026-09-30`, + ) + const explicit = JSON.parse(fetchStub.getCall(1).args[1].body as string) + expect(explicit.pricing_model).to.equal('CPM') + expect(explicit.start_time).to.equal('2026-09-01T00:00:00Z') + expect(explicit.end_time).to.equal('2026-09-30T00:00:00Z') + }) + + it('ad-groups update passes a schedule only when asked', async () => { + fetchStub = mockFetch([AD_GROUP_OK]) + await runCommand(`asa ad-groups update --yes ${TEST_RESOURCE_ID} --start-time 2026-09-01`) + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body).to.deep.equal({start_time: '2026-09-01T00:00:00Z'}) + }) + + it('ad-groups create refuses a malformed date before the network', async () => { + fetchStub = mockFetch([AD_GROUP_OK]) + const {error} = await runCommand( + `asa ad-groups create --yes --campaign ${TEST_RESOURCE_ID} --name AG --default-bid 1 --start-time 01.09.2026`, + ) + expect(error?.message).to.contain('YYYY-MM-DD') + expect(fetchStub.callCount).to.equal(0) + }) + + it('keywords add turns repeated --text into one batch', async () => { + fetchStub = mockFetch([KEYWORDS_OK]) + const {stdout} = await runCommand( + `asa keywords add --yes --ad-group ${TEST_RESOURCE_ID} --text "running shoes" --text "trail shoes" --bid 1.20 --match-type EXACT`, + ) + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body.keywords).to.have.length(2) + expect(body.keywords[0]).to.deep.equal({ + ad_group_id: TEST_RESOURCE_ID, + bid_amount: {amount: '1.20', currency: 'USD'}, + match_type: 'EXACT', + status: 'ACTIVE', + text: 'running shoes', + }) + expect(stdout).to.contain('1 keywords applied, 0 rejected') + }) + + it('keywords add reads a file and merges it with --text', async () => { + const dir = await mkdtemp(join(tmpdir(), 'asa-cli-')) + const path = join(dir, 'keywords.txt') + await writeFile(path, 'from file\n\n second one \n') + fetchStub = mockFetch([KEYWORDS_OK]) + await runCommand(`asa keywords add --yes --ad-group ${TEST_RESOURCE_ID} --text inline --from-file ${path}`) + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body.keywords.map((item: {text: string}) => item.text)).to.deep.equal(['inline', 'from file', 'second one']) + }) + + it('keywords add refuses an empty batch and one over the cap', async () => { + fetchStub = mockFetch([KEYWORDS_OK]) + const empty = await runCommand(`asa keywords add --yes --ad-group ${TEST_RESOURCE_ID}`) + expect(empty.error?.message).to.contain('at least one --text') + const texts = Array.from({length: 101}, (_, index) => `--text kw${index}`).join(' ') + const overCap = await runCommand(`asa keywords add --yes --ad-group ${TEST_RESOURCE_ID} ${texts}`) + expect(overCap.error?.message).to.contain('at most 100') + expect(fetchStub.callCount).to.equal(0) + }) + + it('keywords add reports the rejected half of a partial batch', async () => { + fetchStub = mockFetch([ + { + errors: [{apple_error_message: 'Duplicate keyword', input_ref: 1}], + is_validation_failure: false, + keywords: [{internal_id: TEST_RESOURCE_ID}], + }, + ]) + const {stdout} = await runCommand(`asa keywords add --yes --ad-group ${TEST_RESOURCE_ID} --text a --text b`) + expect(stdout).to.contain('1 keywords applied, 1 rejected') + expect(stdout).to.contain('Duplicate keyword (item 2)') + }) + + it('keywords update applies one change to several ids', async () => { + fetchStub = mockFetch([KEYWORDS_OK]) + await runCommand(`asa keywords update --yes ${TEST_RESOURCE_ID} ${TEST_APP_ID} --status PAUSED`) + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body.keywords).to.deep.equal([ + {internal_id: TEST_RESOURCE_ID, status: 'PAUSED'}, + {internal_id: TEST_APP_ID, status: 'PAUSED'}, + ]) + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'PUT', path: '/keywords/', stub: fetchStub}) + }) + + it('keywords update refuses to give many keywords the same text', async () => { + fetchStub = mockFetch([KEYWORDS_OK]) + const {error} = await runCommand(`asa keywords update --yes ${TEST_RESOURCE_ID} ${TEST_APP_ID} --text same`) + expect(error?.message).to.contain('one at a time') + expect(fetchStub.callCount).to.equal(0) + }) + + it('negative keywords pick the scope from the parent flag', async () => { + fetchStub = mockFetch([NEGATIVES_OK, NEGATIVES_OK, NEGATIVES_OK]) + await runCommand(`asa negative-keywords add --yes --ad-group ${TEST_RESOURCE_ID} --text free`) + await runCommand(`asa negative-keywords add --yes --campaign ${TEST_RESOURCE_ID} --text free`) + await runCommand(`asa negative-keywords add --yes --campaign ${TEST_RESOURCE_ID} --all-ad-groups --text free`) + + const bodies = [0, 1, 2].map((index) => JSON.parse(fetchStub.getCall(index).args[1].body as string)) + expect(bodies[0].scope).to.equal('AD_GROUP') + expect(bodies[0].negative_keywords[0].ad_group_id).to.equal(TEST_RESOURCE_ID) + expect(bodies[0].negative_keywords[0].campaign_id).to.equal(undefined) + expect(bodies[1].scope).to.equal('CAMPAIGN') + expect(bodies[1].negative_keywords[0].campaign_id).to.equal(TEST_RESOURCE_ID) + expect(bodies[2].scope).to.equal('ALL_CAMPAIGN_AD_GROUPS') + }) + + it('negative keywords refuse both parents at once', async () => { + fetchStub = mockFetch([NEGATIVES_OK]) + const {error} = await runCommand( + `asa negative-keywords add --yes --ad-group ${TEST_RESOURCE_ID} --campaign ${TEST_APP_ID} --text free`, + ) + expect(error?.message).to.contain('cannot also be provided') + expect(fetchStub.callCount).to.equal(0) + }) + + it('ads create sends the ad group and creative, update only the touched fields', async () => { + fetchStub = mockFetch([AD_OK, AD_OK]) + await runCommand(`asa ads create --yes --ad-group ${TEST_RESOURCE_ID} --creative-id 4321 --name "Summer ad"`) + await runCommand(`asa ads update --yes ${TEST_RESOURCE_ID} --status PAUSED`) + assertFetch({ + base: ASA_API_BASE, + body: {ad_group_id: TEST_RESOURCE_ID, creative_id: 4321, name: 'Summer ad'}, + callIndex: 0, + method: 'POST', + path: '/ads/', + stub: fetchStub, + }) + const updateBody = JSON.parse(fetchStub.getCall(1).args[1].body as string) + expect(updateBody).to.deep.equal({status: 'PAUSED'}) + }) + + it('product-pages sync posts with --yes and carries an idempotency key', async () => { + fetchStub = mockFetch([ + {accepted_at: '2026-08-03T10:00:00Z', message: 'queued', org_targets: 2, replayed: false, state: 'accepted', sync_id: 'x'}, + ]) + await runCommand('asa product-pages sync --yes --adam-id 123456') + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body).to.deep.equal({adam_id: 123_456}) + const headers = fetchStub.getCall(0).args[1].headers as Record + expect(headers['Idempotency-Key']).to.be.a('string').and.not.equal('') + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'POST', path: '/product-pages/sync/', stub: fetchStub}) + }) + + it('product-pages sync refuses without --yes when nobody can answer', async () => { + fetchStub = mockFetch([{}]) + const {error, stderr} = await runCommand('asa product-pages sync') + expect(error?.oclif?.exit).to.equal(2) + expect(stderr).to.contain('POST /product-pages/sync/') + expect(fetchStub.callCount).to.equal(0) + }) + + it('automations create reads the rule from a file and can request the first run', async () => { + const dir = await mkdtemp(join(tmpdir(), 'asa-cli-')) + const path = join(dir, 'rule.json') + await writeFile(path, JSON.stringify({conditions: [], name: 'pause expensive', operate_with: 'targeting-keyword', status: 1})) + fetchStub = mockFetch([{automation: {id: TEST_RESOURCE_ID, name: 'pause expensive'}}]) + await runCommand(`asa automations create --yes --file ${path} --run-now`) + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body.name).to.equal('pause expensive') + expect(body.run_immediately).to.equal(true) + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'POST', path: '/automations/', stub: fetchStub}) + }) + + it('automations create rejects a file that is not JSON', async () => { + const dir = await mkdtemp(join(tmpdir(), 'asa-cli-')) + const path = join(dir, 'rule.json') + await writeFile(path, 'not json at all') + fetchStub = mockFetch([{}]) + const {error} = await runCommand(`asa automations create --yes --file ${path}`) + expect(error?.message).to.contain('not valid JSON') + expect(fetchStub.callCount).to.equal(0) + }) + + it('automations update maps --stop to status 0 and refuses an id inside the file', async () => { + fetchStub = mockFetch([{automation: {id: TEST_RESOURCE_ID}}]) + await runCommand(`asa automations update --yes ${TEST_RESOURCE_ID} --stop`) + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body).to.deep.equal({status: 0}) + + const dir = await mkdtemp(join(tmpdir(), 'asa-cli-')) + const path = join(dir, 'rule.json') + await writeFile(path, JSON.stringify({internal_id: TEST_RESOURCE_ID, name: 'x'})) + const {error} = await runCommand(`asa automations update --yes ${TEST_RESOURCE_ID} --file ${path}`) + expect(error?.message).to.contain('Remove internal_id') + }) + + it('automations run passes dry_run as a query flag', async () => { + fetchStub = mockFetch([{automation_id: TEST_RESOURCE_ID, dry_run: true, run_id: 'run-42'}]) + const {stdout} = await runCommand(`asa automations run --yes ${TEST_RESOURCE_ID} --dry-run`) + assertFetch({ + base: ASA_API_BASE, + callIndex: 0, + method: 'POST', + path: `/automations/${TEST_RESOURCE_ID}/run/`, + query: {dry_run: 'true'}, + stub: fetchStub, + }) + expect(stdout).to.contain('Dry run queued') + expect(stdout).to.contain('run-42') + }) + + it('metrics posts the period and the resolved metric names', async () => { + fetchStub = mockFetch([{data: [], meta: {pagination: {count: 0, page: 1, pages: 1}}}]) + await runCommand('asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric spend --metric roas') + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body).to.deep.include({date_from: '2026-07-01', date_to: '2026-07-31', entity: 'campaign', order: 'desc'}) + expect(body.metrics).to.deep.equal(['spend', 'roas']) + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'POST', path: '/metrics/', stub: fetchStub}) + }) + + it('metrics sends the requested renewal windows and caps them client-side', async () => { + fetchStub = mockFetch([{data: [], meta: {pagination: {count: 0, page: 1, pages: 1}}}]) + await runCommand( + 'asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric roas --by-days 7 --by-days 90', + ) + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body.by_days).to.deep.equal([7, 90]) + + await runCommand( + 'asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --by-days 90 --order-by gross_roas --order-by-day 90', + ) + const ranked = JSON.parse(fetchStub.getCall(1).args[1].body as string) + expect(ranked).to.deep.include({order_by: 'gross_roas', order_by_day: 90}) + + const byDays = Array.from({length: 17}, (_, index) => `--by-days ${index}`).join(' ') + const {error} = await runCommand(`asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 ${byDays}`) + expect(error?.message).to.contain('At most 16') + expect(fetchStub.callCount).to.equal(2) + }) + + it('metrics overview caps the renewal windows client-side', async () => { + fetchStub = mockFetch([{}]) + const byDays = Array.from({length: 17}, (_, index) => `--by-days ${index}`).join(' ') + const {error} = await runCommand( + `asa metrics overview --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 ${byDays}`, + ) + expect(error?.message).to.contain('At most 16') + expect(fetchStub.callCount).to.equal(0) + }) + + it('competitors summary posts the parsed app ids and prints the totals', async () => { + fetchStub = mockFetch([ + { + byApps: [], + total: { + competitorsCount: 3, + countriesAsaCount: 12, + countriesWithAsaTerms: 9, + mostContestedTerms: [{competitorCount: 2, maxSov: '41.20', term: 'meditation'}], + topAppsByPerformance: [ + {adamId: 1_668_337_467, avgSov: '17.50', countries: ['US', 'GB'], iconUrl: null, name: 'Calm', termsCount: 84}, + ], + totalUniqueTerms: 240, + }, + }, + ]) + const {stdout} = await runCommand('asa competitors summary --app-ids 1668337467,6503873027') + const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) + expect(body).to.deep.equal({app_ids: [1_668_337_467, 6_503_873_027]}) + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'POST', path: '/competitors/summary/', stub: fetchStub}) + expect(stdout).to.contain('Competitors Count: 3') + expect(stdout).to.contain('Top apps by performance:') + expect(stdout).to.contain('Avg Sov: 17.50') + expect(stdout).to.contain('Most contested terms:') + expect(stdout).to.contain('Term: meditation') + }) + + it('competitors summary refuses a bad app id list before the network', async () => { + fetchStub = mockFetch([{}]) + const overCap = await runCommand('asa competitors summary --app-ids 1,2,3,4,5,6') + expect(overCap.error?.message).to.contain('1 to 5') + const empty = await runCommand('asa competitors summary --app-ids ,') + expect(empty.error?.message).to.contain('1 to 5') + const notNumbers = await runCommand('asa competitors summary --app-ids abc,123') + expect(notNumbers.error?.message).to.contain('numbers') + expect(fetchStub.callCount).to.equal(0) + }) + + it('connect asks the ASA host for the authorization link', async () => { + fetchStub = mockFetchFailure( + { + errors: [ + { + error_code: 'ads_manager_subscription_required', + message: 'An active Adapty Ads Manager subscription is required to use the CLI.', + }, + ], + }, + {status: 402}, + ) + const {error} = await runCommand('asa connect') + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: '/apple/oauth/', stub: fetchStub}) + expect(error?.message).to.contain('subscription is required') + expect(fetchStub.callCount).to.equal(1) + }) + + it('connect refuses to start without a token', async () => { + delete process.env.ADAPTY_TOKEN + fetchStub = mockFetch([{auth_url: 'https://appleid.apple.com/auth?state=abc'}]) + const {error} = await runCommand('asa connect') + expect(error?.message).to.contain('adapty auth login') + expect(fetchStub.callCount).to.equal(0) + }) +}) diff --git a/test/commands/asa.test.ts b/test/commands/asa.test.ts new file mode 100644 index 0000000..50a96fb --- /dev/null +++ b/test/commands/asa.test.ts @@ -0,0 +1,107 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import sinon from 'sinon' + +import {ASA_API_BASE, assertFetch, EMPTY_LIST_RESPONSE, mockFetch, mockFetchFailure, restoreFetch} from '../helpers/mock-fetch.js' + +const ME_RESPONSE = { + access_source: 'payg', + apple_credentials_status: 'active', + company_id: '550e8400-e29b-41d4-a716-446655440000', +} + +describe('asa', () => { + let fetchStub: sinon.SinonStub + + beforeEach(() => { + process.env.ADAPTY_TOKEN = 'dev_live_test' + delete process.env.ADAPTY_ASA_API_URL + }) + + afterEach(() => { + restoreFetch(fetchStub) + delete process.env.ADAPTY_TOKEN + delete process.env.ADAPTY_ASA_API_URL + }) + + it('whoami calls GET /me on the ASA host', async () => { + fetchStub = mockFetch([ME_RESPONSE]) + const {stdout} = await runCommand('asa whoami') + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: '/me/', stub: fetchStub}) + expect(stdout).to.contain('Access Source: payg') + expect(stdout).to.not.contain('asa connect') + }) + + it('whoami nudges to connect when Apple Ads is missing', async () => { + fetchStub = mockFetch([{...ME_RESPONSE, apple_credentials_status: 'unset'}]) + const {stdout} = await runCommand('asa whoami') + expect(stdout).to.contain('adapty asa connect') + }) + + it('whoami explains that a company without access can still connect', async () => { + fetchStub = mockFetch([{...ME_RESPONSE, access_source: 'none', apple_credentials_status: 'unset'}]) + const {stdout} = await runCommand('asa whoami') + expect(stdout).to.contain('Access Source: none') + expect(stdout).to.contain('No active Ads Manager subscription') + expect(stdout).to.contain('402') + }) + + it('apps list calls GET /apps with pagination', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa apps list --page 2 --page-size 50') + assertFetch({ + base: ASA_API_BASE, + callIndex: 0, + method: 'GET', + path: '/apps/', + query: {'page[number]': '2', 'page[size]': '50'}, + stub: fetchStub, + }) + }) + + it('orgs list calls GET /campaign-groups', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand('asa orgs list') + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'GET', path: '/campaign-groups/', stub: fetchStub}) + }) + + it('does not warn about a non-default URL when using the ASA default', async () => { + fetchStub = mockFetch([ME_RESPONSE]) + const {stderr} = await runCommand('asa whoami') + expect(stderr).to.not.contain('non-default API URL') + }) + + it('honours ADAPTY_ASA_API_URL without touching the core API host', async () => { + process.env.ADAPTY_ASA_API_URL = 'https://asa.dev.example/api/v1/cli' + fetchStub = mockFetch([ME_RESPONSE]) + await runCommand('asa whoami') + assertFetch({base: 'https://asa.dev.example/api/v1/cli', callIndex: 0, method: 'GET', path: '/me/', stub: fetchStub}) + }) + + it('reports the ASA error envelope instead of a bare status code', async () => { + fetchStub = mockFetchFailure( + { + errors: [ + { + error_code: 'ads_manager_subscription_required', + field_name: null, + message: 'An active Adapty Ads Manager subscription is required to use the CLI.', + status_code: 402, + }, + ], + }, + {status: 402}, + ) + const {error} = await runCommand('asa whoami') + expect(error?.message).to.contain('subscription is required') + }) + + it('surfaces the throttling message from a 429', async () => { + fetchStub = mockFetchFailure( + {errors: [{error_code: 'cli_rate_limit_exceeded', message: 'Rate limit exceeded. Retry in 7 second(s).'}]}, + {headers: {'Retry-After': '7'}, status: 429}, + ) + const {error} = await runCommand('asa whoami') + expect(error?.message).to.contain('Retry in 7') + }) +}) diff --git a/test/helpers/isolate-config.ts b/test/helpers/isolate-config.ts new file mode 100644 index 0000000..550d8ca --- /dev/null +++ b/test/helpers/isolate-config.ts @@ -0,0 +1,9 @@ +import {mkdtemp} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +export const mochaHooks = { + async beforeAll() { + process.env.XDG_CONFIG_HOME = await mkdtemp(join(tmpdir(), 'adapty-cli-config-')) + }, +} diff --git a/test/helpers/mock-fetch.ts b/test/helpers/mock-fetch.ts index ad3dade..8e5cee4 100644 --- a/test/helpers/mock-fetch.ts +++ b/test/helpers/mock-fetch.ts @@ -1,6 +1,7 @@ -import sinon from 'sinon' +import * as sinon from 'sinon' const API_BASE = 'https://api-admin.adapty.io/api/v1/developer' +export const ASA_API_BASE = 'https://api-asa-admin.adapty.io/api/v1/cli' export const TEST_APP_ID = '550e8400-e29b-41d4-a716-446655440000' export const TEST_RESOURCE_ID = '660e8400-e29b-41d4-a716-446655440001' @@ -20,28 +21,68 @@ export function mockFetch(responses: unknown[] = [{}]): sinon.SinonStub { return stub } +interface FailureOpts { + headers?: Record + status: number +} + +export function mockFetchFailure(body: unknown, opts: FailureOpts): sinon.SinonStub { + return sinon.stub(globalThis, 'fetch').callsFake( + async () => + new Response(JSON.stringify(body), { + headers: {'Content-Type': 'application/json', ...opts.headers}, + status: opts.status, + }), + ) +} + export function restoreFetch(stub: sinon.SinonStub): void { stub.restore() } interface AssertFetchOpts { + base?: string body?: Record callIndex: number + headers?: Record method: string path: string + query?: Record stub: sinon.SinonStub } -export function assertFetch({body, callIndex, method, path, stub}: AssertFetchOpts): void { +export function assertFetch({base = API_BASE, body, callIndex, headers, method, path, query, stub}: AssertFetchOpts): void { const call = stub.getCall(callIndex) const url = call.args[0] as string - const init = call.args[1] as {body?: string; method: string} + const init = call.args[1] as {body?: string; headers?: Record; method: string} + + if (headers) { + const actual = init.headers ?? {} + for (const [key, value] of Object.entries(headers)) { + if (actual[key] !== value) { + throw new Error(`Header "${key}": expected "${value}", got "${actual[key]}"`) + } + } + } + + if (!url.startsWith(base)) { + throw new Error(`Expected base "${base}", got "${url}"`) + } - const urlPath = url.replace(API_BASE, '').split('?')[0] + const urlPath = url.replace(base, '').split('?')[0] if (urlPath !== path) { throw new Error(`Expected path "${path}", got "${urlPath}"`) } + if (query) { + const actual = new URLSearchParams(url.split('?')[1] ?? '') + for (const [key, value] of Object.entries(query)) { + if (actual.get(key) !== value) { + throw new Error(`Query "${key}": expected "${value}", got "${actual.get(key)}"`) + } + } + } + if (init.method !== method) { throw new Error(`Expected method "${method}", got "${init.method}"`) } diff --git a/test/lib/asa-confirm.test.ts b/test/lib/asa-confirm.test.ts new file mode 100644 index 0000000..ac5cf5a --- /dev/null +++ b/test/lib/asa-confirm.test.ts @@ -0,0 +1,36 @@ +import {expect} from 'chai' + +import {decideConfirmation, renderPreview} from '../../src/lib/asa-confirm.js' + +describe('asa confirmation', () => { + it('applies without asking when --yes is given, whatever the terminal is', () => { + expect(decideConfirmation({isTty: true, json: false, yes: true})).to.equal('proceed') + expect(decideConfirmation({isTty: false, json: true, yes: true})).to.equal('proceed') + }) + + it('asks only when a human is on the other end', () => { + expect(decideConfirmation({isTty: true, json: false, yes: false})).to.equal('ask') + }) + + it('refuses instead of hanging when nobody can answer', () => { + expect(decideConfirmation({isTty: false, json: false, yes: false})).to.equal('refuse') + expect(decideConfirmation({isTty: true, json: true, yes: false})).to.equal('refuse') + }) + + it('shows the summary, the call and the exact body that will be sent', () => { + const preview = renderPreview({ + body: {daily_budget_amount: {amount: '50', currency: 'USD'}, name: 'Winter push'}, + method: 'POST', + path: '/campaigns/', + summary: 'Create campaign Winter push', + }) + expect(preview).to.contain('Create campaign Winter push') + expect(preview).to.contain('POST /campaigns/') + expect(preview).to.contain('"amount": "50"') + }) + + it('omits the body for calls that carry none', () => { + const preview = renderPreview({method: 'POST', path: '/automations/x/run/', summary: 'Run the rule for real'}) + expect(preview.split('\n')).to.have.length(2) + }) +}) diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts new file mode 100644 index 0000000..a250e10 --- /dev/null +++ b/test/lib/errors.test.ts @@ -0,0 +1,147 @@ +import {expect} from 'chai' + +import {parseApiError} from '../../src/lib/errors.js' + +describe('parseApiError', () => { + it('keeps the Developer API shape working', () => { + const error = parseApiError(400, {error_code: 'validation_error', errors: {title: ['is required']}}) + expect(error.errorCode).to.equal('validation_error') + expect(error.fieldErrors.title).to.deep.equal(['is required']) + expect(error.detail).to.equal(undefined) + }) + + it('leaves the Developer API untouched by the ASA branches', () => { + const listed = parseApiError(404, {errors: [{error_code: 'cli_entity_not_found', message: 'No campaign.'}]}) + const stringDetail = parseApiError(400, {detail: 'idempotency_key is required'}) + const listDetail = parseApiError(422, {detail: [{loc: ['body', 'scope'], msg: 'Field required'}]}) + + expect(listed.errorCode).to.equal('http_404') + expect(listed.message).to.equal('http_404') + expect(stringDetail.detail).to.equal(undefined) + expect(stringDetail.message).to.equal('http_400') + expect(listDetail.fieldErrors).to.deep.equal({}) + }) + + it('reads code and human message out of the ASA error list', () => { + const error = parseApiError( + 404, + { + errors: [ + { + error_code: 'cli_entity_not_found', + field_name: null, + message: 'No campaign with id abc exists for this company.', + status_code: 404, + }, + ], + }, + {}, + 'asa', + ) + expect(error.errorCode).to.equal('cli_entity_not_found') + expect(error.detail).to.equal('No campaign with id abc exists for this company.') + expect(error.message).to.equal('No campaign with id abc exists for this company.') + expect(error.toJSON().error_code).to.equal('cli_entity_not_found') + }) + + it('collects several listed errors and their field names', () => { + const error = parseApiError( + 422, + { + errors: [ + {error_code: 'first', field_name: 'bid_amount', message: 'too low'}, + {error_code: 'second', field_name: 'text', message: 'too long'}, + ], + }, + {}, + 'asa', + ) + expect(error.errorCode).to.equal('first') + expect(error.detail).to.equal('too low; too long') + expect(error.fieldErrors).to.deep.equal({bid_amount: ['too low'], text: ['too long']}) + }) + + it('reads the Apple rejection shape that mutations answer with', () => { + const error = parseApiError( + 400, + { + ad_group: null, + errors: [ + { + apple_error_code: 'REQUIRED_VALUE', + apple_error_message: 'Field [pricingModel] is missing.', + entity_type: 'AD_GROUP', + operation: 'CREATE', + }, + ], + }, + {}, + 'asa', + ) + expect(error.errorCode).to.equal('REQUIRED_VALUE') + expect(error.message).to.equal('Field [pricingModel] is missing.') + }) + + it('reads our own validation shape and points at the offending item', () => { + const error = parseApiError( + 400, + { + errors: [ + { + input_ref: 0, + validation_error_code: 'DUPLICATE_KEYWORD', + validation_error_message: 'Keyword text already exists in this ad group.', + }, + ], + is_validation_failure: true, + keywords: [], + }, + {}, + 'asa', + ) + expect(error.errorCode).to.equal('DUPLICATE_KEYWORD') + expect(error.message).to.equal('Keyword text already exists in this ad group. (item 1)') + }) + + it('falls back to the code when a rejection carries no message', () => { + const error = parseApiError(400, {errors: [{apple_error_code: 'INVALID_BID'}]}, {}, 'asa') + expect(error.errorCode).to.equal('INVALID_BID') + expect(error.message).to.equal('INVALID_BID') + }) + + it('carries Retry-After into the error and its JSON form', () => { + const error = parseApiError( + 429, + {errors: [{error_code: 'cli_rate_limit_exceeded', message: 'Slow down.'}]}, + {retryAfterSeconds: 7}, + 'asa', + ) + expect(error.retryAfterSeconds).to.equal(7) + expect(error.message).to.equal('Slow down.') + expect(error.toJSON().retry_after_seconds).to.equal(7) + }) + + it('unpacks a FastAPI validation detail into field errors', () => { + const error = parseApiError( + 422, + { + detail: [ + {loc: ['body', 'keywords', 0, 'text'], msg: 'Field required'}, + {loc: ['body', 'scope'], msg: 'Input should be AD_GROUP'}, + ], + }, + {}, + 'asa', + ) + expect(error.fieldErrors['keywords.0.text']).to.deep.equal(['Field required']) + expect(error.fieldErrors.scope).to.deep.equal(['Input should be AD_GROUP']) + }) + + it('falls back to the status code when the body says nothing useful', () => { + expect(parseApiError(500, 'oops').errorCode).to.equal('http_500') + expect(parseApiError(503, {}).errorCode).to.equal('http_503') + expect(parseApiError(400, {detail: 'idempotency_key is required'}, {}, 'asa').detail).to.equal( + 'idempotency_key is required', + ) + }) +}) diff --git a/test/lib/output.test.ts b/test/lib/output.test.ts new file mode 100644 index 0000000..42b1d01 --- /dev/null +++ b/test/lib/output.test.ts @@ -0,0 +1,66 @@ +import {expect} from 'chai' + +import {printResponse} from '../../src/lib/output.js' + +function render(data: Record): string[] { + const lines: string[] = [] + printResponse(data, (msg) => lines.push(msg)) + return lines +} + +describe('printResponse', () => { + it('skips empty arrays instead of printing a bare label', () => { + const lines = render({errors: [], internal_id: 'abc', keywords: []}) + expect(lines).to.deep.equal(['Internal ID: abc']) + }) + + it('keeps scalar arrays on one line', () => { + const lines = render({countries_or_regions: ['US', 'GB'], name: 'Winter push'}) + expect(lines).to.deep.equal(['Countries Or Regions: US, GB', 'Name: Winter push']) + }) + + it('renders a deep metrics overview structure one level per line', () => { + const lines = render({ + gross: { + by_days: { + data: [ + {day: 7, values: [{x: '2026-07-01', y: '3.1'}]}, + {day: 90, values: [{x: '2026-07-01', y: '9.8'}]}, + ], + }, + total: { + data: [ + {values: [{x: '2026-07-01', y: '12.5'}, {x: '2026-07-02', y: '14.0'}]}, + ], + }, + }, + metric: 'revenue', + }) + expect(lines).to.deep.equal([ + 'Gross:', + ' By Days:', + ' Data:', + ' - Day: 7', + ' Values:', + ' - X: 2026-07-01', + ' Y: 3.1', + ' - Day: 90', + ' Values:', + ' - X: 2026-07-01', + ' Y: 9.8', + ' Total:', + ' Data:', + ' - Values:', + ' - X: 2026-07-01', + ' Y: 12.5', + ' - X: 2026-07-02', + ' Y: 14.0', + 'Metric: revenue', + ]) + }) + + it('skips null and undefined fields at every depth', () => { + const lines = render({budget: {amount: '50', currency: null}, end_time: null, name: 'x'}) + expect(lines).to.deep.equal(['Budget:', ' Amount: 50', 'Name: x']) + }) +})