@aicopycoders/exodus 2026.7.200-beta.5 → 2026.7.200-beta.7

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.
@@ -7,6 +7,7 @@ import { findWorkspaceRoot } from "../lib/state.js";
7
7
  import { detectLayout, listBrandDirs, resolveActiveBrand, brandStateDir, } from "../lib/layout.js";
8
8
  import { auth401Hint, isPlaceholderApiUrl } from "../lib/backend-hint.js";
9
9
  import { compareVersions, readPackageVersion, } from "../lib/release.js";
10
+ import { channelOf, pkgRef, stampChannel } from "../lib/channel.js";
10
11
  export const helpText = `
11
12
  exodus doctor — Preflight checks for your local Claude Code setup
12
13
 
@@ -58,7 +59,7 @@ export function checkEnvFile() {
58
59
  return {
59
60
  ok: false,
60
61
  label: ".env",
61
- fix: "run `npx @aicopycoders/exodus init`, then paste your dashboard .env block (Settings → Claude Code → Copy .env block)",
62
+ fix: `run \`npx ${pkgRef()} init\`, then paste your dashboard .env block (Settings → Claude Code → Copy .env block)`,
62
63
  };
63
64
  }
64
65
  const content = fs.readFileSync(envPath, "utf8");
@@ -80,7 +81,7 @@ export function checkEnvFile() {
80
81
  ok: false,
81
82
  label: ".env",
82
83
  detail: "missing EXODUS_API_URL or EXODUS_API_KEY",
83
- fix: "run `npx @aicopycoders/exodus init`, then paste your dashboard .env block (Settings → Claude Code → Copy .env block)",
84
+ fix: `run \`npx ${pkgRef()} init\`, then paste your dashboard .env block (Settings → Claude Code → Copy .env block)`,
84
85
  };
85
86
  }
86
87
  export async function checkWhoami() {
@@ -124,7 +125,7 @@ export async function checkWhoami() {
124
125
  todo: true,
125
126
  label: "Brand primer",
126
127
  detail: `${d.workspaceSlug} has no primer yet — set one up to unlock the pipelines`,
127
- fix: `say "exodus, set up my brand primer" (or run \`npx @aicopycoders/exodus foundation\` from a source doc, or paste it at /settings?tab=brands&brand=${d.workspaceSlug})`,
128
+ fix: `say "exodus, set up my brand primer" (or run \`npx ${pkgRef()} foundation\` from a source doc, or paste it at /settings?tab=brands&brand=${d.workspaceSlug})`,
128
129
  };
129
130
  }
130
131
  return {
@@ -182,7 +183,7 @@ export async function checkActiveBrandMatch() {
182
183
  ok: false,
183
184
  label: "Active brand",
184
185
  detail: `local active brand "${resolved.slug}" (${source}) is NOT in your accessible list — the server ignores it and every command silently targets the key's default brand. available: ${available}`,
185
- fix: `run \`npx @aicopycoders/exodus brand use <slug>\` with one of your brands, or \`npx @aicopycoders/exodus brand clear\` to fall back to the key default`,
186
+ fix: `run \`npx ${pkgRef()} brand use <slug>\` with one of your brands, or \`npx ${pkgRef()} brand clear\` to fall back to the key default`,
186
187
  };
187
188
  }
188
189
  const FRESHNESS_SUBTREES = ["commands", "lib", "bin"];
@@ -251,7 +252,9 @@ export function checkExodusDistFreshness(pkgRootOverride) {
251
252
  }
252
253
  return { ok: true, label: "Exodus CLI build", detail: "dist up-to-date" };
253
254
  }
254
- const NPM_REGISTRY = "https://registry.npmjs.org/@aicopycoders/exodus/latest";
255
+ function npmRegistryUrl(channel) {
256
+ return `https://registry.npmjs.org/@aicopycoders/exodus/${channel}`;
257
+ }
255
258
  export async function checkVersionCurrency(pkgRootOverride, fetchImpl = fetch) {
256
259
  let pkgRoot;
257
260
  if (pkgRootOverride) {
@@ -265,28 +268,29 @@ export async function checkVersionCurrency(pkgRootOverride, fetchImpl = fetch) {
265
268
  }
266
269
  }
267
270
  const local = readPackageVersion(pkgRoot);
271
+ const channel = channelOf(local);
268
272
  try {
269
273
  const ctrl = new AbortController();
270
274
  const t = setTimeout(() => ctrl.abort(), 8_000);
271
275
  let res;
272
276
  try {
273
- res = await fetchImpl(NPM_REGISTRY, { signal: ctrl.signal });
277
+ res = await fetchImpl(npmRegistryUrl(channel), { signal: ctrl.signal });
274
278
  }
275
279
  finally {
276
280
  clearTimeout(t);
277
281
  }
278
282
  if (!res.ok)
279
283
  throw new Error(`HTTP ${res.status}`);
280
- const latest = (await res.json()).version;
281
- if (compareVersions(local, latest) >= 0) {
284
+ const published = (await res.json()).version;
285
+ if (compareVersions(local, published) >= 0) {
282
286
  return { ok: true, label: "Exodus version", detail: `${local} — up to date` };
283
287
  }
284
288
  return {
285
289
  ok: true,
286
290
  warn: true,
287
291
  label: "Exodus version",
288
- detail: `${local} installed, ${latest} available`,
289
- fix: "run `npx @aicopycoders/exodus@latest init` to update (or `npm update -g @aicopycoders/exodus`)",
292
+ detail: `${local} installed, ${published} available`,
293
+ fix: `run \`npx ${pkgRef(channel)} init\` to update (or \`npm install -g ${pkgRef(channel)}\`)`,
290
294
  };
291
295
  }
292
296
  catch (err) {
@@ -326,7 +330,7 @@ export async function checkApiAndDrive() {
326
330
  ok: false,
327
331
  label: "Dashboard API key",
328
332
  detail: `HTTP ${res.status}`,
329
- fix: "regenerate at Settings → API Keys, then paste the new .env block (or re-run `npx @aicopycoders/exodus init`)",
333
+ fix: `regenerate at Settings → API Keys, then paste the new .env block (or re-run \`npx ${pkgRef()} init\`)`,
330
334
  },
331
335
  {
332
336
  ok: false,
@@ -445,7 +449,9 @@ export async function checkDashboardAuth() {
445
449
  ok: false,
446
450
  label: "Dashboard auth (dashboard-routed pipelines)",
447
451
  detail: res.data?.error ?? "401 from /api/doctor/dashboard-ping",
448
- fix: hint ?? "run `npx @aicopycoders/exodus@latest init` to update the CLI — older releases skipped the Bearer header on dashboard routes",
452
+ fix: hint
453
+ ? stampChannel(hint)
454
+ : `run \`npx ${pkgRef()} init\` to update the CLI — older releases skipped the Bearer header on dashboard routes`,
449
455
  };
450
456
  }
451
457
  return {
@@ -476,7 +482,7 @@ export function checkLayout() {
476
482
  return {
477
483
  ok: true,
478
484
  label: "Layout",
479
- detail: "single-brand (legacy) — run `npx @aicopycoders/exodus migrate` to switch to the multi-brand layout",
485
+ detail: `single-brand (legacy) — run \`npx ${pkgRef()} migrate\` to switch to the multi-brand layout`,
480
486
  };
481
487
  }
482
488
  const brands = listBrandDirs(root);
@@ -490,7 +496,7 @@ export function checkLayout() {
490
496
  warn: true,
491
497
  label: "Layout",
492
498
  detail: `multi-brand — no brand folders yet; ${active}`,
493
- fix: "run `npx @aicopycoders/exodus@latest init` (or `npx @aicopycoders/exodus brand use <slug>`) to create your brand folders",
499
+ fix: `run \`npx ${pkgRef()} init\` (or \`npx ${pkgRef()} brand use <slug>\`) to create your brand folders`,
494
500
  };
495
501
  }
496
502
  const missingProfiles = brands.filter((b) => !fs.existsSync(path.join(b.dir, "state", "brand-profile.md")));
@@ -500,7 +506,7 @@ export function checkLayout() {
500
506
  warn: true,
501
507
  label: "Layout",
502
508
  detail: `multi-brand, ${brands.length} brand folder(s); ${active}; missing brand profile in: ${missingProfiles.map((b) => b.slug).join(", ")}`,
503
- fix: "run `npx @aicopycoders/exodus@latest init` to refresh every brand folder's profile",
509
+ fix: `run \`npx ${pkgRef()} init\` to refresh every brand folder's profile`,
504
510
  };
505
511
  }
506
512
  return {
@@ -518,7 +524,7 @@ export function checkBrandProfileGenesisDepth() {
518
524
  ok: false,
519
525
  label: "brand-profile",
520
526
  detail: `${rel} is missing`,
521
- fix: "run `npx @aicopycoders/exodus brand use <slug>` to generate it",
527
+ fix: `run \`npx ${pkgRef()} brand use <slug>\` to generate it`,
522
528
  };
523
529
  }
524
530
  const contents = fs.readFileSync(filePath, "utf-8");
@@ -3,12 +3,13 @@ import path from "node:path";
3
3
  import { writeEnvScaffold, ensureGitignore, writeSkills, writeReferences, writeDocs, ensureBaseDirs, } from "../lib/scaffold.js";
4
4
  import { loadWorkspaceEnv } from "../lib/load-env.js";
5
5
  import { ensureBrandDir } from "../lib/layout.js";
6
+ import { pkgRef } from "../lib/channel.js";
6
7
  export const helpText = `
7
8
  exodus init — Set up (or refresh) an Exodus workspace in the current folder
8
9
 
9
10
  Usage:
10
- npx @aicopycoders/exodus init Scaffold the workspace + install skills
11
- npx @aicopycoders/exodus init --root <dir> Target a specific folder
11
+ npx ${pkgRef()} init Scaffold the workspace + install skills
12
+ npx ${pkgRef()} init --root <dir> Target a specific folder
12
13
 
13
14
  Run this in a fresh, empty folder. It creates the workspace layout, writes the
14
15
  Exodus + Genesis skills into .claude/skills/, scaffolds a comment-only .env, and
@@ -1,5 +1,6 @@
1
1
  import { apiPostDashboard, getDashboardUrl } from "../lib/client.js";
2
2
  import { formatError } from "../lib/format.js";
3
+ import { pkgRef } from "../lib/channel.js";
3
4
  import { readFileSync } from "node:fs";
4
5
  export const helpText = `
5
6
  exodus meme — Meme generator: brief → recommended formats → one batched run
@@ -26,7 +27,7 @@ Flow:
26
27
 
27
28
  Keys (strict BYOK — checked server-side before anything starts):
28
29
  classic (layer 1) memes need your Imgflip login; AI (layer 2/3) memes need
29
- your Kie.ai key; captions always need your LLM key. Run \`npx @aicopycoders/exodus doctor\`.
30
+ your Kie.ai key; captions always need your LLM key. Run \`npx ${pkgRef()} doctor\`.
30
31
 
31
32
  Examples:
32
33
  exodus meme recommend --brief "grounding sheets reduce inflammation"
@@ -114,7 +115,7 @@ async function runRecommend(flags) {
114
115
  if (!res.ok) {
115
116
  console.error(formatError(res));
116
117
  if (res.status === 400) {
117
- console.error("Hint: run `npx @aicopycoders/exodus doctor` to check your keys.");
118
+ console.error(`Hint: run \`npx ${pkgRef()} doctor\` to check your keys.`);
118
119
  }
119
120
  process.exit(1);
120
121
  return;
@@ -171,7 +172,7 @@ async function runRun(flags) {
171
172
  if (!res.ok) {
172
173
  console.error(formatError(res));
173
174
  if (res.status === 400) {
174
- console.error("Hint: run `npx @aicopycoders/exodus doctor` to check your keys.");
175
+ console.error(`Hint: run \`npx ${pkgRef()} doctor\` to check your keys.`);
175
176
  }
176
177
  process.exit(1);
177
178
  return;
@@ -233,7 +234,7 @@ async function runRegenerate(flags) {
233
234
  if (!res.ok) {
234
235
  console.error(formatError(res));
235
236
  if (res.status === 400) {
236
- console.error("Hint: run `npx @aicopycoders/exodus doctor` to check your keys.");
237
+ console.error(`Hint: run \`npx ${pkgRef()} doctor\` to check your keys.`);
237
238
  }
238
239
  process.exit(1);
239
240
  return;
@@ -4,6 +4,7 @@ import { execSync } from "node:child_process";
4
4
  import { apiGet, apiGetDashboard } from "../lib/client.js";
5
5
  import { getActiveBrand, setLayoutVersion, findWorkspaceRoot } from "../lib/state.js";
6
6
  import { detectLayout, brandDirFor, ensureBrandDir, BRAND_MARKER_FILE, } from "../lib/layout.js";
7
+ import { pkgRef } from "../lib/channel.js";
7
8
  export const helpText = `
8
9
  exodus migrate — convert a single-brand install to the multi-brand layout
9
10
 
@@ -15,7 +16,7 @@ What it does (one-time, opt-in — updates never force this):
15
16
  falling back to the key's bound brand).
16
17
  2. Creates a subfolder for it and MOVES your brand-specific files in:
17
18
  state/ and output/ → <brand>/state/ and <brand>/output/
18
- 3. Marks the install as multi-brand. From then on, \`npx @aicopycoders/exodus@latest init\`
19
+ 3. Marks the install as multi-brand. From then on, \`npx ${pkgRef()} init\`
19
20
  creates a subfolder for every brand you own, and running a command
20
21
  from inside a brand's folder targets that brand automatically.
21
22
 
@@ -94,7 +95,7 @@ async function resolveBrand() {
94
95
  }
95
96
  if (!slug) {
96
97
  fail("could not determine which brand this install belongs to.\n" +
97
- " Run `npx @aicopycoders/exodus brand use <slug>` first (or check your network/.env), then re-run `npx @aicopycoders/exodus migrate`.");
98
+ ` Run \`npx ${pkgRef()} brand use <slug>\` first (or check your network/.env), then re-run \`npx ${pkgRef()} migrate\`.`);
98
99
  }
99
100
  if (!name) {
100
101
  try {
@@ -115,7 +116,7 @@ export async function run(flags) {
115
116
  const root = findWorkspaceRoot();
116
117
  if (detectLayout(root) === "v2") {
117
118
  console.log("✓ Already on the multi-brand layout — nothing to migrate.");
118
- console.log(" Run `npx @aicopycoders/exodus@latest init` to sync brand folders.");
119
+ console.log(` Run \`npx ${pkgRef()} init\` to sync brand folders.`);
119
120
  return;
120
121
  }
121
122
  const brand = await resolveBrand();
@@ -128,7 +129,7 @@ export async function run(flags) {
128
129
  if (conflicts.length > 0) {
129
130
  fail(`"${brandDirRel}/" already contains different versions of:\n` +
130
131
  conflicts.map((f) => ` ${f}`).join("\n") +
131
- "\n Reconcile or remove them, then re-run `npx @aicopycoders/exodus migrate`. Nothing was changed.");
132
+ `\n Reconcile or remove them, then re-run \`npx ${pkgRef()} migrate\`. Nothing was changed.`);
132
133
  }
133
134
  console.log(`Migrating this install to the multi-brand layout...`);
134
135
  console.log(` brand: ${brand.slug} (${brand.name})`);
@@ -151,8 +152,8 @@ export async function run(flags) {
151
152
  Shared files (.env, exodus/, .claude/skills/, references/) did not move.
152
153
 
153
154
  Next steps:
154
- • Run \`npx @aicopycoders/exodus@latest init\` to pull folders for your other brands.
155
+ • Run \`npx ${pkgRef()} init\` to pull folders for your other brands.
155
156
  • Run commands from inside ${brandDirRel}/ to target it automatically,
156
- or keep using \`npx @aicopycoders/exodus brand use <slug>\` from anywhere.
157
+ or keep using \`npx ${pkgRef()} brand use <slug>\` from anywhere.
157
158
  `.trimEnd());
158
159
  }
@@ -0,0 +1,10 @@
1
+ export declare const helpText: string;
2
+ export interface LocalValidation {
3
+ ok: boolean;
4
+ errors: string[];
5
+ warnings: string[];
6
+ winnerCount: number;
7
+ }
8
+ export declare function validatePackageLocally(pkg: unknown): LocalValidation;
9
+ export declare function contentTypeFor(filePath: string): string;
10
+ export declare function run(flags: Record<string, string | boolean>): Promise<void>;
@@ -0,0 +1,401 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { apiGet, apiPost } from "../lib/client.js";
4
+ import { formatError } from "../lib/format.js";
5
+ import { pollUntilDone } from "../lib/poll.js";
6
+ export const helpText = `
7
+ exodus winners — Import your own brand's winning ads as generative fuel
8
+
9
+ Your agent (holding the Meta Ads MCP) assembles a winner-package JSON of
10
+ designated ad-account winners; this command pushes it into Exodus. Each
11
+ winner lands as a swipe row under an auto-created own-brand entry, enriched
12
+ server-side (Ad Library match → scrape, or your pushed media files), and
13
+ surfaces in generation selection with its verdict.
14
+
15
+ Usage:
16
+ exodus winners import <file.json | -> Push a winner package (- reads stdin)
17
+ exodus winners status <importId> Re-poll an import later
18
+ exodus winners list Winners Exodus already holds
19
+
20
+ Import flags:
21
+ --dry-run Local schema check + server dry-run: reports would-create vs
22
+ would-update per winner. Zero writes, no media upload.
23
+ --no-wait Return the importId immediately instead of polling
24
+ --json Machine-readable JSON output
25
+
26
+ Notes:
27
+ • Scopes to your active brand's workspace (exodus brand current).
28
+ • Requires your Scrape Creators API key (Settings → Keys) — the own-page
29
+ match scrape bills your account.
30
+ • Re-pushing the same file is safe: no duplicate rows, the verdict snapshot
31
+ is replaced wholesale, and previously gap-filled winners that now match
32
+ upgrade in place. Winners absent from a re-push are untouched.
33
+ • assets paths in the package resolve relative to the JSON file's folder.
34
+
35
+ Examples:
36
+ exodus winners import winners.json
37
+ exodus winners import winners.json --dry-run
38
+ cat winners.json | exodus winners import -
39
+ exodus winners status k97abc...
40
+ exodus winners list
41
+ `.trim();
42
+ export function validatePackageLocally(pkg) {
43
+ const errors = [];
44
+ const warnings = [];
45
+ let winnerCount = 0;
46
+ if (pkg === null || typeof pkg !== "object") {
47
+ return { ok: false, errors: ["package is not a JSON object"], warnings, winnerCount };
48
+ }
49
+ const p = pkg;
50
+ if (p.version !== 1)
51
+ errors.push("version must be 1");
52
+ const source = (p.source ?? {});
53
+ if (typeof source.pageId !== "string" || !source.pageId.trim())
54
+ errors.push("source.pageId is required (the scrape target + own-brand key)");
55
+ if (typeof source.pageName !== "string" || !source.pageName.trim())
56
+ errors.push("source.pageName is required (names the own-brand entry)");
57
+ if (typeof source.adAccountId !== "string" || !source.adAccountId.trim())
58
+ errors.push("source.adAccountId is required");
59
+ if (!Array.isArray(p.winners) || p.winners.length === 0) {
60
+ errors.push("winners array is required and must be non-empty");
61
+ return { ok: errors.length === 0, errors, warnings, winnerCount };
62
+ }
63
+ winnerCount = p.winners.length;
64
+ const seen = new Set();
65
+ p.winners.forEach((raw, i) => {
66
+ const label = `winner #${i + 1}`;
67
+ if (raw === null || typeof raw !== "object") {
68
+ warnings.push(`${label}: not an object — the server will reject it`);
69
+ return;
70
+ }
71
+ const w = raw;
72
+ const id = typeof w.accountAdId === "string" ? w.accountAdId : undefined;
73
+ if (!id)
74
+ warnings.push(`${label}: missing accountAdId`);
75
+ else if (seen.has(id))
76
+ warnings.push(`${label} (${id}): duplicate accountAdId`);
77
+ else
78
+ seen.add(id);
79
+ if (w.format !== "video" && w.format !== "image")
80
+ warnings.push(`${label}${id ? ` (${id})` : ""}: format must be "video" or "image"`);
81
+ const verdict = (w.verdict ?? {});
82
+ if (typeof verdict.sentence !== "string" || !verdict.sentence.trim())
83
+ warnings.push(`${label}${id ? ` (${id})` : ""}: missing verdict.sentence`);
84
+ if ((typeof w.bodyText !== "string" || !w.bodyText.trim()) &&
85
+ (typeof w.headline !== "string" || !w.headline.trim()))
86
+ warnings.push(`${label}${id ? ` (${id})` : ""}: needs bodyText or headline`);
87
+ });
88
+ return { ok: errors.length === 0, errors, warnings, winnerCount };
89
+ }
90
+ export function contentTypeFor(filePath) {
91
+ const ext = path.extname(filePath).toLowerCase();
92
+ const map = {
93
+ ".jpg": "image/jpeg",
94
+ ".jpeg": "image/jpeg",
95
+ ".png": "image/png",
96
+ ".webp": "image/webp",
97
+ ".gif": "image/gif",
98
+ ".mp4": "video/mp4",
99
+ ".m4v": "video/mp4",
100
+ ".mov": "video/quicktime",
101
+ ".webm": "video/webm",
102
+ };
103
+ return map[ext] ?? "application/octet-stream";
104
+ }
105
+ export async function run(flags) {
106
+ const positional = parsePositional();
107
+ const [sub, ...rest] = positional;
108
+ if (sub === "import")
109
+ return runImport(rest, flags);
110
+ if (sub === "status")
111
+ return runStatus(rest, flags);
112
+ if (sub === "list")
113
+ return runList(flags);
114
+ if (!sub) {
115
+ console.log(helpText);
116
+ return;
117
+ }
118
+ console.error(`Unknown subcommand: "${sub}"\n`);
119
+ console.log(helpText);
120
+ process.exit(1);
121
+ }
122
+ function parsePositional() {
123
+ return process.argv.slice(3).filter((a) => !a.startsWith("--"));
124
+ }
125
+ async function runImport(positional, flags) {
126
+ const json = !!flags["json"];
127
+ const dryRun = !!flags["dry-run"] || !!flags["dryRun"];
128
+ const noWait = flags["wait"] === false || flags["no-wait"] === true;
129
+ const fileArg = positional[0];
130
+ if (!fileArg) {
131
+ console.error("Error: a package file is required (or - for stdin)");
132
+ console.log("Usage: exodus winners import <file.json | ->");
133
+ process.exit(1);
134
+ }
135
+ let rawText;
136
+ let baseDir;
137
+ if (fileArg === "-") {
138
+ rawText = fs.readFileSync(0, "utf-8");
139
+ baseDir = process.cwd();
140
+ }
141
+ else {
142
+ if (!fs.existsSync(fileArg)) {
143
+ console.error(`Error: file not found: ${fileArg}`);
144
+ process.exit(1);
145
+ }
146
+ rawText = fs.readFileSync(fileArg, "utf-8");
147
+ baseDir = path.dirname(path.resolve(fileArg));
148
+ }
149
+ let pkg;
150
+ try {
151
+ pkg = JSON.parse(rawText);
152
+ }
153
+ catch (err) {
154
+ console.error(`Error: package is not valid JSON: ${err instanceof Error ? err.message : err}`);
155
+ process.exit(1);
156
+ }
157
+ const validation = validatePackageLocally(pkg);
158
+ if (!validation.ok) {
159
+ if (json) {
160
+ console.log(JSON.stringify({ ok: false, errors: validation.errors }));
161
+ }
162
+ else {
163
+ console.error("Package failed local validation:");
164
+ for (const e of validation.errors)
165
+ console.error(` • ${e}`);
166
+ }
167
+ process.exit(1);
168
+ }
169
+ if (!json) {
170
+ for (const w of validation.warnings)
171
+ console.error(`Warning: ${w}`);
172
+ }
173
+ const winners = (pkg.winners ?? []).filter((w) => w !== null && typeof w === "object");
174
+ const missingFiles = [];
175
+ for (const w of winners) {
176
+ const assets = (w.assets ?? {});
177
+ for (const key of ["imagePath", "videoPath", "posterPath"]) {
178
+ const p = assets[key];
179
+ if (typeof p === "string" && p.trim() && !fs.existsSync(path.resolve(baseDir, p))) {
180
+ missingFiles.push(p);
181
+ }
182
+ }
183
+ }
184
+ if (!dryRun && missingFiles.length > 0) {
185
+ console.error("Error: referenced asset file(s) not found:");
186
+ for (const f of missingFiles)
187
+ console.error(` • ${f}`);
188
+ process.exit(1);
189
+ }
190
+ if (dryRun) {
191
+ const res = await apiPost("/api/v2/winners", {
192
+ ...pkg,
193
+ dryRun: true,
194
+ });
195
+ if (!res.ok) {
196
+ if (json)
197
+ console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
198
+ else
199
+ console.log(formatError(res));
200
+ process.exit(1);
201
+ }
202
+ if (json) {
203
+ console.log(JSON.stringify({ ok: true, ...res.data, localWarnings: validation.warnings }));
204
+ return;
205
+ }
206
+ const per = res.data.perWinner ?? [];
207
+ const creates = per.filter((p) => p.action === "create").length;
208
+ const updates = per.filter((p) => p.action === "update").length;
209
+ console.log("Dry run — no writes, no Trigger fire, no media upload.");
210
+ console.log(res.data.wouldCreateBrand
211
+ ? " own-brand entry: would be created"
212
+ : ` own-brand entry: exists (${res.data.brandName ?? "?"})`);
213
+ console.log(` winners: ${creates} would-create, ${updates} would-update`);
214
+ for (const p of per)
215
+ console.log(` ${p.accountAdId} ${p.action}`);
216
+ for (const r of res.data.rejected ?? []) {
217
+ console.log(` ${r.accountAdId} would-reject (${r.reason})`);
218
+ }
219
+ if (res.data.scrapecreatorsKey === "missing") {
220
+ console.log("\nHeads up: no Scrape Creators key on your account — a real import will fail.");
221
+ console.log("Add it in Settings → Keys (get one at scrapecreators.com).");
222
+ }
223
+ return;
224
+ }
225
+ for (const w of winners) {
226
+ const assets = (w.assets ?? {});
227
+ const swapped = {};
228
+ const pairs = [
229
+ ["imagePath", "imageStorageId"],
230
+ ["videoPath", "videoStorageId"],
231
+ ["posterPath", "posterStorageId"],
232
+ ];
233
+ for (const [pathKey, idKey] of pairs) {
234
+ const rel = assets[pathKey];
235
+ if (typeof rel !== "string" || !rel.trim())
236
+ continue;
237
+ const abs = path.resolve(baseDir, rel);
238
+ const storageId = await uploadAsset(abs, json);
239
+ if (!storageId)
240
+ process.exit(1);
241
+ swapped[idKey] = storageId;
242
+ }
243
+ if (Object.keys(swapped).length > 0) {
244
+ w.assets = swapped;
245
+ }
246
+ else {
247
+ delete w.assets;
248
+ }
249
+ }
250
+ const res = await apiPost("/api/v2/winners", pkg);
251
+ if (!res.ok || !res.data.importId) {
252
+ if (json)
253
+ console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
254
+ else
255
+ console.log(formatError(res));
256
+ process.exit(1);
257
+ }
258
+ const importId = res.data.importId;
259
+ if (noWait) {
260
+ if (json) {
261
+ console.log(JSON.stringify({ ok: true, importId, triggerRunId: res.data.triggerRunId, accepted: res.data.accepted, rejected: res.data.rejected }));
262
+ return;
263
+ }
264
+ console.log(`Import started: ${importId}`);
265
+ console.log(` accepted: ${res.data.accepted ?? "?"} winner(s)`);
266
+ for (const r of res.data.rejected ?? []) {
267
+ console.log(` rejected: ${r.accountAdId} (${r.reason})`);
268
+ }
269
+ console.log(`\nPoll: exodus winners status ${importId}`);
270
+ return;
271
+ }
272
+ if (!json) {
273
+ console.log(`Import started: ${importId} — waiting for outcomes…`);
274
+ }
275
+ let lastStatus = "";
276
+ const poll = await pollUntilDone({
277
+ path: `/api/v2/winners/imports/${importId}`,
278
+ terminalStatuses: ["done"],
279
+ onProgress: (data) => {
280
+ const status = typeof data.status === "string" ? data.status : "";
281
+ if (!json && status && status !== lastStatus) {
282
+ lastStatus = status;
283
+ console.log(` status: ${status}`);
284
+ }
285
+ },
286
+ });
287
+ const outcome = poll.data;
288
+ if (json) {
289
+ console.log(JSON.stringify({ ok: poll.ok, timedOut: poll.timedOut, ...outcome }));
290
+ if (!poll.ok)
291
+ process.exit(1);
292
+ return;
293
+ }
294
+ if (poll.timedOut) {
295
+ console.log(`Timed out waiting. Re-poll: exodus winners status ${importId}`);
296
+ process.exit(1);
297
+ }
298
+ printOutcomeTable(outcome);
299
+ if (!poll.ok)
300
+ process.exit(1);
301
+ }
302
+ async function uploadAsset(absPath, json) {
303
+ const upRes = await apiPost("/api/v2/winners/upload-url", {});
304
+ if (!upRes.ok || !upRes.data.uploadUrl) {
305
+ if (json)
306
+ console.log(JSON.stringify({ ok: false, step: "upload-url", data: upRes.data }));
307
+ else
308
+ console.log(formatError(upRes));
309
+ return null;
310
+ }
311
+ const bytes = fs.readFileSync(absPath);
312
+ const put = await fetch(upRes.data.uploadUrl, {
313
+ method: "POST",
314
+ headers: { "Content-Type": contentTypeFor(absPath) },
315
+ body: new Blob([bytes]),
316
+ });
317
+ if (!put.ok) {
318
+ const text = await put.text().catch(() => "(unreadable)");
319
+ if (json)
320
+ console.log(JSON.stringify({ ok: false, step: "upload", status: put.status }));
321
+ else
322
+ console.error(`Error uploading ${path.basename(absPath)}: ${put.status} ${text.slice(0, 200)}`);
323
+ return null;
324
+ }
325
+ const { storageId } = (await put.json());
326
+ if (!storageId) {
327
+ console.error(`Error: no storageId returned for ${path.basename(absPath)}`);
328
+ return null;
329
+ }
330
+ if (!json)
331
+ console.log(` uploaded ${path.basename(absPath)}`);
332
+ return storageId;
333
+ }
334
+ function printOutcomeTable(data) {
335
+ const winners = data.winners ?? [];
336
+ console.log(`\nImport ${data.importId ?? "?"}: ${data.status ?? "?"}`);
337
+ if (data.error)
338
+ console.log(` error: ${data.error}`);
339
+ const created = winners.filter((w) => w.created === true).length;
340
+ const updated = winners.filter((w) => w.created === false).length;
341
+ console.log(` ${winners.length} winner(s): ${created} created, ${updated} updated`);
342
+ for (const w of winners) {
343
+ let detail = "";
344
+ if (w.outcome === "partial" && w.missing?.length)
345
+ detail = ` (missing: ${w.missing.join(", ")})`;
346
+ if (w.outcome === "rejected" && w.reason)
347
+ detail = ` (${w.reason})`;
348
+ console.log(` ${w.accountAdId} ${w.outcome}${detail}`);
349
+ }
350
+ }
351
+ async function runStatus(positional, flags) {
352
+ const importId = positional[0];
353
+ if (!importId) {
354
+ console.error("Error: importId is required");
355
+ console.log("Usage: exodus winners status <importId>");
356
+ process.exit(1);
357
+ }
358
+ const json = !!flags["json"];
359
+ const res = await apiGet(`/api/v2/winners/imports/${encodeURIComponent(importId)}`);
360
+ if (!res.ok) {
361
+ if (json)
362
+ console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
363
+ else
364
+ console.log(formatError(res));
365
+ process.exit(1);
366
+ }
367
+ if (json) {
368
+ console.log(JSON.stringify({ ok: true, ...res.data }));
369
+ return;
370
+ }
371
+ printOutcomeTable(res.data);
372
+ }
373
+ async function runList(flags) {
374
+ const json = !!flags["json"];
375
+ const res = await apiGet("/api/v2/winners");
376
+ if (!res.ok) {
377
+ if (json)
378
+ console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
379
+ else
380
+ console.log(formatError(res));
381
+ process.exit(1);
382
+ }
383
+ const winners = res.data.winners ?? [];
384
+ if (json) {
385
+ console.log(JSON.stringify({ ok: true, winners }));
386
+ return;
387
+ }
388
+ if (winners.length === 0) {
389
+ console.log("No imported winners yet. Push some: exodus winners import <file.json>");
390
+ return;
391
+ }
392
+ console.log(`Winners (${winners.length}):`);
393
+ for (const w of winners) {
394
+ const status = w.enrichmentStatus === "partial"
395
+ ? `partial${w.enrichmentMissing?.length ? ` (missing: ${w.enrichmentMissing.join(", ")})` : ""}`
396
+ : (w.enrichmentStatus ?? "?");
397
+ const when = w.designatedAt ? new Date(w.designatedAt).toLocaleDateString() : "?";
398
+ console.log(` ${w.sourceAdId ?? w.id} [${w.format}] ${status} designated=${when}`);
399
+ console.log(` ${w.verdictSentence}`);
400
+ }
401
+ }