@wrangled/cli 0.3.49 → 0.3.51
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 +3 -0
- package/dist/main.js +162 -19
- package/dist/meta-ad.js +35 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -21,6 +21,8 @@ export class UnreadableCredentialsError extends CliError {
|
|
|
21
21
|
* in src/lib/knowledge/tags.ts. format's flag is --ad-format, because --format picks the output.
|
|
22
22
|
*/
|
|
23
23
|
export const entryTagFlags = { angle: "angle", hook_kind: "hook-kind", format: "ad-format", awareness: "awareness" };
|
|
24
|
+
/** The most ads or ad sets one meta ads|adset pause|resume sends; the server refuses more. */
|
|
25
|
+
export const META_IDS_MAX = 10;
|
|
24
26
|
/** The days before a sale in which a click or reply helps it, as the app counts them (MESSAGE_CREDIT_DAYS). */
|
|
25
27
|
export const MESSAGE_CREDIT_DAYS = 7;
|
|
26
28
|
/** 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
|
@@ -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,7 @@ ${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
|
|
385
388
|
${cli} content publish-record <sku> --file <publication.json> record a publish before sending it
|
|
386
389
|
${cli} content publish-finish <publication-id> --status sending|published|failed|not_sent_practice [--input '<json>']
|
|
387
390
|
${cli} content track-link <sku> <url> [--channel facebook|email|sms|web] [--campaign <id>] tag a link with its SKU
|
package/dist/main.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
7
|
import { parseArgs } from "node:util";
|
|
7
|
-
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, 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, IMPORT_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, entryTagFlags, streamSigned, } from "./api.js";
|
|
8
9
|
import { brand, envPrefix } from "./brand.js";
|
|
10
|
+
import { META_AD_CREATE_USAGE, metaAdCreateBody } from "./meta-ad.js";
|
|
9
11
|
import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
|
|
10
12
|
import { scheduleCreateBody, scheduleIdFor, SCHEDULES_CREATE_USAGE } from "./schedules.js";
|
|
11
13
|
import { secretBody, secretProvider, SECRETS_SET_USAGE } from "./secrets.js";
|
|
@@ -17,6 +19,7 @@ import { AGENT_ENV, HARNESS_COMMAND_ENV, RUNNER_TOKEN_ENV, runLoop, runOnce } fr
|
|
|
17
19
|
import { SKILL_NAME_PATTERN } from "./skill-zip.js";
|
|
18
20
|
import { installSkillFolder, operatorConnection, packSkill, parseSkillRef, readSkillFolder, unpackSkill, } from "./skills.js";
|
|
19
21
|
const DEFAULT_WAIT_SECONDS = 300;
|
|
22
|
+
const META_CHANGE_COLUMNS = ["id", "level", "object", "change", "status", "when", "by", "reason"];
|
|
20
23
|
const SERVER_WAIT_CAP = 50;
|
|
21
24
|
const VOICE_USAGE = "voice [--kind opener|objection|pain|desire|phrase] [--since <YYYY-MM-DD>] [--sort common|useful] [--limit <n>] [--json]";
|
|
22
25
|
const commands = {
|
|
@@ -997,6 +1000,71 @@ const commands = {
|
|
|
997
1000
|
printAdSetPublication(adsetPublication, options.format);
|
|
998
1001
|
},
|
|
999
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
|
+
},
|
|
1028
|
+
"meta ads pause": metaStatusCommand("ads", "pause"),
|
|
1029
|
+
"meta ads resume": metaStatusCommand("ads", "resume"),
|
|
1030
|
+
"meta adset pause": metaStatusCommand("adset", "pause"),
|
|
1031
|
+
"meta adset resume": metaStatusCommand("adset", "resume"),
|
|
1032
|
+
"meta changes": {
|
|
1033
|
+
usage: "meta changes [--since <YYYY-MM-DD>] the log of Meta ads and ad sets turned on or off, newest first",
|
|
1034
|
+
async run(args, options) {
|
|
1035
|
+
if (args.length !== 0)
|
|
1036
|
+
throw new CliError("Usage: meta changes [--since <YYYY-MM-DD>]", exitCodes.usage);
|
|
1037
|
+
const query = new URLSearchParams();
|
|
1038
|
+
if (options.since)
|
|
1039
|
+
query.set("since", options.since);
|
|
1040
|
+
const { changes } = await api(await resolveConnection(options), `/api/v1/meta/changes${query.size ? `?${query}` : ""}`);
|
|
1041
|
+
if (options.format === "json")
|
|
1042
|
+
return printJson({ changes });
|
|
1043
|
+
console.log(changes.length ? renderTable(META_CHANGE_COLUMNS, changes.map(metaChangeRow)) : "No Meta changes yet.");
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
"meta changes undo": {
|
|
1047
|
+
usage: 'meta changes undo <change-id> --reason "<why>" [--idempotency-key <key>] put the ad or ad set back the way it was',
|
|
1048
|
+
async run(args, options) {
|
|
1049
|
+
if (args.length !== 1 || !options.reason)
|
|
1050
|
+
throw new CliError('Usage: meta changes undo <change-id> --reason "<why>" [--idempotency-key <key>]', exitCodes.usage);
|
|
1051
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`undo-${randomUUID()}`);
|
|
1052
|
+
const result = await api(await resolveConnection(options), `/api/v1/meta/changes/${encodeURIComponent(args[0])}/undo`, {
|
|
1053
|
+
reason: options.reason,
|
|
1054
|
+
idempotencyKey,
|
|
1055
|
+
});
|
|
1056
|
+
printMetaStatusResult(result, options.format);
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
"meta changes reconcile": {
|
|
1060
|
+
usage: "meta changes reconcile <change-id> settle a change Meta's answer was lost for, from what Meta reads now",
|
|
1061
|
+
async run(args, options) {
|
|
1062
|
+
if (args.length !== 1)
|
|
1063
|
+
throw new CliError("Usage: meta changes reconcile <change-id>", exitCodes.usage);
|
|
1064
|
+
const { change } = await api(await resolveConnection(options), `/api/v1/meta/changes/${encodeURIComponent(args[0])}/reconcile`, {});
|
|
1065
|
+
printMetaStatusResult({ changes: [change], unchanged: [] }, options.format);
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1000
1068
|
"content unpublish": {
|
|
1001
1069
|
usage: "content unpublish <deployment-id> [--force]",
|
|
1002
1070
|
async run(args, options) {
|
|
@@ -2817,13 +2885,20 @@ Options:
|
|
|
2817
2885
|
--source <text> where the content content capture saves came from, in words
|
|
2818
2886
|
--url <url> the address of the content content capture saves
|
|
2819
2887
|
--tool-run <id> a succeeded tool run content create and edit link; repeat for several
|
|
2820
|
-
--run <id> the agent run making the content (content)
|
|
2821
|
-
run whose outputs media list shows
|
|
2888
|
+
--run <id> the agent run making the content (content) or the Meta change (meta ads|adset), the tool run to
|
|
2889
|
+
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 and meta ad create --status active record in the change log
|
|
2891
|
+
--account <act_id> meta ad create: the ad account
|
|
2892
|
+
--adset <id> meta ad create: the ad set the new ad goes into
|
|
2893
|
+
--text <text | @file> meta ad create: the ad's primary text
|
|
2894
|
+
--link <https url> meta ad create: where the ad links; the SKU's tracking tags are added
|
|
2895
|
+
--cta <type> meta ad create: the button, such as SHOP_NOW
|
|
2896
|
+
--status paused|active meta ad create: how the new ad starts (default paused)
|
|
2822
2897
|
--sku <sku> with media list, only the output that became this creative
|
|
2823
2898
|
--tool <tool> with media list, only this tool's outputs, such as kie:nano-banana
|
|
2824
2899
|
--kind image|video with media list, only images or only videos
|
|
2825
2900
|
--since, --until <YYYY-MM-DD | ISO time> with media list, made at or after --since and before --until (a day: through
|
|
2826
|
-
its end, in UTC)
|
|
2901
|
+
its end, in UTC); with meta changes, only changes from --since on
|
|
2827
2902
|
--cursor <media-id> with media list, the next page: the id it printed after "More"
|
|
2828
2903
|
--platform, --external-id where content status launched says it was published, and that platform's id for it
|
|
2829
2904
|
--object-type <type> what that platform's sync calls it (campaign_message or flow_message on klaviyo); needed where
|
|
@@ -2916,6 +2991,7 @@ function parseOptions(argv) {
|
|
|
2916
2991
|
ad: { type: "string" },
|
|
2917
2992
|
limit: { type: "string" },
|
|
2918
2993
|
run: { type: "string" },
|
|
2994
|
+
reason: { type: "string" },
|
|
2919
2995
|
lineage: { type: "string" },
|
|
2920
2996
|
tags: { type: "string" },
|
|
2921
2997
|
sources: { type: "string" },
|
|
@@ -2943,6 +3019,10 @@ function parseOptions(argv) {
|
|
|
2943
3019
|
grain: { type: "string" },
|
|
2944
3020
|
channel: { type: "string" },
|
|
2945
3021
|
campaign: { type: "string" },
|
|
3022
|
+
account: { type: "string" },
|
|
3023
|
+
adset: { type: "string" },
|
|
3024
|
+
link: { type: "string" },
|
|
3025
|
+
cta: { type: "string" },
|
|
2946
3026
|
top: { type: "string" },
|
|
2947
3027
|
week: { type: "string" },
|
|
2948
3028
|
days: { type: "string" },
|
|
@@ -3041,6 +3121,7 @@ function parseOptions(argv) {
|
|
|
3041
3121
|
ad: values.ad,
|
|
3042
3122
|
limit: values.limit,
|
|
3043
3123
|
run: values.run,
|
|
3124
|
+
reason: values.reason,
|
|
3044
3125
|
lineage: values.lineage,
|
|
3045
3126
|
tags: values.tags,
|
|
3046
3127
|
sources: values.sources,
|
|
@@ -3069,6 +3150,10 @@ function parseOptions(argv) {
|
|
|
3069
3150
|
grain: values.grain,
|
|
3070
3151
|
channel: values.channel,
|
|
3071
3152
|
campaign: values.campaign,
|
|
3153
|
+
account: values.account,
|
|
3154
|
+
adset: values.adset,
|
|
3155
|
+
link: values.link,
|
|
3156
|
+
cta: values.cta,
|
|
3072
3157
|
top: values.top,
|
|
3073
3158
|
week: values.week,
|
|
3074
3159
|
days: values.days,
|
|
@@ -3319,21 +3404,7 @@ const INLINE_CONTENT_BYTES = 3 * 1024 * 1024;
|
|
|
3319
3404
|
*/
|
|
3320
3405
|
async function contentInput(options, connection) {
|
|
3321
3406
|
const html = options.html === undefined ? undefined : await textOrFile(options.html);
|
|
3322
|
-
|
|
3323
|
-
const files = [];
|
|
3324
|
-
for (const path of options.files) {
|
|
3325
|
-
const contentType = FILE_TYPES[extname(path).toLowerCase()];
|
|
3326
|
-
if (!contentType)
|
|
3327
|
-
throw new CliError(`${path}: --file takes ${Object.keys(FILE_TYPES).join(", ")} files.`, exitCodes.usage);
|
|
3328
|
-
const data = await readFile(await inRunFolder(path));
|
|
3329
|
-
if (inline + data.byteLength <= INLINE_CONTENT_BYTES) {
|
|
3330
|
-
inline += data.byteLength;
|
|
3331
|
-
files.push({ base64: data.toString("base64"), contentType });
|
|
3332
|
-
}
|
|
3333
|
-
else {
|
|
3334
|
-
files.push({ uploadId: await uploadContentFile(connection, data, contentType) });
|
|
3335
|
-
}
|
|
3336
|
-
}
|
|
3407
|
+
const files = await sendableFiles(await readContentFiles(options.files), connection, html === undefined ? 0 : Buffer.byteLength(html));
|
|
3337
3408
|
const input = {
|
|
3338
3409
|
name: options.name,
|
|
3339
3410
|
subject: options.subject,
|
|
@@ -3355,6 +3426,30 @@ function deploymentLine(d) {
|
|
|
3355
3426
|
return line;
|
|
3356
3427
|
return `${line}, read as ${d.pageItemId} v${d.currentPageVersion}, edited on the host ${d.editedOnHost} ${d.editedOnHost === 1 ? "time" : "times"} since it went live`;
|
|
3357
3428
|
}
|
|
3429
|
+
async function readContentFiles(paths) {
|
|
3430
|
+
const read = [];
|
|
3431
|
+
for (const path of paths) {
|
|
3432
|
+
const contentType = FILE_TYPES[extname(path).toLowerCase()];
|
|
3433
|
+
if (!contentType)
|
|
3434
|
+
throw new CliError(`${path}: --file takes ${Object.keys(FILE_TYPES).join(", ")} files.`, exitCodes.usage);
|
|
3435
|
+
read.push({ contentType, data: await readFile(await inRunFolder(path)) });
|
|
3436
|
+
}
|
|
3437
|
+
return read;
|
|
3438
|
+
}
|
|
3439
|
+
/** Files go base64 while they fit in one request beside `inline` bytes already in it; any other file goes to storage first. */
|
|
3440
|
+
async function sendableFiles(read, connection, inline) {
|
|
3441
|
+
const files = [];
|
|
3442
|
+
for (const { contentType, data } of read) {
|
|
3443
|
+
if (inline + data.byteLength <= INLINE_CONTENT_BYTES) {
|
|
3444
|
+
inline += data.byteLength;
|
|
3445
|
+
files.push({ base64: data.toString("base64"), contentType });
|
|
3446
|
+
}
|
|
3447
|
+
else {
|
|
3448
|
+
files.push({ uploadId: await uploadContentFile(connection, data, contentType) });
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
return files;
|
|
3452
|
+
}
|
|
3358
3453
|
async function uploadContentFile(connection, data, contentType) {
|
|
3359
3454
|
const upload = await api(connection, "/api/v1/content/uploads", { contentType, bytes: data.byteLength });
|
|
3360
3455
|
await putSigned(upload.url, data, contentType);
|
|
@@ -3581,6 +3676,54 @@ function printPublication(publication, format) {
|
|
|
3581
3676
|
finished: publication.finishedAt,
|
|
3582
3677
|
});
|
|
3583
3678
|
}
|
|
3679
|
+
function metaStatusCommand(noun, verb) {
|
|
3680
|
+
const usage = `meta ${noun} ${verb} <${noun === "ads" ? "ad" : "adset"}-id>... --reason "<why>" [--run <run-id>] [--idempotency-key <key>]`;
|
|
3681
|
+
return {
|
|
3682
|
+
usage: `${usage} ${verb === "pause" ? "turn off" : "turn on"} up to ${META_IDS_MAX} ${noun === "ads" ? "ads" : "ad sets"} on the control list`,
|
|
3683
|
+
async run(args, options) {
|
|
3684
|
+
if (args.length === 0 || args.length > META_IDS_MAX || !options.reason)
|
|
3685
|
+
throw new CliError(`Usage: ${usage}`, exitCodes.usage);
|
|
3686
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`${verb}-${randomUUID()}`);
|
|
3687
|
+
const result = await api(await resolveConnection(options), "/api/v1/meta/changes", {
|
|
3688
|
+
level: noun === "ads" ? "ad" : "adset",
|
|
3689
|
+
ids: args,
|
|
3690
|
+
status: verb === "pause" ? "paused" : "active",
|
|
3691
|
+
reason: options.reason,
|
|
3692
|
+
idempotencyKey,
|
|
3693
|
+
...(options.run === undefined ? {} : { runId: options.run }),
|
|
3694
|
+
});
|
|
3695
|
+
printMetaStatusResult(result, options.format);
|
|
3696
|
+
},
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3699
|
+
function metaChangeRow(c) {
|
|
3700
|
+
return {
|
|
3701
|
+
id: c.id,
|
|
3702
|
+
level: c.level,
|
|
3703
|
+
object: c.objectId,
|
|
3704
|
+
change: `${c.before.status} -> ${c.after.status}`,
|
|
3705
|
+
status: c.undoneBy ? `${c.status} (undone by ${c.undoneBy})` : c.error ? `${c.status}: ${c.error}` : c.status,
|
|
3706
|
+
when: c.createdAt,
|
|
3707
|
+
by: c.requestedBy,
|
|
3708
|
+
reason: excerpt(c.reason, 60),
|
|
3709
|
+
};
|
|
3710
|
+
}
|
|
3711
|
+
/** Told on stderr before the request goes out, so a request whose answer is lost can be sent again with the same key. */
|
|
3712
|
+
function announceKey(key) {
|
|
3713
|
+
console.error(`Idempotency key: ${key} (if no answer comes, send the same command again with --idempotency-key ${key})`);
|
|
3714
|
+
return key;
|
|
3715
|
+
}
|
|
3716
|
+
function printMetaStatusResult(result, format) {
|
|
3717
|
+
if (format === "json")
|
|
3718
|
+
return printJson(result);
|
|
3719
|
+
if (result.changes.length > 0)
|
|
3720
|
+
console.log(renderTable(META_CHANGE_COLUMNS, result.changes.map(metaChangeRow)));
|
|
3721
|
+
for (const u of result.unchanged)
|
|
3722
|
+
console.log(`${u.level === "ad" ? "Ad" : "Ad set"} ${u.objectId} was already ${u.status}; nothing was sent.`);
|
|
3723
|
+
for (const c of result.changes.filter((c) => c.status === "sending")) {
|
|
3724
|
+
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
|
+
}
|
|
3726
|
+
}
|
|
3584
3727
|
function printAdSetPublication(publication, format) {
|
|
3585
3728
|
if (format === "json")
|
|
3586
3729
|
return printJson({ adsetPublication: publication });
|
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
|
+
}
|