@aicopycoders/exodus 2026.7.200-beta.6 → 2026.7.200-beta.8
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/docs/CLAUDE.md +1 -1
- package/assets/references/cast-video.md +9 -0
- package/assets/skills/exodus-brand/SKILL.md +2 -2
- 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/package.json +1 -1
package/assets/docs/CLAUDE.md
CHANGED
|
@@ -31,7 +31,7 @@ This folder usually holds **two related products that share the same Genesis eng
|
|
|
31
31
|
|
|
32
32
|
## Multi-Brand Operating Notes
|
|
33
33
|
|
|
34
|
-
Any user can create and own unlimited brands — `npx @aicopycoders/exodus brand create "<name>"` (or the dashboard); it is NOT admin-only, and brand names are scoped per user (two users can each have
|
|
34
|
+
Any user can create and own unlimited brands — `npx @aicopycoders/exodus brand create "<name>"` (or the dashboard); it is NOT admin-only, and brand names are scoped per user (two users can each have an "Evergreen Fitness"). One account key then switches across every brand the user owns (admins: every brand). The only exception is a brand someone ELSE invited the user into — they don't own it, so their key stays pinned to just that one. Never tell a user they can't make brands or are "locked to one brand" just because their key shows `role: member`. The active brand resolves **folder > pointer > key default**: being inside a brand's subfolder wins, then the `brand use` pointer, then the key's default brand.
|
|
35
35
|
|
|
36
36
|
- **Identify the active brand:** `npx @aicopycoders/exodus brand current` (also says whether it came from the folder or the pointer)
|
|
37
37
|
- **List accessible brands:** `npx @aicopycoders/exodus brand list`
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# CAST — a generative grammar for video ad formats
|
|
2
2
|
|
|
3
|
+
> **SUPERSEDED (2026-07, map #528):** the 4-axis CAST grammar below is superseded by the
|
|
4
|
+
> 5-knob **CASTS** system — **Conceit · Actor · Style · Terrain · Setup** — whose canonical
|
|
5
|
+
> terms live in the repo-root **`CONTEXT.md`** glossary. CASTS adds the **Setup** knob (frame
|
|
6
|
+
> arrangement / opening / pacing / overlays) and restructures **Actor** as Role × Look; the
|
|
7
|
+
> product's shelf registry and Rig entity (#538) are built on those terms. This file's
|
|
8
|
+
> thinking still carries forward — conceit = the engine room, novelty vs diversity knobs,
|
|
9
|
+
> and the conceit-primer idea (now the registry's per-value `promptGuidance`) — but for
|
|
10
|
+
> terminology and knob shapes, defer to `CONTEXT.md`.
|
|
11
|
+
|
|
3
12
|
> Part of the creative-strategy framework set; compact index lives in `exodus-strategist`.
|
|
4
13
|
> A faceted system for **decoding any video ad** into its parts and **generating infinite new
|
|
5
14
|
> ones** — while staying human-readable. This is a *thinking* framework: the base Exodus CLI
|
|
@@ -18,7 +18,7 @@ Returns:
|
|
|
18
18
|
|
|
19
19
|
## Strategic Context
|
|
20
20
|
|
|
21
|
-
One API key, many brands. **Any user can create and own unlimited brands** — via `npx @aicopycoders/exodus brand create "<name>"` or the dashboard. This is NOT admin-only, and brand names are scoped per user (two different users can each have
|
|
21
|
+
One API key, many brands. **Any user can create and own unlimited brands** — via `npx @aicopycoders/exodus brand create "<name>"` or the dashboard. This is NOT admin-only, and brand names are scoped per user (two different users can each have an "Evergreen Fitness"). The key works for every brand the user OWNS (admin keys see everything); the CLI attaches `X-Active-Brand: <slug>` on every request. The ONLY "pinned to one brand" case is a brand someone ELSE invited the user into: they don't own it, so the header is ignored for it and it's the single brand their key sees. Never tell a user they can't create brands or are "locked to one brand" because their key shows `role: member` — a member who owns brands sees and creates as many as they want.
|
|
22
22
|
|
|
23
23
|
The active brand resolves **folder > pointer > key default**: on a multi-brand install each brand has a subfolder (marked by `.exodus-brand.json`), and running from inside one targets that brand automatically — it wins over the `{ activeBrand: "<slug>" }` pointer in `.exodus/state.json` that `brand use` writes. `brand current` tells you which one applied.
|
|
24
24
|
|
|
@@ -50,7 +50,7 @@ Shows role, the active brand (marked `★`), and the full accessible list. Flags
|
|
|
50
50
|
### Create a new brand
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
npx @aicopycoders/exodus brand create "
|
|
53
|
+
npx @aicopycoders/exodus brand create "Evergreen Fitness"
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Creates a brand the user owns (any user can — no admin role required), then switches the CLI into it and, on multi-brand installs, sets up its subfolder. Brand names are scoped per user, so this succeeds even if another account already has a brand by that name (the slug is auto-disambiguated under the hood). Tell the user the next step is the primer (`exodus, set up my brand primer`). If the dashboard ever reports a name conflict for a brand the user is *sure* they've never made, that's a separate bug — not a sign the name is taken for them.
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ApiResponse } from "../lib/client.js";
|
|
2
2
|
import { type PollOptions, type PollResult } from "../lib/poll.js";
|
|
3
3
|
export declare const helpText: string;
|
|
4
|
-
export type WorkflowNodeKind = "brief" | "bot" | "primer" | "image" | "output";
|
|
4
|
+
export type WorkflowNodeKind = "brief" | "bot" | "primer" | "image" | "rig" | "storyboard" | "reference" | "scene-frames" | "video" | "voiceover" | "output";
|
|
5
5
|
export interface WorkflowNode {
|
|
6
6
|
id: string;
|
|
7
7
|
kind: WorkflowNodeKind;
|
|
@@ -59,8 +59,22 @@ export interface WorkflowImportError {
|
|
|
59
59
|
issues?: GraphIssue[];
|
|
60
60
|
currentUpdatedAt?: string;
|
|
61
61
|
}
|
|
62
|
-
export type WorkflowPortType = "text" | "primer" | "image";
|
|
62
|
+
export type WorkflowPortType = "text" | "primer" | "image" | "rig" | "storyboard" | "frames" | "video" | "audio";
|
|
63
63
|
export type WorkflowPrimerKind = "body" | "hook" | "headline" | "summary";
|
|
64
|
+
export type WorkflowDurationSpec = {
|
|
65
|
+
kind: "fixed";
|
|
66
|
+
values: number[];
|
|
67
|
+
} | {
|
|
68
|
+
kind: "range";
|
|
69
|
+
min: number;
|
|
70
|
+
max: number;
|
|
71
|
+
};
|
|
72
|
+
export interface CatalogVideoModel {
|
|
73
|
+
id: string;
|
|
74
|
+
label: string;
|
|
75
|
+
durations: WorkflowDurationSpec;
|
|
76
|
+
audioTogglable: boolean;
|
|
77
|
+
}
|
|
64
78
|
export type WorkflowParamKind = "select" | "text" | "textarea" | "toggle" | "number" | "multiselect";
|
|
65
79
|
export interface CatalogInput {
|
|
66
80
|
id: string;
|
|
@@ -103,6 +117,7 @@ export interface WorkflowCatalog {
|
|
|
103
117
|
imageModels: string[];
|
|
104
118
|
aspectRatios: string[];
|
|
105
119
|
imageQuantityModes: string[];
|
|
120
|
+
videoModels: CatalogVideoModel[];
|
|
106
121
|
categories: Array<{
|
|
107
122
|
id: string;
|
|
108
123
|
label: string;
|
|
@@ -130,7 +145,7 @@ export interface WorkflowPrerequisiteDescriptor {
|
|
|
130
145
|
nodeIds: string[];
|
|
131
146
|
}
|
|
132
147
|
export interface WorkflowOutputDescriptor {
|
|
133
|
-
type: "text" | "image";
|
|
148
|
+
type: "text" | "image" | "video" | "audio" | "frames" | "storyboard";
|
|
134
149
|
label: string;
|
|
135
150
|
nodeId: string;
|
|
136
151
|
botSlug?: string;
|
|
@@ -154,11 +169,13 @@ export interface WorkflowListItem {
|
|
|
154
169
|
edgeCount: number;
|
|
155
170
|
createdAt: string;
|
|
156
171
|
updatedAt: string;
|
|
172
|
+
isCrossBrand?: boolean;
|
|
173
|
+
homeBrandName?: string | null;
|
|
157
174
|
}
|
|
158
175
|
export interface WorkflowListResponse {
|
|
159
176
|
workflows: WorkflowListItem[];
|
|
160
177
|
}
|
|
161
|
-
export type WorkflowRunStatus = "queued" | "running" | "completed" | "partial" | "failed";
|
|
178
|
+
export type WorkflowRunStatus = "queued" | "running" | "awaiting-review" | "completed" | "partial" | "failed" | "canceled";
|
|
162
179
|
export type WorkflowNodeRunStatus = "idle" | "running" | "done" | "failed" | "skipped";
|
|
163
180
|
export type WorkflowArtifact = {
|
|
164
181
|
type: "text";
|
|
@@ -176,11 +193,21 @@ export type WorkflowArtifact = {
|
|
|
176
193
|
export interface WorkflowRunOutput {
|
|
177
194
|
nodeId: string;
|
|
178
195
|
botSlug?: string;
|
|
179
|
-
type: "text" | "image";
|
|
196
|
+
type: "text" | "image" | "video" | "audio" | "frames" | "storyboard";
|
|
180
197
|
label: string;
|
|
181
198
|
text?: string;
|
|
182
199
|
imageUrl?: string;
|
|
183
200
|
imageId?: string;
|
|
201
|
+
videoUrl?: string;
|
|
202
|
+
audioUrl?: string;
|
|
203
|
+
durationSec?: number;
|
|
204
|
+
sceneIndex?: number;
|
|
205
|
+
final?: boolean;
|
|
206
|
+
frames?: Array<{
|
|
207
|
+
sceneIndex: number;
|
|
208
|
+
imageUrl?: string;
|
|
209
|
+
}>;
|
|
210
|
+
storyboardJson?: string;
|
|
184
211
|
}
|
|
185
212
|
export interface WorkflowRunNode {
|
|
186
213
|
nodeId: string;
|
|
@@ -171,7 +171,7 @@ export function formatWorkflowList(workflows) {
|
|
|
171
171
|
if (workflows.length === 0)
|
|
172
172
|
return "No workflows found for the active brand.";
|
|
173
173
|
return table(["name", "nodes", "edges", "updated", "id"], workflows.map((w) => [
|
|
174
|
-
w.name,
|
|
174
|
+
w.isCrossBrand && w.homeBrandName ? `${w.name} · from ${w.homeBrandName}` : w.name,
|
|
175
175
|
String(w.nodeCount),
|
|
176
176
|
String(w.edgeCount),
|
|
177
177
|
dateOnly(w.updatedAt),
|
|
@@ -252,6 +252,20 @@ function runOutputLines(output) {
|
|
|
252
252
|
if (output.type === "image") {
|
|
253
253
|
return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}`];
|
|
254
254
|
}
|
|
255
|
+
if (output.type === "video") {
|
|
256
|
+
const tag = output.final === true ? "final video" : "video";
|
|
257
|
+
return [` ${output.label} [${tag}]${slug}: ${output.videoUrl ?? "(no url)"}`];
|
|
258
|
+
}
|
|
259
|
+
if (output.type === "audio") {
|
|
260
|
+
return [` ${output.label} [audio]${slug}: ${output.audioUrl ?? "(no url)"}`];
|
|
261
|
+
}
|
|
262
|
+
if (output.type === "frames") {
|
|
263
|
+
const n = output.frames?.length ?? 0;
|
|
264
|
+
return [` ${output.label} [frames]${slug}: ${n} scene${n === 1 ? "" : "s"}`];
|
|
265
|
+
}
|
|
266
|
+
if (output.type === "storyboard") {
|
|
267
|
+
return [` ${output.label} [storyboard]${slug}: use --json for the scene plan`];
|
|
268
|
+
}
|
|
255
269
|
const raw = output.text ?? "";
|
|
256
270
|
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
257
271
|
const body = truncateText(raw, 400);
|
|
@@ -523,14 +537,19 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
523
537
|
lines.length = 0;
|
|
524
538
|
}
|
|
525
539
|
const seen = new Map();
|
|
540
|
+
let pausedNotified = false;
|
|
526
541
|
const pollResult = await deps.poll({
|
|
527
542
|
path: `${STATUS_PATH}?runId=${encodeURIComponent(data.runId)}`,
|
|
528
543
|
intervalMs: 3_000,
|
|
529
544
|
timeoutMs: 60 * 60 * 1000,
|
|
530
|
-
terminalStatuses: ["completed", "partial", "failed"],
|
|
545
|
+
terminalStatuses: ["completed", "partial", "failed", "canceled"],
|
|
531
546
|
onProgress: (raw) => {
|
|
532
547
|
if (opts.json || !opts.onProgressLine)
|
|
533
548
|
return;
|
|
549
|
+
if (raw["status"] === "awaiting-review" && !pausedNotified) {
|
|
550
|
+
pausedNotified = true;
|
|
551
|
+
opts.onProgressLine(" ⏸ paused at the cost gate — approve or edit the storyboard in the web app to continue.");
|
|
552
|
+
}
|
|
534
553
|
const nodes = Array.isArray(raw["nodes"]) ? raw["nodes"] : [];
|
|
535
554
|
for (const node of nodes) {
|
|
536
555
|
if (seen.get(node.nodeId) === node.status)
|