@koda-sl/baker-cli 0.257.0 → 0.258.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 CHANGED
@@ -658,17 +658,19 @@ baker tag-manager draft show --chat <chat-id> gtm_temp_ab12
658
658
  baker actions draft --chat <chat-id>
659
659
  ```
660
660
 
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), `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.json>` (flags override file 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**.
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), `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**.
662
662
 
663
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.
664
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
+
665
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.
666
668
 
667
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.
668
670
 
669
671
  **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.
670
672
 
671
- **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.
673
+ **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.
672
674
 
673
675
  **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.
674
676
 
package/dist/cli.js CHANGED
@@ -2309,15 +2309,24 @@ var ASSET_FIELD_TYPES = [
2309
2309
  * `assetLink.attach` — see that message for what Google actually does with it.
2310
2310
  */
2311
2311
  "MARKETING_IMAGE",
2312
- "BUSINESS_NAME"
2312
+ "BUSINESS_NAME",
2313
+ /**
2314
+ * The one image link a Search campaign takes. It carries the advertiser's logo beside the ad and
2315
+ * is the only image field type that survives Google's field-type check at all — swept live on v23
2316
+ * and v25, campaign and customer level both answer `CUSTOMER_NOT_VERIFIED` (an account fact,
2317
+ * fixed by advertiser verification) rather than a field-type refusal. Ad group level is not a
2318
+ * level Google links it at — see {@link BUSINESS_LOGO_LEVEL_MESSAGE}.
2319
+ */
2320
+ "BUSINESS_LOGO"
2313
2321
  ];
2322
+ var BUSINESS_LOGO_LEVEL_MESSAGE = "Google links a BUSINESS_LOGO at campaign or customer level only \u2014 an ad group link comes back UNSUPPORTED_FIELD_TYPE. Attach it to the campaign instead.";
2314
2323
  var IMAGE_ASSET_LINK_FIELD_TYPES = /* @__PURE__ */ new Set([
2315
2324
  "MARKETING_IMAGE",
2316
2325
  "SQUARE_MARKETING_IMAGE",
2317
2326
  "PORTRAIT_MARKETING_IMAGE",
2318
2327
  "AD_IMAGE"
2319
2328
  ]);
2320
- var IMAGE_ASSET_LINK_MESSAGE = "Google does not accept an image through an asset link. MARKETING_IMAGE is a Performance Max asset-group slot (use `asset-groups attach`), and AD_IMAGE \u2014 the field type Google names for a Search image extension \u2014 comes back UNSUPPORTED_FIELD_TYPE at campaign and ad group level alike. Image extensions are not reachable through the API: tell the user to add them in the Google Ads interface, and carry on with the rest of the job.";
2329
+ var IMAGE_ASSET_LINK_MESSAGE = "Google refuses the LINK, not the upload \u2014 so do not stop at this refusal, finish the handover. `assets create` with an image DOES publish: the image lands in the advertiser's Google Ads asset library and is theirs to use. Only this link is refused (MARKETING_IMAGE is a Performance Max asset-group slot \u2014 use `asset-groups attach`; AD_IMAGE comes back UNSUPPORTED_FIELD_TYPE at every level, on every API version). So: stage the image assets anyway, drop this link, and tell the user the images are already in their Google Ads account and only need adding to the campaign under Assets \u2014 name them. Baker stages the Task for that step itself, alongside the image, so it is already in this Session's changes \u2014 do not stage a second one with `baker actions`. Never tell them to upload the images; they are uploaded. The one image a Search campaign does take through a link is the advertiser's logo \u2014 BUSINESS_LOGO at campaign or customer level, on a verified account.";
2321
2330
  function isImageAssetLinkFieldType(fieldType) {
2322
2331
  return IMAGE_ASSET_LINK_FIELD_TYPES.has(fieldType);
2323
2332
  }
@@ -3214,7 +3223,9 @@ var assetLinkAttachSchema = z4.object({
3214
3223
  message: `parent is required for a ${value.level}-level asset link (--parent-ref)`
3215
3224
  });
3216
3225
  }
3217
- if (isImageAssetLinkFieldType(value.fieldType)) {
3226
+ if (value.fieldType === "BUSINESS_LOGO" && value.level === "adGroup") {
3227
+ ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["fieldType"], message: BUSINESS_LOGO_LEVEL_MESSAGE });
3228
+ } else if (isImageAssetLinkFieldType(value.fieldType)) {
3218
3229
  ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["fieldType"], message: IMAGE_ASSET_LINK_MESSAGE });
3219
3230
  } else if (!ASSET_FIELD_TYPES.includes(value.fieldType)) {
3220
3231
  ctx.addIssue({
@@ -9909,7 +9920,7 @@ function renderSection(title, nodes, lines, showAccount) {
9909
9920
  }
9910
9921
  function renderAdvisories(data, lines) {
9911
9922
  const advisories = data.advisories ?? [];
9912
- if (advisories.length === 0) {
9923
+ if (advisories.length === 0 || data.status === "applied") {
9913
9924
  return;
9914
9925
  }
9915
9926
  lines.push("");
@@ -9918,15 +9929,37 @@ function renderAdvisories(data, lines) {
9918
9929
  lines.push(` \u2022 ${advisory.message}`);
9919
9930
  }
9920
9931
  }
9932
+ function renderOutcome(data, lines) {
9933
+ const counts = /* @__PURE__ */ new Map();
9934
+ for (const op of data.ops) {
9935
+ const status = op.result?.status ?? "pending";
9936
+ counts.set(status, (counts.get(status) ?? 0) + 1);
9937
+ }
9938
+ const summary = [...counts].map(([status, count]) => `${count} ${status}`).join(", ");
9939
+ lines.push(`Google Ads \u2014 published \xB7 ${summary || `${data.count} op(s)`} \xB7 ${data.mode}`);
9940
+ for (const op of data.ops) {
9941
+ const status = op.result?.status;
9942
+ if (status !== "failed" && status !== "skipped") {
9943
+ continue;
9944
+ }
9945
+ const reason = op.result?.error ?? op.result?.skippedBecause ?? "no reason recorded";
9946
+ lines.push(` \u2717 ${op.summary} \u2014 ${status}: ${reason}`);
9947
+ }
9948
+ }
9921
9949
  function renderDraftStatus(data) {
9922
9950
  if (data.status === "none" || data.count === 0) {
9923
9951
  return "No staged Google Ads changes on this chat.";
9924
9952
  }
9925
9953
  const modeNote = data.mode === "live" ? "Live \u2014 publishing writes directly to Google Ads." : "Simulated \u2014 publishing completes the whole draft without calling Google Ads.";
9926
- const lines = [
9927
- `Google Ads \u2014 ${data.count} staged change${data.count === 1 ? "" : "s"} \xB7 ${data.mode}`,
9928
- modeNote
9929
- ];
9954
+ const lines = [];
9955
+ if (data.status === "applied") {
9956
+ renderOutcome(data, lines);
9957
+ } else if (data.status === "publishing") {
9958
+ lines.push(`Google Ads \u2014 publishing ${data.count} change${data.count === 1 ? "" : "s"} \xB7 ${data.mode}`);
9959
+ } else {
9960
+ lines.push(`Google Ads \u2014 ${data.count} staged change${data.count === 1 ? "" : "s"} \xB7 ${data.mode}`);
9961
+ }
9962
+ lines.push(modeNote);
9930
9963
  const tree = data.tree ?? [];
9931
9964
  const showAccount = accountsInTree(tree).size > 1;
9932
9965
  renderSection(
@@ -10043,15 +10076,17 @@ function loadJsonFileArg(path38) {
10043
10076
  if (typeof path38 !== "string" || path38.length === 0) {
10044
10077
  return {};
10045
10078
  }
10079
+ const inline = path38.trimStart().startsWith("{");
10080
+ const source = inline ? "inline JSON" : path38;
10046
10081
  try {
10047
- const parsed = JSON.parse(readFileSync2(path38, "utf8"));
10082
+ const parsed = JSON.parse(inline ? path38 : readFileSync2(path38, "utf8"));
10048
10083
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
10049
- failWriteValidation(`${path38} must contain a JSON object`);
10084
+ failWriteValidation(`${source} must contain a JSON object`);
10050
10085
  }
10051
10086
  return parsed;
10052
10087
  } catch (err) {
10053
10088
  if (err instanceof SyntaxError) {
10054
- failWriteValidation(`${path38} is not valid JSON: ${err.message}`);
10089
+ failWriteValidation(`${source} is not valid JSON: ${err.message}`);
10055
10090
  }
10056
10091
  throw err;
10057
10092
  }
@@ -13877,7 +13912,10 @@ Examples:
13877
13912
  import { defineCommand as defineCommand30 } from "citty";
13878
13913
  var customerIdArg = { "customer-id": { type: "string", description: "10-digit Google Ads customer id" } };
13879
13914
  var fileArg = {
13880
- file: { type: "string", description: "JSON file with the full op payload (flags override)" }
13915
+ file: {
13916
+ type: "string",
13917
+ description: "The op payload: a JSON file path, or the JSON object inline (flags override it)"
13918
+ }
13881
13919
  };
13882
13920
  var BUDGET_DEMAND_HINTS = [
13883
13921
  "Check this daily budget against the search volume you measured for these keywords, not against the plan you had before you measured it. A budget many times larger than the traffic the target pool can absorb never spends, and reads to the client as a number nobody costed.",
@@ -14877,10 +14915,19 @@ function fileCreateCommand(name, kind, description, hints2) {
14877
14915
  if (Object.keys(payload).length === 0) {
14878
14916
  failWriteValidation(`${kind} needs --file with the op payload`);
14879
14917
  }
14880
- await stageGoogleOp({ kind, customerId, payload }, hints2);
14918
+ await stageGoogleOp({ kind, customerId, payload }, typeof hints2 === "function" ? hints2(payload) : hints2);
14881
14919
  }
14882
14920
  });
14883
14921
  }
14922
+ function imageAssetHints(payload) {
14923
+ if (payload.type !== "image") {
14924
+ return [];
14925
+ }
14926
+ return [
14927
+ "This image publishes into the advertiser's Google Ads asset library \u2014 that half works. What Google refuses is the link that puts it on a Search campaign, so do not stage an `assets attach` for it.",
14928
+ "Finish the handover rather than reporting a limitation: tell the user the images are already in their Google Ads account and only need adding to the campaign under Assets, and name the ones you staged. Baker stages the Task for that step itself the moment you stage the image, so it is already in this chat's changes \u2014 do not stage a second one with `baker actions`. Never tell them to upload images they already have. (A Performance Max asset group is different \u2014 images do link there, with `asset-groups attach`.)"
14929
+ ];
14930
+ }
14884
14931
  var assetsUpdateCommand = defineCommand30({
14885
14932
  meta: {
14886
14933
  name: "update",
@@ -14921,17 +14968,18 @@ var assetsUpdateCommand = defineCommand30({
14921
14968
  var assetsCommand = defineCommand30({
14922
14969
  meta: {
14923
14970
  name: "assets",
14924
- description: "Stage asset create/update + asset-link attach/detach (via --file). For a high-performance Search campaign add \u22654 sitelinks, \u22653 callouts and \u22651 structured snippet per campaign. Images are not among them: Google refuses every image asset link, so image extensions have to be added in the Google Ads interface."
14971
+ description: "Stage asset create/update + asset-link attach/detach (via --file). For a high-performance Search campaign add \u22654 sitelinks, \u22653 callouts and \u22651 structured snippet per campaign. Image extensions are the one thing you cannot finish here \u2014 `create` uploads the image into the advertiser's account, but Google refuses the link that puts it on a Search campaign, so hand that one step over rather than telling them to upload anything. The image a Search campaign does take through a link is the advertiser's logo \u2014 BUSINESS_LOGO, at campaign or customer level, on a verified account."
14925
14972
  },
14926
14973
  subCommands: {
14927
14974
  create: fileCreateCommand(
14928
14975
  "create",
14929
14976
  "google.asset.create",
14930
- "Stage an asset (text/image/sitelink/callout/structuredSnippet/\u2026)"
14977
+ "Stage an asset (text/image/sitelink/callout/structuredSnippet/\u2026)",
14978
+ imageAssetHints
14931
14979
  ),
14932
14980
  update: assetsUpdateCommand,
14933
14981
  attach: fileCreateCommand("attach", "google.assetLink.attach", "Attach an asset to a campaign/adGroup/customer", [
14934
- "Images are not attachable here at all \u2014 Google refuses every image field type through an asset link, at every level. Image extensions have to be added in the Google Ads interface; say so and carry on. Performance Max asset-group images are a different mechanism and do work (`asset-groups attach`)."
14982
+ "Image extensions are not attachable here \u2014 Google refuses AD_IMAGE and every marketing-image field type at every level, on every API version. The upload is not refused, though: stage the images with `assets create`, then tell the user they are already in their Google Ads account and only need adding to the campaign under Assets. Baker stages that Task by itself when the image is staged. Two images that DO work: the advertiser's logo through this command (`BUSINESS_LOGO`, campaign or customer level, on a verified account), and Performance Max asset-group images, which are a different mechanism (`asset-groups attach`)."
14935
14983
  ]),
14936
14984
  detach: statusCommand2("google.assetLink.detach", "asset link")
14937
14985
  }
@@ -18714,7 +18762,7 @@ function renderSection2(title, nodes, lines, showAccount) {
18714
18762
  }
18715
18763
  }
18716
18764
  var CAMPAIGN_TREE_ENTITIES = /* @__PURE__ */ new Set(["campaignGroup", "campaign", "creative"]);
18717
- function renderOutcome(data, lines) {
18765
+ function renderOutcome2(data, lines) {
18718
18766
  const counts = /* @__PURE__ */ new Map();
18719
18767
  for (const op of data.ops ?? []) {
18720
18768
  const status = op.result?.status ?? "pending";
@@ -18738,7 +18786,7 @@ function renderDraftStatus2(data) {
18738
18786
  const modeNote = data.mode === "live" ? "Live \u2014 publishing writes directly to LinkedIn." : "Simulated \u2014 publishing completes the whole draft without calling LinkedIn.";
18739
18787
  const lines = [];
18740
18788
  if (data.status === "applied") {
18741
- renderOutcome(data, lines);
18789
+ renderOutcome2(data, lines);
18742
18790
  } else if (data.status === "publishing") {
18743
18791
  lines.push(`LinkedIn Ads \u2014 publishing ${data.count} change${data.count === 1 ? "" : "s"} \xB7 ${data.mode}`);
18744
18792
  } else {