@wrangled/cli 0.3.51 → 0.3.53
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/api.js +2 -0
- package/dist/guide.js +7 -0
- package/dist/main.js +181 -12
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -23,6 +23,8 @@ export class UnreadableCredentialsError extends CliError {
|
|
|
23
23
|
export const entryTagFlags = { angle: "angle", hook_kind: "hook-kind", format: "ad-format", awareness: "awareness" };
|
|
24
24
|
/** The most ads or ad sets one meta ads|adset pause|resume sends; the server refuses more. */
|
|
25
25
|
export const META_IDS_MAX = 10;
|
|
26
|
+
/** The most ads or ad groups one google-ads ads|adgroup pause|resume sends; the server refuses more. */
|
|
27
|
+
export const GOOGLE_ADS_IDS_MAX = 10;
|
|
26
28
|
/** The days before a sale in which a click or reply helps it, as the app counts them (MESSAGE_CREDIT_DAYS). */
|
|
27
29
|
export const MESSAGE_CREDIT_DAYS = 7;
|
|
28
30
|
/** Copies of the server's IMPORT_LIMITS and import name rule; src/lib/cli-consistency.test.ts keeps them in step. */
|
package/dist/guide.js
CHANGED
|
@@ -187,6 +187,8 @@ The \`meta_ads\` publisher puts an approved ad on Facebook, always paused, in an
|
|
|
187
187
|
|
|
188
188
|
\`${cli} meta ad create --account act_<id> --adset <id> --file ad.png --text "<primary text>" --headline "<headline>" --link <https url>\` does all of that in one step: it saves the files and words as an ad with a new SKU, approves it and publishes it with \`meta_ads\` into the ad set, then prints the SKU, the publication, the Meta ad id and its status. Repeat \`--file\` for more pictures; a big file is uploaded first. \`--cta SHOP_NOW\` sets the button and \`--name\` names the ad (the headline otherwise). The same rules apply and are all checked before anything is saved, so a refusal leaves nothing behind. Your own key sends to Facebook without \`--run\`, but the account and campaign must also be on the workspace's control list; from a hand's run in practice mode it is recorded and nothing is sent. The ad starts paused; \`--status active --reason "<why>"\` switches it on 15 seconds after it is made, as a change in the change log that \`${cli} meta changes undo <change-id>\` reverses, and needs room under the day's limit of on/off changes. Without \`--idempotency-key\` the key comes from the files and the flags, so the same command run twice makes one ad.
|
|
189
189
|
|
|
190
|
+
Google Ads has the same controls. \`${cli} google-ads ads pause <adgroup-id>~<ad-id> --reason "<why>"\` (ids from \`gads_ads\`) and \`${cli} google-ads adgroup pause|resume <adgroup-id>\` turn ads and ad groups off or on, and \`${cli} google-ads campaign budget <campaign-id> --percent 10 --reason "<why>"\` (or \`--daily 60.50\`, in the account's currency) changes a campaign's daily budget. The account and campaign must be on the workspace's Google Ads control list, which an Admin sets on the Settings page, and the same daily limits, largest budget step, total daily budget limit and approval rule apply; a shared budget is never changed. With approval on (the default), a hand proposes \`gads_budget_change\` and the change goes out once a person approves it. Only your own key writes to Google Ads: no hand does yet. \`${cli} google-ads changes\` lists every change with who asked and why, \`${cli} google-ads changes undo <id>\` puts one back, and \`${cli} google-ads changes reconcile <id>\` settles one whose answer was lost.
|
|
191
|
+
|
|
190
192
|
\`${cli} meta adset publish --plan '<json>' --idempotency-key <key> --run <run-id>\` makes one new Meta ad set, always paused, for ads to go into. A copy, \`{"kind": "copy", "accountId": "act_<id>", "campaignId": "<id>", "sourceAdsetId": "<id>", "name": "<new name>"}\`, copies a proven ad set without its ads; the source must be on the hand's list of ad sets that may be copied. A new one from settings is \`{"kind": "create", "accountId": ..., "campaignId": ..., "name": ..., "settings": {"optimizationGoal": "...", "billingEvent": "...", "targeting": {...}, "dailyBudget": 5000}}\`, with the budget in the currency's smallest unit (5000 is 50.00 USD) and left out when the campaign holds it. The account and campaign must be on the publish list, the name unused in the campaign, and the day under the limit of new ad sets (3 unless an operator sets another). Ad sets and ads share one account's turn: one unfinished send at a time, and at least 15 seconds apart. ${brand.name} reads the new ad set back and checks it: paused, in the right campaign, with the right name, and for a copy, targeting equal to the source's. Its \`verification.ok\` must be true before you add ads; an ad set that fails the check stays paused and takes none. In practice mode nothing is sent and the ad set is recorded as \`not_sent_practice\`, and ads published into it are practice too. \`${cli} meta adset show <id>\` prints it with its budget. One left \`sending\` or \`unknown\` is never sent again; \`${cli} meta adset reconcile <id>\` looks for it in the campaign by its exact name.
|
|
191
193
|
|
|
192
194
|
When you send something yourself instead, record it in ${brand.name} before anything is sent, so the record never depends on the platform remembering it:
|
|
@@ -385,6 +387,10 @@ ${cli} content unpublish <deployment-id> [--force] take one deployed landing p
|
|
|
385
387
|
${cli} meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>] make a paused Meta ad set to add ads to
|
|
386
388
|
${cli} meta adset show <id> | meta adset reconcile <id> read an ad set publication, or check whether one left sending landed
|
|
387
389
|
${cli} meta ad create --account act_<id> --adset <id> --file <path> --text "<words>" --headline "<h>" --link <url> [--cta <type>] [--name <n>] [--status paused|active --reason "<why>"] [--run <run-id>] make a Facebook ad from files in one step
|
|
390
|
+
${cli} meta adset budget <id> | meta campaign budget <id> (--percent <n> | --daily <n>) --reason "<why>" change a daily budget within the workspace's limits
|
|
391
|
+
${cli} google-ads ads pause|resume <adgroup-id~ad-id>... | google-ads adgroup pause|resume <adgroup-id>... --reason "<why>" turn Google ads or ad groups on or off
|
|
392
|
+
${cli} google-ads campaign budget <id> (--percent <n> | --daily <amount>) --reason "<why>" change a Google Ads daily budget within the workspace's limits
|
|
393
|
+
${cli} google-ads changes [--since <date>] | google-ads changes undo <id> --reason "<why>" | google-ads changes reconcile <id> the Google Ads change log
|
|
388
394
|
${cli} content publish-record <sku> --file <publication.json> record a publish before sending it
|
|
389
395
|
${cli} content publish-finish <publication-id> --status sending|published|failed|not_sent_practice [--input '<json>']
|
|
390
396
|
${cli} content track-link <sku> <url> [--channel facebook|email|sms|web] [--campaign <id>] tag a link with its SKU
|
|
@@ -392,6 +398,7 @@ ${cli} agent start | show <run-id> | runs | finish <run-id> --status <s> --file
|
|
|
392
398
|
${cli} agent draft --agent p3_refresh the P3 refresh hand's counted draft
|
|
393
399
|
${cli} brand get | brand set --file <path> [--sources '<json>' | @file.json]
|
|
394
400
|
${cli} proposals create --input '<json>' | proposals list [--status <s>] [--run <run-id>]
|
|
401
|
+
${cli} proposals execute <proposal-id> apply an approved pause or budget proposal to Meta, in live mode only
|
|
395
402
|
${cli} content add-page <url> add a landing page and read it now
|
|
396
403
|
${cli} plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>'] [--landing <sku> | --landing-url <url>] [--funnel <key> --step <key>] [--run <agent-run-id>] plan a campaign push
|
|
397
404
|
${cli} plans show <plan-id> | plans add <plan-id> <sku> [--role <role>] [--position <n>] | plans remove <plan-id> <sku>
|
package/dist/main.js
CHANGED
|
@@ -5,7 +5,7 @@ import { mkdir, readFile, stat } from "node:fs/promises";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
|
-
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, META_IDS_MAX, IMPORT_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, entryTagFlags, streamSigned, } from "./api.js";
|
|
8
|
+
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, META_IDS_MAX, GOOGLE_ADS_IDS_MAX, IMPORT_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, entryTagFlags, streamSigned, } from "./api.js";
|
|
9
9
|
import { brand, envPrefix } from "./brand.js";
|
|
10
10
|
import { META_AD_CREATE_USAGE, metaAdCreateBody } from "./meta-ad.js";
|
|
11
11
|
import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
|
|
@@ -20,6 +20,7 @@ import { SKILL_NAME_PATTERN } from "./skill-zip.js";
|
|
|
20
20
|
import { installSkillFolder, operatorConnection, packSkill, parseSkillRef, readSkillFolder, unpackSkill, } from "./skills.js";
|
|
21
21
|
const DEFAULT_WAIT_SECONDS = 300;
|
|
22
22
|
const META_CHANGE_COLUMNS = ["id", "level", "object", "change", "status", "when", "by", "reason"];
|
|
23
|
+
const GOOGLE_ADS_CHANGE_COLUMNS = ["id", "account", "level", "object", "change", "status", "when", "by", "reason"];
|
|
23
24
|
const SERVER_WAIT_CAP = 50;
|
|
24
25
|
const VOICE_USAGE = "voice [--kind opener|objection|pain|desire|phrase] [--since <YYYY-MM-DD>] [--sort common|useful] [--limit <n>] [--json]";
|
|
25
26
|
const commands = {
|
|
@@ -1029,8 +1030,10 @@ const commands = {
|
|
|
1029
1030
|
"meta ads resume": metaStatusCommand("ads", "resume"),
|
|
1030
1031
|
"meta adset pause": metaStatusCommand("adset", "pause"),
|
|
1031
1032
|
"meta adset resume": metaStatusCommand("adset", "resume"),
|
|
1033
|
+
"meta adset budget": metaBudgetCommand("adset"),
|
|
1034
|
+
"meta campaign budget": metaBudgetCommand("campaign"),
|
|
1032
1035
|
"meta changes": {
|
|
1033
|
-
usage: "meta changes [--since <YYYY-MM-DD>] the log of Meta ads and ad sets turned on or off, newest first",
|
|
1036
|
+
usage: "meta changes [--since <YYYY-MM-DD>] the log of Meta ads and ad sets turned on or off and budgets changed, newest first",
|
|
1034
1037
|
async run(args, options) {
|
|
1035
1038
|
if (args.length !== 0)
|
|
1036
1039
|
throw new CliError("Usage: meta changes [--since <YYYY-MM-DD>]", exitCodes.usage);
|
|
@@ -1044,7 +1047,7 @@ const commands = {
|
|
|
1044
1047
|
},
|
|
1045
1048
|
},
|
|
1046
1049
|
"meta changes undo": {
|
|
1047
|
-
usage: 'meta changes undo <change-id> --reason "<why>" [--idempotency-key <key>] put the ad
|
|
1050
|
+
usage: 'meta changes undo <change-id> --reason "<why>" [--idempotency-key <key>] put the ad, ad set or budget back the way it was',
|
|
1048
1051
|
async run(args, options) {
|
|
1049
1052
|
if (args.length !== 1 || !options.reason)
|
|
1050
1053
|
throw new CliError('Usage: meta changes undo <change-id> --reason "<why>" [--idempotency-key <key>]', exitCodes.usage);
|
|
@@ -1053,7 +1056,7 @@ const commands = {
|
|
|
1053
1056
|
reason: options.reason,
|
|
1054
1057
|
idempotencyKey,
|
|
1055
1058
|
});
|
|
1056
|
-
|
|
1059
|
+
printMetaChangeResult(result, options.format);
|
|
1057
1060
|
},
|
|
1058
1061
|
},
|
|
1059
1062
|
"meta changes reconcile": {
|
|
@@ -1062,7 +1065,75 @@ const commands = {
|
|
|
1062
1065
|
if (args.length !== 1)
|
|
1063
1066
|
throw new CliError("Usage: meta changes reconcile <change-id>", exitCodes.usage);
|
|
1064
1067
|
const { change } = await api(await resolveConnection(options), `/api/v1/meta/changes/${encodeURIComponent(args[0])}/reconcile`, {});
|
|
1065
|
-
|
|
1068
|
+
printMetaChangeResult({ changes: [change], unchanged: [] }, options.format);
|
|
1069
|
+
},
|
|
1070
|
+
},
|
|
1071
|
+
"google-ads ads pause": googleAdsStatusCommand("ads", "pause"),
|
|
1072
|
+
"google-ads ads resume": googleAdsStatusCommand("ads", "resume"),
|
|
1073
|
+
"google-ads adgroup pause": googleAdsStatusCommand("adgroup", "pause"),
|
|
1074
|
+
"google-ads adgroup resume": googleAdsStatusCommand("adgroup", "resume"),
|
|
1075
|
+
"google-ads campaign budget": {
|
|
1076
|
+
usage: 'google-ads campaign budget <campaign-id> (--percent <n> | --daily <amount>) --reason "<why>" [--idempotency-key <key>] change a Google Ads campaign\'s daily budget on the control list, within the workspace\'s limits',
|
|
1077
|
+
async run(args, options) {
|
|
1078
|
+
const usage = 'Usage: google-ads campaign budget <campaign-id> (--percent <n> | --daily <amount>) --reason "<why>" [--idempotency-key <key>]';
|
|
1079
|
+
if (args.length !== 1 || !options.reason || (options.percent === undefined) === (options.daily === undefined))
|
|
1080
|
+
throw new CliError(usage, exitCodes.usage);
|
|
1081
|
+
let amount;
|
|
1082
|
+
if (options.percent !== undefined) {
|
|
1083
|
+
const n = Number(options.percent);
|
|
1084
|
+
if (options.percent.trim() === "" || !Number.isInteger(n))
|
|
1085
|
+
throw new CliError("--percent must be a whole number.", exitCodes.usage);
|
|
1086
|
+
amount = { changePercent: n };
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
if (!/^\d+(\.\d{1,6})?$/.test(options.daily.trim()))
|
|
1090
|
+
throw new CliError("--daily must be an amount such as 60 or 60.50.", exitCodes.usage);
|
|
1091
|
+
amount = { dailyBudget: options.daily.trim() };
|
|
1092
|
+
}
|
|
1093
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`budget-${randomUUID()}`);
|
|
1094
|
+
const result = await api(await resolveConnection(options), "/api/v1/google-ads/budgets", {
|
|
1095
|
+
campaignId: args[0],
|
|
1096
|
+
...amount,
|
|
1097
|
+
reason: options.reason,
|
|
1098
|
+
idempotencyKey,
|
|
1099
|
+
});
|
|
1100
|
+
printGoogleAdsChangeResult(result, options.format);
|
|
1101
|
+
},
|
|
1102
|
+
},
|
|
1103
|
+
"google-ads changes": {
|
|
1104
|
+
usage: "google-ads changes [--since <YYYY-MM-DD>] the log of Google ads and ad groups turned on or off and budgets changed, newest first",
|
|
1105
|
+
async run(args, options) {
|
|
1106
|
+
if (args.length !== 0)
|
|
1107
|
+
throw new CliError("Usage: google-ads changes [--since <YYYY-MM-DD>]", exitCodes.usage);
|
|
1108
|
+
const query = new URLSearchParams();
|
|
1109
|
+
if (options.since)
|
|
1110
|
+
query.set("since", options.since);
|
|
1111
|
+
const { changes } = await api(await resolveConnection(options), `/api/v1/google-ads/changes${query.size ? `?${query}` : ""}`);
|
|
1112
|
+
if (options.format === "json")
|
|
1113
|
+
return printJson({ changes });
|
|
1114
|
+
console.log(changes.length ? renderTable(GOOGLE_ADS_CHANGE_COLUMNS, changes.map(googleAdsChangeRow)) : "No Google Ads changes yet.");
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
"google-ads changes undo": {
|
|
1118
|
+
usage: 'google-ads changes undo <change-id> --reason "<why>" [--idempotency-key <key>] put the ad, ad group or budget back the way it was',
|
|
1119
|
+
async run(args, options) {
|
|
1120
|
+
if (args.length !== 1 || !options.reason)
|
|
1121
|
+
throw new CliError('Usage: google-ads changes undo <change-id> --reason "<why>" [--idempotency-key <key>]', exitCodes.usage);
|
|
1122
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`undo-${randomUUID()}`);
|
|
1123
|
+
const result = await api(await resolveConnection(options), `/api/v1/google-ads/changes/${encodeURIComponent(args[0])}/undo`, {
|
|
1124
|
+
reason: options.reason,
|
|
1125
|
+
idempotencyKey,
|
|
1126
|
+
});
|
|
1127
|
+
printGoogleAdsChangeResult(result, options.format);
|
|
1128
|
+
},
|
|
1129
|
+
},
|
|
1130
|
+
"google-ads changes reconcile": {
|
|
1131
|
+
usage: "google-ads changes reconcile <change-id> settle a change Google's answer was lost for, from what Google reads now",
|
|
1132
|
+
async run(args, options) {
|
|
1133
|
+
if (args.length !== 1)
|
|
1134
|
+
throw new CliError("Usage: google-ads changes reconcile <change-id>", exitCodes.usage);
|
|
1135
|
+
const { change } = await api(await resolveConnection(options), `/api/v1/google-ads/changes/${encodeURIComponent(args[0])}/reconcile`, {});
|
|
1136
|
+
printGoogleAdsChangeResult({ changes: [change], unchanged: [] }, options.format);
|
|
1066
1137
|
},
|
|
1067
1138
|
},
|
|
1068
1139
|
"content unpublish": {
|
|
@@ -2058,6 +2129,19 @@ const commands = {
|
|
|
2058
2129
|
console.log(renderTable(PROPOSAL_COLUMNS, proposals.map(proposalRow)));
|
|
2059
2130
|
},
|
|
2060
2131
|
},
|
|
2132
|
+
"proposals execute": {
|
|
2133
|
+
usage: "proposals execute <proposal-id> apply an approved pause or budget proposal to Meta or Google Ads (live mode only), within the control list",
|
|
2134
|
+
async run(args, options) {
|
|
2135
|
+
if (args.length !== 1)
|
|
2136
|
+
throw new CliError("Usage: proposals execute <proposal-id>", exitCodes.usage);
|
|
2137
|
+
const result = await api(await resolveConnection(options), `/api/v1/proposals/${encodeURIComponent(args[0])}/execute`, {});
|
|
2138
|
+
const google = result.changes.some((c) => "customerId" in c) || result.unchanged.some((u) => "amountMicros" in u);
|
|
2139
|
+
if (google)
|
|
2140
|
+
printGoogleAdsChangeResult(result, options.format);
|
|
2141
|
+
else
|
|
2142
|
+
printMetaChangeResult(result, options.format);
|
|
2143
|
+
},
|
|
2144
|
+
},
|
|
2061
2145
|
"skill publish": {
|
|
2062
2146
|
usage: "skill publish <dir> [--notes <text>] [--harness claude,codex]",
|
|
2063
2147
|
async run(args, options) {
|
|
@@ -2887,7 +2971,11 @@ Options:
|
|
|
2887
2971
|
--tool-run <id> a succeeded tool run content create and edit link; repeat for several
|
|
2888
2972
|
--run <id> the agent run making the content (content) or the Meta change (meta ads|adset), the tool run to
|
|
2889
2973
|
record (creatives create), or the tool run whose outputs media list shows
|
|
2890
|
-
--reason <why> why: what meta ads|adset pause|resume, meta changes undo
|
|
2974
|
+
--reason <why> why: what meta ads|adset pause|resume, meta adset|campaign budget, meta changes undo, meta ad create --status active and
|
|
2975
|
+
the google-ads commands record in the change log
|
|
2976
|
+
--percent <n> with meta adset|campaign budget or google-ads campaign budget, the whole-number percent change from the daily budget now (--percent=-10 lowers it)
|
|
2977
|
+
--daily <n> with meta adset|campaign budget, the new daily budget in the currency's smallest unit (6000 is 60.00 USD); with
|
|
2978
|
+
google-ads campaign budget, in the currency itself (60.50 is 60.50 USD)
|
|
2891
2979
|
--account <act_id> meta ad create: the ad account
|
|
2892
2980
|
--adset <id> meta ad create: the ad set the new ad goes into
|
|
2893
2981
|
--text <text | @file> meta ad create: the ad's primary text
|
|
@@ -2898,7 +2986,7 @@ Options:
|
|
|
2898
2986
|
--tool <tool> with media list, only this tool's outputs, such as kie:nano-banana
|
|
2899
2987
|
--kind image|video with media list, only images or only videos
|
|
2900
2988
|
--since, --until <YYYY-MM-DD | ISO time> with media list, made at or after --since and before --until (a day: through
|
|
2901
|
-
its end, in UTC); with meta changes, only changes from --since on
|
|
2989
|
+
its end, in UTC); with meta changes or google-ads changes, only changes from --since on
|
|
2902
2990
|
--cursor <media-id> with media list, the next page: the id it printed after "More"
|
|
2903
2991
|
--platform, --external-id where content status launched says it was published, and that platform's id for it
|
|
2904
2992
|
--object-type <type> what that platform's sync calls it (campaign_message or flow_message on klaviyo); needed where
|
|
@@ -2992,6 +3080,8 @@ function parseOptions(argv) {
|
|
|
2992
3080
|
limit: { type: "string" },
|
|
2993
3081
|
run: { type: "string" },
|
|
2994
3082
|
reason: { type: "string" },
|
|
3083
|
+
percent: { type: "string" },
|
|
3084
|
+
daily: { type: "string" },
|
|
2995
3085
|
lineage: { type: "string" },
|
|
2996
3086
|
tags: { type: "string" },
|
|
2997
3087
|
sources: { type: "string" },
|
|
@@ -3122,6 +3212,8 @@ function parseOptions(argv) {
|
|
|
3122
3212
|
limit: values.limit,
|
|
3123
3213
|
run: values.run,
|
|
3124
3214
|
reason: values.reason,
|
|
3215
|
+
percent: values.percent,
|
|
3216
|
+
daily: values.daily,
|
|
3125
3217
|
lineage: values.lineage,
|
|
3126
3218
|
tags: values.tags,
|
|
3127
3219
|
sources: values.sources,
|
|
@@ -3692,7 +3784,35 @@ function metaStatusCommand(noun, verb) {
|
|
|
3692
3784
|
idempotencyKey,
|
|
3693
3785
|
...(options.run === undefined ? {} : { runId: options.run }),
|
|
3694
3786
|
});
|
|
3695
|
-
|
|
3787
|
+
printMetaChangeResult(result, options.format);
|
|
3788
|
+
},
|
|
3789
|
+
};
|
|
3790
|
+
}
|
|
3791
|
+
/** A daily budget change, for agents to compute from Meta's budget now (--percent) or to set outright in the currency's smallest unit (--daily). */
|
|
3792
|
+
function metaBudgetCommand(level) {
|
|
3793
|
+
const usage = `meta ${level} budget <${level}-id> (--percent <n> | --daily <smallest units>) --reason "<why>" [--run <run-id>] [--idempotency-key <key>]`;
|
|
3794
|
+
return {
|
|
3795
|
+
usage: `${usage} change ${level === "adset" ? "an ad set's" : "a campaign's"} daily budget on the control list, within the workspace's limits`,
|
|
3796
|
+
async run(args, options) {
|
|
3797
|
+
if (args.length !== 1 || !options.reason || (options.percent === undefined) === (options.daily === undefined))
|
|
3798
|
+
throw new CliError(`Usage: ${usage}`, exitCodes.usage);
|
|
3799
|
+
const whole = (text, flag) => {
|
|
3800
|
+
const n = Number(text);
|
|
3801
|
+
if (text.trim() === "" || !Number.isInteger(n))
|
|
3802
|
+
throw new CliError(`${flag} must be a whole number.`, exitCodes.usage);
|
|
3803
|
+
return n;
|
|
3804
|
+
};
|
|
3805
|
+
const amount = options.percent !== undefined ? { changePercent: whole(options.percent, "--percent") } : { dailyBudget: whole(options.daily, "--daily") };
|
|
3806
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`budget-${randomUUID()}`);
|
|
3807
|
+
const result = await api(await resolveConnection(options), "/api/v1/meta/budgets", {
|
|
3808
|
+
level,
|
|
3809
|
+
id: args[0],
|
|
3810
|
+
...amount,
|
|
3811
|
+
reason: options.reason,
|
|
3812
|
+
idempotencyKey,
|
|
3813
|
+
...(options.run === undefined ? {} : { runId: options.run }),
|
|
3814
|
+
});
|
|
3815
|
+
printMetaChangeResult(result, options.format);
|
|
3696
3816
|
},
|
|
3697
3817
|
};
|
|
3698
3818
|
}
|
|
@@ -3701,7 +3821,7 @@ function metaChangeRow(c) {
|
|
|
3701
3821
|
id: c.id,
|
|
3702
3822
|
level: c.level,
|
|
3703
3823
|
object: c.objectId,
|
|
3704
|
-
change: `${c.before.status} -> ${c.after.status}`,
|
|
3824
|
+
change: c.kind === "budget" ? `daily budget ${c.before.dailyBudget} -> ${c.after.dailyBudget}` : `${c.before.status} -> ${c.after.status}`,
|
|
3705
3825
|
status: c.undoneBy ? `${c.status} (undone by ${c.undoneBy})` : c.error ? `${c.status}: ${c.error}` : c.status,
|
|
3706
3826
|
when: c.createdAt,
|
|
3707
3827
|
by: c.requestedBy,
|
|
@@ -3713,17 +3833,66 @@ function announceKey(key) {
|
|
|
3713
3833
|
console.error(`Idempotency key: ${key} (if no answer comes, send the same command again with --idempotency-key ${key})`);
|
|
3714
3834
|
return key;
|
|
3715
3835
|
}
|
|
3716
|
-
function
|
|
3836
|
+
function printMetaChangeResult(result, format) {
|
|
3717
3837
|
if (format === "json")
|
|
3718
3838
|
return printJson(result);
|
|
3719
3839
|
if (result.changes.length > 0)
|
|
3720
3840
|
console.log(renderTable(META_CHANGE_COLUMNS, result.changes.map(metaChangeRow)));
|
|
3721
|
-
for (const u of result.unchanged)
|
|
3722
|
-
|
|
3841
|
+
for (const u of result.unchanged) {
|
|
3842
|
+
const what = { ad: "Ad", adset: "Ad set", campaign: "Campaign" }[u.level];
|
|
3843
|
+
console.log("status" in u ? `${what} ${u.objectId} was already ${u.status}; nothing was sent.` : `${what} ${u.objectId}'s daily budget is already ${u.dailyBudget}; nothing was sent.`);
|
|
3844
|
+
}
|
|
3723
3845
|
for (const c of result.changes.filter((c) => c.status === "sending")) {
|
|
3724
3846
|
console.log(`Meta's answer for change ${c.id} was lost. Run \`${brand.slug} meta changes reconcile ${c.id}\` in a minute to settle it.`);
|
|
3725
3847
|
}
|
|
3726
3848
|
}
|
|
3849
|
+
function googleAdsStatusCommand(noun, verb) {
|
|
3850
|
+
const usage = `google-ads ${noun} ${verb} <${noun === "ads" ? "adgroup-id~ad-id" : "adgroup-id"}>... --reason "<why>" [--idempotency-key <key>]`;
|
|
3851
|
+
return {
|
|
3852
|
+
usage: `${usage} ${verb === "pause" ? "turn off" : "turn on"} up to ${GOOGLE_ADS_IDS_MAX} Google ${noun === "ads" ? "ads" : "ad groups"} on the control list`,
|
|
3853
|
+
async run(args, options) {
|
|
3854
|
+
if (args.length === 0 || args.length > GOOGLE_ADS_IDS_MAX || !options.reason)
|
|
3855
|
+
throw new CliError(`Usage: ${usage}`, exitCodes.usage);
|
|
3856
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`${verb}-${randomUUID()}`);
|
|
3857
|
+
const result = await api(await resolveConnection(options), "/api/v1/google-ads/changes", {
|
|
3858
|
+
level: noun === "ads" ? "ad" : "ad_group",
|
|
3859
|
+
ids: args,
|
|
3860
|
+
status: verb === "pause" ? "paused" : "enabled",
|
|
3861
|
+
reason: options.reason,
|
|
3862
|
+
idempotencyKey,
|
|
3863
|
+
});
|
|
3864
|
+
printGoogleAdsChangeResult(result, options.format);
|
|
3865
|
+
},
|
|
3866
|
+
};
|
|
3867
|
+
}
|
|
3868
|
+
/** Micros as the account's currency amount, e.g. 60500000 is 60.50. */
|
|
3869
|
+
const fromMicros = (micros) => (Number(micros) / 1_000_000).toFixed(2);
|
|
3870
|
+
function googleAdsChangeRow(c) {
|
|
3871
|
+
return {
|
|
3872
|
+
id: c.id,
|
|
3873
|
+
account: c.customerId,
|
|
3874
|
+
level: c.level,
|
|
3875
|
+
object: c.objectId,
|
|
3876
|
+
change: c.kind === "budget" ? `daily budget ${fromMicros(c.before.amountMicros)} -> ${fromMicros(c.after.amountMicros)}` : `${c.before.status} -> ${c.after.status}`,
|
|
3877
|
+
status: c.undoneBy ? `${c.status} (undone by ${c.undoneBy})` : c.error ? `${c.status}: ${c.error}` : c.status,
|
|
3878
|
+
when: c.createdAt,
|
|
3879
|
+
by: c.requestedBy,
|
|
3880
|
+
reason: excerpt(c.reason, 60),
|
|
3881
|
+
};
|
|
3882
|
+
}
|
|
3883
|
+
function printGoogleAdsChangeResult(result, format) {
|
|
3884
|
+
if (format === "json")
|
|
3885
|
+
return printJson(result);
|
|
3886
|
+
if (result.changes.length > 0)
|
|
3887
|
+
console.log(renderTable(GOOGLE_ADS_CHANGE_COLUMNS, result.changes.map(googleAdsChangeRow)));
|
|
3888
|
+
for (const u of result.unchanged) {
|
|
3889
|
+
const what = { ad: "Ad", ad_group: "Ad group", campaign: "Campaign" }[u.level];
|
|
3890
|
+
console.log("status" in u ? `${what} ${u.objectId} was already ${u.status}; nothing was sent.` : `${what} ${u.objectId}'s daily budget is already ${fromMicros(u.amountMicros)}; nothing was sent.`);
|
|
3891
|
+
}
|
|
3892
|
+
for (const c of result.changes.filter((c) => c.status === "sending")) {
|
|
3893
|
+
console.log(`Google's answer for change ${c.id} was lost. Run \`${brand.slug} google-ads changes reconcile ${c.id}\` in a minute to settle it.`);
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3727
3896
|
function printAdSetPublication(publication, format) {
|
|
3728
3897
|
if (format === "json")
|
|
3729
3898
|
return printJson({ adsetPublication: publication });
|