@koda-sl/baker-cli 0.292.0 → 0.293.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
@@ -6019,6 +6019,12 @@ Pushing to `main` with changes in `packages/cli/` triggers the GitHub Actions wo
6019
6019
 
6020
6020
  - **0.271.0**: **`--format demandGenVideo` — the Demand Gen ad most Demand Gen spend actually runs through can be edited.** A `DEMAND_GEN_VIDEO_RESPONSIVE_AD` keeps its copy in `demandGenVideoResponsiveAd`, which has a `longHeadlines` list no other ad format has and takes its business name as a text asset rather than a string. Baker modelled only the multi-asset Demand Gen ad and labelled all four Demand Gen types as one format, so every served video ad was uneditable: the strict content schema refused `longHeadlines` and named a field instead of a missing format, and the only route left was the Google Ads interface. On the account that surfaced it, Demand Gen was 76% of the spend. New flags on `ads create|update`: `--long-headlines` (up to 5 × 90 chars, this format only), `--video-assets`, `--logo-image-assets`, `--call-to-action-assets`; a create with no video is refused with the `assets create` call to make first, and an omitted media list on an update leaves that media on the ad. Two more gates ship with it. **A content flag a format does not read is now refused instead of dropped** — `--long-headlines` on `--format demandGen` staged the copy without them and reported success, the same silent-drop the backend's strict schemas exist to stop. And **the format is checked against the live `ad_group_ad.ad.type` when it stages**: a mismatch is refused with the right format named, and the two Demand Gen types Baker writes no format for (`DEMAND_GEN_CAROUSEL_AD`, `DEMAND_GEN_PRODUCT_AD`) are refused by name, so "Baker cannot edit this ad" arrives at stage time rather than as a publish failure a Session too late.
6021
6021
 
6022
+ - **0.293.0**: an `assets detach` whose asset link is not on the account is refused **at stage time**, and an asset-link read that leaves the link's own status unscoped is refused in preflight. Both come from one publish: 74 of 85 staged ops came back `RESOURCE_NOT_FOUND` — 67 ad-group sitelinks and 7 campaign ones — twelve hours after the agent reported them staged, against a Session that had already merged. Nothing checked them: `google.assetLink.detach` had no entry in the stage-time snapshot map, so its target read back as "read failed" (silent) instead of "not found" (refused), exactly the gap that was closed for `audienceCriterion`. The read that produced the list had the same hole from the other side — it scoped `campaign.status` and `ad_group.status` and said nothing about `campaign_asset.status` / `ad_group_asset.status`, and Google returns a removed link like any other row, so already-detached extensions read as live. The snapshot query excludes `REMOVED` for that reason: a retired link is zero rows, which is the refusal. A bare-id target still only warns — it names no level, so there is no row to read and a refusal would be a guess.
6023
+
6024
+ Two rules the new chains brought with them. **`campaign_asset` and `ad_group_asset` segment on their owners**, so a `campaign.*` / `ad_group.*` field used in WHERE must also be SELECTed or Google refuses the whole statement (`EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`) — the rule that already applied to `campaign_budget`, now keyed off a table rather than one resource, and applied to the query `INCOMPLETE_STATUS_CHAIN` hands back so the corrected command is one you can actually run. Checked live on v23 against 3865083115: `ad_group_ad`, `ad_group_criterion` and `asset_group_asset` filter their ancestors freely, these two do not. And **`asset_group_asset` carries the same status** its campaign and asset group do not, so its chain gained `asset_group_asset.status` too — the Performance Max `asset-performance` preset scopes and shows it.
6025
+
6026
+ Underneath both: **`query` now returns a `.resource_name` you selected.** Google injects `resource_name` into every row whether or not it was asked for, and the backend stripped all of them as noise — including the one the SELECT clause named. For an asset link that column *is* the identity (`customers/{cid}/adGroupAssets/{adGroupId}~{assetId}~{FIELD_TYPE}`; there is no id to detach by), so the read the docs prescribe — `SELECT ad_group_asset.resource_name …` — came back without it and composing the string by hand was the only route left. Auto-injected resource names are still stripped; a selected one is kept. This also repairs the `asset-groups detach` instruction, which pointed at `asset_group_asset.resource_name` and could not have worked either.
6027
+
6022
6028
  - **0.291.0**: **the image handover Task no longer depends on having uploaded the images.** It was built only from staged `asset.create` image ops, so a Session that audited a live account — found the brand campaign carrying 22 image extensions and the generic one carrying none, and reached for `assets attach` on assets that were *already in the library* — got Google's refusal and nothing else: no upload to hang a Task on, and a CLI hint telling the agent not to raise one by hand because "Baker stages it itself". The one step only the user can take reached them as a sentence in a chat that scrolls away. An image `assets attach` is now **skipped rather than refused**: it still never reaches Google (re-verified live with `validate_only` on v23 *and* v25 against 3865083115, against a control op that validates clean in the same request — `AD_IMAGE` → `UNSUPPORTED_FIELD_TYPE` at customer and campaign level, `MARKETING_IMAGE` → `FIELD_TYPE_INCOMPATIBLE_WITH_CAMPAIGN_TYPE`, `BUSINESS_LOGO` → `CUSTOMER_NOT_VERIFIED`, which is what proves the field type is the objection; **we are not on Google's allowlist for it**), but the skip carries the image and campaign names, recorded on the draft so the next staged op cannot recompute the Task away, and a batch that also carries sitelinks keeps them instead of failing whole. The schema stopped refusing the field type for the same reason: a boundary refusal has no chat to record a handover against.
6023
6029
 
6024
6030
  ### Testing a pre-release in sandboxes
package/dist/cli.js CHANGED
@@ -11558,7 +11558,14 @@ var SERVING_CHAINS = {
11558
11558
  search_term_view: ["campaign.status", "ad_group.status"],
11559
11559
  ad_group: ["campaign.status", "ad_group.status"],
11560
11560
  asset_group: ["campaign.status", "asset_group.status"],
11561
- asset_group_asset: ["campaign.status", "asset_group.status"]
11561
+ // An asset link carries its own status, and it is not the campaign's, the ad group's or the asset
11562
+ // group's. Google returns a REMOVED link like any other row, so a read scoped only to its owners
11563
+ // lists extensions that are no longer attached to anything — which is what 74 detaches were planned
11564
+ // from on a live account, every one of them refused at publish as `RESOURCE_NOT_FOUND`.
11565
+ // `customer_asset` is deliberately absent: one rung, no ancestor to forget.
11566
+ asset_group_asset: ["campaign.status", "asset_group.status", "asset_group_asset.status"],
11567
+ campaign_asset: ["campaign.status", "campaign_asset.status"],
11568
+ ad_group_asset: ["campaign.status", "ad_group.status", "ad_group_asset.status"]
11562
11569
  };
11563
11570
  var PRESETS = [
11564
11571
  {
@@ -11641,10 +11648,10 @@ var PRESETS = [
11641
11648
  {
11642
11649
  name: "asset-performance",
11643
11650
  description: "Performance Max asset performance labels",
11644
- gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, asset_group.name, asset_group.status, asset_group_asset.field_type, asset_group_asset.performance_label, asset.type, asset.text_asset.text, asset.image_asset.full_size.url FROM asset_group_asset WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' AND segments.date DURING {dateRange}{statusScope} LIMIT {limit}`,
11651
+ gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, asset_group.name, asset_group.status, asset_group_asset.status, asset_group_asset.field_type, asset_group_asset.performance_label, asset.type, asset.text_asset.text, asset.image_asset.full_size.url FROM asset_group_asset WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' AND segments.date DURING {dateRange}{statusScope} LIMIT {limit}`,
11645
11652
  defaultDateRange: "LAST_30_DAYS",
11646
11653
  defaultLimit: 200,
11647
- statusFields: ["campaign.status", "asset_group.status"]
11654
+ statusFields: ["campaign.status", "asset_group.status", "asset_group_asset.status"]
11648
11655
  },
11649
11656
  {
11650
11657
  name: "shopping-products",
@@ -11693,7 +11700,10 @@ var RUNG_PIN_FIELDS = {
11693
11700
  "ad_group.status": ["ad_group.id", "ad_group.resource_name"],
11694
11701
  "ad_group_criterion.status": ["ad_group_criterion.criterion_id", "ad_group_criterion.resource_name"],
11695
11702
  "ad_group_ad.status": ["ad_group_ad.ad.id", "ad_group_ad.resource_name"],
11696
- "asset_group.status": ["asset_group.id", "asset_group.resource_name"]
11703
+ "asset_group.status": ["asset_group.id", "asset_group.resource_name"],
11704
+ "asset_group_asset.status": ["asset_group_asset.resource_name"],
11705
+ "campaign_asset.status": ["campaign_asset.resource_name"],
11706
+ "ad_group_asset.status": ["ad_group_asset.resource_name"]
11697
11707
  };
11698
11708
  function whereClause(query) {
11699
11709
  const match = query.match(/\bWHERE\b([\s\S]*)$/i);
@@ -11729,7 +11739,7 @@ function analyzeServingChain(query) {
11729
11739
  function rejectPartialChain(query, customerId, analysis) {
11730
11740
  if (!analysis.assertsServing || analysis.unscoped.length === 0) return null;
11731
11741
  const added = analysis.unscoped.map((rung) => `${rung} = 'ENABLED'`).join(" AND ");
11732
- const fixed = query.replace(/\bWHERE\b/i, `WHERE ${added} AND`);
11742
+ const fixed = addRequiredAncestorFields(query.replace(/\bWHERE\b/i, `WHERE ${added} AND`)).query;
11733
11743
  return {
11734
11744
  ok: false,
11735
11745
  error: {
@@ -11769,21 +11779,30 @@ function isSingleRowRead(query) {
11769
11779
  if (!resource || !SINGLE_ROW_RESOURCES.includes(resource)) return false;
11770
11780
  return !/\bsegments\./i.test(query);
11771
11781
  }
11772
- function addRequiredCampaignFields(query) {
11773
- if (!/FROM\s+campaign_budget\b/i.test(query)) return { query, warnings: [] };
11774
- const whereClause3 = query.split(/\bWHERE\b/i)[1] ?? "";
11775
- const selectClause2 = query.split(/\bFROM\b/i)[0] ?? "";
11776
- const selectFields = new Set(selectClause2.match(/campaign\.[\w.]+/g) ?? []);
11777
- const missing = [...new Set(whereClause3.match(/campaign\.[\w.]+/g) ?? [])].filter(
11778
- (field) => !selectFields.has(field)
11782
+ var SEGMENTING_ANCESTORS = {
11783
+ campaign_budget: ["campaign"],
11784
+ campaign_asset: ["campaign"],
11785
+ ad_group_asset: ["campaign", "ad_group"]
11786
+ };
11787
+ function missingRequiredSelectFields(query) {
11788
+ const ancestors = SEGMENTING_ANCESTORS[fromResource(query) ?? ""];
11789
+ if (!ancestors) return [];
11790
+ const select = selectClause(query);
11791
+ const where = query.split(/\bWHERE\b/i)[1] ?? "";
11792
+ const filtered = ancestors.flatMap(
11793
+ (ancestor) => where.match(new RegExp(`(?<![\\w.])${ancestor}\\.[\\w.]+`, "g")) ?? []
11779
11794
  );
11795
+ return [...new Set(filtered)].filter((field) => !mentions(select, field));
11796
+ }
11797
+ function addRequiredAncestorFields(query) {
11798
+ const missing = missingRequiredSelectFields(query);
11780
11799
  if (missing.length === 0) return { query, warnings: [] };
11781
11800
  return {
11782
11801
  query: query.replace(/SELECT\s+/i, `SELECT ${missing.join(", ")}, `),
11783
11802
  warnings: [
11784
11803
  {
11785
11804
  code: "REQUIRED_FIELD_ADDED",
11786
- message: `Added ${missing.join(", ")} to SELECT \u2014 campaign_budget queries must select campaign fields used in WHERE`
11805
+ message: `Added ${missing.join(", ")} to SELECT \u2014 ${fromResource(query)} queries must select the ancestor fields used in WHERE`
11787
11806
  }
11788
11807
  ]
11789
11808
  };
@@ -11807,9 +11826,9 @@ function applyAutoFixes(query, limit) {
11807
11826
  corrected = corrected.replace(/asset\.\w+_asset\.final_(mobile_)?urls\b/g, "asset.final_$1urls");
11808
11827
  warnings.push({ code: "FIELD_RENAMED", message: "asset.<type>_asset.final_urls \u2192 asset.final_urls" });
11809
11828
  }
11810
- const budgetFix = addRequiredCampaignFields(corrected);
11811
- corrected = budgetFix.query;
11812
- warnings.push(...budgetFix.warnings);
11829
+ const ancestorFix = addRequiredAncestorFields(corrected);
11830
+ corrected = ancestorFix.query;
11831
+ warnings.push(...ancestorFix.warnings);
11813
11832
  if (/campaign\.status\s*=\s*'ACTIVE'/i.test(corrected)) {
11814
11833
  corrected = corrected.replace(/campaign\.status\s*=\s*'ACTIVE'/gi, "campaign.status = 'ENABLED'");
11815
11834
  warnings.push({ code: "ENUM_CORRECTED", message: "campaign.status ACTIVE \u2192 ENABLED" });
@@ -16550,7 +16569,7 @@ var assetGroupsCommand = defineCommand31({
16550
16569
  }),
16551
16570
  detach: statusCommand2("google.assetGroupAsset.detach", "asset group asset", [
16552
16571
  "Stage order is execution order \u2014 if you are replacing this image, its `asset-groups attach` must already be staged BEFORE this detach, or Google sees the group below its per-ratio minimum and rejects the removal.",
16553
- "Pass the full composite resource name (customers/{cid}/assetGroupAssets/{assetGroupId}~{assetId}~{FIELD_TYPE}) \u2014 read it from `asset_group_asset.resource_name` via `ads google query`."
16572
+ "Pass the full composite resource name (customers/{cid}/assetGroupAssets/{assetGroupId}~{assetId}~{FIELD_TYPE}) \u2014 read it from `asset_group_asset.resource_name` via `ads google query`, scoping `asset_group_asset.status != 'REMOVED'`: the link carries its own status and Google returns an already-detached one like any other row."
16554
16573
  ])
16555
16574
  }
16556
16575
  });
@@ -16630,7 +16649,9 @@ var assetsCommand = defineCommand31({
16630
16649
  attach: fileCreateCommand("attach", "google.assetLink.attach", "Attach an asset to a campaign/adGroup/customer", [
16631
16650
  "Image extensions cannot be linked \u2014 Google refuses AD_IMAGE and every marketing-image field type at every level, on every API version \u2014 but stage the link anyway: it is skipped rather than stored, the rest of a batch is unaffected, and it is what records the handover Task naming those images and that campaign. That is the ONLY record when the images are already in the account and you are uploading nothing. Then tell the user they are already in their Google Ads account and only need adding to the campaign under Assets; do not stage a second Task with `baker actions`. Two images that DO link: 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`)."
16632
16651
  ]),
16633
- detach: statusCommand2("google.assetLink.detach", "asset link")
16652
+ detach: statusCommand2("google.assetLink.detach", "asset link", [
16653
+ "Pass the resource name Google gave you \u2014 `SELECT campaign_asset.resource_name \u2026 FROM campaign_asset WHERE campaign_asset.status != 'REMOVED'` (or `ad_group_asset` / `customer_asset`) \u2014 never one composed out of an owner id and an asset id. A link's own status is not its campaign's and not its ad group's, so an inventory read scoped only to those lists extensions that are already detached, and a link between an asset and an owner that never held it parses fine and exists nowhere. Either way the target is read back before it is staged, and a detach of a link that is not there is refused here rather than at publish."
16654
+ ])
16634
16655
  }
16635
16656
  });
16636
16657
  var audienceModeArg = {