@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.
- package/assets/references/cast-video.md +9 -0
- package/assets/references/kie-video-models.md +207 -0
- package/assets/references/lipsync-alternatives.md +217 -0
- package/assets/references/meta-ads-mcp-probe.md +167 -0
- package/assets/references/native-audio-dialogue.md +178 -0
- package/assets/references/own-brand-enrichment-path.md +160 -0
- package/dist/commands/brand.js +2 -1
- package/dist/commands/doctor.js +22 -16
- package/dist/commands/init.js +3 -2
- package/dist/commands/meme.js +5 -4
- package/dist/commands/migrate.js +7 -6
- package/dist/commands/winners.d.ts +10 -0
- package/dist/commands/winners.js +401 -0
- package/dist/commands/workflow.d.ts +32 -5
- package/dist/commands/workflow.js +21 -2
- package/dist/lib/channel.d.ts +5 -0
- package/dist/lib/channel.js +15 -0
- package/dist/lib/scaffold.d.ts +5 -4
- package/dist/lib/scaffold.js +27 -6
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
281
|
-
if (compareVersions(local,
|
|
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, ${
|
|
289
|
-
fix:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
527
|
+
fix: `run \`npx ${pkgRef()} brand use <slug>\` to generate it`,
|
|
522
528
|
};
|
|
523
529
|
}
|
|
524
530
|
const contents = fs.readFileSync(filePath, "utf-8");
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
11
|
-
npx
|
|
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
|
package/dist/commands/meme.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
237
|
+
console.error(`Hint: run \`npx ${pkgRef()} doctor\` to check your keys.`);
|
|
237
238
|
}
|
|
238
239
|
process.exit(1);
|
|
239
240
|
return;
|
package/dist/commands/migrate.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|