@wrangled/cli 0.3.56 → 0.3.57

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.
@@ -0,0 +1,51 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { CliError, exitCodes } from "./api.js";
4
+ // As the server reads an article (src/lib/publishing/wordpress/html.ts): a tag ends at the first > outside quotes.
5
+ const IMG_TAG = /<img\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
6
+ const ATTRIBUTE = /(\s+)([^\s"'>/=]+)(?:(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
7
+ /**
8
+ * Points each <img src> that names a --file (as given, or by its file name) at that file as sha256:<its SHA-256>,
9
+ * the reference a publisher replaces with the address the file gets where it is published. Other sources are left.
10
+ */
11
+ export function pointImagesAtFiles(html, files) {
12
+ if (files.length === 0)
13
+ return html;
14
+ const refs = new Map();
15
+ for (const { path, data } of files) {
16
+ const ref = `sha256:${createHash("sha256").update(data).digest("hex")}`;
17
+ for (const name of [path, basename(path), `./${basename(path)}`])
18
+ refs.set(name, ref);
19
+ }
20
+ return html.replace(IMG_TAG, (tag) => {
21
+ let seen = false;
22
+ return tag.replace(ATTRIBUTE, (whole, space, name, equals, double, single, bare) => {
23
+ if (seen || name.toLowerCase() !== "src" || equals === undefined)
24
+ return whole;
25
+ seen = true;
26
+ const ref = refs.get((double ?? single ?? bare ?? "").trim());
27
+ if (ref === undefined)
28
+ return whole;
29
+ const quote = single !== undefined ? "'" : '"';
30
+ return `${space}${name}${equals}${quote}${ref}${quote}`;
31
+ });
32
+ });
33
+ }
34
+ const WORDPRESS = "wordpress:";
35
+ /**
36
+ * The publisher and target `content publish` sends. `--to wordpress:<site>` fills the target itself, with --status
37
+ * draft (the default) or publish; any other publisher takes its target as --target JSON, given here parsed.
38
+ */
39
+ export function publishDestination(to, { target, status }) {
40
+ if (to.startsWith(WORDPRESS)) {
41
+ if (target !== undefined)
42
+ throw new CliError("--to wordpress:<site> sets the target itself; leave out --target.", exitCodes.usage);
43
+ const site = to.slice(WORDPRESS.length);
44
+ return { publisher: "wordpress", target: status === undefined ? { site } : { site, status } };
45
+ }
46
+ if (status !== undefined)
47
+ throw new CliError("--status goes with --to wordpress:<site>.", exitCodes.usage);
48
+ if (target === undefined)
49
+ throw new CliError(`--to ${to} needs --target '<json>'.`, exitCodes.usage);
50
+ return { publisher: to, target };
51
+ }
package/dist/guide.js CHANGED
@@ -93,7 +93,7 @@ To find content, such as "the 10 emails with the best click rate", "our 5 best v
93
93
 
94
94
  - \`--text "<words>"\` finds words in the current version's subject, headline and body, or in a video's or call recording's transcript. Words are stemmed ("guarantee" finds "guaranteed"); use "quotes" for a phrase and -word to leave a word out.
95
95
  - \`--mode meaning\` finds content that means something close to \`--text\`, even in other words: \`--text "saves time"\` also finds "done in five minutes". Results come nearest first, each with its distance (0 is the same meaning) and the closest passage as its words. It takes no \`--sort\` or \`--order\`, and finds only content embedded so far (each hour, new versions and transcripts are embedded).
96
- - \`--kind\` (ad, email, sms, landing_page, video, social_post, conversation), \`--platform\` (meta, ghl, klaviyo, roezan, bayengage, activecampaign, mailchimp, hubspot, kit, instantly, kajabi, omnisend, google_ads, tiktok, microsoft_ads, pinterest, linkedin, web, youtube, instagram, facebook_page, callrail, zoom, fathom, google_drive, ad_library, workspace, upload) and \`--origin\` (discovered or made) narrow it. \`--from\` and \`--to\` keep content live on those days: from its first day with numbers (or the day the library first saw it) to its last.
96
+ - \`--kind\` (ad, email, sms, landing_page, video, social_post, article, conversation), \`--platform\` (meta, ghl, klaviyo, roezan, bayengage, activecampaign, mailchimp, hubspot, kit, instantly, kajabi, omnisend, google_ads, tiktok, microsoft_ads, pinterest, linkedin, web, wordpress, youtube, instagram, facebook_page, callrail, zoom, fathom, google_drive, ad_library, workspace, upload) and \`--origin\` (discovered or made) narrow it. \`--from\` and \`--to\` keep content live on those days: from its first day with numbers (or the day the library first saw it) to its last.
97
97
  - \`--sort\` is \`recent\` (the default, newest first) or any number in the \`content_scorecard\` view, such as \`click_rate\`, \`open_rate\`, \`roas\`, \`spend\` or their \`_30d\` twins for the last 30 days. Content with money in several currencies has a number per currency and sorts by its best. Content without that number comes last. \`--order asc\` turns it around; \`--limit\` is 1 to 50 (default 10).
98
98
  - Each result has the item's id or SKU, its words, how many files it has, whether it has a transcript, and its scorecard numbers. \`${cli} content show <sku | item-id>\` prints one item in full: its words, how many versions it has, its transcript and every number; \`--format json\` adds every version, and links to its files.
99
99
  - To add marketing that lives somewhere no source connects to, such as a sales video, a webinar recording, a script or an email from another tool, run \`${cli} content capture --kind <kind> --name <name>\` with \`--body\`, \`--file\`, \`--source\` (where it came from) and \`--url\`. It is kept as found, with no SKU, so it is never approved or published; \`--platform upload\` finds these. Videos and audio are transcribed on their own; give \`--transcript @file\` when you already have one, so none is paid for.
@@ -213,7 +213,7 @@ Save anything you make before it goes anywhere: an ad written without a tool, an
213
213
 
214
214
  ### Publishing
215
215
 
216
- \`${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key>\` publishes an approved item through one of ${brand.name}'s publishers, adding \`--plan <plan-id>\` when it is part of a launch plan and \`--run <run-id>\` from a hand's run. Only the approved version is published; an item edited after approval must be approved again. The server checks every rule first and refuses with each failure listed, recording nothing: one unfinished send per platform account, and at least 15 seconds between sends to one account. Otherwise it records exactly what it sends before sending it, then saves the platform's ids, marks the item launched, and moves the launch plan to live once all its items are out. For Meta without the \`--run\` of a hand in live mode, nothing is sent: the publication is saved as \`not_sent_practice\` with the full request. A send that may or may not have landed stays \`sending\` and is never retried; \`${cli} content reconcile <publication-id>\` asks the platform and finishes it. The same key returns the same publication. A page host is the publisher \`page_host:<host>\` with the target \`{"site":"...","path":"/..."}\`: it puts an approved landing page's HTML online, tags each link that leaves the page with the SKU, and is never held back by practice mode. \`${cli} content show <sku>\` lists the page's deployments with their ids. \`${cli} content unpublish <deployment-id>\` takes one live page down; the site's other pages stay up, and the deployment records when and by whom. It is refused while a synced Meta ad that is delivering links to the page, naming each ad, because a broken link in a running ad wastes money; \`--force\` takes it down anyway. Never roll a site back in Vercel's dashboard instead: the next deploy brings the rolled-away pages back.
216
+ \`${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key>\` publishes an approved item through one of ${brand.name}'s publishers, adding \`--plan <plan-id>\` when it is part of a launch plan and \`--run <run-id>\` from a hand's run. Only the approved version is published; an item edited after approval must be approved again. The server checks every rule first and refuses with each failure listed, recording nothing: one unfinished send per platform account, and at least 15 seconds between sends to one account. Otherwise it records exactly what it sends before sending it, then saves the platform's ids, marks the item launched, and moves the launch plan to live once all its items are out. For Meta without the \`--run\` of a hand in live mode, nothing is sent: the publication is saved as \`not_sent_practice\` with the full request. A send that may or may not have landed stays \`sending\` and is never retried; \`${cli} content reconcile <publication-id>\` asks the platform and finishes it. The same key returns the same publication. A page host is the publisher \`page_host:<host>\` with the target \`{"site":"...","path":"/..."}\`: it puts an approved landing page's HTML online, tags each link that leaves the page with the SKU, and is never held back by practice mode. \`${cli} content show <sku>\` lists the page's deployments with their ids. \`${cli} content unpublish <deployment-id>\` takes one live page down; the site's other pages stay up, and the deployment records when and by whom. It is refused while a synced Meta ad that is delivering links to the page, naming each ad, because a broken link in a running ad wastes money; \`--force\` takes it down anyway. Never roll a site back in Vercel's dashboard instead: the next deploy brings the rolled-away pages back. An article (\`${cli} content create --kind article --headline "<title>" --html @post.html --file hero.jpg\`) goes to the client's WordPress site with \`${cli} content publish <sku> --to wordpress:<site> --idempotency-key <key>\`, as a draft, or live with \`--status publish\`. Its images are uploaded once per site and each \`<img src>\` naming a \`--file\` points at the uploaded copy. The site's sign-in is saved first with \`${cli} secrets set wordpress:<site> --file wp.json\`, the file holding \`{"baseUrl": "https://...", "username": "...", "applicationPassword": "..."}\` (an application password made in WordPress under Users, Profile).
217
217
 
218
218
  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.
219
219
 
@@ -402,7 +402,7 @@ ${cli} tools get <run-id> show a tool run, waiting for it to finish (-
402
402
  ${cli} tools usage [--month <YYYY-MM>] [--by tool|run] what tool runs cost in a month, per tool or per scheduled run, split by whose key paid
403
403
  ${cli} enrich email --file <people.csv | people.json> [--steps <tool,tool>] find one verified work email per person, cheapest provider first
404
404
  ${cli} secrets list which provider keys the client has and which key runs use; never the keys themselves
405
- ${cli} secrets set <provider> [--file <path>] save the client's own provider key from stdin or a file (keys with "Can manage settings")
405
+ ${cli} secrets set <provider> [--file <path>] save the client's own provider key from stdin or a file (keys with "Can manage settings"); wordpress:<site> saves a WordPress site's sign-in
406
406
  ${cli} secrets remove <provider> remove it, so runs use the shared key again
407
407
  ${cli} creatives list list creatives (--status, --kind, --limit)
408
408
  ${cli} creatives show <sku> show one creative with its spec and lineage
@@ -418,6 +418,7 @@ ${cli} content pull <dir> [--kind <kind>] [--platform <p>] [--origin <o>] [--med
418
418
  ${cli} content show <sku | item-id> | content edit <sku> [...] --note <why> | content status <sku> <status>
419
419
  ${cli} content diff <sku | item-id> [--from <n>] [--to <n>] what changed between two versions
420
420
  ${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key> [--plan <id>] [--version <n>] [--run <run-id>] publish an approved item
421
+ ${cli} content publish <sku> --to wordpress:<site> [--status publish] --idempotency-key <key> put an approved article on a WordPress site, as a draft unless --status publish
421
422
  ${cli} content reconcile <publication-id> check whether a publish left sending landed
422
423
  ${cli} content unpublish <deployment-id> [--force] take one deployed landing page down
423
424
  ${cli} meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>] make a paused Meta ad set to add ads to
package/dist/main.js CHANGED
@@ -12,6 +12,7 @@ import { META_AD_CREATE_USAGE, metaAdCreateBody } from "./meta-ad.js";
12
12
  import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
13
13
  import { scheduleCreateBody, scheduleIdFor, SCHEDULES_CREATE_USAGE } from "./schedules.js";
14
14
  import { secretBody, secretProvider, SECRETS_SET_USAGE } from "./secrets.js";
15
+ import { pointImagesAtFiles, publishDestination } from "./articles.js";
15
16
  import { dollars, toolUsageReport, toolUsageRequest, TOOLS_USAGE_USAGE } from "./tool-usage.js";
16
17
  import { guide, skill } from "./guide.js";
17
18
  import { PULL_STATE_FILE, pullContent } from "./pull.js";
@@ -25,6 +26,7 @@ const META_CHANGE_COLUMNS = ["id", "level", "object", "change", "status", "when"
25
26
  const GOOGLE_ADS_CHANGE_COLUMNS = ["id", "account", "level", "object", "change", "status", "when", "by", "reason"];
26
27
  const SERVER_WAIT_CAP = 50;
27
28
  const VOICE_USAGE = "voice [--kind opener|objection|pain|desire|phrase] [--since <YYYY-MM-DD>] [--sort common|useful] [--limit <n>] [--json]";
29
+ const CONTENT_PUBLISH_USAGE = "content publish <sku | item-id> (--to <publisher> --target '<json>' | --to wordpress:<site> [--status draft|publish]) --idempotency-key <key> [--plan <plan-id>] [--version <n>] [--run <run-id>]";
28
30
  const META_MOVE_USAGE = 'meta ads move <ad-id> --to-adset <adset-id> --reason "<why>" [--keep-original] [--status active|paused] [--run <run-id>] [--idempotency-key <key>]';
29
31
  const OUTBOUND_PUSH_USAGE = `outbound push --to instantly:<campaign-id> (--csv <file> | --table <import_ or agent_ table>) [--dry-run] [--max <n>] [--idempotency-key <key>]`;
30
32
  const commands = {
@@ -448,6 +450,9 @@ const commands = {
448
450
  const { secret } = await api(connection, secretPath(provider), secretBody(text), "PUT");
449
451
  if (options.format === "json")
450
452
  return printJson({ secret });
453
+ if (secret.provider.startsWith("wordpress:")) {
454
+ return console.log(`Saved the sign-in for the WordPress site ${secret.provider.slice("wordpress:".length)}. content publish --to ${secret.provider} uses it.`);
455
+ }
451
456
  console.log(`Saved the client's own ${secret.provider} key. Its ${secret.provider} runs now use it instead of the shared one.`);
452
457
  },
453
458
  },
@@ -858,11 +863,11 @@ const commands = {
858
863
  usage: "content create --kind <kind> [--name <name>] [--subject <s>] [--preview <s>] [--headline <s>] [--body <text> | @file] [--html @file] [--fields '<json>' | @file.json] [--file <path> ...] [--tool-run <id> ...] [--run <agent-run-id>] [--note <why>] [--plan <plan-id> [--role <role>]]",
859
864
  async run(args, options) {
860
865
  if (args.length > 0 || !options.kind)
861
- throw new CliError("Usage: content create --kind ad|email|sms|landing_page|video|social_post [...]", exitCodes.usage);
866
+ throw new CliError("Usage: content create --kind ad|email|sms|landing_page|video|social_post|article [...]", exitCodes.usage);
862
867
  const connection = await resolveConnection(options);
863
868
  const content = await api(connection, "/api/v1/content", {
864
869
  kind: options.kind,
865
- ...(await contentInput(options, connection)),
870
+ ...(await contentInput(options, connection, async () => options.kind)),
866
871
  ...(options.plan === undefined ? {} : { launchPlanId: options.plan }),
867
872
  ...(options.role === undefined ? {} : { launchPlanRole: options.role }),
868
873
  });
@@ -880,7 +885,7 @@ const commands = {
880
885
  const connection = await resolveConnection(options);
881
886
  const content = await api(connection, "/api/v1/content/captures", {
882
887
  kind: options.kind,
883
- ...(await contentInput(options, connection)),
888
+ ...(await contentInput(options, connection, async () => options.kind)),
884
889
  transcript: options.transcript === undefined ? undefined : { text: await textOrFile(options.transcript), language: options.language },
885
890
  source: options.source,
886
891
  url: options.url,
@@ -1107,7 +1112,7 @@ const commands = {
1107
1112
  if (args.length !== 1)
1108
1113
  throw new CliError("Usage: content edit <sku | item-id> [--body <text> | @file] [--note <why>] [...]", exitCodes.usage);
1109
1114
  const connection = await resolveConnection(options);
1110
- const input = await contentInput(options, connection);
1115
+ const input = await contentInput(options, connection, async () => (await api(connection, `/api/v1/content/${encodeURIComponent(args[0])}`)).item.kind);
1111
1116
  if (Object.keys(input).every((k) => k === "runId" || k === "note")) {
1112
1117
  throw new CliError("content edit needs something to change, such as --body, --subject or --file.", exitCodes.usage);
1113
1118
  }
@@ -1130,17 +1135,19 @@ const commands = {
1130
1135
  },
1131
1136
  },
1132
1137
  "content publish": {
1133
- usage: "content publish <sku | item-id> --to <publisher> --target '<json>' --idempotency-key <key> [--plan <plan-id>] [--version <n>] [--run <run-id>]",
1138
+ usage: CONTENT_PUBLISH_USAGE,
1134
1139
  async run(args, options) {
1135
- if (args.length !== 1 || !options.to || options.target === undefined || !options.idempotencyKey) {
1136
- throw new CliError("Usage: content publish <sku | item-id> --to <publisher> --target '<json>' --idempotency-key <key> [--plan <plan-id>] [--version <n>] [--run <run-id>]", exitCodes.usage);
1137
- }
1140
+ if (args.length !== 1 || !options.to || !options.idempotencyKey)
1141
+ throw new CliError(`Usage: ${CONTENT_PUBLISH_USAGE}`, exitCodes.usage);
1138
1142
  const version = options.version === undefined ? undefined : Number(options.version);
1139
1143
  if (version !== undefined && !(Number.isInteger(version) && version >= 1))
1140
1144
  throw new CliError("--version must be a whole number from 1.", exitCodes.usage);
1145
+ const destination = publishDestination(options.to, {
1146
+ target: options.target === undefined ? undefined : await parseJson(options.target, "--target"),
1147
+ status: options.status,
1148
+ });
1141
1149
  const { publication } = await api(await resolveConnection(options), `/api/v1/content/${encodeURIComponent(args[0])}/publish`, {
1142
- publisher: options.to,
1143
- target: await parseJson(options.target, "--target"),
1150
+ ...destination,
1144
1151
  idempotencyKey: options.idempotencyKey,
1145
1152
  ...(options.plan === undefined ? {} : { launchPlanId: options.plan }),
1146
1153
  ...(version === undefined ? {} : { version }),
@@ -3771,9 +3778,12 @@ const INLINE_CONTENT_BYTES = 3 * 1024 * 1024;
3771
3778
  * The request body the content create and edit flags describe; flags not given are left out. Files go base64 while
3772
3779
  * they and the HTML fit in one request; any other file goes to storage first and is named by its upload id.
3773
3780
  */
3774
- async function contentInput(options, connection) {
3775
- const html = options.html === undefined ? undefined : await textOrFile(options.html);
3776
- const files = await sendableFiles(await readContentFiles(options.files), connection, html === undefined ? 0 : Buffer.byteLength(html));
3781
+ async function contentInput(options, connection, kindOf) {
3782
+ const read = await readContentFiles(options.files);
3783
+ const given = options.html === undefined ? undefined : await textOrFile(options.html);
3784
+ // Only an article's publisher swaps sha256: sources for uploaded files; any other kind keeps its HTML as written.
3785
+ const html = given !== undefined && read.length > 0 && (await kindOf()) === "article" ? pointImagesAtFiles(given, read) : given;
3786
+ const files = await sendableFiles(read, connection, html === undefined ? 0 : Buffer.byteLength(html));
3777
3787
  const input = {
3778
3788
  name: options.name,
3779
3789
  subject: options.subject,
@@ -3801,7 +3811,7 @@ async function readContentFiles(paths) {
3801
3811
  const contentType = FILE_TYPES[extname(path).toLowerCase()];
3802
3812
  if (!contentType)
3803
3813
  throw new CliError(`${path}: --file takes ${Object.keys(FILE_TYPES).join(", ")} files.`, exitCodes.usage);
3804
- read.push({ contentType, data: await readFile(await inRunFolder(path)) });
3814
+ read.push({ path, contentType, data: await readFile(await inRunFolder(path)) });
3805
3815
  }
3806
3816
  return read;
3807
3817
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrangled/cli",
3
- "version": "0.3.56",
3
+ "version": "0.3.57",
4
4
  "description": "Command-line access to your marketing data warehouse, for people and coding agents",
5
5
  "type": "module",
6
6
  "bin": {