@koda-sl/baker-cli 0.243.0 → 0.245.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/dist/cli.js CHANGED
@@ -9679,7 +9679,7 @@ function renderAdvisories(data, lines) {
9679
9679
  return;
9680
9680
  }
9681
9681
  lines.push("");
9682
- lines.push("Advisories (non-blocking \u2014 build these out for a higher-performance campaign)");
9682
+ lines.push("Advisories (non-blocking, checked across the whole draft \u2014 settle these before you publish)");
9683
9683
  for (const advisory of advisories) {
9684
9684
  lines.push(` \u2022 ${advisory.message}`);
9685
9685
  }
@@ -10188,10 +10188,33 @@ function warnDefaults(ctx) {
10188
10188
  }
10189
10189
  }
10190
10190
 
10191
+ // src/commands/ads/google/keywords/provenance.ts
10192
+ var PLANNER_SOURCE = "keyword_planner_estimate";
10193
+ var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["keyword"]);
10194
+ function stampPlannerRows(rows) {
10195
+ return rows.map((row) => {
10196
+ const stamped = { source: PLANNER_SOURCE };
10197
+ for (const [key, value] of Object.entries(row)) {
10198
+ stamped[IDENTITY_FIELDS.has(key) || key.startsWith("planner_") ? key : `planner_${key}`] = value;
10199
+ }
10200
+ return stamped;
10201
+ });
10202
+ }
10203
+ var PLANNER_FIELD_DESCRIPTIONS = {
10204
+ source: `Always "${PLANNER_SOURCE}". These rows are Keyword Planner ESTIMATES of total market search volume, NOT this account's data. Never report them as impressions, clicks or any account metric \u2014 read those from a query against the account.`,
10205
+ keyword: "The keyword this estimate is for",
10206
+ planner_avg_monthly_searches: "ESTIMATE: average monthly searches across the whole market (string), last 12 months",
10207
+ planner_competition: "ESTIMATE: competition level \u2014 LOW, MEDIUM, HIGH",
10208
+ planner_competition_index: "ESTIMATE: competition index 0-100 as string (higher = more competitive)",
10209
+ planner_low_top_of_page_bid_micros: "ESTIMATE: low-range top-of-page CPC bid in micros (\xF7 1,000,000 for currency)",
10210
+ planner_high_top_of_page_bid_micros: "ESTIMATE: high-range top-of-page CPC bid in micros (\xF7 1,000,000 for currency)",
10211
+ planner_monthly_search_volumes: "ESTIMATE: monthly market search volume breakdown (array of {year, month, monthly_searches})"
10212
+ };
10213
+
10191
10214
  // src/commands/ads/google/keywords/discover.ts
10192
10215
  registerSchema({
10193
10216
  command: "ads.google.keywords.discover",
10194
- description: "Discover keyword ideas from seed keywords or URLs. Returns { keywords, total_results, next_page_token? } with snake_case fields matching the Google Ads API. IMPORTANT: If --location and --language are omitted, defaults to United States (2840) and English (1000). The response includes a query_context object showing which location/language were used.",
10217
+ description: 'Discover keyword ideas from seed keywords or URLs. Returns { source: "keyword_planner_estimate", keywords, total_results, next_page_token? }. THESE ARE ESTIMATES OF TOTAL MARKET SEARCH VOLUME, NOT THIS ACCOUNT\'S DATA: every row carries a source column and every metric is prefixed planner_ (planner_avg_monthly_searches, planner_competition, ...). Never report a planner_ number as impressions, clicks or any account metric \u2014 read those with `baker ads google query` against the account. IMPORTANT: If --location and --language are omitted, defaults to United States (2840) and English (1000). The response includes a query_context object showing which location/language were used.',
10195
10218
  args: {
10196
10219
  "customer-id": {
10197
10220
  type: "string",
@@ -10220,15 +10243,9 @@ registerSchema({
10220
10243
  "no-cache": { type: "boolean", description: "Skip cache", required: false }
10221
10244
  }
10222
10245
  });
10223
- var KEYWORD_IDEA_FIELDS = {
10224
- keyword: "Suggested keyword text",
10225
- avg_monthly_searches: "Average monthly search volume (string)",
10226
- competition: "Competition level: LOW, MEDIUM, HIGH",
10227
- competition_index: "Competition index 0-100 as string (higher = more competitive)",
10228
- low_top_of_page_bid_micros: "Low-range CPC bid in micros (\xF7 1,000,000 for currency)",
10229
- high_top_of_page_bid_micros: "High-range CPC bid in micros (\xF7 1,000,000 for currency)",
10230
- monthly_search_volumes: "Monthly search volume breakdown (array of {year, month, monthly_searches})"
10231
- };
10246
+ function stampDiscoverResponse(data) {
10247
+ return { ...data, source: PLANNER_SOURCE, keywords: stampPlannerRows(data.keywords ?? []) };
10248
+ }
10232
10249
  function buildDiscoverBody(customerId, args) {
10233
10250
  const body = {
10234
10251
  customerId,
@@ -10269,6 +10286,8 @@ var discoverCommand = defineCommand23({
10269
10286
  name: "discover",
10270
10287
  description: `Discover new keyword ideas from seed keywords or competitor URLs.
10271
10288
 
10289
+ Returns Keyword Planner ESTIMATES of market search volume, not this account's numbers. Every row carries source=keyword_planner_estimate and planner_-prefixed metrics; keep those names when you quote a figure, and never present one as an impression or click count.
10290
+
10272
10291
  Examples:
10273
10292
  baker ads google keywords discover --customer-id 1234567890 --seeds "running shoes,athletic footwear"
10274
10293
  baker ads google keywords discover --customer-id 1234567890 --url "https://competitor.com"
@@ -10303,7 +10322,7 @@ Examples:
10303
10322
  const cached = cacheGet("keywords", cacheKey);
10304
10323
  if (cached) {
10305
10324
  warnDefaults(queryContext);
10306
- writeAdsJson({ ok: true, data: cached.data, cached: true, query_context: queryContext });
10325
+ writeAdsJson({ ok: true, data: stampDiscoverResponse(cached.data), cached: true, query_context: queryContext });
10307
10326
  return;
10308
10327
  }
10309
10328
  }
@@ -10313,12 +10332,13 @@ Examples:
10313
10332
  cacheSet("keywords", cacheKey, data, 24 * 60 * 60 * 1e3);
10314
10333
  }
10315
10334
  warnDefaults(queryContext);
10335
+ const stamped = stampDiscoverResponse(data);
10316
10336
  const format = args.output || "json";
10317
10337
  if (format !== "json") {
10318
- writeAdsOutput(data.keywords, format);
10338
+ writeAdsOutput(stamped.keywords, format);
10319
10339
  return;
10320
10340
  }
10321
- writeAdsJson({ ok: true, data, fields: KEYWORD_IDEA_FIELDS, query_context: queryContext });
10341
+ writeAdsJson({ ok: true, data: stamped, fields: PLANNER_FIELD_DESCRIPTIONS, query_context: queryContext });
10322
10342
  } catch (err) {
10323
10343
  handleKeywordError(err);
10324
10344
  }
@@ -10371,7 +10391,7 @@ var locationsCommand = defineCommand25({
10371
10391
  import { defineCommand as defineCommand26 } from "citty";
10372
10392
  registerSchema({
10373
10393
  command: "ads.google.keywords.metrics",
10374
- description: "Get historical metrics for specific keywords. Returns { historical_metrics: [...] } with snake_case fields matching the Google Ads API. IMPORTANT: If --location and --language are omitted, defaults to United States (2840) and English (1000). The response includes a query_context object showing which location/language were used.",
10394
+ description: 'Get historical market metrics for specific keywords. Returns { source: "keyword_planner_estimate", historical_metrics: [...] }. THESE ARE KEYWORD PLANNER ESTIMATES OF TOTAL MARKET SEARCH VOLUME, NOT THIS ACCOUNT\'S DATA: every row carries a source column and every metric is prefixed planner_ (planner_avg_monthly_searches, planner_competition, ...). Never report a planner_ number as impressions, clicks or any account metric \u2014 read those with `baker ads google query` against the account. IMPORTANT: If --location and --language are omitted, defaults to United States (2840) and English (1000). The response includes a query_context object showing which location/language were used.',
10375
10395
  args: {
10376
10396
  "customer-id": {
10377
10397
  type: "string",
@@ -10393,6 +10413,9 @@ registerSchema({
10393
10413
  "no-cache": { type: "boolean", description: "Skip cache", required: false }
10394
10414
  }
10395
10415
  });
10416
+ function stampMetricsResponse(data) {
10417
+ return { ...data, source: PLANNER_SOURCE, historical_metrics: stampPlannerRows(data.historical_metrics ?? []) };
10418
+ }
10396
10419
  function handleMetricsError(err) {
10397
10420
  if (err instanceof ApiError) {
10398
10421
  if (isNotConnectedError(err.code, err.message)) {
@@ -10418,6 +10441,8 @@ var metricsCommand = defineCommand26({
10418
10441
  name: "metrics",
10419
10442
  description: `Get historical search metrics for specific keywords.
10420
10443
 
10444
+ Returns Keyword Planner ESTIMATES of market search volume, not this account's numbers. Every row carries source=keyword_planner_estimate and planner_-prefixed metrics; keep those names when you quote a figure, and never present one as an impression or click count.
10445
+
10421
10446
  Examples:
10422
10447
  baker ads google keywords metrics --customer-id 1234567890 --keywords "running shoes,nike shoes,adidas shoes"
10423
10448
  baker ads google keywords metrics --customer-id 1234567890 --keywords "seo tools" --location 2826`
@@ -10454,7 +10479,7 @@ Examples:
10454
10479
  const cached = cacheGet("keywords", cacheKey);
10455
10480
  if (cached) {
10456
10481
  warnDefaults(queryContext);
10457
- writeAdsJson({ ok: true, data: cached.data, cached: true, query_context: queryContext });
10482
+ writeAdsJson({ ok: true, data: stampMetricsResponse(cached.data), cached: true, query_context: queryContext });
10458
10483
  return;
10459
10484
  }
10460
10485
  }
@@ -10464,21 +10489,13 @@ Examples:
10464
10489
  cacheSet("keywords", cacheKey, data, 24 * 60 * 60 * 1e3);
10465
10490
  }
10466
10491
  warnDefaults(queryContext);
10492
+ const stamped = stampMetricsResponse(data);
10467
10493
  const format = args.output || "json";
10468
10494
  if (format !== "json") {
10469
- writeAdsOutput(data.historical_metrics, format);
10495
+ writeAdsOutput(stamped.historical_metrics, format);
10470
10496
  return;
10471
10497
  }
10472
- const fields = {
10473
- keyword: "The keyword analyzed",
10474
- avg_monthly_searches: "Average monthly search volume as string (last 12 months)",
10475
- competition: "Competition level: LOW, MEDIUM, HIGH",
10476
- competition_index: "Competition index 0-100 as string (higher = more competitive)",
10477
- low_top_of_page_bid_micros: "Low-range CPC bid in micros (\xF7 1,000,000 for currency)",
10478
- high_top_of_page_bid_micros: "High-range CPC bid in micros (\xF7 1,000,000 for currency)",
10479
- monthly_search_volumes: "Monthly search volume breakdown (array of {year, month, monthly_searches})"
10480
- };
10481
- writeAdsJson({ ok: true, data, fields, query_context: queryContext });
10498
+ writeAdsJson({ ok: true, data: stamped, fields: PLANNER_FIELD_DESCRIPTIONS, query_context: queryContext });
10482
10499
  } catch (err) {
10483
10500
  handleMetricsError(err);
10484
10501
  }
@@ -10625,10 +10642,211 @@ function keywordServingWarnings(rows, options) {
10625
10642
  return renderKeywordLimits(collectKeywordLimits(rows, options));
10626
10643
  }
10627
10644
 
10645
+ // src/commands/ads/google/presets.ts
10646
+ var GAQL_DATE_RANGE_RE = /^(?:TODAY|YESTERDAY|LAST_7_DAYS|LAST_14_DAYS|LAST_30_DAYS|LAST_90_DAYS|THIS_MONTH|LAST_MONTH|THIS_QUARTER|LAST_QUARTER|THIS_YEAR|LAST_YEAR|ALL_TIME|BETWEEN\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}'\s+AND\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}')$/i;
10647
+ function isValidDateRange(value) {
10648
+ return GAQL_DATE_RANGE_RE.test(value.trim());
10649
+ }
10650
+ var SERVING_CHAINS = {
10651
+ keyword_view: ["campaign.status", "ad_group.status", "ad_group_criterion.status"],
10652
+ ad_group_criterion: ["campaign.status", "ad_group.status", "ad_group_criterion.status"],
10653
+ ad_group_ad: ["campaign.status", "ad_group.status", "ad_group_ad.status"],
10654
+ search_term_view: ["campaign.status", "ad_group.status"],
10655
+ ad_group: ["campaign.status", "ad_group.status"],
10656
+ asset_group: ["campaign.status", "asset_group.status"],
10657
+ asset_group_asset: ["campaign.status", "asset_group.status"]
10658
+ };
10659
+ var PRESETS = [
10660
+ {
10661
+ name: "campaign-performance",
10662
+ description: "Campaign-level metrics overview",
10663
+ gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.ctr, metrics.average_cpc FROM campaign WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.cost_micros DESC LIMIT {limit}`,
10664
+ defaultDateRange: "LAST_30_DAYS",
10665
+ defaultLimit: 200,
10666
+ statusFields: ["campaign.status"]
10667
+ },
10668
+ {
10669
+ name: "keyword-analysis",
10670
+ description: "Keyword performance with match type and quality",
10671
+ gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.id, ad_group.name, ad_group.status, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.quality_info.quality_score, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.ctr FROM keyword_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
10672
+ defaultDateRange: "LAST_30_DAYS",
10673
+ defaultLimit: 200,
10674
+ statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
10675
+ },
10676
+ {
10677
+ name: "keyword-serving",
10678
+ description: "Why a keyword is limited \u2014 Google Ads' 'Eligible (Limited) / Below first page bid' column, with the first-page bid estimate, the current max CPC and quality score",
10679
+ gaqlTemplate: `SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.primary_status, ad_group_criterion.primary_status_reasons, ad_group_criterion.system_serving_status, ad_group_criterion.approval_status, ad_group_criterion.effective_cpc_bid_micros, ad_group_criterion.effective_cpc_bid_source, ad_group_criterion.position_estimates.first_page_cpc_micros, ad_group_criterion.position_estimates.top_of_page_cpc_micros, ad_group_criterion.quality_info.quality_score, ad_group_criterion.quality_info.creative_quality_score, ad_group_criterion.quality_info.post_click_quality_score, ad_group_criterion.quality_info.search_predicted_ctr FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = FALSE{statusScope} ORDER BY campaign.name LIMIT {limit}`,
10680
+ defaultDateRange: "ALL_TIME",
10681
+ defaultLimit: 500,
10682
+ statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
10683
+ },
10684
+ {
10685
+ name: "positive-keywords",
10686
+ description: "Positive (targeting) keywords only \u2014 excludes negatives",
10687
+ gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.id, ad_group.name, ad_group.status, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.negative FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = FALSE{statusScope} ORDER BY campaign.name LIMIT {limit}`,
10688
+ defaultDateRange: "ALL_TIME",
10689
+ defaultLimit: 500,
10690
+ statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
10691
+ },
10692
+ {
10693
+ name: "negative-keywords",
10694
+ description: "Negative keywords only \u2014 campaign and ad group level",
10695
+ gaqlTemplate: `SELECT campaign.id, campaign.name, ad_group.name, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.negative FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = TRUE AND campaign.status != 'REMOVED' ORDER BY campaign.name LIMIT {limit}`,
10696
+ defaultDateRange: "ALL_TIME",
10697
+ defaultLimit: 500
10698
+ },
10699
+ {
10700
+ name: "negative-keyword-lists",
10701
+ description: "Shared negative keyword lists and the terms inside them (SharedSet members)",
10702
+ gaqlTemplate: `SELECT shared_set.id, shared_set.name, shared_criterion.keyword.text, shared_criterion.keyword.match_type FROM shared_criterion WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND shared_set.status = 'ENABLED' ORDER BY shared_set.name LIMIT {limit}`,
10703
+ defaultDateRange: "ALL_TIME",
10704
+ defaultLimit: 500
10705
+ },
10706
+ {
10707
+ name: "negative-list-attachments",
10708
+ description: "Which campaigns each shared negative keyword list is attached to",
10709
+ gaqlTemplate: `SELECT campaign.id, campaign.name, shared_set.id, shared_set.name FROM campaign_shared_set WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' ORDER BY campaign.name LIMIT {limit}`,
10710
+ defaultDateRange: "ALL_TIME",
10711
+ defaultLimit: 500
10712
+ },
10713
+ {
10714
+ name: "search-terms",
10715
+ description: "Actual user search queries triggering ads",
10716
+ gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.name, ad_group.status, search_term_view.search_term, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
10717
+ defaultDateRange: "LAST_7_DAYS",
10718
+ defaultLimit: 200,
10719
+ statusFields: ["campaign.status", "ad_group.status"]
10720
+ },
10721
+ {
10722
+ name: "ad-copy-performance",
10723
+ description: "Ad headline and description effectiveness",
10724
+ gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.status, ad_group_ad.status, ad_group_ad.ad.responsive_search_ad.headlines, ad_group_ad.ad.responsive_search_ad.descriptions, ad_group_ad.ad.final_urls, metrics.impressions, metrics.clicks, metrics.conversions, metrics.ctr FROM ad_group_ad WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
10725
+ defaultDateRange: "LAST_30_DAYS",
10726
+ defaultLimit: 200,
10727
+ statusFields: ["campaign.status", "ad_group.status", "ad_group_ad.status"]
10728
+ },
10729
+ {
10730
+ name: "asset-performance",
10731
+ description: "Performance Max asset performance labels",
10732
+ 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}`,
10733
+ defaultDateRange: "LAST_30_DAYS",
10734
+ defaultLimit: 200,
10735
+ statusFields: ["campaign.status", "asset_group.status"]
10736
+ },
10737
+ {
10738
+ name: "shopping-products",
10739
+ description: "Product-level shopping performance metrics",
10740
+ gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, segments.product_title, segments.product_item_id, segments.product_brand, segments.product_type_l1, metrics.clicks, metrics.impressions, metrics.cost_micros, metrics.conversions FROM shopping_performance_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.cost_micros DESC LIMIT {limit}`,
10741
+ defaultDateRange: "LAST_30_DAYS",
10742
+ defaultLimit: 200,
10743
+ statusFields: ["campaign.status"]
10744
+ },
10745
+ {
10746
+ name: "account-summary",
10747
+ description: "Account-level totals for a date range",
10748
+ gaqlTemplate: `SELECT customer.id, customer.descriptive_name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value FROM customer WHERE segments.date DURING {dateRange} LIMIT 1`,
10749
+ defaultDateRange: "LAST_30_DAYS",
10750
+ defaultLimit: 1
10751
+ }
10752
+ ];
10753
+ function getPreset(name) {
10754
+ return PRESETS.find((p) => p.name === name);
10755
+ }
10756
+ function buildStatusScope(statusFields, includePaused) {
10757
+ if (!statusFields || statusFields.length === 0) return "";
10758
+ const predicate = includePaused ? "!= 'REMOVED'" : "= 'ENABLED'";
10759
+ return statusFields.map((field) => ` AND ${field} ${predicate}`).join("");
10760
+ }
10761
+ function expandPreset(preset, params) {
10762
+ const dateRange = params.dateRange ?? preset.defaultDateRange;
10763
+ const limit = params.limit ?? preset.defaultLimit;
10764
+ if (!isValidDateRange(dateRange)) {
10765
+ return {
10766
+ query: "",
10767
+ dateRangeError: `Invalid date range: "${dateRange}". Use a GAQL date literal (e.g. LAST_30_DAYS, TODAY) or BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'.`
10768
+ };
10769
+ }
10770
+ const statusScope = buildStatusScope(preset.statusFields, params.includePaused ?? false);
10771
+ const query = preset.gaqlTemplate.replace(/\{dateRange\}/g, dateRange).replace(/\{limit\}/g, String(limit)).replace(/\{statusScope\}/g, statusScope);
10772
+ return { query };
10773
+ }
10774
+
10628
10775
  // src/commands/ads/google/preflight.ts
10629
10776
  function buildCommand2(query, customerId) {
10630
10777
  return `baker ads google query "${query}" --customer-id ${customerId}`;
10631
10778
  }
10779
+ var RUNG_PIN_FIELDS = {
10780
+ "campaign.status": ["campaign.id", "campaign.resource_name"],
10781
+ "ad_group.status": ["ad_group.id", "ad_group.resource_name"],
10782
+ "ad_group_criterion.status": ["ad_group_criterion.criterion_id", "ad_group_criterion.resource_name"],
10783
+ "ad_group_ad.status": ["ad_group_ad.ad.id", "ad_group_ad.resource_name"],
10784
+ "asset_group.status": ["asset_group.id", "asset_group.resource_name"]
10785
+ };
10786
+ function whereClause(query) {
10787
+ const match = query.match(/\bWHERE\b([\s\S]*)$/i);
10788
+ if (!match?.[1]) return "";
10789
+ let rest = match[1];
10790
+ for (const trailing of [/\bORDER\s+BY\b/i, /\bLIMIT\b/i, /\bPARAMETERS\b/i]) {
10791
+ const idx = rest.search(trailing);
10792
+ if (idx !== -1) rest = rest.slice(0, idx);
10793
+ }
10794
+ return rest;
10795
+ }
10796
+ function selectClause(query) {
10797
+ return query.split(/\bFROM\b/i)[0] ?? "";
10798
+ }
10799
+ function mentions(clause, field) {
10800
+ return new RegExp(`(?<![\\w.])${field.replace(/\./g, "\\.")}\\b`, "i").test(clause);
10801
+ }
10802
+ function analyzeServingChain(query) {
10803
+ const resource = fromResource(query);
10804
+ const chain = resource ? SERVING_CHAINS[resource] : void 0;
10805
+ if (!chain) return null;
10806
+ const where = whereClause(query);
10807
+ if (/ad_group_criterion\.negative\s*=\s*TRUE/i.test(where)) return null;
10808
+ const unscoped = chain.filter((rung) => {
10809
+ if (mentions(where, rung)) return false;
10810
+ return !(RUNG_PIN_FIELDS[rung] ?? []).some((pin) => mentions(where, pin));
10811
+ });
10812
+ const assertsServing = chain.some(
10813
+ (rung) => new RegExp(`${rung.replace(/\./g, "\\.")}\\s*=\\s*'ENABLED'`, "i").test(where)
10814
+ );
10815
+ return { chain, unscoped, assertsServing };
10816
+ }
10817
+ function rejectPartialChain(query, customerId, analysis) {
10818
+ if (!analysis.assertsServing || analysis.unscoped.length === 0) return null;
10819
+ const added = analysis.unscoped.map((rung) => `${rung} = 'ENABLED'`).join(" AND ");
10820
+ const fixed = query.replace(/\bWHERE\b/i, `WHERE ${added} AND`);
10821
+ return {
10822
+ ok: false,
10823
+ error: {
10824
+ code: "INCOMPLETE_STATUS_CHAIN",
10825
+ message: `This query filters on serving status but leaves ${analysis.unscoped.join(", ")} unscoped. Google Ads has no single serving flag \u2014 a row is live only when its whole chain (${analysis.chain.join(" \u2192 ")}) is ENABLED, so rows under a paused parent will come back looking live.`,
10826
+ fix: {
10827
+ action: "retry_with_modified_query",
10828
+ correctedCommand: buildCommand2(fixed, customerId),
10829
+ explanation: `Add the missing rungs: ${added}. To look at paused entities on purpose, scope every rung explicitly instead (e.g. ${analysis.unscoped.map((rung) => `${rung} != 'REMOVED'`).join(" AND ")}), or use --preset ... --include-paused.`
10830
+ },
10831
+ retryable: false
10832
+ }
10833
+ };
10834
+ }
10835
+ function annotateUnscopedChain(query, analysis) {
10836
+ if (analysis.assertsServing || analysis.unscoped.length === 0) return { query, warnings: [] };
10837
+ const select = selectClause(query);
10838
+ const missing = analysis.unscoped.filter((rung) => !mentions(select, rung));
10839
+ if (missing.length === 0) return { query, warnings: [] };
10840
+ return {
10841
+ query: query.replace(/SELECT\s+/i, `SELECT ${missing.join(", ")}, `),
10842
+ warnings: [
10843
+ {
10844
+ code: "SERVING_STATUS_ADDED",
10845
+ message: `Added ${missing.join(", ")} to SELECT \u2014 this query does not scope ${analysis.unscoped.join(", ")}, so it returns paused-parent rows that are not serving. Read those columns before calling any row live; filter every rung = 'ENABLED' to get serving rows only.`
10846
+ }
10847
+ ]
10848
+ };
10849
+ }
10632
10850
  var SINGLE_ROW_RESOURCES = ["customer"];
10633
10851
  function fromResource(query) {
10634
10852
  const match = query.match(/\bFROM\s+([A-Za-z_][A-Za-z0-9_]*)/i);
@@ -10641,10 +10859,10 @@ function isSingleRowRead(query) {
10641
10859
  }
10642
10860
  function addRequiredCampaignFields(query) {
10643
10861
  if (!/FROM\s+campaign_budget\b/i.test(query)) return { query, warnings: [] };
10644
- const whereClause2 = query.split(/\bWHERE\b/i)[1] ?? "";
10645
- const selectClause = query.split(/\bFROM\b/i)[0] ?? "";
10646
- const selectFields = new Set(selectClause.match(/campaign\.[\w.]+/g) ?? []);
10647
- const missing = [...new Set(whereClause2.match(/campaign\.[\w.]+/g) ?? [])].filter(
10862
+ const whereClause3 = query.split(/\bWHERE\b/i)[1] ?? "";
10863
+ const selectClause2 = query.split(/\bFROM\b/i)[0] ?? "";
10864
+ const selectFields = new Set(selectClause2.match(/campaign\.[\w.]+/g) ?? []);
10865
+ const missing = [...new Set(whereClause3.match(/campaign\.[\w.]+/g) ?? [])].filter(
10648
10866
  (field) => !selectFields.has(field)
10649
10867
  );
10650
10868
  if (missing.length === 0) return { query, warnings: [] };
@@ -10794,128 +11012,13 @@ function validatePreflight(query, customerId, limit) {
10794
11012
  }
10795
11013
  const { query: autoFixed, warnings: autoFixWarnings } = applyAutoFixes(corrected, limit);
10796
11014
  warnings.push(...autoFixWarnings);
10797
- return { valid: true, correctedQuery: autoFixed, warnings };
10798
- }
10799
-
10800
- // src/commands/ads/google/presets.ts
10801
- var GAQL_DATE_RANGE_RE = /^(?:TODAY|YESTERDAY|LAST_7_DAYS|LAST_14_DAYS|LAST_30_DAYS|LAST_90_DAYS|THIS_MONTH|LAST_MONTH|THIS_QUARTER|LAST_QUARTER|THIS_YEAR|LAST_YEAR|ALL_TIME|BETWEEN\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}'\s+AND\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}')$/i;
10802
- function isValidDateRange(value) {
10803
- return GAQL_DATE_RANGE_RE.test(value.trim());
10804
- }
10805
- var PRESETS = [
10806
- {
10807
- name: "campaign-performance",
10808
- description: "Campaign-level metrics overview",
10809
- gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.ctr, metrics.average_cpc FROM campaign WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.cost_micros DESC LIMIT {limit}`,
10810
- defaultDateRange: "LAST_30_DAYS",
10811
- defaultLimit: 200,
10812
- statusFields: ["campaign.status"]
10813
- },
10814
- {
10815
- name: "keyword-analysis",
10816
- description: "Keyword performance with match type and quality",
10817
- gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.id, ad_group.name, ad_group.status, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.quality_info.quality_score, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.ctr FROM keyword_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
10818
- defaultDateRange: "LAST_30_DAYS",
10819
- defaultLimit: 200,
10820
- statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
10821
- },
10822
- {
10823
- name: "keyword-serving",
10824
- description: "Why a keyword is limited \u2014 Google Ads' 'Eligible (Limited) / Below first page bid' column, with the first-page bid estimate, the current max CPC and quality score",
10825
- gaqlTemplate: `SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.primary_status, ad_group_criterion.primary_status_reasons, ad_group_criterion.system_serving_status, ad_group_criterion.approval_status, ad_group_criterion.effective_cpc_bid_micros, ad_group_criterion.effective_cpc_bid_source, ad_group_criterion.position_estimates.first_page_cpc_micros, ad_group_criterion.position_estimates.top_of_page_cpc_micros, ad_group_criterion.quality_info.quality_score, ad_group_criterion.quality_info.creative_quality_score, ad_group_criterion.quality_info.post_click_quality_score, ad_group_criterion.quality_info.search_predicted_ctr FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = FALSE{statusScope} ORDER BY campaign.name LIMIT {limit}`,
10826
- defaultDateRange: "ALL_TIME",
10827
- defaultLimit: 500,
10828
- statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
10829
- },
10830
- {
10831
- name: "positive-keywords",
10832
- description: "Positive (targeting) keywords only \u2014 excludes negatives",
10833
- gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.id, ad_group.name, ad_group.status, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.negative FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = FALSE{statusScope} ORDER BY campaign.name LIMIT {limit}`,
10834
- defaultDateRange: "ALL_TIME",
10835
- defaultLimit: 500,
10836
- statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
10837
- },
10838
- {
10839
- name: "negative-keywords",
10840
- description: "Negative keywords only \u2014 campaign and ad group level",
10841
- gaqlTemplate: `SELECT campaign.id, campaign.name, ad_group.name, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.negative FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = TRUE AND campaign.status != 'REMOVED' ORDER BY campaign.name LIMIT {limit}`,
10842
- defaultDateRange: "ALL_TIME",
10843
- defaultLimit: 500
10844
- },
10845
- {
10846
- name: "negative-keyword-lists",
10847
- description: "Shared negative keyword lists and the terms inside them (SharedSet members)",
10848
- gaqlTemplate: `SELECT shared_set.id, shared_set.name, shared_criterion.keyword.text, shared_criterion.keyword.match_type FROM shared_criterion WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND shared_set.status = 'ENABLED' ORDER BY shared_set.name LIMIT {limit}`,
10849
- defaultDateRange: "ALL_TIME",
10850
- defaultLimit: 500
10851
- },
10852
- {
10853
- name: "negative-list-attachments",
10854
- description: "Which campaigns each shared negative keyword list is attached to",
10855
- gaqlTemplate: `SELECT campaign.id, campaign.name, shared_set.id, shared_set.name FROM campaign_shared_set WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' ORDER BY campaign.name LIMIT {limit}`,
10856
- defaultDateRange: "ALL_TIME",
10857
- defaultLimit: 500
10858
- },
10859
- {
10860
- name: "search-terms",
10861
- description: "Actual user search queries triggering ads",
10862
- gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.name, ad_group.status, search_term_view.search_term, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
10863
- defaultDateRange: "LAST_7_DAYS",
10864
- defaultLimit: 200,
10865
- statusFields: ["campaign.status", "ad_group.status"]
10866
- },
10867
- {
10868
- name: "ad-copy-performance",
10869
- description: "Ad headline and description effectiveness",
10870
- gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.status, ad_group_ad.status, ad_group_ad.ad.responsive_search_ad.headlines, ad_group_ad.ad.responsive_search_ad.descriptions, ad_group_ad.ad.final_urls, metrics.impressions, metrics.clicks, metrics.conversions, metrics.ctr FROM ad_group_ad WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
10871
- defaultDateRange: "LAST_30_DAYS",
10872
- defaultLimit: 200,
10873
- statusFields: ["campaign.status", "ad_group.status", "ad_group_ad.status"]
10874
- },
10875
- {
10876
- name: "asset-performance",
10877
- description: "Performance Max asset performance labels",
10878
- 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}`,
10879
- defaultDateRange: "LAST_30_DAYS",
10880
- defaultLimit: 200,
10881
- statusFields: ["campaign.status", "asset_group.status"]
10882
- },
10883
- {
10884
- name: "shopping-products",
10885
- description: "Product-level shopping performance metrics",
10886
- gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, segments.product_title, segments.product_item_id, segments.product_brand, segments.product_type_l1, metrics.clicks, metrics.impressions, metrics.cost_micros, metrics.conversions FROM shopping_performance_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.cost_micros DESC LIMIT {limit}`,
10887
- defaultDateRange: "LAST_30_DAYS",
10888
- defaultLimit: 200,
10889
- statusFields: ["campaign.status"]
10890
- },
10891
- {
10892
- name: "account-summary",
10893
- description: "Account-level totals for a date range",
10894
- gaqlTemplate: `SELECT customer.id, customer.descriptive_name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value FROM customer WHERE segments.date DURING {dateRange} LIMIT 1`,
10895
- defaultDateRange: "LAST_30_DAYS",
10896
- defaultLimit: 1
10897
- }
10898
- ];
10899
- function getPreset(name) {
10900
- return PRESETS.find((p) => p.name === name);
10901
- }
10902
- function buildStatusScope(statusFields, includePaused) {
10903
- if (!statusFields || statusFields.length === 0) return "";
10904
- const predicate = includePaused ? "!= 'REMOVED'" : "= 'ENABLED'";
10905
- return statusFields.map((field) => ` AND ${field} ${predicate}`).join("");
10906
- }
10907
- function expandPreset(preset, params) {
10908
- const dateRange = params.dateRange ?? preset.defaultDateRange;
10909
- const limit = params.limit ?? preset.defaultLimit;
10910
- if (!isValidDateRange(dateRange)) {
10911
- return {
10912
- query: "",
10913
- dateRangeError: `Invalid date range: "${dateRange}". Use a GAQL date literal (e.g. LAST_30_DAYS, TODAY) or BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'.`
10914
- };
10915
- }
10916
- const statusScope = buildStatusScope(preset.statusFields, params.includePaused ?? false);
10917
- const query = preset.gaqlTemplate.replace(/\{dateRange\}/g, dateRange).replace(/\{limit\}/g, String(limit)).replace(/\{statusScope\}/g, statusScope);
10918
- return { query };
11015
+ const analysis = analyzeServingChain(autoFixed);
11016
+ if (!analysis) return { valid: true, correctedQuery: autoFixed, warnings };
11017
+ const rejection = rejectPartialChain(autoFixed, customerId, analysis);
11018
+ if (rejection) return { valid: false, warnings: [], error: rejection };
11019
+ const annotated = annotateUnscopedChain(autoFixed, analysis);
11020
+ warnings.push(...annotated.warnings);
11021
+ return { valid: true, correctedQuery: annotated.query, warnings };
10919
11022
  }
10920
11023
 
10921
11024
  // src/commands/ads/google/query.ts
@@ -10944,12 +11047,17 @@ registerSchema({
10944
11047
  description: "Include paused entities in preset results. Off by default: presets return only actually-serving entities. Google Ads has no single serving flag \u2014 an entity serves only when its whole chain (campaign, ad group, ad) is ENABLED, so recommendations should target serving entities unless the user asks about paused ones.",
10945
11048
  required: false
10946
11049
  },
10947
- limit: { type: "number", description: "Max rows (default 200)", required: false, default: 200 },
11050
+ limit: {
11051
+ type: "number",
11052
+ description: "Max rows in ONE page (default 200), AND the LIMIT added to a query that has none \u2014 so it caps the whole read with --all too. Values up to 10000 are accepted. Any read that fills this cap comes back flagged TRUNCATED_RESULTS \u2014 never count or total from one.",
11053
+ required: false,
11054
+ default: 200
11055
+ },
10948
11056
  "list-presets": { type: "boolean", description: "List all available query presets", required: false },
10949
11057
  cursor: { type: "string", description: "Pagination cursor from previous response", required: false },
10950
11058
  all: {
10951
11059
  type: "boolean",
10952
- description: "Auto-paginate all results (use with --out for large datasets)",
11060
+ description: "Follow every page until the data set is exhausted (use with --out for large datasets). Needed for a read you may count or total, but not sufficient: it exhausts the pages, not the statement's LIMIT, so pair it with a --limit above the row count you expect (or your own LIMIT) and check the read came back without TRUNCATED_RESULTS.",
10953
11061
  required: false
10954
11062
  },
10955
11063
  out: {
@@ -11004,53 +11112,117 @@ function writeRowsToFile(filePath, rows, fields, append) {
11004
11112
  writeFileSync(filePath, JSON.stringify(rows, null, 2), "utf-8");
11005
11113
  }
11006
11114
  }
11115
+ function effectiveRowLimit(finalQuery, limit, all = false) {
11116
+ const match = finalQuery.match(/\bLIMIT\s+(\d+)/i);
11117
+ const statementLimit = match?.[1] ? Number(match[1]) : void 0;
11118
+ if (all) return statementLimit;
11119
+ return statementLimit === void 0 ? limit : Math.min(limit, statementLimit);
11120
+ }
11121
+ function warningLines(warnings, format) {
11122
+ return warnings.map((w) => {
11123
+ const message = w.message.replace(/\s*\n\s*/g, " ");
11124
+ if (format === "jsonl") return JSON.stringify({ _baker_warning: { code: w.code, message } });
11125
+ if (format === "md") return `> **${w.code}** \u2014 ${message}`;
11126
+ return `# baker ${w.code}: ${message}`;
11127
+ });
11128
+ }
11129
+ function completenessWarnings(read) {
11130
+ const cap3 = read.effectiveLimit;
11131
+ const filledTheCap = cap3 !== void 0 && cap3 > 0 && read.rowCount >= cap3;
11132
+ if (!read.hasMore && !filledTheCap) return [];
11133
+ const cause = read.hasMore ? `More rows are waiting: the account returned a next-page cursor this run did not follow.` : `The ${read.rowCount} rows returned exactly fill the row cap in force (${cap3}).`;
11134
+ const fix = read.all ? `Every page was followed, so the cap is the LIMIT in the statement itself \u2014 raise it above ${cap3}, or drop it and pass a larger --limit, then re-run.` : `Re-run with --all --out <file> to page through everything, or raise --limit above ${cap3}.`;
11135
+ return [
11136
+ {
11137
+ code: "TRUNCATED_RESULTS",
11138
+ message: `INCOMPLETE READ \u2014 ${cause} These rows are a page, not the data set. Do NOT count, total or describe them as the whole picture. ${fix}`
11139
+ }
11140
+ ];
11141
+ }
11007
11142
  function buildQueryEnvelope(rows, fieldDescs, warnings, options) {
11008
- const envelope = { ok: true, data: rows };
11009
- if (options.full && Object.keys(fieldDescs).length > 0) {
11010
- envelope.fields = fieldDescs;
11011
- }
11012
- if (options.nextCursor) {
11013
- envelope.pagination = { hasMore: true, cursor: options.nextCursor };
11014
- }
11015
- if (warnings.length > 0) {
11016
- envelope.warnings = warnings;
11017
- }
11018
- if (options.cached) {
11019
- envelope.cached = true;
11020
- }
11021
- return envelope;
11143
+ const notice = summariseWarnings(warnings);
11144
+ return {
11145
+ ok: true,
11146
+ ...options.cached ? { cached: true } : {},
11147
+ ...warnings.length > 0 ? { warnings } : {},
11148
+ ...options.nextCursor ? { pagination: { hasMore: true, cursor: options.nextCursor } } : {},
11149
+ data: rows,
11150
+ ...options.full && Object.keys(fieldDescs).length > 0 ? { fields: fieldDescs } : {},
11151
+ ...notice ? { notice } : {}
11152
+ };
11022
11153
  }
11023
- function outputResults(rows, fieldDescs, warnings, args, cached, nextCursor) {
11024
- const format = args.output || "json";
11025
- const fields = Object.keys(rows[0] ?? {});
11026
- if (format === "csv") {
11027
- process.stdout.write(`${toCsvRow(fields)}
11028
- `);
11029
- for (const row of rows) {
11030
- process.stdout.write(`${toCsvRow(fields.map((f) => String(row[f] ?? "")))}
11154
+ function summariseWarnings(warnings) {
11155
+ if (warnings.length === 0) return void 0;
11156
+ const codes = [...new Set(warnings.map((w) => w.code))].join(", ");
11157
+ return `Read the "warnings" above before using these rows: ${codes}.`;
11158
+ }
11159
+ function buildFileSummary(input) {
11160
+ const notice = summariseWarnings(input.warnings);
11161
+ return {
11162
+ ok: true,
11163
+ fields: {},
11164
+ file: input.file,
11165
+ rows: input.rows,
11166
+ complete: input.complete,
11167
+ ...input.warnings.length > 0 ? { warnings: input.warnings } : {},
11168
+ ...notice ? { notice } : {}
11169
+ };
11170
+ }
11171
+ var REPEAT_NOTICE_ABOVE_ROWS = 20;
11172
+ function emitToStderr(notices) {
11173
+ for (const line of notices) process.stderr.write(`${line}
11031
11174
  `);
11032
- }
11033
- return;
11034
- }
11035
- if (format === "jsonl") {
11036
- for (const row of rows) {
11037
- process.stdout.write(`${JSON.stringify(row)}
11175
+ }
11176
+ function writeLine(line) {
11177
+ process.stdout.write(`${line}
11038
11178
  `);
11039
- }
11040
- return;
11179
+ }
11180
+ var writeCsvRows = (rows, fields, notices) => {
11181
+ writeLine(toCsvRow(fields));
11182
+ for (const line of notices) writeLine(line);
11183
+ for (const row of rows) writeLine(toCsvRow(fields.map((f) => String(row[f] ?? ""))));
11184
+ };
11185
+ var writeJsonlRows = (rows, _fields, notices) => {
11186
+ for (const line of notices) writeLine(line);
11187
+ for (const row of rows) writeLine(JSON.stringify(row));
11188
+ };
11189
+ var writeMdRows = (rows, fields, notices) => {
11190
+ for (const line of notices) writeLine(line);
11191
+ writeLine(`| ${fields.join(" | ")} |`);
11192
+ writeLine(`| ${fields.map(() => "---").join(" | ")} |`);
11193
+ for (const row of rows) writeLine(`| ${fields.map((f) => String(row[f] ?? "")).join(" | ")} |`);
11194
+ };
11195
+ var ROW_WRITERS = { csv: writeCsvRows, jsonl: writeJsonlRows, md: writeMdRows };
11196
+ function writeRows(format, rows, notices) {
11197
+ const write2 = ROW_WRITERS[format] ?? writeMdRows;
11198
+ write2(rows, Object.keys(rows[0] ?? {}), notices);
11199
+ if (rows.length > REPEAT_NOTICE_ABOVE_ROWS) {
11200
+ for (const line of notices) writeLine(line);
11041
11201
  }
11042
- if (format === "md") {
11043
- process.stdout.write(`| ${fields.join(" | ")} |
11044
- `);
11045
- process.stdout.write(`| ${fields.map(() => "---").join(" | ")} |
11046
- `);
11047
- for (const row of rows) {
11048
- process.stdout.write(`| ${fields.map((f) => String(row[f] ?? "")).join(" | ")} |
11049
- `);
11050
- }
11202
+ }
11203
+ function outputResults(rows, fieldDescs, warnings, args, cached, read) {
11204
+ const format = args.output || "json";
11205
+ const allWarnings = [
11206
+ ...warnings,
11207
+ ...completenessWarnings({
11208
+ rowCount: rows.length,
11209
+ effectiveLimit: read.effectiveLimit,
11210
+ all: read.all,
11211
+ hasMore: Boolean(read.nextCursor)
11212
+ })
11213
+ ];
11214
+ emitToStderr(warningLines(allWarnings, "csv"));
11215
+ if (format === "json") {
11216
+ writeAdsJson(
11217
+ buildQueryEnvelope(rows, fieldDescs, allWarnings, {
11218
+ full: Boolean(args.full),
11219
+ cached,
11220
+ nextCursor: read.nextCursor
11221
+ })
11222
+ );
11051
11223
  return;
11052
11224
  }
11053
- writeAdsJson(buildQueryEnvelope(rows, fieldDescs, warnings, { full: Boolean(args.full), cached, nextCursor }));
11225
+ writeRows(format, rows, warningLines(allWarnings, format));
11054
11226
  }
11055
11227
  function resolveGaql(args, limit) {
11056
11228
  if (args.preset) {
@@ -11099,8 +11271,8 @@ var SERVING_HIERARCHY_RESOURCES = [
11099
11271
  "search_term_view",
11100
11272
  "shopping_performance_view",
11101
11273
  "asset_group_asset"
11102
- ];
11103
- function whereClause(upperQuery) {
11274
+ ].filter((resource) => !(resource in SERVING_CHAINS));
11275
+ function whereClause2(upperQuery) {
11104
11276
  const whereIdx = upperQuery.indexOf(" WHERE ");
11105
11277
  if (whereIdx === -1) return "";
11106
11278
  let rest = upperQuery.slice(whereIdx + 7);
@@ -11124,7 +11296,7 @@ function servingScopeWarnings(args, finalQuery) {
11124
11296
  const fromMatch = upper2.match(/\bFROM\s+([A-Z_]+)/);
11125
11297
  const resource = fromMatch?.[1]?.toLowerCase();
11126
11298
  const hierarchical = resource ? SERVING_HIERARCHY_RESOURCES.includes(resource) : false;
11127
- const filtersStatus = whereClause(upper2).includes(".STATUS");
11299
+ const filtersStatus = whereClause2(upper2).includes(".STATUS");
11128
11300
  if (hierarchical && !filtersStatus) {
11129
11301
  return [
11130
11302
  {
@@ -11141,11 +11313,12 @@ function parseSelectedFields(gaql) {
11141
11313
  if (!list) return [];
11142
11314
  return list.split(",").map((field) => field.trim()).filter((field) => /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$/.test(field));
11143
11315
  }
11144
- function unobservedSelectedFields(gaql, rows) {
11145
- const observed = /* @__PURE__ */ new Set();
11316
+ function foldObservedFields(rows, observed) {
11146
11317
  for (const row of rows) {
11147
11318
  for (const key of Object.keys(row)) observed.add(key);
11148
11319
  }
11320
+ }
11321
+ function unobservedFields(gaql, observed) {
11149
11322
  return parseSelectedFields(gaql).filter((field) => {
11150
11323
  if (field.endsWith(".resource_name")) return false;
11151
11324
  if (observed.has(field)) return false;
@@ -11153,8 +11326,13 @@ function unobservedSelectedFields(gaql, rows) {
11153
11326
  });
11154
11327
  }
11155
11328
  function missingFieldWarnings(gaql, rows) {
11156
- if (rows.length === 0) return [];
11157
- const missing = unobservedSelectedFields(gaql, rows);
11329
+ const observed = /* @__PURE__ */ new Set();
11330
+ foldObservedFields(rows, observed);
11331
+ return missingFieldWarningsFor(gaql, observed, rows.length);
11332
+ }
11333
+ function missingFieldWarningsFor(gaql, observed, rowCount2) {
11334
+ if (rowCount2 === 0) return [];
11335
+ const missing = unobservedFields(gaql, observed);
11158
11336
  if (missing.length === 0) return [];
11159
11337
  return [
11160
11338
  {
@@ -11171,6 +11349,7 @@ async function fetchAllPages(body, args, servingOptions) {
11171
11349
  let pageToken = args.cursor;
11172
11350
  let totalRowsWritten = 0;
11173
11351
  let keywordLimits;
11352
+ const observedFields = /* @__PURE__ */ new Set();
11174
11353
  do {
11175
11354
  const requestBody2 = { ...body };
11176
11355
  if (pageToken) requestBody2.pageToken = pageToken;
@@ -11178,11 +11357,11 @@ async function fetchAllPages(body, args, servingOptions) {
11178
11357
  const rows = response.rows ?? [];
11179
11358
  pageToken = response.pageToken;
11180
11359
  if (args.out) {
11181
- const filePath = resolve(args.out);
11182
11360
  const shouldAppend = args.append || totalRowsWritten > 0;
11183
- writeRowsToFile(filePath, rows, extractFields(rows), shouldAppend);
11361
+ writeRowsToFile(resolve(args.out), rows, extractFields(rows), shouldAppend);
11184
11362
  totalRowsWritten += rows.length;
11185
11363
  keywordLimits = collectKeywordLimits(rows, servingOptions, keywordLimits);
11364
+ foldObservedFields(rows, observedFields);
11186
11365
  } else {
11187
11366
  allRows = allRows.concat(rows);
11188
11367
  }
@@ -11192,7 +11371,8 @@ async function fetchAllPages(body, args, servingOptions) {
11192
11371
  allRows,
11193
11372
  lastPageToken: pageToken,
11194
11373
  totalRowsWritten,
11195
- keywordLimits: keywordLimits ?? collectKeywordLimits([], servingOptions)
11374
+ keywordLimits: keywordLimits ?? collectKeywordLimits([], servingOptions),
11375
+ observedFields
11196
11376
  };
11197
11377
  }
11198
11378
  async function executeQuery(finalQuery, args, customerId, limit, warnings, useCache, cacheKey) {
@@ -11202,12 +11382,33 @@ async function executeQuery(finalQuery, args, customerId, limit, warnings, useCa
11202
11382
  if (args.cursor) body.pageToken = args.cursor;
11203
11383
  if (!useCache) body.skipCache = true;
11204
11384
  const servingOptions = keywordServingOptions(finalQuery);
11205
- const { allRows, lastPageToken, totalRowsWritten, keywordLimits } = await fetchAllPages(body, args, servingOptions);
11385
+ const { allRows, lastPageToken, totalRowsWritten, keywordLimits, observedFields } = await fetchAllPages(
11386
+ body,
11387
+ args,
11388
+ servingOptions
11389
+ );
11206
11390
  if (args.out) {
11207
- const summary = { ok: true, fields: {}, file: resolve(args.out), rows: totalRowsWritten };
11208
- const limitWarnings = renderKeywordLimits(keywordLimits);
11209
- if (limitWarnings.length > 0) summary.warnings = limitWarnings;
11210
- writeAdsJson(summary);
11391
+ const truncation = completenessWarnings({
11392
+ rowCount: totalRowsWritten,
11393
+ effectiveLimit: effectiveRowLimit(finalQuery, limit, Boolean(args.all)),
11394
+ all: Boolean(args.all),
11395
+ hasMore: Boolean(lastPageToken)
11396
+ });
11397
+ const fileWarnings = [
11398
+ ...warnings,
11399
+ ...renderKeywordLimits(keywordLimits),
11400
+ ...missingFieldWarningsFor(finalQuery, observedFields, totalRowsWritten),
11401
+ ...truncation
11402
+ ];
11403
+ emitToStderr(warningLines(fileWarnings, "csv"));
11404
+ writeAdsJson(
11405
+ buildFileSummary({
11406
+ file: resolve(args.out),
11407
+ rows: totalRowsWritten,
11408
+ warnings: fileWarnings,
11409
+ complete: truncation.length === 0
11410
+ })
11411
+ );
11211
11412
  return;
11212
11413
  }
11213
11414
  if (useCache && allRows.length > 0) {
@@ -11221,9 +11422,16 @@ async function executeQuery(finalQuery, args, customerId, limit, warnings, useCa
11221
11422
  [...warnings, ...missingFieldWarnings(finalQuery, allRows), ...keywordServingWarnings(allRows, servingOptions)],
11222
11423
  args,
11223
11424
  false,
11224
- lastPageToken
11425
+ {
11426
+ effectiveLimit: effectiveRowLimit(finalQuery, limit, Boolean(args.all)),
11427
+ all: Boolean(args.all),
11428
+ nextCursor: lastPageToken
11429
+ }
11225
11430
  );
11226
11431
  }
11432
+ function shouldReadCache(args) {
11433
+ return !args["no-cache"] && !args.out;
11434
+ }
11227
11435
  async function validateArgs(args) {
11228
11436
  const customerId = await resolveCustomerId(args);
11229
11437
  const limit = args.limit ? Number(args.limit) : 200;
@@ -11286,10 +11494,14 @@ Examples:
11286
11494
  description: "Include paused entities in preset results (default: serving entities only)",
11287
11495
  required: false
11288
11496
  },
11289
- limit: { type: "string", description: "Max rows (default 200)", required: false },
11497
+ limit: {
11498
+ type: "string",
11499
+ description: "Max rows in one page (default 200); without --all the read stops there",
11500
+ required: false
11501
+ },
11290
11502
  "list-presets": { type: "boolean", description: "List available presets", required: false },
11291
11503
  cursor: { type: "string", description: "Pagination cursor", required: false },
11292
- all: { type: "boolean", description: "Auto-paginate all results", required: false },
11504
+ all: { type: "boolean", description: "Follow every page (use with --out for large datasets)", required: false },
11293
11505
  out: { type: "string", description: "File path for output", required: false },
11294
11506
  append: { type: "boolean", description: "Append to file", required: false },
11295
11507
  output: { type: "string", description: "Format: json|csv|jsonl|md", required: false, default: "json" },
@@ -11313,7 +11525,7 @@ Examples:
11313
11525
  const validated = await validateArgs(args);
11314
11526
  if (!validated) return;
11315
11527
  const { customerId, limit, finalQuery, warnings, useCache, cacheKey } = validated;
11316
- if (useCache) {
11528
+ if (shouldReadCache(args)) {
11317
11529
  const cached = cacheGet("queries", cacheKey);
11318
11530
  if (cached) {
11319
11531
  const fields = extractFields(cached.data);
@@ -11326,7 +11538,8 @@ Examples:
11326
11538
  ...keywordServingWarnings(cached.data, keywordServingOptions(finalQuery))
11327
11539
  ],
11328
11540
  args,
11329
- true
11541
+ true,
11542
+ { effectiveLimit: effectiveRowLimit(finalQuery, limit, Boolean(args.all)), all: Boolean(args.all) }
11330
11543
  );
11331
11544
  return;
11332
11545
  }
@@ -13402,9 +13615,16 @@ var customerIdArg = { "customer-id": { type: "string", description: "10-digit Go
13402
13615
  var fileArg = {
13403
13616
  file: { type: "string", description: "JSON file with the full op payload (flags override)" }
13404
13617
  };
13618
+ var BUDGET_DEMAND_HINTS = [
13619
+ "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.",
13620
+ "No volume measured yet? Pull it first \u2014 `baker ads google query --preset search-terms` for what the account already captures, `baker ads google keywords metrics` for the market estimate (those are planner figures, never account performance)."
13621
+ ];
13405
13622
  var budgetsCommand = defineCommand30({
13406
13623
  meta: { name: "budgets", description: "Stage campaign budget create/update" },
13407
13624
  subCommands: {
13625
+ // Fires where the budget is actually set, rather than on whatever the chat happened to stage
13626
+ // first. A budget several times what the target pool can absorb never spends, and reads to the
13627
+ // client as a plan nobody costed.
13408
13628
  create: defineCommand30({
13409
13629
  meta: { name: "create", description: "Stage a campaign budget" },
13410
13630
  args: {
@@ -13423,7 +13643,7 @@ var budgetsCommand = defineCommand30({
13423
13643
  deliveryMethod: args.delivery,
13424
13644
  explicitlyShared: args.shared || void 0
13425
13645
  });
13426
- await stageCreate("google.budget.create", customerId, payload);
13646
+ await stageCreate("google.budget.create", customerId, payload, BUDGET_DEMAND_HINTS);
13427
13647
  }
13428
13648
  }),
13429
13649
  update: defineCommand30({
@@ -13443,7 +13663,7 @@ var budgetsCommand = defineCommand30({
13443
13663
  amountMicros: microsFlag(args.amount, "--amount"),
13444
13664
  deliveryMethod: args.delivery
13445
13665
  });
13446
- await stageUpdate("google.budget.update", customerId, target, payload);
13666
+ await stageUpdate("google.budget.update", customerId, target, payload, BUDGET_DEMAND_HINTS);
13447
13667
  }
13448
13668
  })
13449
13669
  }
@@ -14456,7 +14676,8 @@ function requireAudienceLevel(value) {
14456
14676
  return level;
14457
14677
  }
14458
14678
  var AUDIENCE_ATTACH_HINTS = [
14459
- 'Read back what the parent ended up with before you report: `baker ads google query "SELECT campaign.id, campaign.targeting_setting.target_restrictions FROM campaign"` (or the ad_group equivalent). A missing AUDIENCE row means TARGETING \u2014 Google does not serialize an empty setting, so absence is a value, not a gap.'
14679
+ 'Read back what the parent ended up with before you report: `baker ads google query "SELECT campaign.id, campaign.targeting_setting.target_restrictions FROM campaign"` (or the ad_group equivalent). A missing AUDIENCE row means TARGETING \u2014 Google does not serialize an empty setting, so absence is a value, not a gap.',
14680
+ "Staging checks the list is big enough to serve on this campaign's network (1,000 users on search, 100 on display) and refuses a targeting attach that is not. It does NOT check delivery afterwards, and Google reports no error when an audience stops a campaign serving \u2014 after publishing, read the campaign's impressions for the hour that follows and say what you saw."
14460
14681
  ];
14461
14682
  var audiencesAttachCommand = defineCommand30({
14462
14683
  meta: {