@aicopycoders/exodus 2026.9.200 → 2026.9.1100-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 +3 -1
- package/assets/docs/PIPELINES.md +22 -0
- package/assets/skills/exodus/SKILL.md +3 -1
- package/assets/skills/exodus-ads/SKILL.md +165 -0
- package/assets/skills/exodus-video/SKILL.md +233 -0
- package/assets/skills/exodus-video/scripts/first-cut.mjs +469 -0
- package/assets/skills/exodus-winners/SKILL.md +65 -17
- 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/video.d.ts +213 -0
- package/dist/commands/video.js +1038 -0
- package/dist/commands/winners.d.ts +50 -0
- package/dist/commands/winners.js +175 -2
- 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
|
+
}
|
|
@@ -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
|
+
}
|