@aicopycoders/exodus 2026.9.1400 → 2026.9.1700-beta.1
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/README.md +11 -0
- package/assets/docs/CLAUDE.md +4 -2
- package/assets/docs/PIPELINES.md +26 -0
- package/assets/skills/exodus/SKILL.md +2 -0
- package/assets/skills/exodus-ads/SKILL.md +165 -0
- package/assets/skills/exodus-video/SKILL.md +532 -0
- package/assets/skills/exodus-video/scripts/first-cut.mjs +568 -0
- package/assets/skills/exodus-winners/SKILL.md +65 -17
- package/assets/skills/exodus-workflow/SKILL.md +4 -2
- package/assets/skills/find-competitors/SKILL.md +1 -1
- package/dist/bin/exodus.js +5 -0
- package/dist/commands/ads.d.ts +50 -0
- package/dist/commands/ads.js +414 -0
- package/dist/commands/comments.d.ts +28 -0
- package/dist/commands/comments.js +198 -0
- package/dist/commands/swipe.js +8 -1
- package/dist/commands/video.d.ts +273 -0
- package/dist/commands/video.js +1450 -0
- package/dist/commands/winners.d.ts +50 -0
- package/dist/commands/winners.js +175 -2
- package/dist/commands/workflow.js +34 -13
- package/dist/lib/meta-ads.d.ts +108 -0
- package/dist/lib/meta-ads.js +156 -0
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AdAccountRef } from "../lib/meta-ads.js";
|
|
1
2
|
export declare const helpText: string;
|
|
2
3
|
export interface LocalValidation {
|
|
3
4
|
ok: boolean;
|
|
@@ -8,3 +9,52 @@ export interface LocalValidation {
|
|
|
8
9
|
export declare function validatePackageLocally(pkg: unknown): LocalValidation;
|
|
9
10
|
export declare function contentTypeFor(filePath: string): string;
|
|
10
11
|
export declare function run(flags: Record<string, string | boolean>): Promise<void>;
|
|
12
|
+
export declare const VALUE_FLAGS: Set<string>;
|
|
13
|
+
export declare function parsePositional(args?: string[]): string[];
|
|
14
|
+
export interface WinnerDefinitionView {
|
|
15
|
+
accountId?: string | null;
|
|
16
|
+
campaignRoleMap?: Record<string, string> | null;
|
|
17
|
+
ruleVariant?: string | null;
|
|
18
|
+
otherDefinition?: string | null;
|
|
19
|
+
ignoredCampaignIds?: string[] | null;
|
|
20
|
+
defaults?: {
|
|
21
|
+
window?: string | null;
|
|
22
|
+
resultsFloor?: number | null;
|
|
23
|
+
contributionLine?: number | null;
|
|
24
|
+
} | null;
|
|
25
|
+
lastAppliedAt?: number | null;
|
|
26
|
+
setupCompletedAt?: number | null;
|
|
27
|
+
summary?: WinnerDefinitionSummary | null;
|
|
28
|
+
}
|
|
29
|
+
export interface WinnerDefinitionSummaryGroup {
|
|
30
|
+
resultLabel?: string | null;
|
|
31
|
+
objective?: string | null;
|
|
32
|
+
instanceCount?: number | null;
|
|
33
|
+
creativeCount?: number | null;
|
|
34
|
+
totalResults?: number | null;
|
|
35
|
+
winnerCount?: number | null;
|
|
36
|
+
winnerShare?: number | null;
|
|
37
|
+
videoWinners?: number | null;
|
|
38
|
+
imageWinners?: number | null;
|
|
39
|
+
flatCurve?: boolean | null;
|
|
40
|
+
}
|
|
41
|
+
export interface WinnerDefinitionSummary {
|
|
42
|
+
instanceCount?: number | null;
|
|
43
|
+
creativeCount?: number | null;
|
|
44
|
+
winnerCount?: number | null;
|
|
45
|
+
groups?: WinnerDefinitionSummaryGroup[] | null;
|
|
46
|
+
ignoredCampaignCount?: number | null;
|
|
47
|
+
computedAt?: number | null;
|
|
48
|
+
}
|
|
49
|
+
export interface WinnerDefinitionResponse {
|
|
50
|
+
account?: AdAccountRef | null;
|
|
51
|
+
definition?: WinnerDefinitionView | null;
|
|
52
|
+
accounts?: AdAccountRef[];
|
|
53
|
+
}
|
|
54
|
+
export declare function roleTally(map: Record<string, string> | null | undefined): {
|
|
55
|
+
total: number;
|
|
56
|
+
line: string;
|
|
57
|
+
};
|
|
58
|
+
export declare function sharePercent(value: unknown): string;
|
|
59
|
+
export declare function summaryLines(summary: WinnerDefinitionSummary | null | undefined): string[];
|
|
60
|
+
export declare function formatDefinition(data: WinnerDefinitionResponse): string[];
|
package/dist/commands/winners.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { apiGet, apiPost } from "../lib/client.js";
|
|
4
4
|
import { displayRunStatus, formatError, tickerRunStatus } from "../lib/format.js";
|
|
5
5
|
import { pollUntilDone } from "../lib/poll.js";
|
|
6
|
+
import { accountLine, accountRequiredLines, count as formatCount, dateOrDash, errorCode, } from "../lib/meta-ads.js";
|
|
6
7
|
export const helpText = `
|
|
7
8
|
exodus winners — Import your own brand's winning ads as generative fuel
|
|
8
9
|
|
|
@@ -16,6 +17,14 @@ Usage:
|
|
|
16
17
|
exodus winners import <file.json | -> Push a winner package (- reads stdin)
|
|
17
18
|
exodus winners status <importId> Re-poll an import later
|
|
18
19
|
exodus winners list Winners Exodus already holds
|
|
20
|
+
exodus winners definition [--account act_…] What THIS brand means by "a winner"
|
|
21
|
+
|
|
22
|
+
Definition flags:
|
|
23
|
+
--account <id> Which connected ad account, e.g. act_1234567890. Required
|
|
24
|
+
only when the brand has more than one connected — a
|
|
25
|
+
definition read off the wrong account is a wrong answer that
|
|
26
|
+
looks right.
|
|
27
|
+
--json Machine-readable JSON output
|
|
19
28
|
|
|
20
29
|
Import flags:
|
|
21
30
|
--dry-run Local schema check + server dry-run: reports would-create vs
|
|
@@ -25,6 +34,10 @@ Import flags:
|
|
|
25
34
|
|
|
26
35
|
Notes:
|
|
27
36
|
• Scopes to your active brand's workspace (exodus brand current).
|
|
37
|
+
• \`definition\` reads the answers a human already gave on the dashboard — it
|
|
38
|
+
never writes, and it never shows a machine guess nobody has confirmed. A
|
|
39
|
+
brand with a Meta integration keeps its definition there, not in a local
|
|
40
|
+
file. Its ads read back with \`exodus ads list\`.
|
|
28
41
|
• Requires your Scrape Creators API key (Settings → Keys) — the own-page
|
|
29
42
|
match scrape bills your account.
|
|
30
43
|
• Re-pushing the same file is safe: no duplicate rows, the verdict snapshot
|
|
@@ -38,6 +51,8 @@ Examples:
|
|
|
38
51
|
cat winners.json | exodus winners import -
|
|
39
52
|
exodus winners status k97abc...
|
|
40
53
|
exodus winners list
|
|
54
|
+
exodus winners definition
|
|
55
|
+
exodus winners definition --account act_1234567890 --json
|
|
41
56
|
`.trim();
|
|
42
57
|
export function validatePackageLocally(pkg) {
|
|
43
58
|
const errors = [];
|
|
@@ -116,6 +131,8 @@ export async function run(flags) {
|
|
|
116
131
|
return runStatus(rest, flags);
|
|
117
132
|
if (sub === "list")
|
|
118
133
|
return runList(flags);
|
|
134
|
+
if (sub === "definition")
|
|
135
|
+
return runDefinition(flags);
|
|
119
136
|
if (!sub) {
|
|
120
137
|
console.log(helpText);
|
|
121
138
|
return;
|
|
@@ -124,8 +141,24 @@ export async function run(flags) {
|
|
|
124
141
|
console.log(helpText);
|
|
125
142
|
process.exit(1);
|
|
126
143
|
}
|
|
127
|
-
|
|
128
|
-
|
|
144
|
+
export const VALUE_FLAGS = new Set(["account"]);
|
|
145
|
+
export function parsePositional(args = process.argv.slice(3)) {
|
|
146
|
+
const out = [];
|
|
147
|
+
let i = 0;
|
|
148
|
+
while (i < args.length) {
|
|
149
|
+
const arg = args[i];
|
|
150
|
+
if (arg.startsWith("--")) {
|
|
151
|
+
const key = arg.slice(2).split("=", 1)[0] ?? "";
|
|
152
|
+
if (!arg.includes("=") && VALUE_FLAGS.has(key))
|
|
153
|
+
i += 2;
|
|
154
|
+
else
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
out.push(arg);
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
129
162
|
}
|
|
130
163
|
async function runImport(positional, flags) {
|
|
131
164
|
const json = !!flags["json"];
|
|
@@ -409,3 +442,143 @@ async function runList(flags) {
|
|
|
409
442
|
console.log(` ${w.verdictSentence}`);
|
|
410
443
|
}
|
|
411
444
|
}
|
|
445
|
+
const RULE_VARIANTS = {
|
|
446
|
+
standard: "standard — the smallest set of creatives that together carry most of a result group's results",
|
|
447
|
+
efficiency: "efficiency — the creatives with the best cost per result in each group",
|
|
448
|
+
"ignore-campaigns": "standard, with some campaigns deliberately left out of the count",
|
|
449
|
+
other: "written out in the brand's own words (below)",
|
|
450
|
+
};
|
|
451
|
+
export function roleTally(map) {
|
|
452
|
+
const entries = map && typeof map === "object" ? Object.values(map) : [];
|
|
453
|
+
const counts = new Map();
|
|
454
|
+
for (const role of entries) {
|
|
455
|
+
if (typeof role !== "string" || !role.trim())
|
|
456
|
+
continue;
|
|
457
|
+
const key = role.trim();
|
|
458
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
459
|
+
}
|
|
460
|
+
const order = ["testing", "scaling", "other"];
|
|
461
|
+
const parts = [...counts.entries()]
|
|
462
|
+
.sort((a, b) => {
|
|
463
|
+
const ai = order.indexOf(a[0]);
|
|
464
|
+
const bi = order.indexOf(b[0]);
|
|
465
|
+
return (ai === -1 ? order.length : ai) - (bi === -1 ? order.length : bi);
|
|
466
|
+
})
|
|
467
|
+
.map(([role, n]) => `${n} ${role}`);
|
|
468
|
+
return { total: entries.length, line: parts.join(" · ") || "none confirmed yet" };
|
|
469
|
+
}
|
|
470
|
+
export function sharePercent(value) {
|
|
471
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
472
|
+
return "—";
|
|
473
|
+
const pct = value <= 1 ? value * 100 : value;
|
|
474
|
+
return `${Math.round(pct)}%`;
|
|
475
|
+
}
|
|
476
|
+
export function summaryLines(summary) {
|
|
477
|
+
if (!summary) {
|
|
478
|
+
return ["Last run", " (the rule has not been run over this account yet)"];
|
|
479
|
+
}
|
|
480
|
+
const lines = [`Last run — ${dateOrDash(summary.computedAt)}`];
|
|
481
|
+
lines.push(` ${formatCount(summary.instanceCount)} ad instances → ${formatCount(summary.creativeCount)} distinct creatives · ${formatCount(summary.winnerCount)} winners`);
|
|
482
|
+
const groups = Array.isArray(summary.groups) ? summary.groups : [];
|
|
483
|
+
for (const group of groups) {
|
|
484
|
+
const label = group.resultLabel?.trim() || group.objective?.trim() || "(unlabelled group)";
|
|
485
|
+
lines.push(` ${label}: ${formatCount(group.winnerCount)} of ${formatCount(group.creativeCount)} creatives carry ${sharePercent(group.winnerShare)} of ${formatCount(group.totalResults)} results (${formatCount(group.videoWinners)} video / ${formatCount(group.imageWinners)} image)`);
|
|
486
|
+
if (group.flatCurve) {
|
|
487
|
+
lines.push(" flat curve — results spread evenly, so there is no clean winner set here; treat these as top contributors, not outliers.");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (typeof summary.ignoredCampaignCount === "number" && summary.ignoredCampaignCount > 0) {
|
|
491
|
+
lines.push(` ${summary.ignoredCampaignCount} campaign(s) were left out of the count.`);
|
|
492
|
+
}
|
|
493
|
+
return lines;
|
|
494
|
+
}
|
|
495
|
+
export function formatDefinition(data) {
|
|
496
|
+
const accounts = Array.isArray(data.accounts) ? data.accounts : [];
|
|
497
|
+
const account = data.account ?? null;
|
|
498
|
+
const definition = data.definition ?? null;
|
|
499
|
+
if (!account) {
|
|
500
|
+
if (accounts.length === 0) {
|
|
501
|
+
return [
|
|
502
|
+
"No Meta ad account is connected to this brand.",
|
|
503
|
+
"Connect one on the dashboard (Settings → Meta) — then the daily sync fills in the ads and this definition.",
|
|
504
|
+
];
|
|
505
|
+
}
|
|
506
|
+
const lines = ["Pick which ad account you mean:", ...accounts.map(accountLine)];
|
|
507
|
+
const first = accounts[0]?.accountId;
|
|
508
|
+
if (first) {
|
|
509
|
+
lines.push("");
|
|
510
|
+
lines.push(` exodus winners definition --account ${first}`);
|
|
511
|
+
}
|
|
512
|
+
return lines;
|
|
513
|
+
}
|
|
514
|
+
const label = account.name?.trim()
|
|
515
|
+
? `${account.name.trim()} (${account.accountId ?? "?"})`
|
|
516
|
+
: String(account.accountId ?? "?");
|
|
517
|
+
if (!definition) {
|
|
518
|
+
return [
|
|
519
|
+
`No winner definition for ${label} yet.`,
|
|
520
|
+
"Someone has to say what a winner means for this account before the rule can run — that happens on the dashboard (Settings → Meta → winner setup).",
|
|
521
|
+
];
|
|
522
|
+
}
|
|
523
|
+
const lines = [];
|
|
524
|
+
lines.push(`Winner definition — ${label}`);
|
|
525
|
+
lines.push(` setup: ${definition.setupCompletedAt ? `confirmed ${dateOrDash(definition.setupCompletedAt)}` : "not finished yet"}`);
|
|
526
|
+
const variant = definition.ruleVariant?.trim() ?? "";
|
|
527
|
+
lines.push(` rule: ${RULE_VARIANTS[variant] ?? (variant || "—")}`);
|
|
528
|
+
const defaults = definition.defaults ?? {};
|
|
529
|
+
lines.push(` dials: window ${defaults.window ?? "—"} · results floor ${formatCount(defaults.resultsFloor)} · contribution line ${sharePercent(defaults.contributionLine)}`);
|
|
530
|
+
const roles = roleTally(definition.campaignRoleMap);
|
|
531
|
+
lines.push(` campaigns: ${roles.total} with a confirmed role — ${roles.line}`);
|
|
532
|
+
const ignored = Array.isArray(definition.ignoredCampaignIds) ? definition.ignoredCampaignIds : [];
|
|
533
|
+
if (ignored.length > 0) {
|
|
534
|
+
lines.push(` ignored: ${ignored.length} campaign(s) left out of the rule`);
|
|
535
|
+
}
|
|
536
|
+
if (definition.lastAppliedAt) {
|
|
537
|
+
lines.push(` last run: ${dateOrDash(definition.lastAppliedAt)}`);
|
|
538
|
+
}
|
|
539
|
+
if (definition.otherDefinition?.trim()) {
|
|
540
|
+
lines.push("");
|
|
541
|
+
lines.push("In the brand's own words");
|
|
542
|
+
for (const line of definition.otherDefinition.trim().split("\n"))
|
|
543
|
+
lines.push(` ${line}`);
|
|
544
|
+
}
|
|
545
|
+
lines.push("");
|
|
546
|
+
lines.push(...summaryLines(definition.summary));
|
|
547
|
+
if (accounts.length > 1) {
|
|
548
|
+
lines.push("");
|
|
549
|
+
lines.push(`This brand has ${accounts.length} connected accounts — each keeps its own definition (--account).`);
|
|
550
|
+
}
|
|
551
|
+
return lines;
|
|
552
|
+
}
|
|
553
|
+
async function runDefinition(flags) {
|
|
554
|
+
const json = !!flags["json"];
|
|
555
|
+
const accountRaw = flags["account"];
|
|
556
|
+
if (accountRaw !== undefined && (typeof accountRaw !== "string" || !accountRaw.trim())) {
|
|
557
|
+
console.error("Error: --account needs an ad account id, e.g. act_1234567890");
|
|
558
|
+
console.log("Usage: exodus winners definition [--account act_…] [--json]");
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
const account = typeof accountRaw === "string" ? accountRaw.trim() : undefined;
|
|
562
|
+
const query = account ? `?account=${encodeURIComponent(account)}` : "";
|
|
563
|
+
const res = await apiGet(`/api/v2/winners/definition${query}`);
|
|
564
|
+
if (!res.ok) {
|
|
565
|
+
if (json) {
|
|
566
|
+
console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
if (res.status === 400 && errorCode(res.data) === "ACCOUNT_REQUIRED") {
|
|
570
|
+
for (const line of accountRequiredLines(res, (id) => `exodus winners definition --account ${id}`)) {
|
|
571
|
+
console.log(line);
|
|
572
|
+
}
|
|
573
|
+
process.exit(1);
|
|
574
|
+
}
|
|
575
|
+
console.log(formatError(res));
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
578
|
+
if (json) {
|
|
579
|
+
console.log(JSON.stringify({ ok: true, ...res.data }));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
for (const line of formatDefinition(res.data))
|
|
583
|
+
console.log(line);
|
|
584
|
+
}
|
|
@@ -9,6 +9,7 @@ import { runVerdict } from "../lib/runVerdict.js";
|
|
|
9
9
|
import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
|
|
10
10
|
import { missingRouteLine } from "../lib/route-support.js";
|
|
11
11
|
import { getChannel } from "../lib/channel.js";
|
|
12
|
+
import { asVideoRun, classifyRun, stopLines } from "./video.js";
|
|
12
13
|
export const helpText = `
|
|
13
14
|
exodus workflow — List, describe, run, inspect, import, and export saved workflows
|
|
14
15
|
|
|
@@ -90,6 +91,13 @@ Flags:
|
|
|
90
91
|
person's verdict. This is a choice you make for ONE
|
|
91
92
|
launch — it is never a setting on the workflow itself.
|
|
92
93
|
--wait Poll until the workflow run reaches a terminal status.
|
|
94
|
+
A video workflow's storyboard gate and final watch END
|
|
95
|
+
the wait too (exit 0): the run is parked on a decision
|
|
96
|
+
only you can make, so the command prints the same
|
|
97
|
+
"Parked: …" block and next commands "exodus video
|
|
98
|
+
status" prints. A Checkpoint / repair / slots park
|
|
99
|
+
prints its pause notice once and keeps polling so a
|
|
100
|
+
resolve from another shell carries the run on.
|
|
93
101
|
(checkpoint retry) Re-runs the step feeding the
|
|
94
102
|
Checkpoint box and waits until its fresh output is
|
|
95
103
|
ready and the run parks again — that IS its finish
|
|
@@ -1796,30 +1804,36 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
1796
1804
|
return { code: waited.code, lines: [...lines, ...waited.lines] };
|
|
1797
1805
|
}
|
|
1798
1806
|
const WAIT_TERMINAL_STATUSES = TERMINAL_RUN_STATUSES.flatMap((s) => storedWorkflowStatusForms(s));
|
|
1807
|
+
function isParkedSnapshot(raw) {
|
|
1808
|
+
const status = raw["status"];
|
|
1809
|
+
return typeof status === "string" && normalizeRunStatus(status) === "awaiting-approval";
|
|
1810
|
+
}
|
|
1811
|
+
function videoParkStop(raw) {
|
|
1812
|
+
if (!isParkedSnapshot(raw))
|
|
1813
|
+
return undefined;
|
|
1814
|
+
const stop = classifyRun(asVideoRun(raw));
|
|
1815
|
+
return stop.at === "storyboard-gate" || stop.at === "final-watch" ? stop : undefined;
|
|
1816
|
+
}
|
|
1799
1817
|
async function waitForRun(runId, opts, deps) {
|
|
1800
1818
|
const seen = new Map();
|
|
1801
1819
|
let pausedNotified = false;
|
|
1802
1820
|
const landOnPark = opts.landOnPark;
|
|
1821
|
+
const isLandingPark = (raw) => (landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason) ||
|
|
1822
|
+
videoParkStop(raw) !== undefined;
|
|
1803
1823
|
const pollResult = await deps.poll({
|
|
1804
1824
|
path: `${STATUS_PATH}?runId=${encodeURIComponent(runId)}`,
|
|
1805
1825
|
intervalMs: 3_000,
|
|
1806
1826
|
timeoutMs: 60 * 60 * 1000,
|
|
1807
|
-
terminalStatuses:
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
isDone: (raw) => !(typeof raw["status"] === "string" &&
|
|
1813
|
-
normalizeRunStatus(raw["status"]) === "awaiting-approval") || raw["pauseReason"] === landOnPark.pauseReason,
|
|
1814
|
-
}
|
|
1815
|
-
: {}),
|
|
1827
|
+
terminalStatuses: [
|
|
1828
|
+
...WAIT_TERMINAL_STATUSES,
|
|
1829
|
+
...storedWorkflowStatusForms("awaiting-approval"),
|
|
1830
|
+
],
|
|
1831
|
+
isDone: (raw) => !isParkedSnapshot(raw) || isLandingPark(raw),
|
|
1816
1832
|
onProgress: (raw) => {
|
|
1817
1833
|
if (opts.json || !opts.onProgressLine)
|
|
1818
1834
|
return;
|
|
1819
|
-
const
|
|
1820
|
-
const
|
|
1821
|
-
normalizeRunStatus(rawStatus) === "awaiting-approval";
|
|
1822
|
-
const isLanding = landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason;
|
|
1835
|
+
const parked = isParkedSnapshot(raw);
|
|
1836
|
+
const isLanding = isLandingPark(raw);
|
|
1823
1837
|
if (parked && !pausedNotified && !isLanding) {
|
|
1824
1838
|
pausedNotified = true;
|
|
1825
1839
|
const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
|
|
@@ -1848,6 +1862,9 @@ async function waitForRun(runId, opts, deps) {
|
|
|
1848
1862
|
const saved = opts.out !== undefined && terminalRun
|
|
1849
1863
|
? await saveDeliveries(terminalRun, opts.out, deps)
|
|
1850
1864
|
: undefined;
|
|
1865
|
+
const videoStop = !pollResult.timedOut && isRecord(pollResult.data)
|
|
1866
|
+
? videoParkStop(pollResult.data)
|
|
1867
|
+
: undefined;
|
|
1851
1868
|
if (opts.json) {
|
|
1852
1869
|
return {
|
|
1853
1870
|
code: pollResult.ok ? 0 : 1,
|
|
@@ -1856,6 +1873,7 @@ async function waitForRun(runId, opts, deps) {
|
|
|
1856
1873
|
...opts.jsonBase,
|
|
1857
1874
|
result: pollResult.data,
|
|
1858
1875
|
timedOut: pollResult.timedOut,
|
|
1876
|
+
...(videoStop ? { stop: videoStop } : {}),
|
|
1859
1877
|
...(saved ? { saved: saved.paths } : {}),
|
|
1860
1878
|
}),
|
|
1861
1879
|
],
|
|
@@ -1884,6 +1902,9 @@ async function waitForRun(runId, opts, deps) {
|
|
|
1884
1902
|
const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
|
|
1885
1903
|
lines.push("", landOnPark.headline, ...formatPauseNotice(landOnPark.pauseReason, runId, dashboardUrl).slice(1));
|
|
1886
1904
|
}
|
|
1905
|
+
if (videoStop) {
|
|
1906
|
+
lines.push("", ...stopLines(videoStop, runId, deps.dashboardUrl ?? getDashboardUrl()));
|
|
1907
|
+
}
|
|
1887
1908
|
return { code: pollResult.ok ? 0 : 1, lines };
|
|
1888
1909
|
}
|
|
1889
1910
|
export async function statusFlow(opts, deps) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { type ApiResponse } from "./client.js";
|
|
2
|
+
import { type Channel } from "./channel.js";
|
|
3
|
+
export interface AdAccountRef {
|
|
4
|
+
accountId?: string | null;
|
|
5
|
+
name?: string | null;
|
|
6
|
+
currency?: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface AdMedia {
|
|
9
|
+
image?: string | null;
|
|
10
|
+
video?: string | null;
|
|
11
|
+
poster?: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface AdWindowStats {
|
|
14
|
+
resultType?: string | null;
|
|
15
|
+
results?: number | null;
|
|
16
|
+
costPerResult?: number | null;
|
|
17
|
+
costPerResultDisplay?: string | null;
|
|
18
|
+
ctr?: number | null;
|
|
19
|
+
ctrDisplay?: string | null;
|
|
20
|
+
cpc?: number | null;
|
|
21
|
+
cpcDisplay?: string | null;
|
|
22
|
+
spend?: number | null;
|
|
23
|
+
spendDisplay?: string | null;
|
|
24
|
+
impressions?: number | null;
|
|
25
|
+
clicks?: number | null;
|
|
26
|
+
currency?: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface AdSummary extends AdWindowStats {
|
|
29
|
+
adId?: string | null;
|
|
30
|
+
accountId?: string | null;
|
|
31
|
+
name?: string | null;
|
|
32
|
+
status?: string | null;
|
|
33
|
+
format?: string | null;
|
|
34
|
+
campaign?: {
|
|
35
|
+
id?: string | null;
|
|
36
|
+
name?: string | null;
|
|
37
|
+
objective?: string | null;
|
|
38
|
+
} | null;
|
|
39
|
+
adset?: {
|
|
40
|
+
id?: string | null;
|
|
41
|
+
name?: string | null;
|
|
42
|
+
optimizationGoal?: string | null;
|
|
43
|
+
} | null;
|
|
44
|
+
createdTime?: string | number | null;
|
|
45
|
+
headline?: string | null;
|
|
46
|
+
bodyText?: string | null;
|
|
47
|
+
winnerStatus?: string | null;
|
|
48
|
+
media?: AdMedia | null;
|
|
49
|
+
syncedAt?: string | number | null;
|
|
50
|
+
}
|
|
51
|
+
export interface AdsListResponse {
|
|
52
|
+
ads?: AdSummary[];
|
|
53
|
+
count?: number;
|
|
54
|
+
window?: string;
|
|
55
|
+
sort?: string;
|
|
56
|
+
accounts?: AdAccountRef[];
|
|
57
|
+
}
|
|
58
|
+
export interface AdShowResponse extends AdSummary {
|
|
59
|
+
lifetime?: AdWindowStats | null;
|
|
60
|
+
last90d?: AdWindowStats | null;
|
|
61
|
+
commentCounts?: {
|
|
62
|
+
facebook?: number | null;
|
|
63
|
+
instagram?: number | null;
|
|
64
|
+
} | null;
|
|
65
|
+
}
|
|
66
|
+
export interface AdComment {
|
|
67
|
+
id?: string | null;
|
|
68
|
+
adId?: string | null;
|
|
69
|
+
platform?: string | null;
|
|
70
|
+
text?: string | null;
|
|
71
|
+
createdAt?: string | number | null;
|
|
72
|
+
likeCount?: number | null;
|
|
73
|
+
parentId?: string | null;
|
|
74
|
+
syncedAt?: string | number | null;
|
|
75
|
+
}
|
|
76
|
+
export interface CommentsResponse {
|
|
77
|
+
adId?: string | null;
|
|
78
|
+
comments?: AdComment[];
|
|
79
|
+
count?: number;
|
|
80
|
+
}
|
|
81
|
+
export interface FlowResult {
|
|
82
|
+
code: number;
|
|
83
|
+
lines: string[];
|
|
84
|
+
warnings?: string[];
|
|
85
|
+
}
|
|
86
|
+
export interface MetaAdsDeps {
|
|
87
|
+
get: (path: string) => Promise<ApiResponse<unknown>>;
|
|
88
|
+
channel: Channel;
|
|
89
|
+
apiUrl: () => string;
|
|
90
|
+
now: () => number;
|
|
91
|
+
}
|
|
92
|
+
export declare const defaultMetaAdsDeps: MetaAdsDeps;
|
|
93
|
+
export declare function displayOr(display: unknown, value: unknown, format: (n: number) => string): string;
|
|
94
|
+
export declare function money(n: number, currency?: string | null): string;
|
|
95
|
+
export declare function percent(n: number): string;
|
|
96
|
+
export declare function count(n: unknown): string;
|
|
97
|
+
export declare function sanitizeForTerminal(text: unknown): string;
|
|
98
|
+
export declare function safeText(text: unknown): string;
|
|
99
|
+
export declare function safeTextOr(text: unknown, fallback: string): string;
|
|
100
|
+
export declare function truncate(text: unknown, max: number): string;
|
|
101
|
+
export declare function dateOrDash(value: unknown): string;
|
|
102
|
+
export declare function table(headers: string[], rows: string[][]): string;
|
|
103
|
+
export declare function accountLine(account: AdAccountRef): string;
|
|
104
|
+
export declare function mediaUrl(apiBase: string, relative: unknown): string | null;
|
|
105
|
+
export declare function errorCode(data: unknown): string | undefined;
|
|
106
|
+
export declare function accountsOf(data: unknown): AdAccountRef[];
|
|
107
|
+
export declare function accountRequiredLines(res: ApiResponse<unknown>, example: (accountId: string) => string): string[];
|
|
108
|
+
export declare function errorFor(res: ApiResponse<unknown>, verb: string, json: boolean, deps: MetaAdsDeps, example: (accountId: string) => string): FlowResult | undefined;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { apiGet, getApiUrl } from "./client.js";
|
|
2
|
+
import { formatApiError } from "./format.js";
|
|
3
|
+
import { auth401Hint } from "./backend-hint.js";
|
|
4
|
+
import { getChannel } from "./channel.js";
|
|
5
|
+
import { missingRouteLine } from "./route-support.js";
|
|
6
|
+
export const defaultMetaAdsDeps = {
|
|
7
|
+
get: (path) => apiGet(path),
|
|
8
|
+
channel: getChannel(),
|
|
9
|
+
apiUrl: () => getApiUrl(),
|
|
10
|
+
now: () => Date.now(),
|
|
11
|
+
};
|
|
12
|
+
export function displayOr(display, value, format) {
|
|
13
|
+
if (typeof display === "string" && display.trim())
|
|
14
|
+
return safeTextOr(display, "—");
|
|
15
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
16
|
+
return format(value);
|
|
17
|
+
return "—";
|
|
18
|
+
}
|
|
19
|
+
export function money(n, currency) {
|
|
20
|
+
const body = n.toLocaleString("en-US", {
|
|
21
|
+
minimumFractionDigits: n < 100 ? 2 : 0,
|
|
22
|
+
maximumFractionDigits: 2,
|
|
23
|
+
});
|
|
24
|
+
return currency ? `${body} ${currency}` : body;
|
|
25
|
+
}
|
|
26
|
+
export function percent(n) {
|
|
27
|
+
return `${n.toFixed(2)}%`;
|
|
28
|
+
}
|
|
29
|
+
export function count(n) {
|
|
30
|
+
return typeof n === "number" && Number.isFinite(n) ? n.toLocaleString("en-US") : "—";
|
|
31
|
+
}
|
|
32
|
+
const ESC_STRING_SEQUENCE = /\x1b[\]PX^_][\s\S]*?(?:\x07|\x1b\\|$)/g;
|
|
33
|
+
const ESC_CSI_SEQUENCE = /\x1b\[[0-?]*[ -\/]*[@-~]?/g;
|
|
34
|
+
const ESC_SIMPLE_SEQUENCE = /\x1b[ -\/]*[0-~]?/g;
|
|
35
|
+
const C0_AND_DEL = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
36
|
+
const C1_CONTROLS = /[\u0080-\u009f]/g;
|
|
37
|
+
export function sanitizeForTerminal(text) {
|
|
38
|
+
if (typeof text !== "string" || text === "")
|
|
39
|
+
return "";
|
|
40
|
+
return text
|
|
41
|
+
.replace(ESC_STRING_SEQUENCE, "")
|
|
42
|
+
.replace(ESC_CSI_SEQUENCE, "")
|
|
43
|
+
.replace(ESC_SIMPLE_SEQUENCE, "")
|
|
44
|
+
.replace(C0_AND_DEL, "")
|
|
45
|
+
.replace(C1_CONTROLS, "");
|
|
46
|
+
}
|
|
47
|
+
export function safeText(text) {
|
|
48
|
+
return sanitizeForTerminal(text).trim();
|
|
49
|
+
}
|
|
50
|
+
export function safeTextOr(text, fallback) {
|
|
51
|
+
return safeText(text) || fallback;
|
|
52
|
+
}
|
|
53
|
+
export function truncate(text, max) {
|
|
54
|
+
if (typeof text !== "string")
|
|
55
|
+
return "";
|
|
56
|
+
const flat = sanitizeForTerminal(text).replace(/\s+/g, " ").trim();
|
|
57
|
+
if (flat.length <= max)
|
|
58
|
+
return flat;
|
|
59
|
+
const cut = flat.slice(0, max - 1);
|
|
60
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
61
|
+
const body = lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut;
|
|
62
|
+
return `${body}…`;
|
|
63
|
+
}
|
|
64
|
+
export function dateOrDash(value) {
|
|
65
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
66
|
+
const d = new Date(value);
|
|
67
|
+
return Number.isNaN(d.getTime()) ? "—" : d.toISOString().slice(0, 10);
|
|
68
|
+
}
|
|
69
|
+
if (typeof value === "string" && value.trim()) {
|
|
70
|
+
const d = new Date(value);
|
|
71
|
+
return Number.isNaN(d.getTime())
|
|
72
|
+
? safeText(value).slice(0, 10)
|
|
73
|
+
: d.toISOString().slice(0, 10);
|
|
74
|
+
}
|
|
75
|
+
return "—";
|
|
76
|
+
}
|
|
77
|
+
export function table(headers, rows) {
|
|
78
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
|
|
79
|
+
const fmt = (row) => row.map((cell, i) => (cell ?? "").padEnd(widths[i])).join(" ").trimEnd();
|
|
80
|
+
return [fmt(headers), fmt(headers.map((h) => "-".repeat(h.length))), ...rows.map(fmt)].join("\n");
|
|
81
|
+
}
|
|
82
|
+
export function accountLine(account) {
|
|
83
|
+
const id = safeTextOr(account.accountId, "(no id)");
|
|
84
|
+
const name = safeText(account.name);
|
|
85
|
+
const currency = safeText(account.currency);
|
|
86
|
+
const suffix = [name, currency ? `(${currency})` : ""].filter(Boolean).join(" ");
|
|
87
|
+
return suffix ? ` ${id} ${suffix}` : ` ${id}`;
|
|
88
|
+
}
|
|
89
|
+
export function mediaUrl(apiBase, relative) {
|
|
90
|
+
const rel = safeText(relative);
|
|
91
|
+
if (!rel)
|
|
92
|
+
return null;
|
|
93
|
+
if (/^https?:\/\//i.test(rel))
|
|
94
|
+
return rel;
|
|
95
|
+
const base = (apiBase || "").replace(/\/$/, "");
|
|
96
|
+
return `${base}${rel.startsWith("/") ? "" : "/"}${rel}`;
|
|
97
|
+
}
|
|
98
|
+
export function errorCode(data) {
|
|
99
|
+
if (typeof data !== "object" || data === null)
|
|
100
|
+
return undefined;
|
|
101
|
+
const rec = data;
|
|
102
|
+
if (typeof rec.code === "string" && rec.code)
|
|
103
|
+
return rec.code;
|
|
104
|
+
const err = rec.error;
|
|
105
|
+
if (typeof err === "object" && err !== null) {
|
|
106
|
+
const nested = err.code;
|
|
107
|
+
if (typeof nested === "string" && nested)
|
|
108
|
+
return nested;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
export function accountsOf(data) {
|
|
113
|
+
if (typeof data !== "object" || data === null)
|
|
114
|
+
return [];
|
|
115
|
+
const raw = data.accounts;
|
|
116
|
+
return Array.isArray(raw) ? raw : [];
|
|
117
|
+
}
|
|
118
|
+
export function accountRequiredLines(res, example) {
|
|
119
|
+
const accounts = accountsOf(res.data);
|
|
120
|
+
const lines = [formatApiError(res)];
|
|
121
|
+
if (accounts.length > 0) {
|
|
122
|
+
lines.push("");
|
|
123
|
+
lines.push("Connected ad accounts:");
|
|
124
|
+
for (const account of accounts)
|
|
125
|
+
lines.push(accountLine(account));
|
|
126
|
+
const first = accounts[0]?.accountId;
|
|
127
|
+
if (first) {
|
|
128
|
+
lines.push("");
|
|
129
|
+
lines.push("Re-run naming the one you mean:");
|
|
130
|
+
lines.push(` ${example(first)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return lines;
|
|
134
|
+
}
|
|
135
|
+
export function errorFor(res, verb, json, deps, example) {
|
|
136
|
+
if (res.ok)
|
|
137
|
+
return undefined;
|
|
138
|
+
if (json) {
|
|
139
|
+
return { code: 1, lines: [JSON.stringify({ ok: false, status: res.status, data: res.data })] };
|
|
140
|
+
}
|
|
141
|
+
const unsupported = missingRouteLine(res, verb, deps.channel);
|
|
142
|
+
if (unsupported)
|
|
143
|
+
return { code: 1, lines: [unsupported] };
|
|
144
|
+
if (res.status === 400 && errorCode(res.data) === "ACCOUNT_REQUIRED") {
|
|
145
|
+
return { code: 1, lines: accountRequiredLines(res, example) };
|
|
146
|
+
}
|
|
147
|
+
const lines = [formatApiError(res)];
|
|
148
|
+
if (res.status === 401) {
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push(auth401Hint(deps.apiUrl()));
|
|
151
|
+
}
|
|
152
|
+
else if (res.status === 400 && errorCode(res.data) === "NO_BRAND") {
|
|
153
|
+
lines.push("Pick a brand first: exodus brand use <slug>");
|
|
154
|
+
}
|
|
155
|
+
return { code: 1, lines };
|
|
156
|
+
}
|