@wrangled/cli 0.3.50 → 0.3.52
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/guide.js +5 -0
- package/dist/main.js +123 -26
- package/dist/meta-ad.js +35 -0
- package/package.json +1 -1
package/dist/guide.js
CHANGED
|
@@ -185,6 +185,8 @@ Save anything you make before it goes anywhere: an ad written without a tool, an
|
|
|
185
185
|
|
|
186
186
|
The \`meta_ads\` publisher puts an approved ad on Facebook, always paused, in an ad set that already exists, \`--to meta_ads --target '{"accountId": "act_<id>", "adsetId": "<id>"}'\`, or in one made with \`${cli} meta adset publish\` (below), \`"adsetPublicationId": "<id>"\` in place of \`adsetId\`. \`"urlTags": "sku"\` (the default) adds the SKU tracking tags to the link; \`"urlTags": "none"\` sends the link exactly as written, Meta macros such as \`{{ad.name}}\` included. The ad needs pictures or a video (not both), body text and an https link in \`fields.link_urls\` (\`fields.cta\`, such as "Shop now", sets the button; \`fields.bodies\`, \`fields.titles\` and \`fields.descriptions\` add variations). The account, the ad set's campaign and the ad set must all be on the hand's publish list, which only an operator sets, and the ad set and campaign must be in the last Meta sync and not archived. A live send also needs a run of the Facebook ads hand, Meta numbers pulled under 12 hours ago, and room under the daily limit of new ads. The ad is named \`<SKU> | <name>\` and its creative's \`url_tags\` carry the SKU. In practice mode the same command stores the full request for review and sends nothing. Each publication's \`targetSnapshot\` names the campaign and ad set as they were at publish time.
|
|
187
187
|
|
|
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
|
+
|
|
188
190
|
\`${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.
|
|
189
191
|
|
|
190
192
|
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:
|
|
@@ -382,6 +384,8 @@ ${cli} content reconcile <publication-id> check whether a publish left sending
|
|
|
382
384
|
${cli} content unpublish <deployment-id> [--force] take one deployed landing page down
|
|
383
385
|
${cli} meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>] make a paused Meta ad set to add ads to
|
|
384
386
|
${cli} meta adset show <id> | meta adset reconcile <id> read an ad set publication, or check whether one left sending landed
|
|
387
|
+
${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
|
|
388
|
+
${cli} meta adset budget <id> | meta campaign budget <id> (--percent <n> | --daily <n>) --reason "<why>" change a daily budget within the workspace's limits
|
|
385
389
|
${cli} content publish-record <sku> --file <publication.json> record a publish before sending it
|
|
386
390
|
${cli} content publish-finish <publication-id> --status sending|published|failed|not_sent_practice [--input '<json>']
|
|
387
391
|
${cli} content track-link <sku> <url> [--channel facebook|email|sms|web] [--campaign <id>] tag a link with its SKU
|
|
@@ -389,6 +393,7 @@ ${cli} agent start | show <run-id> | runs | finish <run-id> --status <s> --file
|
|
|
389
393
|
${cli} agent draft --agent p3_refresh the P3 refresh hand's counted draft
|
|
390
394
|
${cli} brand get | brand set --file <path> [--sources '<json>' | @file.json]
|
|
391
395
|
${cli} proposals create --input '<json>' | proposals list [--status <s>] [--run <run-id>]
|
|
396
|
+
${cli} proposals execute <proposal-id> apply an approved pause or budget proposal to Meta, in live mode only
|
|
392
397
|
${cli} content add-page <url> add a landing page and read it now
|
|
393
398
|
${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
|
|
394
399
|
${cli} plans show <plan-id> | plans add <plan-id> <sku> [--role <role>] [--position <n>] | plans remove <plan-id> <sku>
|
package/dist/main.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
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
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";
|
|
9
9
|
import { brand, envPrefix } from "./brand.js";
|
|
10
|
+
import { META_AD_CREATE_USAGE, metaAdCreateBody } from "./meta-ad.js";
|
|
10
11
|
import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
|
|
11
12
|
import { scheduleCreateBody, scheduleIdFor, SCHEDULES_CREATE_USAGE } from "./schedules.js";
|
|
12
13
|
import { secretBody, secretProvider, SECRETS_SET_USAGE } from "./secrets.js";
|
|
@@ -999,12 +1000,39 @@ const commands = {
|
|
|
999
1000
|
printAdSetPublication(adsetPublication, options.format);
|
|
1000
1001
|
},
|
|
1001
1002
|
},
|
|
1003
|
+
"meta ad create": {
|
|
1004
|
+
usage: `${META_AD_CREATE_USAGE} make a Facebook ad from pictures or a video in one step`,
|
|
1005
|
+
async run(args, options) {
|
|
1006
|
+
if (args.length !== 0)
|
|
1007
|
+
throw new CliError(`Usage: ${META_AD_CREATE_USAGE}`, exitCodes.usage);
|
|
1008
|
+
const read = await readContentFiles(options.files);
|
|
1009
|
+
const body = metaAdCreateBody({ ...options, text: options.text === undefined ? undefined : await textOrFile(options.text) }, read.map(({ data }) => createHash("sha256").update(data).digest("hex")));
|
|
1010
|
+
const connection = await resolveConnection(options);
|
|
1011
|
+
const created = await api(connection, "/api/v1/meta/ads", { ...body, files: await sendableFiles(read, connection, 0) });
|
|
1012
|
+
if (options.format === "json")
|
|
1013
|
+
return printJson(created);
|
|
1014
|
+
const { publication, change } = created;
|
|
1015
|
+
printFields({
|
|
1016
|
+
sku: created.sku,
|
|
1017
|
+
publication: publication.id,
|
|
1018
|
+
"meta ad": publication.externalId,
|
|
1019
|
+
status: publication.status,
|
|
1020
|
+
error: publication.error,
|
|
1021
|
+
change: change && `${change.id} (${change.before.status} -> ${change.after.status}, ${change.status})`,
|
|
1022
|
+
"idempotency key": body.idempotencyKey,
|
|
1023
|
+
});
|
|
1024
|
+
if (publication.status === "not_sent_practice")
|
|
1025
|
+
console.log("Practice mode: the ad was recorded with its full request and nothing was sent to Meta.");
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1002
1028
|
"meta ads pause": metaStatusCommand("ads", "pause"),
|
|
1003
1029
|
"meta ads resume": metaStatusCommand("ads", "resume"),
|
|
1004
1030
|
"meta adset pause": metaStatusCommand("adset", "pause"),
|
|
1005
1031
|
"meta adset resume": metaStatusCommand("adset", "resume"),
|
|
1032
|
+
"meta adset budget": metaBudgetCommand("adset"),
|
|
1033
|
+
"meta campaign budget": metaBudgetCommand("campaign"),
|
|
1006
1034
|
"meta changes": {
|
|
1007
|
-
usage: "meta changes [--since <YYYY-MM-DD>] the log of Meta ads and ad sets turned on or off, newest first",
|
|
1035
|
+
usage: "meta changes [--since <YYYY-MM-DD>] the log of Meta ads and ad sets turned on or off and budgets changed, newest first",
|
|
1008
1036
|
async run(args, options) {
|
|
1009
1037
|
if (args.length !== 0)
|
|
1010
1038
|
throw new CliError("Usage: meta changes [--since <YYYY-MM-DD>]", exitCodes.usage);
|
|
@@ -1018,7 +1046,7 @@ const commands = {
|
|
|
1018
1046
|
},
|
|
1019
1047
|
},
|
|
1020
1048
|
"meta changes undo": {
|
|
1021
|
-
usage: 'meta changes undo <change-id> --reason "<why>" [--idempotency-key <key>] put the ad
|
|
1049
|
+
usage: 'meta changes undo <change-id> --reason "<why>" [--idempotency-key <key>] put the ad, ad set or budget back the way it was',
|
|
1022
1050
|
async run(args, options) {
|
|
1023
1051
|
if (args.length !== 1 || !options.reason)
|
|
1024
1052
|
throw new CliError('Usage: meta changes undo <change-id> --reason "<why>" [--idempotency-key <key>]', exitCodes.usage);
|
|
@@ -1027,7 +1055,7 @@ const commands = {
|
|
|
1027
1055
|
reason: options.reason,
|
|
1028
1056
|
idempotencyKey,
|
|
1029
1057
|
});
|
|
1030
|
-
|
|
1058
|
+
printMetaChangeResult(result, options.format);
|
|
1031
1059
|
},
|
|
1032
1060
|
},
|
|
1033
1061
|
"meta changes reconcile": {
|
|
@@ -1036,7 +1064,7 @@ const commands = {
|
|
|
1036
1064
|
if (args.length !== 1)
|
|
1037
1065
|
throw new CliError("Usage: meta changes reconcile <change-id>", exitCodes.usage);
|
|
1038
1066
|
const { change } = await api(await resolveConnection(options), `/api/v1/meta/changes/${encodeURIComponent(args[0])}/reconcile`, {});
|
|
1039
|
-
|
|
1067
|
+
printMetaChangeResult({ changes: [change], unchanged: [] }, options.format);
|
|
1040
1068
|
},
|
|
1041
1069
|
},
|
|
1042
1070
|
"content unpublish": {
|
|
@@ -2032,6 +2060,15 @@ const commands = {
|
|
|
2032
2060
|
console.log(renderTable(PROPOSAL_COLUMNS, proposals.map(proposalRow)));
|
|
2033
2061
|
},
|
|
2034
2062
|
},
|
|
2063
|
+
"proposals execute": {
|
|
2064
|
+
usage: "proposals execute <proposal-id> apply an approved pause or budget proposal to Meta (live mode only), within the control list",
|
|
2065
|
+
async run(args, options) {
|
|
2066
|
+
if (args.length !== 1)
|
|
2067
|
+
throw new CliError("Usage: proposals execute <proposal-id>", exitCodes.usage);
|
|
2068
|
+
const result = await api(await resolveConnection(options), `/api/v1/proposals/${encodeURIComponent(args[0])}/execute`, {});
|
|
2069
|
+
printMetaChangeResult(result, options.format);
|
|
2070
|
+
},
|
|
2071
|
+
},
|
|
2035
2072
|
"skill publish": {
|
|
2036
2073
|
usage: "skill publish <dir> [--notes <text>] [--harness claude,codex]",
|
|
2037
2074
|
async run(args, options) {
|
|
@@ -2861,7 +2898,15 @@ Options:
|
|
|
2861
2898
|
--tool-run <id> a succeeded tool run content create and edit link; repeat for several
|
|
2862
2899
|
--run <id> the agent run making the content (content) or the Meta change (meta ads|adset), the tool run to
|
|
2863
2900
|
record (creatives create), or the tool run whose outputs media list shows
|
|
2864
|
-
--reason <why> why: what meta ads|adset pause|resume
|
|
2901
|
+
--reason <why> why: what meta ads|adset pause|resume, meta adset|campaign budget, meta changes undo and meta ad create --status active record in the change log
|
|
2902
|
+
--percent <n> with meta adset|campaign budget, the whole-number percent change from Meta's daily budget now (--percent=-10 lowers it)
|
|
2903
|
+
--daily <n> with meta adset|campaign budget, the new daily budget in the currency's smallest unit (6000 is 60.00 USD)
|
|
2904
|
+
--account <act_id> meta ad create: the ad account
|
|
2905
|
+
--adset <id> meta ad create: the ad set the new ad goes into
|
|
2906
|
+
--text <text | @file> meta ad create: the ad's primary text
|
|
2907
|
+
--link <https url> meta ad create: where the ad links; the SKU's tracking tags are added
|
|
2908
|
+
--cta <type> meta ad create: the button, such as SHOP_NOW
|
|
2909
|
+
--status paused|active meta ad create: how the new ad starts (default paused)
|
|
2865
2910
|
--sku <sku> with media list, only the output that became this creative
|
|
2866
2911
|
--tool <tool> with media list, only this tool's outputs, such as kie:nano-banana
|
|
2867
2912
|
--kind image|video with media list, only images or only videos
|
|
@@ -2960,6 +3005,8 @@ function parseOptions(argv) {
|
|
|
2960
3005
|
limit: { type: "string" },
|
|
2961
3006
|
run: { type: "string" },
|
|
2962
3007
|
reason: { type: "string" },
|
|
3008
|
+
percent: { type: "string" },
|
|
3009
|
+
daily: { type: "string" },
|
|
2963
3010
|
lineage: { type: "string" },
|
|
2964
3011
|
tags: { type: "string" },
|
|
2965
3012
|
sources: { type: "string" },
|
|
@@ -2987,6 +3034,10 @@ function parseOptions(argv) {
|
|
|
2987
3034
|
grain: { type: "string" },
|
|
2988
3035
|
channel: { type: "string" },
|
|
2989
3036
|
campaign: { type: "string" },
|
|
3037
|
+
account: { type: "string" },
|
|
3038
|
+
adset: { type: "string" },
|
|
3039
|
+
link: { type: "string" },
|
|
3040
|
+
cta: { type: "string" },
|
|
2990
3041
|
top: { type: "string" },
|
|
2991
3042
|
week: { type: "string" },
|
|
2992
3043
|
days: { type: "string" },
|
|
@@ -3086,6 +3137,8 @@ function parseOptions(argv) {
|
|
|
3086
3137
|
limit: values.limit,
|
|
3087
3138
|
run: values.run,
|
|
3088
3139
|
reason: values.reason,
|
|
3140
|
+
percent: values.percent,
|
|
3141
|
+
daily: values.daily,
|
|
3089
3142
|
lineage: values.lineage,
|
|
3090
3143
|
tags: values.tags,
|
|
3091
3144
|
sources: values.sources,
|
|
@@ -3114,6 +3167,10 @@ function parseOptions(argv) {
|
|
|
3114
3167
|
grain: values.grain,
|
|
3115
3168
|
channel: values.channel,
|
|
3116
3169
|
campaign: values.campaign,
|
|
3170
|
+
account: values.account,
|
|
3171
|
+
adset: values.adset,
|
|
3172
|
+
link: values.link,
|
|
3173
|
+
cta: values.cta,
|
|
3117
3174
|
top: values.top,
|
|
3118
3175
|
week: values.week,
|
|
3119
3176
|
days: values.days,
|
|
@@ -3364,21 +3421,7 @@ const INLINE_CONTENT_BYTES = 3 * 1024 * 1024;
|
|
|
3364
3421
|
*/
|
|
3365
3422
|
async function contentInput(options, connection) {
|
|
3366
3423
|
const html = options.html === undefined ? undefined : await textOrFile(options.html);
|
|
3367
|
-
|
|
3368
|
-
const files = [];
|
|
3369
|
-
for (const path of options.files) {
|
|
3370
|
-
const contentType = FILE_TYPES[extname(path).toLowerCase()];
|
|
3371
|
-
if (!contentType)
|
|
3372
|
-
throw new CliError(`${path}: --file takes ${Object.keys(FILE_TYPES).join(", ")} files.`, exitCodes.usage);
|
|
3373
|
-
const data = await readFile(await inRunFolder(path));
|
|
3374
|
-
if (inline + data.byteLength <= INLINE_CONTENT_BYTES) {
|
|
3375
|
-
inline += data.byteLength;
|
|
3376
|
-
files.push({ base64: data.toString("base64"), contentType });
|
|
3377
|
-
}
|
|
3378
|
-
else {
|
|
3379
|
-
files.push({ uploadId: await uploadContentFile(connection, data, contentType) });
|
|
3380
|
-
}
|
|
3381
|
-
}
|
|
3424
|
+
const files = await sendableFiles(await readContentFiles(options.files), connection, html === undefined ? 0 : Buffer.byteLength(html));
|
|
3382
3425
|
const input = {
|
|
3383
3426
|
name: options.name,
|
|
3384
3427
|
subject: options.subject,
|
|
@@ -3400,6 +3443,30 @@ function deploymentLine(d) {
|
|
|
3400
3443
|
return line;
|
|
3401
3444
|
return `${line}, read as ${d.pageItemId} v${d.currentPageVersion}, edited on the host ${d.editedOnHost} ${d.editedOnHost === 1 ? "time" : "times"} since it went live`;
|
|
3402
3445
|
}
|
|
3446
|
+
async function readContentFiles(paths) {
|
|
3447
|
+
const read = [];
|
|
3448
|
+
for (const path of paths) {
|
|
3449
|
+
const contentType = FILE_TYPES[extname(path).toLowerCase()];
|
|
3450
|
+
if (!contentType)
|
|
3451
|
+
throw new CliError(`${path}: --file takes ${Object.keys(FILE_TYPES).join(", ")} files.`, exitCodes.usage);
|
|
3452
|
+
read.push({ contentType, data: await readFile(await inRunFolder(path)) });
|
|
3453
|
+
}
|
|
3454
|
+
return read;
|
|
3455
|
+
}
|
|
3456
|
+
/** Files go base64 while they fit in one request beside `inline` bytes already in it; any other file goes to storage first. */
|
|
3457
|
+
async function sendableFiles(read, connection, inline) {
|
|
3458
|
+
const files = [];
|
|
3459
|
+
for (const { contentType, data } of read) {
|
|
3460
|
+
if (inline + data.byteLength <= INLINE_CONTENT_BYTES) {
|
|
3461
|
+
inline += data.byteLength;
|
|
3462
|
+
files.push({ base64: data.toString("base64"), contentType });
|
|
3463
|
+
}
|
|
3464
|
+
else {
|
|
3465
|
+
files.push({ uploadId: await uploadContentFile(connection, data, contentType) });
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
return files;
|
|
3469
|
+
}
|
|
3403
3470
|
async function uploadContentFile(connection, data, contentType) {
|
|
3404
3471
|
const upload = await api(connection, "/api/v1/content/uploads", { contentType, bytes: data.byteLength });
|
|
3405
3472
|
await putSigned(upload.url, data, contentType);
|
|
@@ -3642,7 +3709,35 @@ function metaStatusCommand(noun, verb) {
|
|
|
3642
3709
|
idempotencyKey,
|
|
3643
3710
|
...(options.run === undefined ? {} : { runId: options.run }),
|
|
3644
3711
|
});
|
|
3645
|
-
|
|
3712
|
+
printMetaChangeResult(result, options.format);
|
|
3713
|
+
},
|
|
3714
|
+
};
|
|
3715
|
+
}
|
|
3716
|
+
/** 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). */
|
|
3717
|
+
function metaBudgetCommand(level) {
|
|
3718
|
+
const usage = `meta ${level} budget <${level}-id> (--percent <n> | --daily <smallest units>) --reason "<why>" [--run <run-id>] [--idempotency-key <key>]`;
|
|
3719
|
+
return {
|
|
3720
|
+
usage: `${usage} change ${level === "adset" ? "an ad set's" : "a campaign's"} daily budget on the control list, within the workspace's limits`,
|
|
3721
|
+
async run(args, options) {
|
|
3722
|
+
if (args.length !== 1 || !options.reason || (options.percent === undefined) === (options.daily === undefined))
|
|
3723
|
+
throw new CliError(`Usage: ${usage}`, exitCodes.usage);
|
|
3724
|
+
const whole = (text, flag) => {
|
|
3725
|
+
const n = Number(text);
|
|
3726
|
+
if (text.trim() === "" || !Number.isInteger(n))
|
|
3727
|
+
throw new CliError(`${flag} must be a whole number.`, exitCodes.usage);
|
|
3728
|
+
return n;
|
|
3729
|
+
};
|
|
3730
|
+
const amount = options.percent !== undefined ? { changePercent: whole(options.percent, "--percent") } : { dailyBudget: whole(options.daily, "--daily") };
|
|
3731
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`budget-${randomUUID()}`);
|
|
3732
|
+
const result = await api(await resolveConnection(options), "/api/v1/meta/budgets", {
|
|
3733
|
+
level,
|
|
3734
|
+
id: args[0],
|
|
3735
|
+
...amount,
|
|
3736
|
+
reason: options.reason,
|
|
3737
|
+
idempotencyKey,
|
|
3738
|
+
...(options.run === undefined ? {} : { runId: options.run }),
|
|
3739
|
+
});
|
|
3740
|
+
printMetaChangeResult(result, options.format);
|
|
3646
3741
|
},
|
|
3647
3742
|
};
|
|
3648
3743
|
}
|
|
@@ -3651,7 +3746,7 @@ function metaChangeRow(c) {
|
|
|
3651
3746
|
id: c.id,
|
|
3652
3747
|
level: c.level,
|
|
3653
3748
|
object: c.objectId,
|
|
3654
|
-
change: `${c.before.status} -> ${c.after.status}`,
|
|
3749
|
+
change: c.kind === "budget" ? `daily budget ${c.before.dailyBudget} -> ${c.after.dailyBudget}` : `${c.before.status} -> ${c.after.status}`,
|
|
3655
3750
|
status: c.undoneBy ? `${c.status} (undone by ${c.undoneBy})` : c.error ? `${c.status}: ${c.error}` : c.status,
|
|
3656
3751
|
when: c.createdAt,
|
|
3657
3752
|
by: c.requestedBy,
|
|
@@ -3663,13 +3758,15 @@ function announceKey(key) {
|
|
|
3663
3758
|
console.error(`Idempotency key: ${key} (if no answer comes, send the same command again with --idempotency-key ${key})`);
|
|
3664
3759
|
return key;
|
|
3665
3760
|
}
|
|
3666
|
-
function
|
|
3761
|
+
function printMetaChangeResult(result, format) {
|
|
3667
3762
|
if (format === "json")
|
|
3668
3763
|
return printJson(result);
|
|
3669
3764
|
if (result.changes.length > 0)
|
|
3670
3765
|
console.log(renderTable(META_CHANGE_COLUMNS, result.changes.map(metaChangeRow)));
|
|
3671
|
-
for (const u of result.unchanged)
|
|
3672
|
-
|
|
3766
|
+
for (const u of result.unchanged) {
|
|
3767
|
+
const what = { ad: "Ad", adset: "Ad set", campaign: "Campaign" }[u.level];
|
|
3768
|
+
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.`);
|
|
3769
|
+
}
|
|
3673
3770
|
for (const c of result.changes.filter((c) => c.status === "sending")) {
|
|
3674
3771
|
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.`);
|
|
3675
3772
|
}
|
package/dist/meta-ad.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { CliError, exitCodes } from "./api.js";
|
|
3
|
+
export const META_AD_CREATE_USAGE = 'meta ad create --account act_<id> --adset <id> --file <path> [--file <path>...] --text "<primary text>" --headline "<headline>" --link <https url> ' +
|
|
4
|
+
'[--cta SHOP_NOW] [--name "<name>"] [--status paused|active] [--reason "<why>"] [--run <run-id>] [--idempotency-key <key>]';
|
|
5
|
+
const usage = (problem) => new CliError(`${problem}\nUsage: ${META_AD_CREATE_USAGE}`, exitCodes.usage);
|
|
6
|
+
/**
|
|
7
|
+
* The request for `meta ad create`. Without --idempotency-key the key comes from the files' sha256 and every flag that
|
|
8
|
+
* shapes the ad, so the same command sent again returns the ad it made instead of making a second.
|
|
9
|
+
*/
|
|
10
|
+
export function metaAdCreateBody(flags, fileSha256s) {
|
|
11
|
+
const { account, adset, text, headline, link } = flags;
|
|
12
|
+
if (!account || !adset || !text || !headline || !link || fileSha256s.length === 0) {
|
|
13
|
+
throw usage("meta ad create needs --account, --adset, at least one --file, --text, --headline and --link.");
|
|
14
|
+
}
|
|
15
|
+
const status = flags.status ?? "paused";
|
|
16
|
+
if (status !== "paused" && status !== "active")
|
|
17
|
+
throw usage(`--status must be paused or active; got '${status}'.`);
|
|
18
|
+
if (status === "active" && !flags.reason)
|
|
19
|
+
throw usage("--status active needs --reason, which goes in the change log.");
|
|
20
|
+
const shape = { accountId: account, adsetId: adset, primaryText: text, headline, linkUrl: link, cta: flags.cta ?? null, name: flags.name ?? null, status };
|
|
21
|
+
const derived = `meta-ad-${createHash("sha256").update(JSON.stringify({ files: fileSha256s, ...shape })).digest("hex")}`;
|
|
22
|
+
return {
|
|
23
|
+
accountId: account,
|
|
24
|
+
adsetId: adset,
|
|
25
|
+
primaryText: text,
|
|
26
|
+
headline,
|
|
27
|
+
linkUrl: link,
|
|
28
|
+
...(flags.cta === undefined ? {} : { cta: flags.cta }),
|
|
29
|
+
...(flags.name === undefined ? {} : { name: flags.name }),
|
|
30
|
+
status,
|
|
31
|
+
...(flags.reason === undefined ? {} : { reason: flags.reason }),
|
|
32
|
+
...(flags.run === undefined ? {} : { runId: flags.run }),
|
|
33
|
+
idempotencyKey: flags.idempotencyKey ?? derived,
|
|
34
|
+
};
|
|
35
|
+
}
|