@koda-sl/baker-cli 0.263.0-dev.ac71c8123 → 0.263.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -16
- package/canvas/tiktok-captions-composition/index.html +2 -19
- package/dist/{chunk-GXRUZ4LP.js → chunk-2IB3IL4K.js} +2 -2
- package/dist/{chunk-TBEVU7XF.js → chunk-6F52WYB7.js} +3 -3
- package/dist/{chunk-POYXIDOD.js → chunk-DZUVUGEP.js} +1 -5
- package/dist/{chunk-POYXIDOD.js.map → chunk-DZUVUGEP.js.map} +1 -1
- package/dist/{chunk-ECPNYXOA.js → chunk-GU7IWEBC.js} +12 -5
- package/dist/chunk-GU7IWEBC.js.map +1 -0
- package/dist/{chunk-2VM2J6MB.js → chunk-KSOGK7SJ.js} +4 -4
- package/dist/{chunk-ZK5S7JV2.js → chunk-SJPRTSGY.js} +37 -155
- package/dist/chunk-SJPRTSGY.js.map +1 -0
- package/dist/cli.js +6663 -5561
- package/dist/cli.js.map +1 -1
- package/dist/client-VDCCDEHE.js +17 -0
- package/dist/engine/index.js +3 -3
- package/dist/{env-HZNFGXAR.js → env-FWMZXMQK.js} +2 -4
- package/dist/{output-MWMAXN4M.js → output-JBYI5IVX.js} +5 -5
- package/dist/{shared-NFLXWGHK.js → shared-GWFXJXKJ.js} +6 -6
- package/package.json +1 -1
- package/dist/chunk-ECPNYXOA.js.map +0 -1
- package/dist/chunk-ZK5S7JV2.js.map +0 -1
- package/dist/client-7QIDRQXG.js +0 -15
- /package/dist/{chunk-GXRUZ4LP.js.map → chunk-2IB3IL4K.js.map} +0 -0
- /package/dist/{chunk-TBEVU7XF.js.map → chunk-6F52WYB7.js.map} +0 -0
- /package/dist/{chunk-2VM2J6MB.js.map → chunk-KSOGK7SJ.js.map} +0 -0
- /package/dist/{client-7QIDRQXG.js.map → client-VDCCDEHE.js.map} +0 -0
- /package/dist/{env-HZNFGXAR.js.map → env-FWMZXMQK.js.map} +0 -0
- /package/dist/{output-MWMAXN4M.js.map → output-JBYI5IVX.js.map} +0 -0
- /package/dist/{shared-NFLXWGHK.js.map → shared-GWFXJXKJ.js.map} +0 -0
package/README.md
CHANGED
|
@@ -226,6 +226,11 @@ baker ads google query "SELECT campaign.name, metrics.clicks, metrics.cost_micro
|
|
|
226
226
|
# Use a preset (saves tokens) — returns only actually-serving entities by default
|
|
227
227
|
baker ads google query --preset campaign-performance --customer-id 1234567890
|
|
228
228
|
|
|
229
|
+
# Campaigns Google reports as limited by budget, with each one's bid target next to its
|
|
230
|
+
# actual cost per conversion. Since 17 Aug 2026 a budget-limited campaign delivers toward
|
|
231
|
+
# its target instead of holding its recent actual cost, so a target above actual raises cost.
|
|
232
|
+
baker ads google query --preset budget-limited --customer-id 1234567890
|
|
233
|
+
|
|
229
234
|
# Include paused entities too (default is serving-only)
|
|
230
235
|
baker ads google query --preset ad-copy-performance --customer-id 1234567890 --include-paused
|
|
231
236
|
|
|
@@ -653,17 +658,21 @@ baker tag-manager draft show --chat <chat-id> gtm_temp_ab12
|
|
|
653
658
|
baker actions draft --chat <chat-id>
|
|
654
659
|
```
|
|
655
660
|
|
|
656
|
-
Command groups: `budgets`, `campaigns`, `ad-groups`, `keywords` (add/update/remove), `negative-keywords`, `keyword-lists`, `ads`, `assets` (create/update/attach/detach), `asset-groups` (create/update/attach/detach — Performance Max), `audiences`, `conversions` (create/update/goal), `bidding-strategies`, `labels`, `campaign-criteria` (add/update/remove), and `draft`. Amounts are in major currency units (converted to micros). Money/bids: `--amount`, `--cpc-bid`, `--target-cpa`, `--max-cpc` take major units; `--target-roas` a ratio. `--max-cpc` sets the max CPC bid ceiling for `TARGET_IMPRESSION_SHARE` / `TARGET_SPEND` / `PERCENT_CPC` and must be paired with `--bidding-strategy`. Less-common ops accept a `--file
|
|
661
|
+
Command groups: `budgets`, `campaigns`, `ad-groups`, `keywords` (add/update/remove), `negative-keywords`, `keyword-lists`, `ads`, `assets` (create/update/attach/detach), `asset-groups` (create/update/attach/detach — Performance Max), `audiences`, `conversions` (create/update/goal/upload), `bidding-strategies`, `labels`, `campaign-criteria` (add/update/remove), and `draft`. Amounts are in major currency units (converted to micros). Money/bids: `--amount`, `--cpc-bid`, `--target-cpa`, `--target-cpc`, `--max-cpc` take major units; `--target-roas` a ratio. `--target-cpc` pairs with `--bidding-strategy TARGET_CPC` and is Demand Gen only. `--max-cpc` sets the max CPC bid ceiling for `TARGET_IMPRESSION_SHARE` / `TARGET_SPEND` / `PERCENT_CPC` and must be paired with `--bidding-strategy`. Less-common ops accept a `--file` payload — a path to a JSON file, or the JSON object written inline (flags override its keys). Updates target a resource name or bare id as the positional argument; a target that names an op staged earlier **amends it in place**.
|
|
657
662
|
|
|
658
663
|
**A device criterion can be added, but never removed** — `campaign-criteria add` with a device criterion works, but Google then rejects `campaign-criteria remove` on it forever, including on a criterion you added yourself (and Baker refuses the removal before publish). Adjust it instead with `campaign-criteria update customers/<cid>/campaignCriteria/<campaignId>~<criterionId> --bid-modifier <n>`: `1` for no adjustment, `1.25` to bid 25% more, `0.9` to bid 10% less, `0` to stop serving on that device. Locations, languages and ad schedules remove normally.
|
|
659
664
|
|
|
665
|
+
**Images on a Search campaign** — `assets create` with an image publishes it into the advertiser's Google Ads asset library, but Google refuses the asset link that puts an image extension on a Search campaign (`AD_IMAGE` and every marketing-image field type, at campaign, ad group and customer level alike), so `assets attach` rejects it at stage time and names what to do instead. Staging an image therefore also stages a **Task** — "Add N images to <campaign> in Google Ads" — which applies with the rest of the chat, so the one step Google keeps inside its own interface is tracked rather than mentioned. Attach the image to a Performance Max asset group (`asset-groups attach`) and the Task withdraws itself. The one image a Search campaign does take through a link is the advertiser's logo: `BUSINESS_LOGO`, at campaign or customer level, on a verified account.
|
|
666
|
+
|
|
660
667
|
**Primary vs secondary conversion actions** — `conversions update <id> --primary` makes an action a primary action (automated bidding optimizes toward it); `--no-primary` (or `--primary=false`) demotes it to secondary (reported only) — the spaced form `--primary false` is rejected, since a boolean flag written with a space is set to true and the word dropped. `conversions create` stages a new action as **secondary** unless its `--file` payload sets `"primaryForGoal": true`, so a new action never silently joins the bidding target of a Maximize Conversions or Target CPA campaign. The staged card shows an "Action optimization" row with the before/after role.
|
|
661
668
|
|
|
662
669
|
**Account-level conversion goals** — `conversions goal --customer-id … --category PURCHASE --origin WEBSITE --biddable` sets Google's `CustomerConversionGoal` for one (category, origin) pair; `--no-biddable` (or `--biddable=false`) excludes the category from bidding while it keeps being measured. Neither flag defaults, and the spaced form `--biddable false` is rejected. A conversion drives automated bidding only when both halves are on — the action is primary AND its category's account-level goal is biddable — so this is where "my conversion is Primary but bidding ignores it" is fixed. One staged op per pair.
|
|
663
670
|
|
|
671
|
+
**Uploading conversions Google never received** — `conversions upload --customer-id 1234567890 --conversion-action 987654321 --file missing.csv` files conversions the account has no record of, for a period when the tracking was broken. The file is a CSV, JSON or one-object-per-line export whose header names the columns `orderId`, `conversionDateTime`, `gclid` / `gbraid` / `wbraid`, `email`, `phone`, `value`, `currency`; column names are matched loosely (`Order ID`, `order_id`, `transaction_id` are one column) and any column an upload has no use for is ignored and listed back in the hints. A single conversion can be given with `--order-id --date --gclid --email --value --currency` instead. Each conversion needs an order id, a date **with its timezone** (a bare `2026-08-01T10:00:00` is refused, since it means one instant to the person who typed it and another to everything else), and something to match on — a click id or the customer's email / phone. Reuse each conversion's **original** order id: that is what makes a re-send a correction rather than a second conversion. A value always carries its currency, because Google reads one without in the account's own. Emails and phone numbers are hashed at stage time, so nothing identifying is stored, sent in the clear, or kept afterwards. Staging checks the conversion action exists and is not removed (either refuses), and warns about conversions older than that action's click-through window — Google accepts those and attributes them to nothing. Like every other Google write it applies on publish; more than 200 conversions stage as several uploads that publish independently.
|
|
672
|
+
|
|
664
673
|
**Retiring a conversion action** — `status` accepts `ENABLED` and `REMOVED` only. Google denies `HIDDEN` on every conversion-action write, so to stop bidding toward an action while it keeps measuring use `--no-primary`, and to stop it counting at all use `"status": "REMOVED"`. Conversion actions **Google owns** cannot be changed at all — Smart campaign call/directions actions, the in-ad lead form's submit, YouTube subscriptions and follow-on views, Floodlight and Search Ads 360 imports, store visits, codeless webpage actions. Staging any edit to one, a rename included, is refused up front; there is no manual route either, since the Google Ads interface will not change them.
|
|
665
674
|
|
|
666
|
-
**Reviewing the draft** — `baker ads google draft list` renders everything you've staged as a grouped campaign ▸ ad group ▸ ad tree (with non-blocking completeness advisories), the CLI counterpart to the dashboard's Google Ads tab. Pass `--json` for the raw envelope. Aim for a fully built campaign — 2–4 ad groups, ≥5 keywords each, 2–4 RSAs with 8–12 headlines, ≥4 sitelinks / ≥3 callouts / ≥1 structured snippet, and ≥1 shared negative list; the advisories flag what's still thin.
|
|
675
|
+
**Reviewing the draft** — `baker ads google draft list` renders everything you've staged as a grouped campaign ▸ ad group ▸ ad tree (with non-blocking completeness advisories), the CLI counterpart to the dashboard's Google Ads tab. Pass `--json` for the raw envelope. Once the chat has published it reports the outcome instead — `published · N applied, M failed`, with a `✗` line naming each op that did not land and why. Aim for a fully built campaign — 2–4 ad groups, ≥5 keywords each, 2–4 RSAs with 8–12 headlines, ≥4 sitelinks / ≥3 callouts / ≥1 structured snippet, and ≥1 shared negative list; the advisories flag what's still thin.
|
|
667
676
|
|
|
668
677
|
**Amending a staged op** — `draft amend <ref> (--file patch.json | --patch '<json>')` merges a JSON patch into an already-staged op's payload (objects deep-merge, `null` deletes a key, arrays/scalars replace) and re-validates it in full. Always prefer this over removing and re-staging — it preserves the op's ref (so dependents don't break) and its position in the draft. `draft show <ref>` prints the full staged payload (including warnings/annotations) so you can verify a change looks right before publish.
|
|
669
678
|
|
|
@@ -1063,6 +1072,8 @@ audit --format md # deliverable-ready markdown table
|
|
|
1063
1072
|
campaign-groups create|update|pause|resume|duplicate # staged writes (see below)
|
|
1064
1073
|
campaigns create|update|pause|resume|archive|duplicate
|
|
1065
1074
|
campaigns url-params <ad-set-id> # UTMs for every ad in the ad set
|
|
1075
|
+
account url-params # UTMs for every ad in the whole account
|
|
1076
|
+
url-params [--ad-sets] # read what is tagged now, and what lands twice
|
|
1066
1077
|
creatives create|update|pause|resume|duplicate
|
|
1067
1078
|
audiences create|upload
|
|
1068
1079
|
conversions create|update
|
|
@@ -1090,6 +1101,10 @@ baker ads linkedin campaigns url-params 123456 \
|
|
|
1090
1101
|
--param "utm_source=linkedin&utm_medium=paid-social" \
|
|
1091
1102
|
--dynamic utm_campaign=CAMPAIGN_NAME --dynamic utm_content=CREATIVE_ID
|
|
1092
1103
|
baker ads linkedin campaigns url-params 123456 --clear # remove them all
|
|
1104
|
+
|
|
1105
|
+
# what never varies across the account goes one level up — LinkedIn appends BOTH,
|
|
1106
|
+
# so a key set at the account AND the ad set lands in the URL twice
|
|
1107
|
+
baker ads linkedin account url-params --param "utm_source=linkedin&utm_medium=paid_social"
|
|
1093
1108
|
# a boolean flag written with a space (`--clear false`) is rejected: citty sets it to TRUE and drops
|
|
1094
1109
|
# the word. Attach it — `--clear=false` — or leave the flag off.
|
|
1095
1110
|
|
|
@@ -2729,6 +2744,15 @@ When `list` or `search` returns nothing, the envelope carries `meta.empty` expla
|
|
|
2729
2744
|
|
|
2730
2745
|
If the source check itself fails, `meta.empty` is omitted entirely and the hint says so — an unverified absence is never reported as one.
|
|
2731
2746
|
|
|
2747
|
+
`list` is paged. `--limit` is the page size (default 50); when the library continues past the page you got, the envelope carries `meta.next_cursor` and a hint, and `meta.empty` is omitted — a page that is not the last page establishes nothing about the corpus. Read on with the same filters plus `--cursor`:
|
|
2748
|
+
|
|
2749
|
+
```bash
|
|
2750
|
+
baker testimonials list --sentiment positive --limit 50
|
|
2751
|
+
baker testimonials list --sentiment positive --limit 50 --cursor <meta.next_cursor>
|
|
2752
|
+
```
|
|
2753
|
+
|
|
2754
|
+
No `meta.next_cursor` means the library ended there.
|
|
2755
|
+
|
|
2732
2756
|
### `baker testimonials tags`
|
|
2733
2757
|
|
|
2734
2758
|
List the available testimonial tag names — built-in defaults plus the company's custom tags. Use it before filtering with `--tags`. Defaults to a markdown list (`--output json` for the `{ ok, data }` envelope).
|
|
@@ -3004,7 +3028,7 @@ baker scheduled-actions create --template baker-ads --name "Weekly ad review" --
|
|
|
3004
3028
|
Rules:
|
|
3005
3029
|
|
|
3006
3030
|
- `--cron` and `--run-at` are mutually exclusive.
|
|
3007
|
-
- `--run-at` must be an ISO UTC timestamp ending in `Z
|
|
3031
|
+
- `--run-at` must be an ISO UTC timestamp ending in `Z`, and still in the future — a time that has already passed is refused when the op is staged. If a one-off's time passes while the chat is still open, publishing creates the task **switched off** with the reason on it rather than dropping it, so it is never lost.
|
|
3008
3032
|
- Draft-created ids use `temp_sched_*`; use them with `get`, `update`, or `delete` before publish.
|
|
3009
3033
|
- Staged commands require `BAKER_CHAT_ID` to point to an `in_progress` chat.
|
|
3010
3034
|
- `trigger` rejects `temp_sched_*` and only accepts published scheduled-action ids.
|
|
@@ -3480,7 +3504,6 @@ Reach for it when you are relaying the **client's** own words. When you are writ
|
|
|
3480
3504
|
|
|
3481
3505
|
```bash
|
|
3482
3506
|
baker studio animate "slow push in, model turns to camera and smiles" --image j57abc123def456ghi789
|
|
3483
|
-
baker studio animate "she looks to camera and says: \"Hola, soy Elena\"" --avatar elena --quality 720p --aspect-ratio 9:16
|
|
3484
3507
|
baker studio animate "handheld drift right, steam rising from the cup" \
|
|
3485
3508
|
--image ./out/hero.png --duration 6 --quality 1080p
|
|
3486
3509
|
baker studio animate "product rotates once on a turntable" --image j57abc…,j57def… --from references
|
|
@@ -5791,18 +5814,7 @@ This CLI is designed for AI agent consumption. Key patterns:
|
|
|
5791
5814
|
|
|
5792
5815
|
- **0.217.0**: new family `baker avatars list|get|create|update|delete`, plus `--avatar <handle>` on `baker studio generate` and `baker studio animate` — company-scoped reusable presenters, typed from the shared `@baker/api` avatars contract. `--avatar` is the way to cast one: it grounds the render on the identity sheet, reuses the subject description verbatim, and records the avatar on the batch. Passing the sheet through `--reference` instead does none of those and leaves the batch unattributed, so the avatar can never show what it has appeared in; the route refuses a handle that is not `ready`, because generating a stranger is worse than an error. `get` returns the sheet URL and that description; every command's `hints` carry the next move (still building → re-check with `get`; changed `--subject` without `--regenerate-sheet` → the sheet is stale). `create` refuses `--source-image` without `--likeness-confirmed` and returns an `error.fix` telling the agent to ask the user for that permission, since only they can give it; an unknown handle returns an `error.fix` pointing at `baker avatars list`. Profile flags on `update` merge over the stored profile instead of replacing it.
|
|
5793
5816
|
|
|
5794
|
-
|
|
5795
|
-
- **0.251.0**: `--accent` on `baker avatars create|update` — the region an avatar's voice comes from, e.g. `"Spanish from Spain, Madrid accent"`. Its own field rather than a sentence inside `--speech`, because that is where it went to die: asked for "pace, register, accent", the agent wrote *"clear Spanish accent"* for a Madrid presenter, the video model read that as no accent, and the clip came out sounding like nowhere. A place can be checked; an adjective cannot. It reaches the render through `buildDeliveryDirection` as its own `ACCENT:` instruction, and `thinProfileHint` now names it first among the missing fields since it is the only one that fails silently — a clip with no accent still looks finished.
|
|
5796
|
-
- **0.252.0**: avatar voices removed. `--voice-id` / `--voice-description` are gone from `baker avatars create|update`, and an avatar no longer holds a voice at all — every clip is voiced by the video model as it renders, directed by the profile's `--accent`. The picker they replaced offered a voice, played its preview, and then shipped a clip that did not sound like it: the re-voice was speech-to-speech, which replaces timbre and keeps pronunciation, so the accent the user picked could never survive. Three other routes were built and judged on video before removing it — TTS + lip-sync (mouth), audio-driven avatar models (picture), and Seedance 2.5 driven by audio (refuses AI faces). Reasoning and the re-open condition: `docs/adr/0005-an-avatar-is-a-face-not-a-voice.md`.
|
|
5797
|
-
- **0.253.0**: the pre-render gate runs `hyperframes check` — lint, runtime, layout, motion and contrast in one browser session, sampled at transition seams — instead of the static `lint` + `inspect` pair, falling back to them when the installed binary predates `check`. The browser half is signal the parser could never produce: measured on HyperFrames' own `warm-grain` example, lint/runtime/layout/motion all reported `ok: true` while the composition rendered an almost entirely blank ten-second video, the only trace being six `GSAP target #a-roll not found` warnings — a tween pointed at an element that does not exist, so nothing it animates ever appears. Baker promotes that warning to blocking, alongside `sweep_static` ("the timeline never advanced", i.e. a still image billed as a video). Warnings are grouped by code with a count (one run returned 62, 53 of them identical) and the highest-value findings carry advice on what to do rather than only what was seen. Requires `hyperframes@0.8.17` in the sandbox, bumped from 0.7.5 in the same change. Reasoning: `docs/adr/0007-the-render-gate-runs-in-a-browser.md`.
|
|
5798
|
-
- **0.254.0**: the nested-composition smoke test now looks at the frames instead of trusting the exit code — exiting 0 is what a blank render does too. `hyperframes snapshot --describe` sends the captured frames to Gemini and writes back what is actually in them, and the credential Studio already uses for Gemini and Omni (`GOOGLE_GENERATIVE_AI_API_KEY`) is the `GEMINI_API_KEY` it reads; the backend now ships it to the sandbox by omission, so a deployment without one keeps the plain smoke test. Verified against a real render: the pass reported "the image is completely empty" and named the single visible element without being told what to look for. Frame verdicts are condensed into the node log, one line per frame — a file the agent would have to know to open is a file it does not read.
|
|
5799
|
-
- **0.263.0**: shot briefs refuse the frame that contradicts its own caption, and the vision pass reads the numbers back. Observed verbatim: a phone screen reading "$215.40 SAVED THIS MONTH" under a caption reading "Save over £900 a year" — wrong currency, and a figure that contradicts the claim it sits beneath, since 215.40 a month is 2,584 a year. The model invents a number whenever a screen or document is in shot, so the frames no longer carry legible text at all: the caption track is where an ad's numbers live. Same brief also pins each object's real-world FORM after a phone rendered with a second screen on its back, and names the market's currency for anything unavoidably visible. The frame-vision question now reads out every number and word inside the picture and says whether it contradicts the caption — the defect only exists between the two, so neither alone can see it.
|
|
5800
|
-
- **0.262.0**: `scaffold-ad` carries the brand. The spec takes `brand.palette` (hex tokens) and `brand.logo` (repo path to the mark), and emits them as the `style.palette` and `branding` the engine already reads to colour its brand plate and CTA. The palette dresses the plate and the type and is deliberately kept OUT of the shot briefs — hex in a frame prompt makes the model paint the whole scene that colour, which reads as a filter. Also takes `market`, since an ad that does not say where it is set is an ad the model sets wherever it likes.
|
|
5801
|
-
- **0.261.0**: four defects that reached a finished ad, fixed in `scaffold-ad` where they originate. Frames render on **`openai/gpt-image-2`**, pinned rather than inheriting the catalogue default (Gemini Flash — right for cheap high-volume work, wrong for the opening still a five-second clip is built from). Every shot brief now states the MARKET, inferred from the voice language when unstated: an ad for Spain came back full of British houses because nothing said where it was. Every shot brief also rules out the impossible — paper and screens are opaque, objects are at real-world scale — after a bill rendered with the text from its reverse showing through, and a solar panel five metres tall. And beat durations snap to lengths the video model can actually render: free-form durations were rounded down per clip, so 11 beats summed to 27.0s of picture against a 31.4s timeline and the 4.4s difference rendered as a black tail. The read rate was re-measured off a real Spanish ad (81 words in 26.48s = 3.06 w/s); the old 2.6 over-predicted every beat by 18%.
|
|
5802
|
-
- **0.260.0**: burned-in captions break where the script punctuates instead of every N words. The karaoke composition sliced the transcript into fixed groups, and a word count knows nothing about the sentence it is cutting — an ad the engine rendered showed cards reading "OTRA VEZ, EN" and "TU CASA GENERA", half-thoughts ending on a preposition. A card now closes when the clause does; the word count stays as a ceiling so a long clause is still split, and a linking word ("de", "al", "y") never ends a card because it belongs to what follows. Same rule the pre-render gate already checked for, now enforced where the cards are actually built.
|
|
5803
|
-
- **0.259.0**: `baker canvas scaffold-ad` is listed in `baker canvas --help` with a description, and the three video routes are separated in the skill. It had a usage line and no description row, so an agent reading help saw two scaffolders explained and a bare name — and three runs from the same prompt took three different wrong paths: hand-directing the flow, reproducing a competitor ad found via `winning-ads`, and hand-authoring a canvas. The routes now state their own boundary: `scaffold-ad` is the default for a new ad, `scaffold-video` only reproduces a video the USER supplied, and `video-flow.md` opens by saying so rather than calling itself the default eleven times.
|
|
5804
|
-
- **0.257.0**: the frame-vision pass now asks whether what is on screen could physically happen, not just whether the frame is empty. The defect that motivated it was a generated shot of a solar panel roughly five metres tall being lifted onto a roof by one person — well lit, on brief, perfectly legible, and impossible. That is what reads loudest as "AI-generated", and the previous question could not see it: nothing was missing, something present was the wrong size. Asked the new question, the pass called the rejected shot *"physically implausible due to the immense weight and surface area"* and the replacement *"consistent with what two people could reasonably maneuver"* — the same verdict a person reached, unprompted.
|
|
5805
|
-
- **0.256.0**: the pre-render gate reports caption cards that end mid-clause. The first cut of this check asked for consistency and got it the wrong way round — stripping terminal punctuation makes the cards agree and leaves them wrong, because "En solo nueve días tu casa" is not a line anyone wrote, it is a sentence halved by a word count. Captions split where the script punctuates; each card is then a clause carrying its own mark, grammatical, and consistent as a by-product. The last card is exempt, since a CTA legitimately ends bare. Reported, never rewritten — the copy belongs to whoever wrote it.
|
|
5817
|
+
## Publishing
|
|
5806
5818
|
|
|
5807
5819
|
### Auto-publish (CI)
|
|
5808
5820
|
|
|
@@ -5815,6 +5827,30 @@ Pushing to `main` with changes in `packages/cli/` triggers the GitHub Actions wo
|
|
|
5815
5827
|
./scripts/publish-package.sh cli next # Publish as @next (pre-release)
|
|
5816
5828
|
```
|
|
5817
5829
|
|
|
5830
|
+
- **0.243.0**: `baker ads linkedin account url-params` stages the URL tracking parameters on the whole **ad account** — the "Account tracking parameters" box in Campaign Manager, which the CLI (and this README, and the playbook) said was UI-only and unreachable through the API. It was, until LinkedIn's 202608 added `sponsoredAccount` to the `adEntity` key of `/adTrackingParameters`; Baker's LinkedIn version pin moves 202606 → 202608 with it. Same flags as the ad-set form (`--param`, `--dynamic`, `--clear`, `--file`) and no positional: the target is built from `--account-id`, because a bare numeric id has always meant an ad set and reading one as an account would silently stage the wrong write. **LinkedIn concatenates the two levels rather than overriding**, so a key set at both appears twice in the served URL — the stage warnings now name the other level for exactly that check, and the staged change says which level it applies to. `baker ads linkedin account` keeps reading account detail unchanged (it is now `account detail`, kept as the default subcommand).
|
|
5831
|
+
|
|
5832
|
+
- **0.244.0**: `baker ads linkedin url-params` **reads** the tracking parameters in effect — the ad account's, optionally every ad set's, and the keys set at BOTH levels. There was no read at all before: the only way to see what an entity carried was to stage a change and look at the before-state, which meant the concatenation trap (a key at the account AND an ad set lands in the served URL twice) was invisible until a report came back split across two values of the same parameter. Account-only by default because `adTrackingParameters` has no batch read, so each ad set is its own call; `--ad-sets` sweeps them, capped at 50, and the response carries `adSetCoverage: {examined, total, capped}` so a partial sweep can't read as a clean account. `--collisions-only` for just the finding.
|
|
5833
|
+
|
|
5834
|
+
- **0.245.0**: **a repeated flag no longer loses everything but its last value.** citty declares no array arg type — it calls `util.parseArgs` without `multiple: true` — so `--param utm_source=linkedin --param utm_medium=paid` reached the command as the single string `utm_medium=paid`. The arg descriptions said "repeatable", the unit tests passed arrays citty never produces, and `url-params` REPLACES an entity's whole parameter set: staging three UTMs set one and deleted the rest at publish, reporting success throughout. Found by staging against the real ad account and reading the payload back, not by any test. Every repeatable flag now reads its values off the command line: LinkedIn `--param`/`--dynamic`, Google Ads `--custom-param` (Google replaces the set wholesale too) and `--text`, `baker mcp add --header`, and `baker flows map --map`, which had already solved this locally and is now the shared helper.
|
|
5835
|
+
|
|
5836
|
+
- **0.245.0**: `baker ads linkedin url-params --campaign-ids` says when an id names no ad set. LinkedIn answers NOT_FOUND identically for "this ad set carries no parameters" and "there is no such ad set", so a mistyped id came back as an ad set that exists and is empty — and the next move on that is a write that only fails at publish. Such ids are now reported under `unreadable` with the reason, and the "nothing is tagged" hint is suppressed, exactly as for a level that could not be read.
|
|
5837
|
+
|
|
5838
|
+
- **0.245.0**: a backend condition the caller can act on reaches the caller. "Analytics reporting is not switched on for this workspace yet" was thrown as an `INTERNAL`, and the HTTP layer withholds the message of every 5xx so a real fault cannot leak its internals — so the CLI printed `INTERNAL_ERROR: Internal server error` and, worse, LinkedIn and Meta commands marked it retryable. It is now a `CONFLICT`, which the CLI maps to a new non-retryable `CONFLICT` code and prints verbatim.
|
|
5839
|
+
|
|
5840
|
+
- **0.246.0**: `baker ads linkedin draft` reports what a publish DID. It used to render a published draft exactly like a staged one — same "1 staged change", same tree, no mention of the outcome — even though the per-op `result` was in the response the whole time. So the one surface an agent reaches for to check a publish could not answer, and a publish that changed LinkedIn and then failed to finish looked identical to one that had not run yet. It now reads `published · 1 applied` / `2 applied, 1 failed`, names each failed or skipped op with its error, and says `publishing` while one is still in flight. Found because a tracking-parameter publish did exactly that: applied at LinkedIn, then left the draft stuck, with the only trace an `Error calling onComplete` in the server log.
|
|
5841
|
+
|
|
5842
|
+
- **0.247.0**: a tracking-parameter write with no `--dynamic` no longer fails at publish. LinkedIn's `/adTrackingParameters` record requires BOTH maps and rejects a body missing either — `ERROR :: /dynamicValueParameters :: field is required but not found and has no default value` — so the most ordinary write there is, a couple of fixed UTMs and no placeholder, was a hard failure, while every write that happened to carry a `--dynamic` succeeded. Both maps are now always sent, empty when unused. Found by the 0.246.0 outcome line, on the first real publish that did not pass `--dynamic`.
|
|
5843
|
+
|
|
5844
|
+
- **0.248.0**: a tracking-parameter read that fails no longer reads as "there is nothing there". The idempotency token is LinkedIn's concurrency guard AND the only look at the before-state, and `url-params` REPLACES the entity's whole set — so writing without it is not a degraded write, it is an unguarded one that overwrites live parameters having never seen them. Both readers collapsed every failure into "none set": the executor logged `not readable; writing as a fresh set` and did exactly that, and the stage-time snapshot logged `previewing as none set`, which silently removed the drop warning that exists to catch this. Only LinkedIn's NOT_FOUND now means "none yet" (a first write — and no longer logged, since it is the normal case that was filling the log). Any other failure fails the publish, and at stage time produces a warning saying the current parameters could not be read rather than none being there. It matters most on the 409 retry: a 409 means somebody changed the parameters between our read and our write, and a swallowed failure on the re-read turned that detected conflict into a blind overwrite of their change.
|
|
5845
|
+
|
|
5846
|
+
Reading it back changes three things beyond the command. **`campaigns url-params` / `account url-params` now warn by name when a write would delete a live key** — LinkedIn replaces an entity's whole parameter set rather than merging into it, so staging one pair on an ad set that already carries three published as "one pair, and the other three are gone". Pairs accumulate inside a chat, which is exactly what made a merge at publish the natural expectation; the stage warning names what would be dropped, and the guidance now says to read first and re-send everything that should survive. **`baker analytics map` can check a proposed LinkedIn mapping against the account** — LinkedIn was on the list of platforms with no configuration read, so its mappings shipped with no evidence behind them; it now reads the account level and up to ten ad sets. And **the `tracking.campaign_parameters` audit check answers before a campaign runs**: with no traffic it used to be `n_a`, honest but useless at the one moment the fix is free, and it now judges the account's own parameters — with wording that says so rather than claiming clicks arrived carrying nothing when no click arrived.
|
|
5847
|
+
|
|
5848
|
+
Three more things the 202608 pin brought with it. **The conversion-type enum went from 8 values to all 34 LinkedIn accepts** — `QUALIFIED_LEAD`, `BOOK_APPOINTMENT`, `SUBMIT_APPLICATION` and 23 others were rejected by our own schema before the request ever left, and 202608 adds `MARKETING_QUALIFIED_LEAD` / `SALES_QUALIFIED_LEAD` on top. **Attribution windows gained 365** (and view-through gained 90), with the type-conditional rule enforced on `conversions create`: 365 is only legal for `SUBMIT_APPLICATION`, `PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, `LEAD`. And **a 429 carrying `SEGMENT_LIMIT_EXCEEDED` is no longer reported as a throttle** — 202608 caps an ad account at 1,000 DMP segments and reuses the rate-limit status for it, so an agent that read it as "retry in a second" would have retried forever against a cap only a human can clear in Campaign Manager.
|
|
5849
|
+
|
|
5850
|
+
- **0.256.0**: **`assets attach` no longer offers an image link, because Google accepts none.** A production publish came back with six `AssetLinkError.FIELD_TYPE_INCOMPATIBLE_WITH_CAMPAIGN_TYPE` rows (trigger `MARKETING_IMAGE,SEARCH`) on an otherwise built and live Search campaign — staged exactly as this CLI's own advisory and the scaffold's ads-google doc instructed, at `"fieldType": "MARKETING_IMAGE"`. The obvious fix was `AD_IMAGE`, which is what Google's documentation and its own forum answers name for a Search image extension. **It does not work either.** Sent to a real account on v23, all four combinations are refused: `MARKETING_IMAGE` on a Search `CampaignAsset` → `FIELD_TYPE_INCOMPATIBLE_WITH_CAMPAIGN_TYPE`; `MARKETING_IMAGE` on a Search `AdGroupAsset` → `UNSUPPORTED_FIELD_TYPE`; `AD_IMAGE` on a `CampaignAsset` → `UNSUPPORTED_FIELD_TYPE`; `AD_IMAGE` on an `AdGroupAsset` → `UNSUPPORTED_FIELD_TYPE`. Image extensions never moved to asset-based extensions, so there is no request the API takes. Every image field type is now refused at the CLI boundary with the interface named instead of another field type to try, and the **"recommend at least 3 image assets" advisory is removed** — it had been firing on every Search campaign since 0.191.0 and could not be satisfied by any draft, because the write it named has never once succeeded. Performance Max asset-group images are a different mechanism and are untouched (`asset-groups attach`). Four force-synced scaffold surfaces were saying the same wrong thing and now agree with the API: the `ads-google.md` tool doc, the `assets` command group description, the account-audit playbook's Dynamic Images row, and `staged-writes.md`. An audit should still raise a missing-images gap — it is a real SERP disadvantage — it just has to name the Google Ads interface as the fix rather than a command.
|
|
5851
|
+
|
|
5852
|
+
- **0.256.0**: a structured snippet value over 25 characters is refused before it is staged. Google caps each VALUE at 25 and answers a longer one with a bare `Too long.` at `structured_snippet_asset.values[i]` — no header, no value, nothing to act on — and it takes the asset's campaign link down with it as `RESOURCE_NOT_FOUND`. `assets create --values` and `assets update --values` now cap each entry; the per-asset 3-10 count check was already there, and only the per-value length was missing. Found on a live publish where four of five values were fine and "Bases de datos vectoriales" (26) was not, and confirmed fixed by re-publishing the same snippet at 25 characters against a real account, where it applied and linked cleanly.
|
|
5853
|
+
|
|
5818
5854
|
### Testing a pre-release in sandboxes
|
|
5819
5855
|
|
|
5820
5856
|
```bash
|
|
@@ -80,27 +80,10 @@
|
|
|
80
80
|
const container = document.getElementById('captions');
|
|
81
81
|
const tl = gsap.timeline({ paused: true });
|
|
82
82
|
|
|
83
|
-
// A card closes where the SCRIPT closes, not on a word count. Slicing every
|
|
84
|
-
// N words gave cards reading "OTRA VEZ, EN" — half a thought ending on a
|
|
85
|
-
// preposition. The count stays as a ceiling (a long clause still has to be
|
|
86
|
-
// split), and a linking word never ends a card: it belongs to what follows.
|
|
87
|
-
const CLOSES = /[.,;:!?\u2026]$/;
|
|
88
|
-
const CARRIES_ON = new Set([
|
|
89
|
-
'de','del','al','a','en','y','e','o','u','que','la','el','los','las','un','una',
|
|
90
|
-
'con','por','para','su','tu','mi','más','mas','the','of','to','and','in','for','your',
|
|
91
|
-
]);
|
|
92
|
-
const carriesOn = (w) => CARRIES_ON.has(String(w.word || '').replace(/[.,;:!?\u2026]$/, '').toLowerCase());
|
|
93
|
-
|
|
94
83
|
const groups = [];
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
current.push(word);
|
|
98
|
-
const last = current[current.length - 1];
|
|
99
|
-
const closes = CLOSES.test(String(last.word || '')) ||
|
|
100
|
-
(current.length >= WORDS_PER_GROUP && !carriesOn(last));
|
|
101
|
-
if (closes) { groups.push(current); current = []; }
|
|
84
|
+
for (let i = 0; i < transcript.length; i += WORDS_PER_GROUP) {
|
|
85
|
+
groups.push(transcript.slice(i, i + WORDS_PER_GROUP));
|
|
102
86
|
}
|
|
103
|
-
if (current.length > 0) groups.push(current);
|
|
104
87
|
|
|
105
88
|
groups.forEach((group, gi) => {
|
|
106
89
|
const el = document.createElement('div');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
debugLogSetting
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-DZUVUGEP.js";
|
|
4
4
|
|
|
5
5
|
// src/debugLog.ts
|
|
6
6
|
import { appendFileSync, mkdirSync, renameSync, statSync } from "fs";
|
|
@@ -150,4 +150,4 @@ export {
|
|
|
150
150
|
readBodyForLog,
|
|
151
151
|
installStreamTaps
|
|
152
152
|
};
|
|
153
|
-
//# sourceMappingURL=chunk-
|
|
153
|
+
//# sourceMappingURL=chunk-2IB3IL4K.js.map
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ApiError,
|
|
3
3
|
apiGet
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-GU7IWEBC.js";
|
|
5
5
|
import {
|
|
6
6
|
getEnv
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-DZUVUGEP.js";
|
|
8
8
|
|
|
9
9
|
// src/error-handler.ts
|
|
10
10
|
var REQUEST_CONNECTION_PLATFORM = {
|
|
@@ -295,4 +295,4 @@ export {
|
|
|
295
295
|
writeAdsOutput,
|
|
296
296
|
resolveCustomerId
|
|
297
297
|
};
|
|
298
|
-
//# sourceMappingURL=chunk-
|
|
298
|
+
//# sourceMappingURL=chunk-6F52WYB7.js.map
|
|
@@ -311,9 +311,6 @@ function requireChatId() {
|
|
|
311
311
|
function resolveChatId(chat) {
|
|
312
312
|
return typeof chat === "string" && chat.length > 0 ? chat : requireChatId();
|
|
313
313
|
}
|
|
314
|
-
function hasFrameVisionKey() {
|
|
315
|
-
return Boolean(process.env.GEMINI_API_KEY);
|
|
316
|
-
}
|
|
317
314
|
function captureBudgetMs() {
|
|
318
315
|
const raw = Number(process.env.BAKER_CAPTURE_BUDGET_MS);
|
|
319
316
|
return Number.isFinite(raw) && raw > 0 ? raw : null;
|
|
@@ -343,10 +340,9 @@ export {
|
|
|
343
340
|
debugLogSetting,
|
|
344
341
|
requireChatId,
|
|
345
342
|
resolveChatId,
|
|
346
|
-
hasFrameVisionKey,
|
|
347
343
|
captureBudgetMs,
|
|
348
344
|
captureProxyCredentials,
|
|
349
345
|
captureVimeoCookie,
|
|
350
346
|
childEnvWith
|
|
351
347
|
};
|
|
352
|
-
//# sourceMappingURL=chunk-
|
|
348
|
+
//# sourceMappingURL=chunk-DZUVUGEP.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../proxy/src/challenge.ts","../../proxy/src/tiers.ts","../../proxy/src/credentials.ts","../../proxy/src/escalate.ts","../../proxy/src/privateAddress.ts","../../proxy/src/publicAddress.ts","../../proxy/src/route.ts","../../proxy/src/ladder.ts","../src/env.ts"],"sourcesContent":["/**\n * Was that a page, or the wall in front of it?\n *\n * A bot filter answers 200. The body parses, it has a title, and every check\n * downstream — `response.ok`, the status, the content type — says the read\n * succeeded. So a challenge is the one block that cannot be seen from the\n * status line, and the only place it is visible is the bytes.\n *\n * This lives in `@baker/proxy` rather than beside any one caller because the\n * escalation ladder is only coherent if every surface agrees on what \"blocked\"\n * means: the rung that gets climbed is chosen from this answer, and a detector\n * that differs per caller would mean the same wall costs money on one surface\n * and is filed as content on another.\n *\n * Pure and dependency-free, so it stays reachable from Convex's V8 runtime\n * through the package barrel.\n */\n\n/**\n * Phrases that only appear on a block or challenge page.\n *\n * Deliberately specific — \"access denied\" alone would match a page *about*\n * access control, so each phrase is one a real marketing page has no reason to\n * use as its title.\n */\nconst CHALLENGE_PHRASES = [\n \"just a moment\",\n \"attention required\",\n \"verify you are human\",\n \"checking your browser\",\n \"enable javascript and cookies to continue\",\n \"unusual traffic\",\n \"access denied\",\n \"you have been blocked\",\n \"request unsuccessful\",\n \"are you a robot\",\n \"security check\",\n \"ddos protection\",\n \"captcha\",\n];\n\n/** Vendors whose block pages carry a fingerprint even when the title does not. */\nconst CHALLENGE_MARKERS = [\"cf-browser-verification\", \"cf_chl_\", \"px-captcha\", \"_incapsula_\", \"distil_r_captcha\"];\n\n/**\n * How long a response may be and still be judged by its *wording*.\n *\n * Phrases like \"captcha\" or \"access denied\" are ordinary English that a real\n * article can legitimately contain, so matching them anywhere would refuse\n * pages we read perfectly well. Length is the precision guard: a page that is\n * mostly content is content.\n */\nexport const CHALLENGE_LENGTH_CEILING = 2_000;\n\n/**\n * Whether these bytes are a bot wall rather than the thing that was asked for.\n *\n * Reads only what the caller already paid for. Nothing here issues a request —\n * asking the host again would deepen the very block this is detecting.\n *\n * The two lists are searched differently, and that asymmetry is the point.\n * Phrases are judged only on a short response, for the precision reason above.\n * Vendor markers are fingerprints nothing but the vendor emits, so they are\n * searched at any length — which is what makes a real interstitial detectable\n * at all. Cloudflare's is 5–15 KB of inlined script wrapped around one visible\n * sentence, so gating markers behind the same length ceiling as the phrases put\n * every genuine large wall in a dead band where neither list could reach it.\n */\nexport function isChallengeBody(content: string, title?: string | null): boolean {\n if (content.length <= CHALLENGE_LENGTH_CEILING) {\n const haystack = `${title ?? \"\"}\\n${content}`.toLowerCase();\n if (CHALLENGE_PHRASES.some((phrase) => haystack.includes(phrase))) return true;\n }\n\n const whole = content.toLowerCase();\n return CHALLENGE_MARKERS.some((marker) => whole.includes(marker));\n}\n","/**\n * The two Oxylabs products, and everything that differs between them.\n *\n * They are NOT credential-swappable. Endpoint, username prefix, geo token and\n * sticky-session mechanism all differ, so \"same URL, other username/password\"\n * fails — and it fails as a 407, which Chromium reports as a plain network\n * error. Before this module existed there was no code path that could tell that\n * apart from the site refusing us, so the failure would have been recorded\n * against the page rather than against our own configuration.\n *\n * That asymmetry is the reason this table exists in exactly one place. Every\n * caller builds its route through `buildProxyRoute`; nobody concatenates an\n * Oxylabs URL by hand.\n */\n\nexport type ProxyTier = \"datacenter\" | \"residential\";\n\nexport interface ProxyTierSpec {\n readonly tier: ProxyTier;\n readonly host: string;\n readonly port: number;\n /** Oxylabs requires this in front of the account name. The two differ. */\n readonly usernamePrefix: \"user-\" | \"customer-\";\n /** Country targeting token. `-country-` on datacenter, `-cc-` on residential. */\n readonly countryKey: \"-country-\" | \"-cc-\";\n /**\n * Sticky-session token, or null when the product has none.\n *\n * Datacenter does stickiness by *port* (8001 for IP #1, 8002 for #2), not by\n * username. We do not implement that: a capture is a single page load, so a\n * reusable IP buys nothing and the knob would only add a way to get it wrong.\n */\n readonly sessionKey: \"-sessid-\" | null;\n}\n\nexport const PROXY_TIERS: Readonly<Record<ProxyTier, ProxyTierSpec>> = {\n datacenter: {\n tier: \"datacenter\",\n host: \"dc.oxylabs.io\",\n port: 8000,\n usernamePrefix: \"user-\",\n countryKey: \"-country-\",\n sessionKey: null,\n },\n residential: {\n tier: \"residential\",\n host: \"pr.oxylabs.io\",\n port: 7777,\n usernamePrefix: \"customer-\",\n countryKey: \"-cc-\",\n sessionKey: \"-sessid-\",\n },\n};\n\n/**\n * Cheapest useful route first, and the ladder never skips a rung.\n *\n * Residential costs materially more than datacenter, so it is only ever reached\n * because datacenter was tried and refused — never as a first guess and never\n * speculatively.\n */\nexport const ESCALATION_ORDER = [\"datacenter\", \"residential\"] as const satisfies readonly ProxyTier[];\n\n/** Every prefix this module knows, used to spot a credential in the wrong slot. */\nexport const KNOWN_USERNAME_PREFIXES = [\"user-\", \"customer-\"] as const;\n","import type { ProxyTier } from \"./tiers.ts\";\nimport { ESCALATION_ORDER } from \"./tiers.ts\";\n\n/**\n * The four environment variable names, in one place.\n *\n * Convex holds these and hands them to the sandbox; the CLI reads them back\n * out. A typo on either side is a *silent* no-op — the capability appears to\n * ship, every fetch quietly takes the direct route, and nothing fails. That is\n * the worst failure mode available to this feature, so the names are a shared\n * constant with a contract test rather than eight string literals spread across\n * two packages.\n */\nexport const PROXY_ENV_VARS = {\n datacenter: { username: \"OXYLABS_DATACENTER_USERNAME\", password: \"OXYLABS_DATACENTER_PASSWORD\" },\n residential: { username: \"OXYLABS_RESIDENTIAL_USERNAME\", password: \"OXYLABS_RESIDENTIAL_PASSWORD\" },\n} as const satisfies Record<ProxyTier, { username: string; password: string }>;\n\nexport interface TierCredentials {\n readonly username: string;\n readonly password: string;\n}\n\nexport type ProxyCredentials = Partial<Readonly<Record<ProxyTier, TierCredentials>>>;\n\n/**\n * Read whichever tiers are fully configured.\n *\n * Takes the env record rather than reading `process.env` itself, so the whole\n * package stays pure and the Convex and CLI sides can be tested identically.\n *\n * Half a pair is not a credential. A username with no password would build a\n * route that authenticates as nobody, and Oxylabs answers that with the same\n * 407 as a wrong password — so it would look like a broken proxy rather than an\n * unconfigured one. Dropping it here means `configuredTiers` tells the truth.\n */\nexport function readProxyCredentials(env: Record<string, string | undefined>): ProxyCredentials {\n const credentials: { -readonly [K in ProxyTier]?: TierCredentials } = {};\n for (const tier of ESCALATION_ORDER) {\n const username = env[PROXY_ENV_VARS[tier].username]?.trim();\n const password = env[PROXY_ENV_VARS[tier].password]?.trim();\n if (username && password) credentials[tier] = { username, password };\n }\n return credentials;\n}\n\n/**\n * The inverse of `readProxyCredentials`: credentials back out as env vars.\n *\n * The writing side used to be a loop inside one Convex function, which is how\n * the ladder came to exist everywhere and work in one place. That loop ran only\n * for `baker landing inspiration scrape`, so every other CLI fetch surface in a\n * Session — `images download`, canvas `ingest`, the two scaffolders, yt-dlp —\n * read four variables nobody had set and went direct, silently, exactly as\n * `PROXY_ENV_VARS` warns.\n *\n * Pairing it with the reader here is the point: the round trip is a test, so the\n * two sides cannot drift into agreeing about three variables out of four.\n */\nexport function proxyCredentialEnvs(credentials: ProxyCredentials): Record<string, string> {\n const envs: Record<string, string> = {};\n for (const tier of ESCALATION_ORDER) {\n const held = credentials[tier];\n if (!held) continue;\n envs[PROXY_ENV_VARS[tier].username] = held.username;\n envs[PROXY_ENV_VARS[tier].password] = held.password;\n }\n return envs;\n}\n\n/**\n * Which tiers this process could actually reach, cheapest first.\n *\n * There is no ceiling to apply. A deployment holding both pairs may climb to\n * residential, full stop — the brake is the credential itself. Removing\n * `OXYLABS_RESIDENTIAL_PASSWORD` takes effect on the next restart, needs no\n * deploy, and withholds the tier by the same mechanism a ceiling would have,\n * so the valve that used to sit here only added a second way to say it. It was\n * also never wired into ad-dna's task definition, which is the highest-volume\n * egress in the repo — a control that could not be turned where it mattered\n * most.\n */\nexport function configuredTiers(credentials: ProxyCredentials): ProxyTier[] {\n return ESCALATION_ORDER.filter((tier) => credentials[tier] !== undefined);\n}\n\n/** Whether there is any proxy to escalate to at all. */\nexport function proxyEscalationConfigured(credentials: ProxyCredentials): boolean {\n return configuredTiers(credentials).length > 0;\n}\n","/**\n * When is a failure worth spending a more expensive route on?\n *\n * The ladder only ever climbs on evidence that *who we are* was the problem. A\n * page that 404s, a domain that does not resolve, a certificate that will not\n * negotiate and a site that timed out all fail identically from every IP on\n * earth, so retrying them through a metered exit buys a second identical\n * failure and a bill. That asymmetry — cheap to be wrong in one direction,\n * expensive in the other — is why this is a closed allow-list rather than\n * \"escalate unless we recognise the error\".\n *\n * Shared deliberately: the capture engine, the image fetchers and the ad-media\n * downloaders all have to agree on what \"blocked\" means, or the corpus and the\n * bill disagree about the same event.\n */\n\n/** What one attempt observed, normalised across Playwright and `fetch`. */\nexport interface BlockSignal {\n /** Main-document / response status, when there was one. */\n readonly status?: number | null;\n /** Chromium `net::` name or a Node error code, when the attempt threw one. */\n readonly netError?: string | null;\n /** A challenge body was detected behind an otherwise successful response. */\n readonly challenge?: boolean;\n /** The attempt ran out of time rather than being refused. */\n readonly timedOut?: boolean;\n}\n\n/** \"We don't like *you*\" — a different exit IP is a different you. */\nconst ESCALATABLE_STATUSES = new Set([403, 429, 451]);\n\n/**\n * A TCP reset on ClientHello is how a WAF null-routes a datacenter range.\n *\n * Today all three of these collapse into one \"site didn't respond\" message and\n * are filed non-retryable, which is precisely why this class of block has been\n * invisible: it looks identical to a site that is genuinely down.\n */\nconst ESCALATABLE_NET_ERRORS = new Set([\"ERR_CONNECTION_RESET\", \"ERR_CONNECTION_CLOSED\", \"ERR_EMPTY_RESPONSE\"]);\n\n/**\n * Failures that belong to our route, not to the site.\n *\n * A 407 cannot come from an origin server — only something speaking proxy\n * produces one — so this needs no \"was this attempt proxied\" flag to be safe.\n */\nconst PROXY_NET_ERRORS = new Set([\n \"ERR_TUNNEL_CONNECTION_FAILED\",\n \"ERR_PROXY_CONNECTION_FAILED\",\n \"ERR_PROXY_AUTH_REQUESTED\",\n \"ERR_PROXY_CERTIFICATE_INVALID\",\n \"ERR_UNEXPECTED_PROXY_AUTH\",\n \"ERR_MANDATORY_PROXY_CONFIGURATION_FAILED\",\n \"ERR_HTTPS_PROXY_TUNNEL_RESPONSE_REDIRECT\",\n]);\n\nexport function isProxyFailure(signal: BlockSignal): boolean {\n if (signal.status === 407) return true;\n return signal.netError ? PROXY_NET_ERRORS.has(signal.netError) : false;\n}\n\nexport function shouldEscalate(signal: BlockSignal): boolean {\n // Our own broken route never advances the ladder — it degrades it. Climbing\n // here would spend a more expensive rung reproducing our misconfiguration.\n if (isProxyFailure(signal)) return false;\n if (signal.timedOut) return false;\n if (signal.challenge) return true;\n if (signal.status != null && ESCALATABLE_STATUSES.has(signal.status)) return true;\n return signal.netError ? ESCALATABLE_NET_ERRORS.has(signal.netError) : false;\n}\n","/**\n * The same question as `refuseNonPublicUrl`, asked of an address instead of a name.\n *\n * A URL carries a hostname, and a hostname is not an address. `localtest.me`\n * and `127.0.0.1.nip.io` are public names, with public DNS records, that\n * resolve to `127.0.0.1` — and nothing stops an attacker pointing a domain they\n * own at `169.254.169.254`. Proven against a real loopback server: the hostname\n * guard passed `http://localtest.me:4321/secret.txt` straight through, the bytes\n * were downloaded, and only the image parser complained afterwards.\n *\n * IPv6 is parsed rather than pattern-matched, and that is the second lesson\n * here. The first version tested prefixes with regexes and unwrapped an\n * IPv4-mapped address only when it still had a dot in it — but a URL parser\n * normalises `::ffff:127.0.0.1` to `::ffff:7f00:1`, so the dot is gone by the\n * time anyone looks. `[::ffff:169.254.169.254]` therefore read as public and the\n * ladder returned the metadata body with `outcome: \"ok\"`. One address has many\n * spellings; comparing spellings is the wrong operation.\n *\n * Anything unparseable is treated as private. This is a safety gate, and the\n * only safe reading of \"I do not know what this is\" is \"do not fetch it\".\n */\n\n/** Every IPv4 range that is not routable on the public internet. */\nfunction isPrivateIpv4Octets(octets: readonly number[]): boolean {\n const [a, b] = octets as [number, number, number, number];\n if (a === 0) return true; // \"this network\" — 0.0.0.0/8\n if (a === 10) return true; // private\n if (a === 127) return true; // loopback\n if (a === 169 && b === 254) return true; // link-local, and the cloud metadata address\n if (a === 172 && b >= 16 && b <= 31) return true; // private /12 — NOT all of 172.*\n if (a === 192 && b === 168) return true; // private\n if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT\n if (a >= 224) return true; // multicast and reserved\n return false;\n}\n\n/** Dotted-quad to octets, or `null` when it is not one. */\nfunction parseIpv4(value: string): number[] | null {\n const parts = value.split(\".\");\n if (parts.length !== 4) return null;\n const octets = parts.map((part) => (/^\\d{1,3}$/.test(part) ? Number(part) : Number.NaN));\n if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;\n return octets;\n}\n\n/**\n * One side of a `::` to its 16-bit groups, or `null` if any part is not one.\n *\n * A trailing dotted-quad stands for the final two groups and is only legal in\n * last position — that is the spelling `::ffff:127.0.0.1` uses before a URL\n * parser rewrites it to `::ffff:7f00:1`.\n */\nfunction expandGroups(part: string): number[] | null {\n if (part === \"\") return [];\n const chunks = part.split(\":\");\n const groups: number[] = [];\n for (let index = 0; index < chunks.length; index++) {\n const chunk = chunks[index] as string;\n if (chunk.includes(\".\")) {\n if (index !== chunks.length - 1) return null;\n const octets = parseIpv4(chunk);\n if (!octets) return null;\n groups.push(((octets[0] as number) << 8) | (octets[1] as number));\n groups.push(((octets[2] as number) << 8) | (octets[3] as number));\n continue;\n }\n if (!/^[0-9a-f]{1,4}$/.test(chunk)) return null;\n groups.push(Number.parseInt(chunk, 16));\n }\n return groups;\n}\n\n/**\n * IPv6 to its eight 16-bit groups, or `null` when it is not one.\n *\n * Handles the `::` elision and a trailing dotted-quad, which together are why\n * one address has so many spellings.\n */\nfunction parseIpv6(value: string): number[] | null {\n const halves = value.split(\"::\");\n if (halves.length > 2) return null;\n\n const head = expandGroups(halves[0] as string);\n const tail = halves.length === 2 ? expandGroups(halves[1] as string) : [];\n if (head === null || tail === null) return null;\n\n if (halves.length === 1) return head.length === 8 ? head : null;\n const missing = 8 - head.length - tail.length;\n if (missing < 1) return null;\n return [...head, ...new Array<number>(missing).fill(0), ...tail];\n}\n\nexport function isPrivateAddress(ip: string): boolean {\n const address = ip.trim().toLowerCase();\n if (!address) return true;\n\n const v4 = parseIpv4(address);\n if (v4) return isPrivateIpv4Octets(v4);\n\n const v6 = parseIpv6(address);\n // Not an address we can read. A safety gate may not guess.\n if (!v6) return true;\n\n const [g0, g1, g2, g3, g4, g5, g6, g7] = v6 as [number, number, number, number, number, number, number, number];\n\n // `::` (unspecified) and `::1` (loopback).\n if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0) return true;\n\n // IPv4-mapped (`::ffff:a.b.c.d`) and IPv4-translated (`::ffff:0:a.b.c.d`).\n // The whole point: by the time this is read the tail is hex, not dotted.\n const mappedV4 = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0;\n if (mappedV4 && g4 === 0 && g5 === 0xffff) {\n return isPrivateIpv4Octets([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]);\n }\n if (mappedV4 && g4 === 0xffff && g5 === 0) {\n return isPrivateIpv4Octets([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]);\n }\n\n if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local\n if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local\n return false;\n}\n","/**\n * Addresses that must never be fetched, and must never be proxied.\n *\n * Lifted from `convex/landingLibrary/utils.ts`, where these three regexes were\n * the only copy in the repo — reachable from Convex and from nowhere else. That\n * was survivable while the capture ran one hop after the check. It is not\n * survivable now: the CLI can be invoked directly, two Convex paths enqueue\n * without re-validating, and a proxy adds a rung where \"which network am I on\"\n * stops being rhetorical.\n *\n * Two distinct reasons live here, and both point the same way:\n *\n * - **There is nothing to read.** A private address only resolves from inside\n * the network asking, so a capture of one produces a screenshot of an error.\n * - **There is something to read, and we must not.** `169.254.169.254` is the\n * cloud metadata endpoint that every capture runner has a route to.\n *\n * The rule for the proxy is *refuse*, not *bypass*. Bypassing would leave a\n * request that still happens, just unproxied; refusing means there is no\n * request to route. That is a stronger guarantee and a simpler one to check.\n */\n\nimport { isPrivateAddress } from \"./privateAddress.ts\";\n\n/**\n * Names — not addresses. Anything that looks like an address goes to\n * `isPrivateAddress`, which parses it instead of matching its spelling.\n */\nconst PRIVATE_HOST = /^(localhost|.*\\.localhost|.*\\.local)$/;\n/** Where a Session's own preview is served while the page is being built. */\nconst SANDBOX_HOST = /\\.e2b\\.(app|dev)$/;\n\n/**\n * A bracketed IPv6 literal, unwrapped.\n *\n * `new URL(\"http://[::1]/\").hostname` keeps the brackets, so a hostname check\n * that does not strip them is comparing against a string no address will ever\n * equal. The previous version tried to absorb that with `\\[?::1\\]?` and caught\n * exactly one address out of the whole of IPv6 — `[::ffff:169.254.169.254]`\n * sailed through and the ladder returned the metadata body with `outcome: \"ok\"`.\n */\nfunction unwrapIpLiteral(host: string): string | null {\n if (host.startsWith(\"[\") && host.endsWith(\"]\")) return host.slice(1, -1);\n // A bare v4 literal, or a v6 one that arrived without brackets.\n if (/^[0-9.]+$/.test(host) || host.includes(\":\")) return host;\n return null;\n}\n\nexport function isPrivateHostname(hostname: string): boolean {\n const host = hostname.trim().toLowerCase();\n if (!host) return true;\n if (PRIVATE_HOST.test(host) || SANDBOX_HOST.test(host)) return true;\n\n // Every address form — v4, v6, bracketed, mapped, elided — is decided by one\n // parser. Spelling an address in a regex is what let four of them through.\n const literal = unwrapIpLiteral(host);\n return literal === null ? false : isPrivateAddress(literal);\n}\n\n/**\n * The same rule again, in the only notation a browser's proxy settings speak.\n *\n * Chromium — and so Playwright and `agent-browser` — matches a bypass entry\n * against the hostname and understands one wildcard and no CIDR at all. The\n * ranges above therefore cannot be handed over as regexes; `172.16/12` has to\n * become sixteen entries, and there is no way to express \"any private address\".\n *\n * That makes this a second copy of one fact, which is worth stating plainly:\n * the day it disagrees with `isPrivateHostname` is the day a Session's own\n * preview at `localhost:4321` goes out through a metered exit and comes back\n * refused. Its test pins the correspondence.\n *\n * Note the direction reverses here, and deliberately. Everywhere else the rule\n * for a private address is *refuse*, because a request that still happens is\n * not a guarantee. A browser the agent drives has to reach `localhost:4321` —\n * that is its main job — so here the private address is the legitimate traffic\n * and the list says \"go direct\", not \"do not go\".\n */\nexport const BROWSER_PROXY_BYPASS = [\n \"localhost\",\n \"*.localhost\",\n \"127.0.0.1\",\n \"0.0.0.0\",\n \"::1\",\n \"*.local\",\n // A Session's own preview, which is served from the sandbox's public host —\n // public in DNS, ours in every sense that matters here.\n \"*.e2b.app\",\n \"*.e2b.dev\",\n \"10.*\",\n \"192.168.*\",\n \"169.254.*\",\n ...Array.from({ length: 16 }, (_, i) => `172.${16 + i}.*`),\n].join(\",\");\n\nexport type NonPublicReason = \"not_public\" | \"unparseable\";\n\n/**\n * `null` when the URL is safe to fetch; a reason when it must be refused.\n *\n * Anything that is not plain http(s) is refused too. A capture is handed URLs\n * from agents and from stored rows, and `file:` reaching a browser we launched\n * is a local file read wearing a URL.\n */\nexport function refuseNonPublicUrl(url: string): NonPublicReason | null {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n return \"unparseable\";\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") return \"not_public\";\n return isPrivateHostname(parsed.hostname) ? \"not_public\" : null;\n}\n","import type { ProxyCredentials } from \"./credentials.ts\";\nimport type { ProxyTier, ProxyTierSpec } from \"./tiers.ts\";\nimport { KNOWN_USERNAME_PREFIXES, PROXY_TIERS } from \"./tiers.ts\";\n\n/**\n * How one attempt reaches the internet.\n *\n * Credentials stay in separate fields instead of being embedded in the URL:\n * Playwright's `proxy` option wants them that way, undici's `ProxyAgent` takes\n * them as a header we build once, and — the real reason — a password that never\n * exists as a substring of a URL cannot be leaked by anything that logs a URL.\n */\nexport type ProxyRoute =\n | { readonly kind: \"direct\" }\n | {\n readonly kind: \"proxy\";\n readonly tier: ProxyTier;\n /** e.g. `http://dc.oxylabs.io:8000` */\n readonly server: string;\n readonly username: string;\n readonly password: string;\n };\n\nexport const DIRECT_ROUTE: ProxyRoute = { kind: \"direct\" };\n\nexport interface RouteOptions {\n /** ISO-3166 alpha-2, upper-cased here. Omit for \"wherever\" — the default. */\n readonly country?: string;\n /** Sticky-session id. Ignored by a tier whose product has no session syntax. */\n readonly session?: string;\n}\n\nexport type RouteProblem = \"missing\" | \"wrong_prefix\";\n\nexport type RouteResult = { ok: true; route: ProxyRoute } | { ok: false; problem: RouteProblem };\n\n/**\n * Put the account name into the shape this specific product expects.\n *\n * Three cases, and the third is the one worth being strict about:\n *\n * 1. Bare (`acme`) — someone pasted the account name. Prepend the prefix.\n * 2. Already correct (`user-acme` in the datacenter slot) — someone pasted the\n * full Oxylabs username. Leave it alone.\n * 3. Carrying the OTHER product's prefix (`customer-acme` in the datacenter\n * slot) — refuse.\n *\n * Case 3 must not be \"helpfully\" rewritten. A `customer-` value in the\n * datacenter slot means the residential credentials were pasted into the wrong\n * variable, and rewriting the prefix would authenticate a residential account\n * against the datacenter endpoint. That either 407s — wasting the cheap rung\n * for no reason — or it works, and bills residential rates from the tier whose\n * entire purpose is to be the cheap one. Refusing is the only answer that\n * cannot silently cost money.\n */\nexport function proxyUsername(\n spec: ProxyTierSpec,\n rawUsername: string,\n options?: RouteOptions,\n): { ok: true; username: string } | { ok: false; problem: \"wrong_prefix\" } {\n const raw = rawUsername.trim();\n const foreignPrefix = KNOWN_USERNAME_PREFIXES.find(\n (prefix) => prefix !== spec.usernamePrefix && raw.startsWith(prefix),\n );\n if (foreignPrefix) return { ok: false, problem: \"wrong_prefix\" };\n\n let username = raw.startsWith(spec.usernamePrefix) ? raw : `${spec.usernamePrefix}${raw}`;\n // Geo before session — Oxylabs reads the username left to right and rejects\n // the pair in the other order.\n if (options?.country) username += `${spec.countryKey}${options.country.toUpperCase()}`;\n if (options?.session && spec.sessionKey) username += `${spec.sessionKey}${options.session}`;\n return { ok: true, username };\n}\n\n/** Build the route for one tier, or say why there isn't one. */\nexport function buildProxyRoute(tier: ProxyTier, credentials: ProxyCredentials, options?: RouteOptions): RouteResult {\n const held = credentials[tier];\n if (!held) return { ok: false, problem: \"missing\" };\n\n const spec = PROXY_TIERS[tier];\n const username = proxyUsername(spec, held.username, options);\n if (!username.ok) return username;\n\n return {\n ok: true,\n route: {\n kind: \"proxy\",\n tier,\n server: `http://${spec.host}:${spec.port}`,\n username: username.username,\n password: held.password,\n },\n };\n}\n","import type { ProxyCredentials } from \"./credentials.ts\";\nimport { configuredTiers } from \"./credentials.ts\";\nimport { refuseNonPublicUrl } from \"./publicAddress.ts\";\nimport type { ProxyRoute, RouteOptions } from \"./route.ts\";\nimport { buildProxyRoute, DIRECT_ROUTE } from \"./route.ts\";\n\n/**\n * Every route this URL is allowed to be attempted on, in the order to try them.\n *\n * Direct is always first and always present: an unblocked page must cost\n * nothing, and a deployment with no credentials has to behave exactly as it did\n * before any of this existed.\n *\n * A tier whose credentials are malformed is *skipped*, not fatal. One\n * mis-pasted variable should cost the use of that rung, not the whole ladder —\n * the alternative is that a typo in the cheap tier silently disables the\n * expensive one that would have worked.\n */\nexport function plannedRoutes(url: string, credentials: ProxyCredentials, options?: RouteOptions): ProxyRoute[] {\n // A private address is refused upstream; if one reaches here anyway it must\n // not become a billed request, and there is no exit node on earth from which\n // `localhost` means us.\n if (refuseNonPublicUrl(url) !== null) return [DIRECT_ROUTE];\n\n const routes: ProxyRoute[] = [DIRECT_ROUTE];\n for (const tier of configuredTiers(credentials)) {\n const built = buildProxyRoute(tier, credentials, options);\n if (built.ok) routes.push(built.route);\n }\n return routes;\n}\n","import type { ProxyCredentials } from \"@baker/proxy\";\nimport { readProxyCredentials } from \"@baker/proxy\";\nimport { createEnv } from \"@t3-oss/env-core\";\nimport { z } from \"zod\";\n\ntype Env = {\n BAKER_API_KEY: string;\n BAKER_API_URL: string;\n BAKER_CHAT_ID?: string;\n BAKER_ACTING_USER_ID?: string;\n BAKER_GOOGLE_ADS_CUSTOMER_ID?: string;\n BAKER_GA4_PROPERTY_ID?: string;\n BAKER_GSC_SITE_URL?: string;\n BAKER_X_ADS_ACCOUNT_ID?: string;\n BAKER_META_AD_ACCOUNT_ID?: string;\n BAKER_LINKEDIN_AD_ACCOUNT_ID?: string;\n};\n\nlet cached: Env | undefined;\n\nexport function getEnv(): Env {\n if (!cached) {\n cached = createEnv({\n server: {\n BAKER_API_KEY: z.string().startsWith(\"bk_\", \"API key must start with 'bk_'\"),\n BAKER_API_URL: z.url(\"BAKER_API_URL must be a valid URL\"),\n BAKER_CHAT_ID: z.string().optional(),\n BAKER_ACTING_USER_ID: z.string().optional(),\n BAKER_GOOGLE_ADS_CUSTOMER_ID: z\n .string()\n .regex(/^\\d{10}$/)\n .optional(),\n BAKER_GA4_PROPERTY_ID: z.string().optional(),\n BAKER_GSC_SITE_URL: z.string().optional(),\n BAKER_X_ADS_ACCOUNT_ID: z\n .string()\n .regex(/^[a-z0-9]+$/, \"X Ads account ID must be a base36 string\")\n .optional(),\n BAKER_META_AD_ACCOUNT_ID: z.string().optional(),\n BAKER_LINKEDIN_AD_ACCOUNT_ID: z\n .string()\n .regex(/^\\d+$/, \"LinkedIn ad account ID must be the numeric portion of urn:li:sponsoredAccount:N\")\n .optional(),\n },\n runtimeEnv: process.env,\n });\n }\n return cached;\n}\n\n// Read directly (not via the validated schema): debug logging must work even when\n// the required BAKER_API_KEY/BAKER_API_URL are missing or malformed.\nexport function debugLogSetting(): string | undefined {\n const raw = process.env.BAKER_DEBUG_LOG?.trim();\n return raw ? raw : undefined;\n}\n\nexport function requireChatId(): string {\n const env = getEnv();\n if (!env.BAKER_CHAT_ID) {\n throw new Error(\n \"BAKER_CHAT_ID is not set. This command stages changes against a chat — run it from a chat-attached environment.\",\n );\n }\n return env.BAKER_CHAT_ID;\n}\n\n/**\n * Which chat a *read* addresses. A `--chat` value names another chat in the same company — how an\n * earlier chat's staged changes are recovered verbatim instead of rebuilt from a summary — and with\n * no flag it is this session's own chat.\n *\n * Read paths only. Staging, amending and discarding keep calling `requireChatId`, so one session can\n * never edit another's draft; the backend enforces the same thing twice over, since every mutating\n * internal requires the draft be `active` and every route checks the chat belongs to the caller's\n * company.\n */\nexport function resolveChatId(chat?: unknown): string {\n return typeof chat === \"string\" && chat.length > 0 ? chat : requireChatId();\n}\n\n/**\n * Whether the frame-vision pass can run at all.\n *\n * `hyperframes snapshot --describe` reads `GEMINI_API_KEY`, and it is the only\n * check that can tell a rendered frame apart from an empty one. The backend\n * ships the credential Studio already uses for Gemini, by omission — a\n * deployment without one simply gets the plain smoke test.\n *\n * Kept out of `getEnv()` for the same reason as the capture budget: the render\n * engine also runs where none of the chat-attached variables exist.\n */\nexport function hasFrameVisionKey(): boolean {\n return Boolean(process.env.GEMINI_API_KEY);\n}\n\n/**\n * Wall clock the whole capture may spend, when the caller bounded it.\n *\n * Read from the environment rather than taken as a flag, deliberately. A\n * `--budget-ms` argument would land in the command schema, and the schema is\n * what the agent reads — one question away from \"what is the budget for?\", and\n * from there the escalation ladder stops being invisible to it.\n *\n * Kept out of `getEnv()` because that validator is for a chat-attached run; the\n * capture engine also runs where none of those variables exist.\n */\nexport function captureBudgetMs(): number | null {\n const raw = Number(process.env.BAKER_CAPTURE_BUDGET_MS);\n return Number.isFinite(raw) && raw > 0 ? raw : null;\n}\n\n/**\n * Egress-proxy credentials for a capture, or none at all.\n *\n * These arrive as sandbox *command* env, never as arguments — a credential in\n * argv is readable by any `ps` sharing the sandbox. With none set,\n * `plannedRoutes` yields the direct route alone and a capture behaves exactly\n * as it did before the ladder existed.\n *\n * Whichever pairs arrive are the rungs this process may climb; there is nothing\n * to filter. A tier the deployment withholds is simply absent from this env, so\n * a ceiling read on this side could only ever disagree with the one place that\n * decides.\n */\nexport function captureProxyCredentials(): ProxyCredentials {\n return readProxyCredentials(process.env);\n}\n\n/**\n * The Vimeo session cookie the deployment supplied, or undefined.\n *\n * Absent is the normal, supported state: a deployment that has not configured\n * one gets the same \"download it and upload the file\" refusal Vimeo gave before\n * this existed. Read here with the other env, so nothing else reaches into\n * `process.env` for it.\n */\nexport function captureVimeoCookie(): string | undefined {\n const raw = process.env.VIMEO_COOKIE?.trim();\n return raw ? raw : undefined;\n}\n\n/**\n * This process's environment plus `extra`, for handing to a child.\n *\n * Lives here because `process.env` reads belong in this file — but it earns its\n * place beyond the lint rule: `spawn`'s `env` *replaces* the environment rather\n * than extending it, so a caller that passes only its additions silently strips\n * `PATH` and the child fails to start for a reason that looks nothing like the\n * cause.\n */\nexport function childEnvWith(extra: Record<string, string>): NodeJS.ProcessEnv {\n return { ...process.env, ...extra };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../proxy/src/challenge.ts","../../proxy/src/tiers.ts","../../proxy/src/credentials.ts","../../proxy/src/escalate.ts","../../proxy/src/privateAddress.ts","../../proxy/src/publicAddress.ts","../../proxy/src/route.ts","../../proxy/src/ladder.ts","../src/env.ts"],"sourcesContent":["/**\n * Was that a page, or the wall in front of it?\n *\n * A bot filter answers 200. The body parses, it has a title, and every check\n * downstream — `response.ok`, the status, the content type — says the read\n * succeeded. So a challenge is the one block that cannot be seen from the\n * status line, and the only place it is visible is the bytes.\n *\n * This lives in `@baker/proxy` rather than beside any one caller because the\n * escalation ladder is only coherent if every surface agrees on what \"blocked\"\n * means: the rung that gets climbed is chosen from this answer, and a detector\n * that differs per caller would mean the same wall costs money on one surface\n * and is filed as content on another.\n *\n * Pure and dependency-free, so it stays reachable from Convex's V8 runtime\n * through the package barrel.\n */\n\n/**\n * Phrases that only appear on a block or challenge page.\n *\n * Deliberately specific — \"access denied\" alone would match a page *about*\n * access control, so each phrase is one a real marketing page has no reason to\n * use as its title.\n */\nconst CHALLENGE_PHRASES = [\n \"just a moment\",\n \"attention required\",\n \"verify you are human\",\n \"checking your browser\",\n \"enable javascript and cookies to continue\",\n \"unusual traffic\",\n \"access denied\",\n \"you have been blocked\",\n \"request unsuccessful\",\n \"are you a robot\",\n \"security check\",\n \"ddos protection\",\n \"captcha\",\n];\n\n/** Vendors whose block pages carry a fingerprint even when the title does not. */\nconst CHALLENGE_MARKERS = [\"cf-browser-verification\", \"cf_chl_\", \"px-captcha\", \"_incapsula_\", \"distil_r_captcha\"];\n\n/**\n * How long a response may be and still be judged by its *wording*.\n *\n * Phrases like \"captcha\" or \"access denied\" are ordinary English that a real\n * article can legitimately contain, so matching them anywhere would refuse\n * pages we read perfectly well. Length is the precision guard: a page that is\n * mostly content is content.\n */\nexport const CHALLENGE_LENGTH_CEILING = 2_000;\n\n/**\n * Whether these bytes are a bot wall rather than the thing that was asked for.\n *\n * Reads only what the caller already paid for. Nothing here issues a request —\n * asking the host again would deepen the very block this is detecting.\n *\n * The two lists are searched differently, and that asymmetry is the point.\n * Phrases are judged only on a short response, for the precision reason above.\n * Vendor markers are fingerprints nothing but the vendor emits, so they are\n * searched at any length — which is what makes a real interstitial detectable\n * at all. Cloudflare's is 5–15 KB of inlined script wrapped around one visible\n * sentence, so gating markers behind the same length ceiling as the phrases put\n * every genuine large wall in a dead band where neither list could reach it.\n */\nexport function isChallengeBody(content: string, title?: string | null): boolean {\n if (content.length <= CHALLENGE_LENGTH_CEILING) {\n const haystack = `${title ?? \"\"}\\n${content}`.toLowerCase();\n if (CHALLENGE_PHRASES.some((phrase) => haystack.includes(phrase))) return true;\n }\n\n const whole = content.toLowerCase();\n return CHALLENGE_MARKERS.some((marker) => whole.includes(marker));\n}\n","/**\n * The two Oxylabs products, and everything that differs between them.\n *\n * They are NOT credential-swappable. Endpoint, username prefix, geo token and\n * sticky-session mechanism all differ, so \"same URL, other username/password\"\n * fails — and it fails as a 407, which Chromium reports as a plain network\n * error. Before this module existed there was no code path that could tell that\n * apart from the site refusing us, so the failure would have been recorded\n * against the page rather than against our own configuration.\n *\n * That asymmetry is the reason this table exists in exactly one place. Every\n * caller builds its route through `buildProxyRoute`; nobody concatenates an\n * Oxylabs URL by hand.\n */\n\nexport type ProxyTier = \"datacenter\" | \"residential\";\n\nexport interface ProxyTierSpec {\n readonly tier: ProxyTier;\n readonly host: string;\n readonly port: number;\n /** Oxylabs requires this in front of the account name. The two differ. */\n readonly usernamePrefix: \"user-\" | \"customer-\";\n /** Country targeting token. `-country-` on datacenter, `-cc-` on residential. */\n readonly countryKey: \"-country-\" | \"-cc-\";\n /**\n * Sticky-session token, or null when the product has none.\n *\n * Datacenter does stickiness by *port* (8001 for IP #1, 8002 for #2), not by\n * username. We do not implement that: a capture is a single page load, so a\n * reusable IP buys nothing and the knob would only add a way to get it wrong.\n */\n readonly sessionKey: \"-sessid-\" | null;\n}\n\nexport const PROXY_TIERS: Readonly<Record<ProxyTier, ProxyTierSpec>> = {\n datacenter: {\n tier: \"datacenter\",\n host: \"dc.oxylabs.io\",\n port: 8000,\n usernamePrefix: \"user-\",\n countryKey: \"-country-\",\n sessionKey: null,\n },\n residential: {\n tier: \"residential\",\n host: \"pr.oxylabs.io\",\n port: 7777,\n usernamePrefix: \"customer-\",\n countryKey: \"-cc-\",\n sessionKey: \"-sessid-\",\n },\n};\n\n/**\n * Cheapest useful route first, and the ladder never skips a rung.\n *\n * Residential costs materially more than datacenter, so it is only ever reached\n * because datacenter was tried and refused — never as a first guess and never\n * speculatively.\n */\nexport const ESCALATION_ORDER = [\"datacenter\", \"residential\"] as const satisfies readonly ProxyTier[];\n\n/** Every prefix this module knows, used to spot a credential in the wrong slot. */\nexport const KNOWN_USERNAME_PREFIXES = [\"user-\", \"customer-\"] as const;\n","import type { ProxyTier } from \"./tiers.ts\";\nimport { ESCALATION_ORDER } from \"./tiers.ts\";\n\n/**\n * The four environment variable names, in one place.\n *\n * Convex holds these and hands them to the sandbox; the CLI reads them back\n * out. A typo on either side is a *silent* no-op — the capability appears to\n * ship, every fetch quietly takes the direct route, and nothing fails. That is\n * the worst failure mode available to this feature, so the names are a shared\n * constant with a contract test rather than eight string literals spread across\n * two packages.\n */\nexport const PROXY_ENV_VARS = {\n datacenter: { username: \"OXYLABS_DATACENTER_USERNAME\", password: \"OXYLABS_DATACENTER_PASSWORD\" },\n residential: { username: \"OXYLABS_RESIDENTIAL_USERNAME\", password: \"OXYLABS_RESIDENTIAL_PASSWORD\" },\n} as const satisfies Record<ProxyTier, { username: string; password: string }>;\n\nexport interface TierCredentials {\n readonly username: string;\n readonly password: string;\n}\n\nexport type ProxyCredentials = Partial<Readonly<Record<ProxyTier, TierCredentials>>>;\n\n/**\n * Read whichever tiers are fully configured.\n *\n * Takes the env record rather than reading `process.env` itself, so the whole\n * package stays pure and the Convex and CLI sides can be tested identically.\n *\n * Half a pair is not a credential. A username with no password would build a\n * route that authenticates as nobody, and Oxylabs answers that with the same\n * 407 as a wrong password — so it would look like a broken proxy rather than an\n * unconfigured one. Dropping it here means `configuredTiers` tells the truth.\n */\nexport function readProxyCredentials(env: Record<string, string | undefined>): ProxyCredentials {\n const credentials: { -readonly [K in ProxyTier]?: TierCredentials } = {};\n for (const tier of ESCALATION_ORDER) {\n const username = env[PROXY_ENV_VARS[tier].username]?.trim();\n const password = env[PROXY_ENV_VARS[tier].password]?.trim();\n if (username && password) credentials[tier] = { username, password };\n }\n return credentials;\n}\n\n/**\n * The inverse of `readProxyCredentials`: credentials back out as env vars.\n *\n * The writing side used to be a loop inside one Convex function, which is how\n * the ladder came to exist everywhere and work in one place. That loop ran only\n * for `baker landing inspiration scrape`, so every other CLI fetch surface in a\n * Session — `images download`, canvas `ingest`, the two scaffolders, yt-dlp —\n * read four variables nobody had set and went direct, silently, exactly as\n * `PROXY_ENV_VARS` warns.\n *\n * Pairing it with the reader here is the point: the round trip is a test, so the\n * two sides cannot drift into agreeing about three variables out of four.\n */\nexport function proxyCredentialEnvs(credentials: ProxyCredentials): Record<string, string> {\n const envs: Record<string, string> = {};\n for (const tier of ESCALATION_ORDER) {\n const held = credentials[tier];\n if (!held) continue;\n envs[PROXY_ENV_VARS[tier].username] = held.username;\n envs[PROXY_ENV_VARS[tier].password] = held.password;\n }\n return envs;\n}\n\n/**\n * Which tiers this process could actually reach, cheapest first.\n *\n * There is no ceiling to apply. A deployment holding both pairs may climb to\n * residential, full stop — the brake is the credential itself. Removing\n * `OXYLABS_RESIDENTIAL_PASSWORD` takes effect on the next restart, needs no\n * deploy, and withholds the tier by the same mechanism a ceiling would have,\n * so the valve that used to sit here only added a second way to say it. It was\n * also never wired into ad-dna's task definition, which is the highest-volume\n * egress in the repo — a control that could not be turned where it mattered\n * most.\n */\nexport function configuredTiers(credentials: ProxyCredentials): ProxyTier[] {\n return ESCALATION_ORDER.filter((tier) => credentials[tier] !== undefined);\n}\n\n/** Whether there is any proxy to escalate to at all. */\nexport function proxyEscalationConfigured(credentials: ProxyCredentials): boolean {\n return configuredTiers(credentials).length > 0;\n}\n","/**\n * When is a failure worth spending a more expensive route on?\n *\n * The ladder only ever climbs on evidence that *who we are* was the problem. A\n * page that 404s, a domain that does not resolve, a certificate that will not\n * negotiate and a site that timed out all fail identically from every IP on\n * earth, so retrying them through a metered exit buys a second identical\n * failure and a bill. That asymmetry — cheap to be wrong in one direction,\n * expensive in the other — is why this is a closed allow-list rather than\n * \"escalate unless we recognise the error\".\n *\n * Shared deliberately: the capture engine, the image fetchers and the ad-media\n * downloaders all have to agree on what \"blocked\" means, or the corpus and the\n * bill disagree about the same event.\n */\n\n/** What one attempt observed, normalised across Playwright and `fetch`. */\nexport interface BlockSignal {\n /** Main-document / response status, when there was one. */\n readonly status?: number | null;\n /** Chromium `net::` name or a Node error code, when the attempt threw one. */\n readonly netError?: string | null;\n /** A challenge body was detected behind an otherwise successful response. */\n readonly challenge?: boolean;\n /** The attempt ran out of time rather than being refused. */\n readonly timedOut?: boolean;\n}\n\n/** \"We don't like *you*\" — a different exit IP is a different you. */\nconst ESCALATABLE_STATUSES = new Set([403, 429, 451]);\n\n/**\n * A TCP reset on ClientHello is how a WAF null-routes a datacenter range.\n *\n * Today all three of these collapse into one \"site didn't respond\" message and\n * are filed non-retryable, which is precisely why this class of block has been\n * invisible: it looks identical to a site that is genuinely down.\n */\nconst ESCALATABLE_NET_ERRORS = new Set([\"ERR_CONNECTION_RESET\", \"ERR_CONNECTION_CLOSED\", \"ERR_EMPTY_RESPONSE\"]);\n\n/**\n * Failures that belong to our route, not to the site.\n *\n * A 407 cannot come from an origin server — only something speaking proxy\n * produces one — so this needs no \"was this attempt proxied\" flag to be safe.\n */\nconst PROXY_NET_ERRORS = new Set([\n \"ERR_TUNNEL_CONNECTION_FAILED\",\n \"ERR_PROXY_CONNECTION_FAILED\",\n \"ERR_PROXY_AUTH_REQUESTED\",\n \"ERR_PROXY_CERTIFICATE_INVALID\",\n \"ERR_UNEXPECTED_PROXY_AUTH\",\n \"ERR_MANDATORY_PROXY_CONFIGURATION_FAILED\",\n \"ERR_HTTPS_PROXY_TUNNEL_RESPONSE_REDIRECT\",\n]);\n\nexport function isProxyFailure(signal: BlockSignal): boolean {\n if (signal.status === 407) return true;\n return signal.netError ? PROXY_NET_ERRORS.has(signal.netError) : false;\n}\n\nexport function shouldEscalate(signal: BlockSignal): boolean {\n // Our own broken route never advances the ladder — it degrades it. Climbing\n // here would spend a more expensive rung reproducing our misconfiguration.\n if (isProxyFailure(signal)) return false;\n if (signal.timedOut) return false;\n if (signal.challenge) return true;\n if (signal.status != null && ESCALATABLE_STATUSES.has(signal.status)) return true;\n return signal.netError ? ESCALATABLE_NET_ERRORS.has(signal.netError) : false;\n}\n","/**\n * The same question as `refuseNonPublicUrl`, asked of an address instead of a name.\n *\n * A URL carries a hostname, and a hostname is not an address. `localtest.me`\n * and `127.0.0.1.nip.io` are public names, with public DNS records, that\n * resolve to `127.0.0.1` — and nothing stops an attacker pointing a domain they\n * own at `169.254.169.254`. Proven against a real loopback server: the hostname\n * guard passed `http://localtest.me:4321/secret.txt` straight through, the bytes\n * were downloaded, and only the image parser complained afterwards.\n *\n * IPv6 is parsed rather than pattern-matched, and that is the second lesson\n * here. The first version tested prefixes with regexes and unwrapped an\n * IPv4-mapped address only when it still had a dot in it — but a URL parser\n * normalises `::ffff:127.0.0.1` to `::ffff:7f00:1`, so the dot is gone by the\n * time anyone looks. `[::ffff:169.254.169.254]` therefore read as public and the\n * ladder returned the metadata body with `outcome: \"ok\"`. One address has many\n * spellings; comparing spellings is the wrong operation.\n *\n * Anything unparseable is treated as private. This is a safety gate, and the\n * only safe reading of \"I do not know what this is\" is \"do not fetch it\".\n */\n\n/** Every IPv4 range that is not routable on the public internet. */\nfunction isPrivateIpv4Octets(octets: readonly number[]): boolean {\n const [a, b] = octets as [number, number, number, number];\n if (a === 0) return true; // \"this network\" — 0.0.0.0/8\n if (a === 10) return true; // private\n if (a === 127) return true; // loopback\n if (a === 169 && b === 254) return true; // link-local, and the cloud metadata address\n if (a === 172 && b >= 16 && b <= 31) return true; // private /12 — NOT all of 172.*\n if (a === 192 && b === 168) return true; // private\n if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT\n if (a >= 224) return true; // multicast and reserved\n return false;\n}\n\n/** Dotted-quad to octets, or `null` when it is not one. */\nfunction parseIpv4(value: string): number[] | null {\n const parts = value.split(\".\");\n if (parts.length !== 4) return null;\n const octets = parts.map((part) => (/^\\d{1,3}$/.test(part) ? Number(part) : Number.NaN));\n if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;\n return octets;\n}\n\n/**\n * One side of a `::` to its 16-bit groups, or `null` if any part is not one.\n *\n * A trailing dotted-quad stands for the final two groups and is only legal in\n * last position — that is the spelling `::ffff:127.0.0.1` uses before a URL\n * parser rewrites it to `::ffff:7f00:1`.\n */\nfunction expandGroups(part: string): number[] | null {\n if (part === \"\") return [];\n const chunks = part.split(\":\");\n const groups: number[] = [];\n for (let index = 0; index < chunks.length; index++) {\n const chunk = chunks[index] as string;\n if (chunk.includes(\".\")) {\n if (index !== chunks.length - 1) return null;\n const octets = parseIpv4(chunk);\n if (!octets) return null;\n groups.push(((octets[0] as number) << 8) | (octets[1] as number));\n groups.push(((octets[2] as number) << 8) | (octets[3] as number));\n continue;\n }\n if (!/^[0-9a-f]{1,4}$/.test(chunk)) return null;\n groups.push(Number.parseInt(chunk, 16));\n }\n return groups;\n}\n\n/**\n * IPv6 to its eight 16-bit groups, or `null` when it is not one.\n *\n * Handles the `::` elision and a trailing dotted-quad, which together are why\n * one address has so many spellings.\n */\nfunction parseIpv6(value: string): number[] | null {\n const halves = value.split(\"::\");\n if (halves.length > 2) return null;\n\n const head = expandGroups(halves[0] as string);\n const tail = halves.length === 2 ? expandGroups(halves[1] as string) : [];\n if (head === null || tail === null) return null;\n\n if (halves.length === 1) return head.length === 8 ? head : null;\n const missing = 8 - head.length - tail.length;\n if (missing < 1) return null;\n return [...head, ...new Array<number>(missing).fill(0), ...tail];\n}\n\nexport function isPrivateAddress(ip: string): boolean {\n const address = ip.trim().toLowerCase();\n if (!address) return true;\n\n const v4 = parseIpv4(address);\n if (v4) return isPrivateIpv4Octets(v4);\n\n const v6 = parseIpv6(address);\n // Not an address we can read. A safety gate may not guess.\n if (!v6) return true;\n\n const [g0, g1, g2, g3, g4, g5, g6, g7] = v6 as [number, number, number, number, number, number, number, number];\n\n // `::` (unspecified) and `::1` (loopback).\n if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0) return true;\n\n // IPv4-mapped (`::ffff:a.b.c.d`) and IPv4-translated (`::ffff:0:a.b.c.d`).\n // The whole point: by the time this is read the tail is hex, not dotted.\n const mappedV4 = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0;\n if (mappedV4 && g4 === 0 && g5 === 0xffff) {\n return isPrivateIpv4Octets([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]);\n }\n if (mappedV4 && g4 === 0xffff && g5 === 0) {\n return isPrivateIpv4Octets([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]);\n }\n\n if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local\n if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local\n return false;\n}\n","/**\n * Addresses that must never be fetched, and must never be proxied.\n *\n * Lifted from `convex/landingLibrary/utils.ts`, where these three regexes were\n * the only copy in the repo — reachable from Convex and from nowhere else. That\n * was survivable while the capture ran one hop after the check. It is not\n * survivable now: the CLI can be invoked directly, two Convex paths enqueue\n * without re-validating, and a proxy adds a rung where \"which network am I on\"\n * stops being rhetorical.\n *\n * Two distinct reasons live here, and both point the same way:\n *\n * - **There is nothing to read.** A private address only resolves from inside\n * the network asking, so a capture of one produces a screenshot of an error.\n * - **There is something to read, and we must not.** `169.254.169.254` is the\n * cloud metadata endpoint that every capture runner has a route to.\n *\n * The rule for the proxy is *refuse*, not *bypass*. Bypassing would leave a\n * request that still happens, just unproxied; refusing means there is no\n * request to route. That is a stronger guarantee and a simpler one to check.\n */\n\nimport { isPrivateAddress } from \"./privateAddress.ts\";\n\n/**\n * Names — not addresses. Anything that looks like an address goes to\n * `isPrivateAddress`, which parses it instead of matching its spelling.\n */\nconst PRIVATE_HOST = /^(localhost|.*\\.localhost|.*\\.local)$/;\n/** Where a Session's own preview is served while the page is being built. */\nconst SANDBOX_HOST = /\\.e2b\\.(app|dev)$/;\n\n/**\n * A bracketed IPv6 literal, unwrapped.\n *\n * `new URL(\"http://[::1]/\").hostname` keeps the brackets, so a hostname check\n * that does not strip them is comparing against a string no address will ever\n * equal. The previous version tried to absorb that with `\\[?::1\\]?` and caught\n * exactly one address out of the whole of IPv6 — `[::ffff:169.254.169.254]`\n * sailed through and the ladder returned the metadata body with `outcome: \"ok\"`.\n */\nfunction unwrapIpLiteral(host: string): string | null {\n if (host.startsWith(\"[\") && host.endsWith(\"]\")) return host.slice(1, -1);\n // A bare v4 literal, or a v6 one that arrived without brackets.\n if (/^[0-9.]+$/.test(host) || host.includes(\":\")) return host;\n return null;\n}\n\nexport function isPrivateHostname(hostname: string): boolean {\n const host = hostname.trim().toLowerCase();\n if (!host) return true;\n if (PRIVATE_HOST.test(host) || SANDBOX_HOST.test(host)) return true;\n\n // Every address form — v4, v6, bracketed, mapped, elided — is decided by one\n // parser. Spelling an address in a regex is what let four of them through.\n const literal = unwrapIpLiteral(host);\n return literal === null ? false : isPrivateAddress(literal);\n}\n\n/**\n * The same rule again, in the only notation a browser's proxy settings speak.\n *\n * Chromium — and so Playwright and `agent-browser` — matches a bypass entry\n * against the hostname and understands one wildcard and no CIDR at all. The\n * ranges above therefore cannot be handed over as regexes; `172.16/12` has to\n * become sixteen entries, and there is no way to express \"any private address\".\n *\n * That makes this a second copy of one fact, which is worth stating plainly:\n * the day it disagrees with `isPrivateHostname` is the day a Session's own\n * preview at `localhost:4321` goes out through a metered exit and comes back\n * refused. Its test pins the correspondence.\n *\n * Note the direction reverses here, and deliberately. Everywhere else the rule\n * for a private address is *refuse*, because a request that still happens is\n * not a guarantee. A browser the agent drives has to reach `localhost:4321` —\n * that is its main job — so here the private address is the legitimate traffic\n * and the list says \"go direct\", not \"do not go\".\n */\nexport const BROWSER_PROXY_BYPASS = [\n \"localhost\",\n \"*.localhost\",\n \"127.0.0.1\",\n \"0.0.0.0\",\n \"::1\",\n \"*.local\",\n // A Session's own preview, which is served from the sandbox's public host —\n // public in DNS, ours in every sense that matters here.\n \"*.e2b.app\",\n \"*.e2b.dev\",\n \"10.*\",\n \"192.168.*\",\n \"169.254.*\",\n ...Array.from({ length: 16 }, (_, i) => `172.${16 + i}.*`),\n].join(\",\");\n\nexport type NonPublicReason = \"not_public\" | \"unparseable\";\n\n/**\n * `null` when the URL is safe to fetch; a reason when it must be refused.\n *\n * Anything that is not plain http(s) is refused too. A capture is handed URLs\n * from agents and from stored rows, and `file:` reaching a browser we launched\n * is a local file read wearing a URL.\n */\nexport function refuseNonPublicUrl(url: string): NonPublicReason | null {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n return \"unparseable\";\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") return \"not_public\";\n return isPrivateHostname(parsed.hostname) ? \"not_public\" : null;\n}\n","import type { ProxyCredentials } from \"./credentials.ts\";\nimport type { ProxyTier, ProxyTierSpec } from \"./tiers.ts\";\nimport { KNOWN_USERNAME_PREFIXES, PROXY_TIERS } from \"./tiers.ts\";\n\n/**\n * How one attempt reaches the internet.\n *\n * Credentials stay in separate fields instead of being embedded in the URL:\n * Playwright's `proxy` option wants them that way, undici's `ProxyAgent` takes\n * them as a header we build once, and — the real reason — a password that never\n * exists as a substring of a URL cannot be leaked by anything that logs a URL.\n */\nexport type ProxyRoute =\n | { readonly kind: \"direct\" }\n | {\n readonly kind: \"proxy\";\n readonly tier: ProxyTier;\n /** e.g. `http://dc.oxylabs.io:8000` */\n readonly server: string;\n readonly username: string;\n readonly password: string;\n };\n\nexport const DIRECT_ROUTE: ProxyRoute = { kind: \"direct\" };\n\nexport interface RouteOptions {\n /** ISO-3166 alpha-2, upper-cased here. Omit for \"wherever\" — the default. */\n readonly country?: string;\n /** Sticky-session id. Ignored by a tier whose product has no session syntax. */\n readonly session?: string;\n}\n\nexport type RouteProblem = \"missing\" | \"wrong_prefix\";\n\nexport type RouteResult = { ok: true; route: ProxyRoute } | { ok: false; problem: RouteProblem };\n\n/**\n * Put the account name into the shape this specific product expects.\n *\n * Three cases, and the third is the one worth being strict about:\n *\n * 1. Bare (`acme`) — someone pasted the account name. Prepend the prefix.\n * 2. Already correct (`user-acme` in the datacenter slot) — someone pasted the\n * full Oxylabs username. Leave it alone.\n * 3. Carrying the OTHER product's prefix (`customer-acme` in the datacenter\n * slot) — refuse.\n *\n * Case 3 must not be \"helpfully\" rewritten. A `customer-` value in the\n * datacenter slot means the residential credentials were pasted into the wrong\n * variable, and rewriting the prefix would authenticate a residential account\n * against the datacenter endpoint. That either 407s — wasting the cheap rung\n * for no reason — or it works, and bills residential rates from the tier whose\n * entire purpose is to be the cheap one. Refusing is the only answer that\n * cannot silently cost money.\n */\nexport function proxyUsername(\n spec: ProxyTierSpec,\n rawUsername: string,\n options?: RouteOptions,\n): { ok: true; username: string } | { ok: false; problem: \"wrong_prefix\" } {\n const raw = rawUsername.trim();\n const foreignPrefix = KNOWN_USERNAME_PREFIXES.find(\n (prefix) => prefix !== spec.usernamePrefix && raw.startsWith(prefix),\n );\n if (foreignPrefix) return { ok: false, problem: \"wrong_prefix\" };\n\n let username = raw.startsWith(spec.usernamePrefix) ? raw : `${spec.usernamePrefix}${raw}`;\n // Geo before session — Oxylabs reads the username left to right and rejects\n // the pair in the other order.\n if (options?.country) username += `${spec.countryKey}${options.country.toUpperCase()}`;\n if (options?.session && spec.sessionKey) username += `${spec.sessionKey}${options.session}`;\n return { ok: true, username };\n}\n\n/** Build the route for one tier, or say why there isn't one. */\nexport function buildProxyRoute(tier: ProxyTier, credentials: ProxyCredentials, options?: RouteOptions): RouteResult {\n const held = credentials[tier];\n if (!held) return { ok: false, problem: \"missing\" };\n\n const spec = PROXY_TIERS[tier];\n const username = proxyUsername(spec, held.username, options);\n if (!username.ok) return username;\n\n return {\n ok: true,\n route: {\n kind: \"proxy\",\n tier,\n server: `http://${spec.host}:${spec.port}`,\n username: username.username,\n password: held.password,\n },\n };\n}\n","import type { ProxyCredentials } from \"./credentials.ts\";\nimport { configuredTiers } from \"./credentials.ts\";\nimport { refuseNonPublicUrl } from \"./publicAddress.ts\";\nimport type { ProxyRoute, RouteOptions } from \"./route.ts\";\nimport { buildProxyRoute, DIRECT_ROUTE } from \"./route.ts\";\n\n/**\n * Every route this URL is allowed to be attempted on, in the order to try them.\n *\n * Direct is always first and always present: an unblocked page must cost\n * nothing, and a deployment with no credentials has to behave exactly as it did\n * before any of this existed.\n *\n * A tier whose credentials are malformed is *skipped*, not fatal. One\n * mis-pasted variable should cost the use of that rung, not the whole ladder —\n * the alternative is that a typo in the cheap tier silently disables the\n * expensive one that would have worked.\n */\nexport function plannedRoutes(url: string, credentials: ProxyCredentials, options?: RouteOptions): ProxyRoute[] {\n // A private address is refused upstream; if one reaches here anyway it must\n // not become a billed request, and there is no exit node on earth from which\n // `localhost` means us.\n if (refuseNonPublicUrl(url) !== null) return [DIRECT_ROUTE];\n\n const routes: ProxyRoute[] = [DIRECT_ROUTE];\n for (const tier of configuredTiers(credentials)) {\n const built = buildProxyRoute(tier, credentials, options);\n if (built.ok) routes.push(built.route);\n }\n return routes;\n}\n","import type { ProxyCredentials } from \"@baker/proxy\";\nimport { readProxyCredentials } from \"@baker/proxy\";\nimport { createEnv } from \"@t3-oss/env-core\";\nimport { z } from \"zod\";\n\ntype Env = {\n BAKER_API_KEY: string;\n BAKER_API_URL: string;\n BAKER_CHAT_ID?: string;\n BAKER_ACTING_USER_ID?: string;\n BAKER_GOOGLE_ADS_CUSTOMER_ID?: string;\n BAKER_GA4_PROPERTY_ID?: string;\n BAKER_GSC_SITE_URL?: string;\n BAKER_X_ADS_ACCOUNT_ID?: string;\n BAKER_META_AD_ACCOUNT_ID?: string;\n BAKER_LINKEDIN_AD_ACCOUNT_ID?: string;\n};\n\nlet cached: Env | undefined;\n\nexport function getEnv(): Env {\n if (!cached) {\n cached = createEnv({\n server: {\n BAKER_API_KEY: z.string().startsWith(\"bk_\", \"API key must start with 'bk_'\"),\n BAKER_API_URL: z.url(\"BAKER_API_URL must be a valid URL\"),\n BAKER_CHAT_ID: z.string().optional(),\n BAKER_ACTING_USER_ID: z.string().optional(),\n BAKER_GOOGLE_ADS_CUSTOMER_ID: z\n .string()\n .regex(/^\\d{10}$/)\n .optional(),\n BAKER_GA4_PROPERTY_ID: z.string().optional(),\n BAKER_GSC_SITE_URL: z.string().optional(),\n BAKER_X_ADS_ACCOUNT_ID: z\n .string()\n .regex(/^[a-z0-9]+$/, \"X Ads account ID must be a base36 string\")\n .optional(),\n BAKER_META_AD_ACCOUNT_ID: z.string().optional(),\n BAKER_LINKEDIN_AD_ACCOUNT_ID: z\n .string()\n .regex(/^\\d+$/, \"LinkedIn ad account ID must be the numeric portion of urn:li:sponsoredAccount:N\")\n .optional(),\n },\n runtimeEnv: process.env,\n });\n }\n return cached;\n}\n\n// Read directly (not via the validated schema): debug logging must work even when\n// the required BAKER_API_KEY/BAKER_API_URL are missing or malformed.\nexport function debugLogSetting(): string | undefined {\n const raw = process.env.BAKER_DEBUG_LOG?.trim();\n return raw ? raw : undefined;\n}\n\nexport function requireChatId(): string {\n const env = getEnv();\n if (!env.BAKER_CHAT_ID) {\n throw new Error(\n \"BAKER_CHAT_ID is not set. This command stages changes against a chat — run it from a chat-attached environment.\",\n );\n }\n return env.BAKER_CHAT_ID;\n}\n\n/**\n * Which chat a *read* addresses. A `--chat` value names another chat in the same company — how an\n * earlier chat's staged changes are recovered verbatim instead of rebuilt from a summary — and with\n * no flag it is this session's own chat.\n *\n * Read paths only. Staging, amending and discarding keep calling `requireChatId`, so one session can\n * never edit another's draft; the backend enforces the same thing twice over, since every mutating\n * internal requires the draft be `active` and every route checks the chat belongs to the caller's\n * company.\n */\nexport function resolveChatId(chat?: unknown): string {\n return typeof chat === \"string\" && chat.length > 0 ? chat : requireChatId();\n}\n\n/**\n * Wall clock the whole capture may spend, when the caller bounded it.\n *\n * Read from the environment rather than taken as a flag, deliberately. A\n * `--budget-ms` argument would land in the command schema, and the schema is\n * what the agent reads — one question away from \"what is the budget for?\", and\n * from there the escalation ladder stops being invisible to it.\n *\n * Kept out of `getEnv()` because that validator is for a chat-attached run; the\n * capture engine also runs where none of those variables exist.\n */\nexport function captureBudgetMs(): number | null {\n const raw = Number(process.env.BAKER_CAPTURE_BUDGET_MS);\n return Number.isFinite(raw) && raw > 0 ? raw : null;\n}\n\n/**\n * Egress-proxy credentials for a capture, or none at all.\n *\n * These arrive as sandbox *command* env, never as arguments — a credential in\n * argv is readable by any `ps` sharing the sandbox. With none set,\n * `plannedRoutes` yields the direct route alone and a capture behaves exactly\n * as it did before the ladder existed.\n *\n * Whichever pairs arrive are the rungs this process may climb; there is nothing\n * to filter. A tier the deployment withholds is simply absent from this env, so\n * a ceiling read on this side could only ever disagree with the one place that\n * decides.\n */\nexport function captureProxyCredentials(): ProxyCredentials {\n return readProxyCredentials(process.env);\n}\n\n/**\n * The Vimeo session cookie the deployment supplied, or undefined.\n *\n * Absent is the normal, supported state: a deployment that has not configured\n * one gets the same \"download it and upload the file\" refusal Vimeo gave before\n * this existed. Read here with the other env, so nothing else reaches into\n * `process.env` for it.\n */\nexport function captureVimeoCookie(): string | undefined {\n const raw = process.env.VIMEO_COOKIE?.trim();\n return raw ? raw : undefined;\n}\n\n/**\n * This process's environment plus `extra`, for handing to a child.\n *\n * Lives here because `process.env` reads belong in this file — but it earns its\n * place beyond the lint rule: `spawn`'s `env` *replaces* the environment rather\n * than extending it, so a caller that passes only its additions silently strips\n * `PATH` and the child fails to start for a reason that looks nothing like the\n * cause.\n */\nexport function childEnvWith(extra: Record<string, string>): NodeJS.ProcessEnv {\n return { ...process.env, ...extra };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
debugLogHttp,
|
|
3
3
|
readBodyForLog
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-2IB3IL4K.js";
|
|
5
5
|
import {
|
|
6
6
|
getEnv
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-DZUVUGEP.js";
|
|
8
8
|
|
|
9
9
|
// src/client.ts
|
|
10
10
|
var MAX_RATE_LIMIT_RETRIES = 3;
|
|
@@ -79,6 +79,9 @@ function mapHttpError(status) {
|
|
|
79
79
|
if (status === 422 || status === 400) {
|
|
80
80
|
return "VALIDATION_ERROR";
|
|
81
81
|
}
|
|
82
|
+
if (status === 409) {
|
|
83
|
+
return "CONFLICT";
|
|
84
|
+
}
|
|
82
85
|
if (status === 429) {
|
|
83
86
|
return "RATE_LIMITED";
|
|
84
87
|
}
|
|
@@ -107,7 +110,7 @@ async function handleResponse(response) {
|
|
|
107
110
|
throw new ApiError("INTERNAL_ERROR", "Failed to parse API response as JSON");
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
|
-
async function
|
|
113
|
+
async function apiGetWithHeaders(path, params) {
|
|
111
114
|
const env = getEnv();
|
|
112
115
|
const url = new URL(path, env.BAKER_API_URL);
|
|
113
116
|
if (params) {
|
|
@@ -145,7 +148,10 @@ async function apiGet(path, params) {
|
|
|
145
148
|
responseBody: await readBodyForLog(response),
|
|
146
149
|
durationMs: Date.now() - startedAt
|
|
147
150
|
});
|
|
148
|
-
return handleResponse(response);
|
|
151
|
+
return { data: await handleResponse(response), headers: response.headers };
|
|
152
|
+
}
|
|
153
|
+
async function apiGet(path, params) {
|
|
154
|
+
return (await apiGetWithHeaders(path, params)).data;
|
|
149
155
|
}
|
|
150
156
|
async function apiPost(path, body, opts) {
|
|
151
157
|
const env = getEnv();
|
|
@@ -195,7 +201,8 @@ async function apiPost(path, body, opts) {
|
|
|
195
201
|
export {
|
|
196
202
|
ApiError,
|
|
197
203
|
validateConvexId,
|
|
204
|
+
apiGetWithHeaders,
|
|
198
205
|
apiGet,
|
|
199
206
|
apiPost
|
|
200
207
|
};
|
|
201
|
-
//# sourceMappingURL=chunk-
|
|
208
|
+
//# sourceMappingURL=chunk-GU7IWEBC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client.ts"],"sourcesContent":["import { debugLogHttp, readBodyForLog } from \"./debugLog.ts\";\nimport { getEnv } from \"./env.ts\";\n\nconst MAX_RATE_LIMIT_RETRIES = 3;\nconst MAX_TOTAL_WAIT_MS = 2 * 60 * 1000;\n\nasync function fetchWithRateLimitRetry(url: string, init: RequestInit): Promise<Response> {\n let totalWaited = 0;\n\n for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) {\n const response = await fetch(url, init);\n\n if (response.status !== 429 || attempt >= MAX_RATE_LIMIT_RETRIES) {\n return response;\n }\n\n const retryAfterHeader = response.headers.get(\"Retry-After\");\n const waitMs = retryAfterHeader ? Number(retryAfterHeader) * 1000 : 2000 * 2 ** attempt;\n\n if (totalWaited + waitMs > MAX_TOTAL_WAIT_MS) {\n return response;\n }\n\n totalWaited += waitMs;\n await new Promise((resolve) => setTimeout(resolve, waitMs));\n }\n\n return fetch(url, init);\n}\n\ntype ErrorCode =\n | \"UNAUTHORIZED\"\n | \"FORBIDDEN\"\n | \"NOT_FOUND\"\n | \"CONFLICT\"\n | \"VALIDATION_ERROR\"\n | \"RATE_LIMITED\"\n | \"INTERNAL_ERROR\"\n | \"NETWORK_ERROR\"\n | \"TIMEOUT\"\n | \"IMAGE_PROCESSING_ERROR\";\n\nexport class ApiError extends Error {\n code: ErrorCode;\n\n constructor(code: ErrorCode, message: string) {\n super(message);\n this.name = \"ApiError\";\n this.code = code;\n }\n}\n\nconst CONVEX_ID_RE = /^[a-zA-Z0-9_]+$/;\n\nfunction hasControlCharacters(value: string): boolean {\n for (let i = 0; i < value.length; i++) {\n const code = value.charCodeAt(i);\n // Allow tab (9), newline (10), carriage return (13)\n if (code < 32 && code !== 9 && code !== 10 && code !== 13) {\n return true;\n }\n }\n return false;\n}\n\nfunction validateStringValue(value: string): void {\n if (hasControlCharacters(value)) {\n throw new ApiError(\"VALIDATION_ERROR\", \"String value contains invalid control characters\");\n }\n}\n\nexport function validateConvexId(id: string): void {\n if (!CONVEX_ID_RE.test(id)) {\n throw new ApiError(\"VALIDATION_ERROR\", `Invalid ID format: \"${id}\". Expected alphanumeric string.`);\n }\n}\n\nfunction sanitizeParams(params: Record<string, string>): Record<string, string> {\n const sanitized: Record<string, string> = {};\n for (const [key, value] of Object.entries(params)) {\n validateStringValue(value);\n sanitized[key] = value;\n }\n return sanitized;\n}\n\n/**\n * Sent on every call so the backend can attribute immediately-applied writes\n * (library images and videos, followed advertisers) to the Session they were\n * made from. Absent outside a chat-attached environment.\n */\nfunction chatHeader(): Record<string, string> {\n const chatId = getEnv().BAKER_CHAT_ID;\n return chatId ? { \"x-baker-chat-id\": chatId } : {};\n}\n\nfunction mapHttpError(status: number): ErrorCode {\n if (status === 401) {\n return \"UNAUTHORIZED\";\n }\n // 403 is never \"not connected\" — every backend `FORBIDDEN` means connected but\n // not allowed (an asset outside the granted scope, a capability the account\n // lacks). Folding it into UNAUTHORIZED sent agents down the reconnect path,\n // which cannot fix a permission gap and wastes the user's time.\n if (status === 403) {\n return \"FORBIDDEN\";\n }\n if (status === 404) {\n return \"NOT_FOUND\";\n }\n if (status === 422 || status === 400) {\n return \"VALIDATION_ERROR\";\n }\n // 409 is a state the caller has to change, never a fault to sit out. It is\n // what the backend returns for \"this workspace has not switched analytics on\n // yet\" and its siblings — folded into INTERNAL_ERROR, that reaches an agent\n // as a transient failure worth retrying, which it never is.\n if (status === 409) {\n return \"CONFLICT\";\n }\n if (status === 429) {\n return \"RATE_LIMITED\";\n }\n return \"INTERNAL_ERROR\";\n}\n\nasync function handleResponse<T>(response: Response): Promise<T> {\n const body = await response.text();\n\n if (!response.ok) {\n let message = `HTTP ${response.status}: ${response.statusText}`;\n try {\n const parsed = JSON.parse(body) as { error?: string | { message?: string }; message?: string };\n if (typeof parsed.error === \"string\") {\n message = parsed.error;\n } else if (parsed.error?.message) {\n message = parsed.error.message;\n } else if (parsed.message) {\n message = parsed.message;\n }\n } catch {\n // Use default message\n }\n throw new ApiError(mapHttpError(response.status), message);\n }\n\n try {\n return JSON.parse(body) as T;\n } catch {\n throw new ApiError(\"INTERNAL_ERROR\", \"Failed to parse API response as JSON\");\n }\n}\n\n/**\n * A GET that also hands back the response headers.\n *\n * Some endpoints have to say something *about* the answer that the answer has\n * no room for — `GET /api/testimonials` returns a bare array and sets\n * `X-Baker-Next-Cursor` when rows past this page remain unexamined, so the rows\n * alone cannot tell one page of a library from the whole of it.\n */\nexport async function apiGetWithHeaders<T>(\n path: string,\n params?: Record<string, string>,\n): Promise<{ data: T; headers: Headers }> {\n const env = getEnv();\n const url = new URL(path, env.BAKER_API_URL);\n if (params) {\n const clean = sanitizeParams(params);\n for (const [key, value] of Object.entries(clean)) {\n url.searchParams.set(key, value);\n }\n }\n\n const urlStr = url.toString();\n const startedAt = Date.now();\n let response: Response;\n try {\n response = await fetchWithRateLimitRetry(urlStr, {\n method: \"GET\",\n headers: {\n Authorization: `Bearer ${env.BAKER_API_KEY}`,\n Accept: \"application/json\",\n ...chatHeader(),\n },\n signal: AbortSignal.timeout(60_000),\n });\n } catch (err) {\n debugLogHttp({ source: \"cli\", method: \"GET\", url: urlStr, durationMs: Date.now() - startedAt, error: err });\n if (err instanceof Error && (err.name === \"TimeoutError\" || err.name === \"AbortError\")) {\n throw new ApiError(\"TIMEOUT\", \"Request timed out after 60 seconds\");\n }\n throw new ApiError(\"NETWORK_ERROR\", `Request failed: ${err instanceof Error ? err.message : \"Unknown error\"}`);\n }\n\n debugLogHttp({\n source: \"cli\",\n method: \"GET\",\n url: urlStr,\n status: response.status,\n ok: response.ok,\n responseBody: await readBodyForLog(response),\n durationMs: Date.now() - startedAt,\n });\n return { data: await handleResponse<T>(response), headers: response.headers };\n}\n\nexport async function apiGet<T>(path: string, params?: Record<string, string>): Promise<T> {\n return (await apiGetWithHeaders<T>(path, params)).data;\n}\n\nexport async function apiPost<T>(path: string, body: unknown, opts?: { timeoutMs?: number }): Promise<T> {\n const env = getEnv();\n const timeoutMs = opts?.timeoutMs ?? 60_000;\n const urlStr = new URL(path, env.BAKER_API_URL).toString();\n const startedAt = Date.now();\n let response: Response;\n try {\n response = await fetchWithRateLimitRetry(urlStr, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${env.BAKER_API_KEY}`,\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n ...chatHeader(),\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n } catch (err) {\n debugLogHttp({\n source: \"cli\",\n method: \"POST\",\n url: urlStr,\n requestBody: body,\n durationMs: Date.now() - startedAt,\n error: err,\n });\n if (err instanceof Error && (err.name === \"TimeoutError\" || err.name === \"AbortError\")) {\n throw new ApiError(\"TIMEOUT\", `Request timed out after ${Math.round(timeoutMs / 1000)} seconds`);\n }\n throw new ApiError(\"NETWORK_ERROR\", `Request failed: ${err instanceof Error ? err.message : \"Unknown error\"}`);\n }\n\n debugLogHttp({\n source: \"cli\",\n method: \"POST\",\n url: urlStr,\n requestBody: body,\n status: response.status,\n ok: response.ok,\n responseBody: await readBodyForLog(response),\n durationMs: Date.now() - startedAt,\n });\n return handleResponse<T>(response);\n}\n"],"mappings":";;;;;;;;;AAGA,IAAM,yBAAyB;AAC/B,IAAM,oBAAoB,IAAI,KAAK;AAEnC,eAAe,wBAAwB,KAAa,MAAsC;AACxF,MAAI,cAAc;AAElB,WAAS,UAAU,GAAG,WAAW,wBAAwB,WAAW;AAClE,UAAM,WAAW,MAAM,MAAM,KAAK,IAAI;AAEtC,QAAI,SAAS,WAAW,OAAO,WAAW,wBAAwB;AAChE,aAAO;AAAA,IACT;AAEA,UAAM,mBAAmB,SAAS,QAAQ,IAAI,aAAa;AAC3D,UAAM,SAAS,mBAAmB,OAAO,gBAAgB,IAAI,MAAO,MAAO,KAAK;AAEhF,QAAI,cAAc,SAAS,mBAAmB;AAC5C,aAAO;AAAA,IACT;AAEA,mBAAe;AACf,UAAM,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,MAAM,CAAC;AAAA,EAC5D;AAEA,SAAO,MAAM,KAAK,IAAI;AACxB;AAcO,IAAM,WAAN,cAAuB,MAAM;AAAA,EAClC;AAAA,EAEA,YAAY,MAAiB,SAAiB;AAC5C,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AAAA,EACd;AACF;AAEA,IAAM,eAAe;AAErB,SAAS,qBAAqB,OAAwB;AACpD,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,UAAM,OAAO,MAAM,WAAW,CAAC;AAE/B,QAAI,OAAO,MAAM,SAAS,KAAK,SAAS,MAAM,SAAS,IAAI;AACzD,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,oBAAoB,OAAqB;AAChD,MAAI,qBAAqB,KAAK,GAAG;AAC/B,UAAM,IAAI,SAAS,oBAAoB,kDAAkD;AAAA,EAC3F;AACF;AAEO,SAAS,iBAAiB,IAAkB;AACjD,MAAI,CAAC,aAAa,KAAK,EAAE,GAAG;AAC1B,UAAM,IAAI,SAAS,oBAAoB,uBAAuB,EAAE,kCAAkC;AAAA,EACpG;AACF;AAEA,SAAS,eAAe,QAAwD;AAC9E,QAAM,YAAoC,CAAC;AAC3C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACjD,wBAAoB,KAAK;AACzB,cAAU,GAAG,IAAI;AAAA,EACnB;AACA,SAAO;AACT;AAOA,SAAS,aAAqC;AAC5C,QAAM,SAAS,OAAO,EAAE;AACxB,SAAO,SAAS,EAAE,mBAAmB,OAAO,IAAI,CAAC;AACnD;AAEA,SAAS,aAAa,QAA2B;AAC/C,MAAI,WAAW,KAAK;AAClB,WAAO;AAAA,EACT;AAKA,MAAI,WAAW,KAAK;AAClB,WAAO;AAAA,EACT;AACA,MAAI,WAAW,KAAK;AAClB,WAAO;AAAA,EACT;AACA,MAAI,WAAW,OAAO,WAAW,KAAK;AACpC,WAAO;AAAA,EACT;AAKA,MAAI,WAAW,KAAK;AAClB,WAAO;AAAA,EACT;AACA,MAAI,WAAW,KAAK;AAClB,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEA,eAAe,eAAkB,UAAgC;AAC/D,QAAM,OAAO,MAAM,SAAS,KAAK;AAEjC,MAAI,CAAC,SAAS,IAAI;AAChB,QAAI,UAAU,QAAQ,SAAS,MAAM,KAAK,SAAS,UAAU;AAC7D,QAAI;AACF,YAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,UAAI,OAAO,OAAO,UAAU,UAAU;AACpC,kBAAU,OAAO;AAAA,MACnB,WAAW,OAAO,OAAO,SAAS;AAChC,kBAAU,OAAO,MAAM;AAAA,MACzB,WAAW,OAAO,SAAS;AACzB,kBAAU,OAAO;AAAA,MACnB;AAAA,IACF,QAAQ;AAAA,IAER;AACA,UAAM,IAAI,SAAS,aAAa,SAAS,MAAM,GAAG,OAAO;AAAA,EAC3D;AAEA,MAAI;AACF,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,QAAQ;AACN,UAAM,IAAI,SAAS,kBAAkB,sCAAsC;AAAA,EAC7E;AACF;AAUA,eAAsB,kBACpB,MACA,QACwC;AACxC,QAAM,MAAM,OAAO;AACnB,QAAM,MAAM,IAAI,IAAI,MAAM,IAAI,aAAa;AAC3C,MAAI,QAAQ;AACV,UAAM,QAAQ,eAAe,MAAM;AACnC,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,UAAI,aAAa,IAAI,KAAK,KAAK;AAAA,IACjC;AAAA,EACF;AAEA,QAAM,SAAS,IAAI,SAAS;AAC5B,QAAM,YAAY,KAAK,IAAI;AAC3B,MAAI;AACJ,MAAI;AACF,eAAW,MAAM,wBAAwB,QAAQ;AAAA,MAC/C,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,eAAe,UAAU,IAAI,aAAa;AAAA,QAC1C,QAAQ;AAAA,QACR,GAAG,WAAW;AAAA,MAChB;AAAA,MACA,QAAQ,YAAY,QAAQ,GAAM;AAAA,IACpC,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,iBAAa,EAAE,QAAQ,OAAO,QAAQ,OAAO,KAAK,QAAQ,YAAY,KAAK,IAAI,IAAI,WAAW,OAAO,IAAI,CAAC;AAC1G,QAAI,eAAe,UAAU,IAAI,SAAS,kBAAkB,IAAI,SAAS,eAAe;AACtF,YAAM,IAAI,SAAS,WAAW,oCAAoC;AAAA,IACpE;AACA,UAAM,IAAI,SAAS,iBAAiB,mBAAmB,eAAe,QAAQ,IAAI,UAAU,eAAe,EAAE;AAAA,EAC/G;AAEA,eAAa;AAAA,IACX,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,KAAK;AAAA,IACL,QAAQ,SAAS;AAAA,IACjB,IAAI,SAAS;AAAA,IACb,cAAc,MAAM,eAAe,QAAQ;AAAA,IAC3C,YAAY,KAAK,IAAI,IAAI;AAAA,EAC3B,CAAC;AACD,SAAO,EAAE,MAAM,MAAM,eAAkB,QAAQ,GAAG,SAAS,SAAS,QAAQ;AAC9E;AAEA,eAAsB,OAAU,MAAc,QAA6C;AACzF,UAAQ,MAAM,kBAAqB,MAAM,MAAM,GAAG;AACpD;AAEA,eAAsB,QAAW,MAAc,MAAe,MAA2C;AACvG,QAAM,MAAM,OAAO;AACnB,QAAM,YAAY,MAAM,aAAa;AACrC,QAAM,SAAS,IAAI,IAAI,MAAM,IAAI,aAAa,EAAE,SAAS;AACzD,QAAM,YAAY,KAAK,IAAI;AAC3B,MAAI;AACJ,MAAI;AACF,eAAW,MAAM,wBAAwB,QAAQ;AAAA,MAC/C,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,eAAe,UAAU,IAAI,aAAa;AAAA,QAC1C,gBAAgB;AAAA,QAChB,QAAQ;AAAA,QACR,GAAG,WAAW;AAAA,MAChB;AAAA,MACA,MAAM,KAAK,UAAU,IAAI;AAAA,MACzB,QAAQ,YAAY,QAAQ,SAAS;AAAA,IACvC,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,iBAAa;AAAA,MACX,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,KAAK;AAAA,MACL,aAAa;AAAA,MACb,YAAY,KAAK,IAAI,IAAI;AAAA,MACzB,OAAO;AAAA,IACT,CAAC;AACD,QAAI,eAAe,UAAU,IAAI,SAAS,kBAAkB,IAAI,SAAS,eAAe;AACtF,YAAM,IAAI,SAAS,WAAW,2BAA2B,KAAK,MAAM,YAAY,GAAI,CAAC,UAAU;AAAA,IACjG;AACA,UAAM,IAAI,SAAS,iBAAiB,mBAAmB,eAAe,QAAQ,IAAI,UAAU,eAAe,EAAE;AAAA,EAC/G;AAEA,eAAa;AAAA,IACX,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,KAAK;AAAA,IACL,aAAa;AAAA,IACb,QAAQ,SAAS;AAAA,IACjB,IAAI,SAAS;AAAA,IACb,cAAc,MAAM,eAAe,QAAQ;AAAA,IAC3C,YAAY,KAAK,IAAI,IAAI;AAAA,EAC3B,CAAC;AACD,SAAO,eAAkB,QAAQ;AACnC;","names":[]}
|
|
@@ -2,13 +2,13 @@ import {
|
|
|
2
2
|
handleConnectionError,
|
|
3
3
|
needsConnectionFix,
|
|
4
4
|
writeAdsJson
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-6F52WYB7.js";
|
|
6
6
|
import {
|
|
7
7
|
ApiError
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-GU7IWEBC.js";
|
|
9
9
|
import {
|
|
10
10
|
getEnv
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-DZUVUGEP.js";
|
|
12
12
|
|
|
13
13
|
// src/commands/ads/meta/shared.ts
|
|
14
14
|
var DAY_MS = 864e5;
|
|
@@ -108,4 +108,4 @@ export {
|
|
|
108
108
|
csvOrJson,
|
|
109
109
|
resolveEffectiveStatus
|
|
110
110
|
};
|
|
111
|
-
//# sourceMappingURL=chunk-
|
|
111
|
+
//# sourceMappingURL=chunk-KSOGK7SJ.js.map
|