@aicopycoders/exodus 2026.7.200-beta.3 → 2026.7.200-beta.4
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/dist/bin/exodus.js +2 -1
- package/dist/commands/brand.js +159 -0
- package/dist/commands/genesis.js +1 -1
- package/dist/commands/primer.js +4 -2
- package/dist/commands/segment.d.ts +57 -0
- package/dist/commands/segment.js +274 -0
- package/dist/lib/image-sniff.d.ts +6 -0
- package/dist/lib/image-sniff.js +18 -0
- package/dist/lib/template-constants.d.ts +1 -1
- package/dist/lib/template-constants.js +102 -0
- package/package.json +1 -1
package/dist/bin/exodus.js
CHANGED
|
@@ -17,6 +17,7 @@ const OTHER_COMMANDS = new Set([
|
|
|
17
17
|
"doctor",
|
|
18
18
|
"init",
|
|
19
19
|
"migrate",
|
|
20
|
+
"segment",
|
|
20
21
|
]);
|
|
21
22
|
const HIDDEN_COMMANDS = new Set([
|
|
22
23
|
"creative",
|
|
@@ -76,7 +77,7 @@ async function buildHelp() {
|
|
|
76
77
|
const fmt = (rows) => rows.map(([n, s]) => ` ${n.padEnd(18)} ${s}`.trimEnd()).join("\n");
|
|
77
78
|
const commonOptions = [
|
|
78
79
|
"Common Options:",
|
|
79
|
-
" --awareness <level> Awareness level: unaware, problem-aware, solution-aware, product-aware",
|
|
80
|
+
" --awareness <level> Awareness level: unaware, problem-aware, solution-aware, product-aware, most-aware",
|
|
80
81
|
" --wait Wait for completion (default)",
|
|
81
82
|
" --no-wait Return immediately after starting",
|
|
82
83
|
" --help, -h Show help (top-level or per-subcommand)",
|
package/dist/commands/brand.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { apiGetDashboard, apiPostDashboard } from "../lib/client.js";
|
|
4
|
+
import { sniffImageType, BRAND_IMAGE_MAX_BYTES } from "../lib/image-sniff.js";
|
|
4
5
|
import { getActiveBrand, setActiveBrand, clearActiveBrand, findWorkspaceRoot, } from "../lib/state.js";
|
|
5
6
|
import { detectLayout, ensureBrandDir, resolveActiveBrand, brandStateDir, brandDirFor, } from "../lib/layout.js";
|
|
6
7
|
import { promptYesNo } from "../lib/prompts.js";
|
|
@@ -15,6 +16,9 @@ Subcommands:
|
|
|
15
16
|
exodus brand current Print the active brand
|
|
16
17
|
exodus brand clear Stop overriding the brand (use the key's default)
|
|
17
18
|
exodus brand delete <slug> Delete a brand you own (asks before removing the local folder)
|
|
19
|
+
exodus brand images add <files...> --kind founder|product|reference
|
|
20
|
+
Upload brand-identity images (png/jpg/webp, max 15MB each)
|
|
21
|
+
exodus brand images list Show the active brand's uploaded identity images
|
|
18
22
|
|
|
19
23
|
Any user can create and own unlimited brands (\`brand create\`) — you do NOT
|
|
20
24
|
need to be an admin, and brands are scoped to you (two users can have the
|
|
@@ -260,11 +264,166 @@ function runClear() {
|
|
|
260
264
|
console.log("from here still target it (the folder wins over the pointer).");
|
|
261
265
|
}
|
|
262
266
|
}
|
|
267
|
+
const BRAND_IMAGE_KINDS = ["founder", "product", "reference"];
|
|
268
|
+
async function runImagesAdd(files, flags) {
|
|
269
|
+
const kind = typeof flags["kind"] === "string" ? flags["kind"] : "";
|
|
270
|
+
if (!BRAND_IMAGE_KINDS.includes(kind)) {
|
|
271
|
+
console.error(`Error: brand images add requires --kind ${BRAND_IMAGE_KINDS.join("|")}. ` +
|
|
272
|
+
`(Logo stays a dashboard upload — it replaces rather than appends.)`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (files.length === 0) {
|
|
277
|
+
console.error("Error: brand images add requires at least one file.");
|
|
278
|
+
process.exit(1);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const valid = [];
|
|
282
|
+
const skipped = [];
|
|
283
|
+
for (const file of files) {
|
|
284
|
+
let buf;
|
|
285
|
+
try {
|
|
286
|
+
buf = fs.readFileSync(file);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
skipped.push({ file, reason: "unreadable (does the path exist?)" });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (buf.length > BRAND_IMAGE_MAX_BYTES) {
|
|
293
|
+
skipped.push({
|
|
294
|
+
file,
|
|
295
|
+
reason: `too large (${(buf.length / 1024 / 1024).toFixed(1)}MB > 15MB)`,
|
|
296
|
+
});
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const sniffed = sniffImageType(buf);
|
|
300
|
+
if (!sniffed) {
|
|
301
|
+
skipped.push({ file, reason: "not a png/jpg/webp (content check failed)" });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
valid.push({ file, buf, mime: sniffed.mime });
|
|
305
|
+
}
|
|
306
|
+
for (const s of skipped) {
|
|
307
|
+
console.error(` skipped ${path.basename(s.file)}: ${s.reason}`);
|
|
308
|
+
}
|
|
309
|
+
if (valid.length === 0) {
|
|
310
|
+
console.error("Error: no valid image files to upload.");
|
|
311
|
+
process.exit(1);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const mint = await apiPostDashboard("/api/brands/images/upload-url", { count: valid.length });
|
|
315
|
+
if (!mint.ok || !Array.isArray(mint.data.urls)) {
|
|
316
|
+
console.error(`Error: ${mint.data.error ?? `HTTP ${mint.status} minting upload URLs`}`);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const storageIds = [];
|
|
321
|
+
const uploadedFiles = [];
|
|
322
|
+
for (let i = 0; i < valid.length; i++) {
|
|
323
|
+
const { file, buf, mime } = valid[i];
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch(mint.data.urls[i], {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers: { "Content-Type": mime },
|
|
328
|
+
body: new Uint8Array(buf),
|
|
329
|
+
});
|
|
330
|
+
const body = JSON.parse(await res.text());
|
|
331
|
+
if (!res.ok || !body.storageId)
|
|
332
|
+
throw new Error(`HTTP ${res.status}`);
|
|
333
|
+
storageIds.push(body.storageId);
|
|
334
|
+
uploadedFiles.push(file);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
console.error(` skipped ${path.basename(file)}: storage upload failed (${err instanceof Error ? err.message : String(err)})`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (storageIds.length === 0) {
|
|
341
|
+
console.error("Error: every storage upload failed.");
|
|
342
|
+
process.exit(1);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const attach = await apiPostDashboard("/api/brands/images", {
|
|
346
|
+
kind,
|
|
347
|
+
storageIds,
|
|
348
|
+
});
|
|
349
|
+
if (!attach.ok) {
|
|
350
|
+
console.error(`Error: ${attach.data.error ?? `HTTP ${attach.status} attaching images`}`);
|
|
351
|
+
process.exit(1);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
for (const s of attach.data.skipped ?? []) {
|
|
355
|
+
console.error(` server skipped ${s.storageId}: ${s.reason}`);
|
|
356
|
+
}
|
|
357
|
+
for (const f of uploadedFiles)
|
|
358
|
+
console.log(` uploaded ${path.basename(f)}`);
|
|
359
|
+
console.log(`✓ ${kind}: ${attach.data.added} added (brand now has ${attach.data.total} ${kind} image${attach.data.total === 1 ? "" : "s"})`);
|
|
360
|
+
}
|
|
361
|
+
async function runImagesList() {
|
|
362
|
+
const res = await apiGetDashboard("/api/brands/images");
|
|
363
|
+
if (!res.ok) {
|
|
364
|
+
console.error(`Error: ${res.data.error ?? `HTTP ${res.status}`}`);
|
|
365
|
+
process.exit(1);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
console.log(`brand: ${res.data.brand}`);
|
|
369
|
+
for (const kind of BRAND_IMAGE_KINDS) {
|
|
370
|
+
const entries = res.data[kind] ?? [];
|
|
371
|
+
console.log(`${kind}: ${entries.length}`);
|
|
372
|
+
for (const e of entries) {
|
|
373
|
+
console.log(` ${e.url ?? e.storageId}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function imagesPositionals() {
|
|
378
|
+
const rest = process.argv.slice(3);
|
|
379
|
+
const out = [];
|
|
380
|
+
let i = 0;
|
|
381
|
+
while (i < rest.length) {
|
|
382
|
+
const arg = rest[i];
|
|
383
|
+
if (arg === "--help" || arg === "-h") {
|
|
384
|
+
i++;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (arg.startsWith("--")) {
|
|
388
|
+
const key = arg.slice(2);
|
|
389
|
+
const next = rest[i + 1];
|
|
390
|
+
if (key.startsWith("no-")) {
|
|
391
|
+
i++;
|
|
392
|
+
}
|
|
393
|
+
else if (next !== undefined && !next.startsWith("--")) {
|
|
394
|
+
i += 2;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
i++;
|
|
398
|
+
}
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
out.push(arg);
|
|
402
|
+
i++;
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
async function runImages(args, flags) {
|
|
407
|
+
const sub = args[0];
|
|
408
|
+
if (sub === "add") {
|
|
409
|
+
await runImagesAdd(args.slice(1), flags);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (sub === "list" || sub === undefined) {
|
|
413
|
+
await runImagesList();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
console.error(`exodus brand images: unknown subcommand "${sub}" (use add | list)`);
|
|
417
|
+
process.exit(1);
|
|
418
|
+
}
|
|
263
419
|
export async function run(flags) {
|
|
264
420
|
const positionals = process.argv.slice(3).filter((a) => !a.startsWith("--"));
|
|
265
421
|
const sub = positionals[0];
|
|
266
422
|
const arg = positionals[1];
|
|
267
423
|
switch (sub) {
|
|
424
|
+
case "images":
|
|
425
|
+
await runImages(imagesPositionals().slice(1), flags);
|
|
426
|
+
return;
|
|
268
427
|
case "list":
|
|
269
428
|
case undefined:
|
|
270
429
|
await runList();
|
package/dist/commands/genesis.js
CHANGED
|
@@ -22,7 +22,7 @@ Options:
|
|
|
22
22
|
--seeds <file|"text"> Per-run creative seeds. File: one per line OR a
|
|
23
23
|
JSON array. Inline: a single seed.
|
|
24
24
|
--steering "<text>" With regenerate: re-roll guidance for the hook pool.
|
|
25
|
-
--awareness <level> unaware | problem-aware (default) | solution-aware | product-aware
|
|
25
|
+
--awareness <level> unaware | problem-aware (default) | solution-aware | product-aware | most-aware
|
|
26
26
|
--passes <n> Writing passes per bot — Mario + Infeed (1-5, default 1 = 2 variants)
|
|
27
27
|
--variants <n> Advanced: raw total variant count (1-10); overrides --passes
|
|
28
28
|
--ad-account <id> Meta ad account ID for the top-ads-biased track
|
package/dist/commands/primer.js
CHANGED
|
@@ -36,8 +36,8 @@ Genesis writer for that primer honors it on every run.
|
|
|
36
36
|
|
|
37
37
|
<lane> is one of: bodyUnaware | bodyAware | hooks | headlines
|
|
38
38
|
(aliases: body-unaware/unaware/problem-aware, body-aware/solution-aware/
|
|
39
|
-
product-aware, hook, headline). Setting just one of --always /
|
|
40
|
-
leaves the other field as-is.
|
|
39
|
+
product-aware/most-aware, hook, headline). Setting just one of --always /
|
|
40
|
+
--never leaves the other field as-is.
|
|
41
41
|
|
|
42
42
|
Interactive build flags:
|
|
43
43
|
--file <path> Read the submission (winning ads + product info) from a file
|
|
@@ -397,6 +397,8 @@ const LANE_ALIASES = {
|
|
|
397
397
|
solutionaware: "bodyAware",
|
|
398
398
|
"product-aware": "bodyAware",
|
|
399
399
|
productaware: "bodyAware",
|
|
400
|
+
"most-aware": "bodyAware",
|
|
401
|
+
mostaware: "bodyAware",
|
|
400
402
|
hooks: "hooks",
|
|
401
403
|
hook: "hooks",
|
|
402
404
|
headlines: "headlines",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export declare const helpText: string;
|
|
2
|
+
interface NValue {
|
|
3
|
+
slug: string;
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
interface MapOutcome {
|
|
7
|
+
slug: string;
|
|
8
|
+
name: string;
|
|
9
|
+
lens?: "Type" | "Location" | "Function" | "Moment" | "Severity";
|
|
10
|
+
subs: NValue[];
|
|
11
|
+
}
|
|
12
|
+
interface MapGroup {
|
|
13
|
+
name: string;
|
|
14
|
+
values: NValue[];
|
|
15
|
+
}
|
|
16
|
+
export interface SegmentMap {
|
|
17
|
+
productWord: string;
|
|
18
|
+
brandLabel: string;
|
|
19
|
+
outcomes: MapOutcome[];
|
|
20
|
+
demoGroups: MapGroup[];
|
|
21
|
+
facetFamilies: MapGroup[];
|
|
22
|
+
}
|
|
23
|
+
export interface ImportSummary {
|
|
24
|
+
added: string[];
|
|
25
|
+
keptRenamed: string[];
|
|
26
|
+
deleted: string[];
|
|
27
|
+
subsDeleted: string[];
|
|
28
|
+
demoValuesDeleted: string[];
|
|
29
|
+
facetValuesDeleted: string[];
|
|
30
|
+
personasArchived: string[];
|
|
31
|
+
personasDeleted: string[];
|
|
32
|
+
}
|
|
33
|
+
export declare function asSummary(data: unknown): ImportSummary;
|
|
34
|
+
export declare function isDestructive(summary: ImportSummary): boolean;
|
|
35
|
+
export declare function formatImportSummary(summary: ImportSummary): string;
|
|
36
|
+
export declare function formatMapSummary(map: SegmentMap): string;
|
|
37
|
+
export interface ImportDeps {
|
|
38
|
+
post: (path: string, body: unknown) => Promise<{
|
|
39
|
+
ok: boolean;
|
|
40
|
+
status: number;
|
|
41
|
+
data: unknown;
|
|
42
|
+
}>;
|
|
43
|
+
readFile: (path: string) => string;
|
|
44
|
+
}
|
|
45
|
+
export interface ImportFlowResult {
|
|
46
|
+
code: number;
|
|
47
|
+
lines: string[];
|
|
48
|
+
calls: Array<{
|
|
49
|
+
dryRun: boolean;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
export declare function importFlow(file: string, opts: {
|
|
53
|
+
yes: boolean;
|
|
54
|
+
json: boolean;
|
|
55
|
+
}, deps: ImportDeps): Promise<ImportFlowResult>;
|
|
56
|
+
export declare function run(flags: Record<string, string | boolean>): Promise<void>;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { apiGet, apiPost } from "../lib/client.js";
|
|
3
|
+
import { formatError } from "../lib/format.js";
|
|
4
|
+
export const helpText = `
|
|
5
|
+
exodus segment — Segment map + personas (WHAT / WHO / WHY), agentic I/O
|
|
6
|
+
|
|
7
|
+
The JSON contract is the edit surface: export the map, edit the file, import it
|
|
8
|
+
back. Import is a FULL REPLACE, slug-stable by name-match — existing outcomes and
|
|
9
|
+
values keep their slugs, new names get new ones, and names absent from the file
|
|
10
|
+
are deleted. Personas the new map orphans are ARCHIVED (never deleted).
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
exodus segment show Human-readable summary of the map
|
|
14
|
+
exodus segment show --json Raw normalized map JSON
|
|
15
|
+
exodus segment export [--out <file>] Contract JSON (map + personas) to stdout or a file
|
|
16
|
+
exodus segment import <file> [--yes] Import a contract JSON file (dry-run first)
|
|
17
|
+
exodus segment personas List saved personas (incl. archived)
|
|
18
|
+
exodus segment personas --json Raw persona list JSON
|
|
19
|
+
|
|
20
|
+
Flags:
|
|
21
|
+
--json Machine-readable JSON output (show / personas)
|
|
22
|
+
--out <file> Write export to a file instead of stdout
|
|
23
|
+
--yes Apply a destructive import (deletes segments / archives personas)
|
|
24
|
+
|
|
25
|
+
Notes:
|
|
26
|
+
• All operations scope to your active brand. Check with: exodus brand current
|
|
27
|
+
• 'import' ALWAYS previews via a server dry-run first and prints what would
|
|
28
|
+
change. If anything would be deleted at ANY layer (outcome, sub-slice,
|
|
29
|
+
demo/facet value) or a persona archived/deleted, it refuses to apply
|
|
30
|
+
without --yes and exits non-zero (never prompts — agents hang on prompts).
|
|
31
|
+
• 'import' on a workspace with no map yet just creates it.
|
|
32
|
+
• 'export' output imports back unchanged — it is the contract document.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
exodus segment show
|
|
36
|
+
exodus segment export --out segments.json
|
|
37
|
+
exodus segment import segments.json
|
|
38
|
+
exodus segment import segments.json --yes
|
|
39
|
+
exodus segment personas --json
|
|
40
|
+
`.trim();
|
|
41
|
+
export function asSummary(data) {
|
|
42
|
+
const d = (typeof data === "object" && data !== null ? data : {});
|
|
43
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
44
|
+
return {
|
|
45
|
+
added: list(d.added),
|
|
46
|
+
keptRenamed: list(d.keptRenamed),
|
|
47
|
+
deleted: list(d.deleted),
|
|
48
|
+
subsDeleted: list(d.subsDeleted),
|
|
49
|
+
demoValuesDeleted: list(d.demoValuesDeleted),
|
|
50
|
+
facetValuesDeleted: list(d.facetValuesDeleted),
|
|
51
|
+
personasArchived: list(d.personasArchived),
|
|
52
|
+
personasDeleted: list(d.personasDeleted),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function isDestructive(summary) {
|
|
56
|
+
return (summary.deleted.length > 0 ||
|
|
57
|
+
summary.subsDeleted.length > 0 ||
|
|
58
|
+
summary.demoValuesDeleted.length > 0 ||
|
|
59
|
+
summary.facetValuesDeleted.length > 0 ||
|
|
60
|
+
summary.personasArchived.length > 0 ||
|
|
61
|
+
summary.personasDeleted.length > 0);
|
|
62
|
+
}
|
|
63
|
+
function line(label, names) {
|
|
64
|
+
const detail = names.length > 0 ? ` (${names.join(", ")})` : "";
|
|
65
|
+
return ` ${label.padEnd(24)} ${String(names.length).padStart(3)}${detail}`;
|
|
66
|
+
}
|
|
67
|
+
export function formatImportSummary(summary) {
|
|
68
|
+
return [
|
|
69
|
+
"Import preview:",
|
|
70
|
+
line("+ outcomes added", summary.added),
|
|
71
|
+
line("~ outcomes kept", summary.keptRenamed),
|
|
72
|
+
line("- outcomes deleted", summary.deleted),
|
|
73
|
+
line("- subs deleted", summary.subsDeleted),
|
|
74
|
+
line("- demo values deleted", summary.demoValuesDeleted),
|
|
75
|
+
line("- facet values deleted", summary.facetValuesDeleted),
|
|
76
|
+
line("personas archived", summary.personasArchived),
|
|
77
|
+
line("personas deleted", summary.personasDeleted),
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
export function formatMapSummary(map) {
|
|
81
|
+
const lines = [];
|
|
82
|
+
lines.push(`Segment map — ${map.brandLabel} (product: ${map.productWord})`);
|
|
83
|
+
lines.push(`\nOutcomes (${map.outcomes.length}):`);
|
|
84
|
+
for (const o of map.outcomes) {
|
|
85
|
+
const lens = o.lens ? ` [${o.lens}]` : "";
|
|
86
|
+
const subs = o.subs.length > 0 ? ` · subs: ${o.subs.map((s) => s.name).join(", ")}` : "";
|
|
87
|
+
lines.push(` ${o.slug.padEnd(24)} ${o.name}${lens}${subs}`);
|
|
88
|
+
}
|
|
89
|
+
const groupBlock = (title, groups) => {
|
|
90
|
+
lines.push(`\n${title}:`);
|
|
91
|
+
for (const g of groups) {
|
|
92
|
+
const vals = g.values.length > 0 ? g.values.map((val) => val.name).join(", ") : "—";
|
|
93
|
+
lines.push(` ${g.name} (${g.values.length}): ${vals}`);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
groupBlock("Demographics (WHO)", map.demoGroups);
|
|
97
|
+
groupBlock("Facets (WHY)", map.facetFamilies);
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
const IMPORT_PATH = "/api/v2/segments/import";
|
|
101
|
+
export async function importFlow(file, opts, deps) {
|
|
102
|
+
const calls = [];
|
|
103
|
+
let text;
|
|
104
|
+
try {
|
|
105
|
+
text = deps.readFile(file);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { code: 1, lines: [`Error: file not found: ${file}`], calls };
|
|
109
|
+
}
|
|
110
|
+
let contract;
|
|
111
|
+
try {
|
|
112
|
+
contract = JSON.parse(text);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
116
|
+
return { code: 1, lines: [`Error: ${file} is not valid JSON: ${msg}`], calls };
|
|
117
|
+
}
|
|
118
|
+
if (typeof contract !== "object" || contract === null || Array.isArray(contract)) {
|
|
119
|
+
return { code: 1, lines: [`Error: ${file} is not a segment map (expected a JSON object).`], calls };
|
|
120
|
+
}
|
|
121
|
+
const clean = { ...contract };
|
|
122
|
+
delete clean.dryRun;
|
|
123
|
+
calls.push({ dryRun: true });
|
|
124
|
+
const dry = await deps.post(IMPORT_PATH, { ...clean, dryRun: true });
|
|
125
|
+
if (!dry.ok) {
|
|
126
|
+
const lines = opts.json
|
|
127
|
+
? [JSON.stringify({ ok: false, status: dry.status, data: dry.data })]
|
|
128
|
+
: [formatError({ ok: dry.ok, status: dry.status, data: dry.data })];
|
|
129
|
+
return { code: 1, lines, calls };
|
|
130
|
+
}
|
|
131
|
+
const summary = asSummary(dry.data);
|
|
132
|
+
const destructive = isDestructive(summary);
|
|
133
|
+
if (destructive && !opts.yes) {
|
|
134
|
+
const lines = opts.json
|
|
135
|
+
? [JSON.stringify({ ok: false, applied: false, destructive: true, summary })]
|
|
136
|
+
: [
|
|
137
|
+
formatImportSummary(summary),
|
|
138
|
+
"",
|
|
139
|
+
"This import is destructive (deletes outcomes, sub-slices, or values,",
|
|
140
|
+
"and/or archives or deletes personas). Re-run with --yes to apply it.",
|
|
141
|
+
];
|
|
142
|
+
return { code: 1, lines, calls };
|
|
143
|
+
}
|
|
144
|
+
calls.push({ dryRun: false });
|
|
145
|
+
const real = await deps.post(IMPORT_PATH, clean);
|
|
146
|
+
if (!real.ok) {
|
|
147
|
+
const lines = opts.json
|
|
148
|
+
? [JSON.stringify({ ok: false, status: real.status, data: real.data })]
|
|
149
|
+
: [formatError({ ok: real.ok, status: real.status, data: real.data })];
|
|
150
|
+
return { code: 1, lines, calls };
|
|
151
|
+
}
|
|
152
|
+
const applied = asSummary(real.data);
|
|
153
|
+
const lines = opts.json
|
|
154
|
+
? [JSON.stringify({ ok: true, applied: true, destructive, summary: applied })]
|
|
155
|
+
: [formatImportSummary(applied), "", "Imported."];
|
|
156
|
+
return { code: 0, lines, calls };
|
|
157
|
+
}
|
|
158
|
+
export async function run(flags) {
|
|
159
|
+
const positional = parsePositional();
|
|
160
|
+
const [sub, ...rest] = positional;
|
|
161
|
+
if (!sub || sub === "show")
|
|
162
|
+
return runShow(flags);
|
|
163
|
+
if (sub === "export")
|
|
164
|
+
return runExport(flags);
|
|
165
|
+
if (sub === "import")
|
|
166
|
+
return runImport(rest, flags);
|
|
167
|
+
if (sub === "personas")
|
|
168
|
+
return runPersonas(flags);
|
|
169
|
+
console.error(`Unknown subcommand: "${sub}"\n`);
|
|
170
|
+
console.log(helpText);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
function parsePositional() {
|
|
174
|
+
const args = process.argv.slice(3);
|
|
175
|
+
const out = [];
|
|
176
|
+
let i = 0;
|
|
177
|
+
while (i < args.length) {
|
|
178
|
+
const a = args[i];
|
|
179
|
+
if (a.startsWith("--")) {
|
|
180
|
+
const next = args[i + 1];
|
|
181
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
182
|
+
i += 2;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
i++;
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
out.push(a);
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
async function runShow(flags) {
|
|
195
|
+
const json = !!flags["json"];
|
|
196
|
+
const res = await apiGet("/api/v2/segments");
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
if (json)
|
|
199
|
+
console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
|
|
200
|
+
else
|
|
201
|
+
console.log(formatError(res));
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (json) {
|
|
205
|
+
console.log(JSON.stringify(res.data.map));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!res.data.map) {
|
|
209
|
+
console.log("No segment map yet for the active brand.");
|
|
210
|
+
console.log("Build one and import it: exodus segment import <file>");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
console.log(formatMapSummary(res.data.map));
|
|
214
|
+
}
|
|
215
|
+
async function runExport(flags) {
|
|
216
|
+
const out = typeof flags["out"] === "string" ? flags["out"] : undefined;
|
|
217
|
+
const res = await apiGet("/api/v2/segments/export");
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
console.log(formatError(res));
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
const doc = JSON.stringify(res.data, null, 2);
|
|
223
|
+
if (out) {
|
|
224
|
+
fs.writeFileSync(out, doc + "\n", "utf-8");
|
|
225
|
+
console.log(`Wrote segment contract to ${out}.`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
console.log(doc);
|
|
229
|
+
}
|
|
230
|
+
async function runImport(positional, flags) {
|
|
231
|
+
const file = positional[0];
|
|
232
|
+
if (!file) {
|
|
233
|
+
console.error("Error: a contract JSON file is required");
|
|
234
|
+
console.log("Usage: exodus segment import <file> [--yes]");
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
const result = await importFlow(file, { yes: !!flags["yes"], json: !!flags["json"] }, {
|
|
238
|
+
post: (path, body) => apiPost(path, body),
|
|
239
|
+
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
|
240
|
+
});
|
|
241
|
+
for (const l of result.lines)
|
|
242
|
+
console.log(l);
|
|
243
|
+
if (result.code !== 0)
|
|
244
|
+
process.exit(result.code);
|
|
245
|
+
}
|
|
246
|
+
async function runPersonas(flags) {
|
|
247
|
+
const json = !!flags["json"];
|
|
248
|
+
const res = await apiGet("/api/v2/segments/personas");
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
if (json)
|
|
251
|
+
console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
|
|
252
|
+
else
|
|
253
|
+
console.log(formatError(res));
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
const personas = res.data.personas ?? [];
|
|
257
|
+
if (json) {
|
|
258
|
+
console.log(JSON.stringify({ personas }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (personas.length === 0) {
|
|
262
|
+
console.log("No personas saved yet for the active brand.");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
console.log(`Personas (${personas.length}):`);
|
|
266
|
+
for (const p of personas) {
|
|
267
|
+
const tag = p.archived ? " [archived]" : "";
|
|
268
|
+
const code = p.code ? `${p.code} ` : "";
|
|
269
|
+
const type = p.type ? ` (${p.type})` : "";
|
|
270
|
+
console.log(` ${code}${p.name}${type}${tag}`);
|
|
271
|
+
if (p.description)
|
|
272
|
+
console.log(` ${p.description}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const BRAND_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
|
|
2
|
+
export function sniffImageType(buf) {
|
|
3
|
+
if (buf.length >= 8) {
|
|
4
|
+
const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
5
|
+
if (png.every((b, i) => buf[i] === b)) {
|
|
6
|
+
return { ext: "png", mime: "image/png" };
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
10
|
+
return { ext: "jpg", mime: "image/jpeg" };
|
|
11
|
+
}
|
|
12
|
+
if (buf.length >= 12 &&
|
|
13
|
+
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
|
|
14
|
+
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) {
|
|
15
|
+
return { ext: "webp", mime: "image/webp" };
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const AD_TYPES: readonly ["before-after", "native-news", "testimonial", "bold", "meme", "screenshot", "hero", "holding-sign", "handwritten", "comment", "breaking-news", "lofi", "comparison", "infographic", "headline", "collage", "product-breakdown", "scientific", "animation", "founder-note", "carousel", "statistics", "post-it-notes", "happy-avatar", "problem-solution", "writing-on-body", "multi-testimonial", "receipt", "step-by-step", "sale-promotional-offer", "cost-of-inaction", "ugc", "quiz-interactive", "action-figure", "at-home-routine", "billboard", "black-and-white", "blurred", "choose", "cutout", "drawing", "facetime", "marker", "notes", "photo", "quadrant", "search", "thumbnail", "xray", "zoom-bubble"];
|
|
1
|
+
export declare const AD_TYPES: readonly ["before-after", "native-news", "testimonial", "bold", "meme", "screenshot", "hero", "holding-sign", "handwritten", "comment", "breaking-news", "lofi", "comparison", "infographic", "headline", "collage", "product-breakdown", "scientific", "animation", "founder-note", "carousel", "statistics", "post-it-notes", "happy-avatar", "problem-solution", "writing-on-body", "multi-testimonial", "receipt", "step-by-step", "sale-promotional-offer", "cost-of-inaction", "ugc", "quiz-interactive", "action-figure", "at-home-routine", "billboard", "black-and-white", "blurred", "choose", "cutout", "drawing", "facetime", "marker", "notes", "photo", "quadrant", "search", "thumbnail", "xray", "zoom-bubble", "advice-cacophony", "annotated-testimonial", "body-morph", "body-portal", "bundle-showcase", "clay-world", "colossus-product", "cost-receipt", "cultural-icon-dressup", "effect-field", "fine-art-intervention", "flavor-story", "giant-tiny", "half-face-reveal", "hero-vs-villain", "horror-illustration", "horror-object-interior", "illustrated-cutaway", "impossible-physics", "ingredient-warrior", "lifestyle-colorway-array", "material-state", "mirror-reveal", "object-launch", "organ-buddy", "polaroid-sticky", "pop-art-comic", "product-as-body", "product-as-hero", "retro-game-screen", "sequential-state-strip", "sketch-equation", "spotlight-hero", "stat-surround-flatlay", "stat-surround-hero", "street-find", "street-poster", "stuffed-container", "surrogate-character", "symbol-swap", "symptom-collage", "threat-map", "tiny-universe", "tiny-world", "toxic-assemblage", "toxic-emanation", "trophy-moment", "villain-pour", "vintage-reframe", "whiteboard", "zipper-reveal"];
|
|
2
2
|
export type AdType = (typeof AD_TYPES)[number];
|
|
3
3
|
export declare const AD_TYPE_NAMES: Record<AdType, string>;
|
|
4
4
|
export declare const REPTILE_TRIGGERS: readonly ["ultra-real", "bizarre", "voyeur", "suffering", "gory", "sexual", "primal-fear", "odd-contrast", "inside-joke", "time-warp", "victory-lap", "selfie", "uncanny-objects"];
|
|
@@ -49,6 +49,57 @@ export const AD_TYPES = [
|
|
|
49
49
|
"thumbnail",
|
|
50
50
|
"xray",
|
|
51
51
|
"zoom-bubble",
|
|
52
|
+
"advice-cacophony",
|
|
53
|
+
"annotated-testimonial",
|
|
54
|
+
"body-morph",
|
|
55
|
+
"body-portal",
|
|
56
|
+
"bundle-showcase",
|
|
57
|
+
"clay-world",
|
|
58
|
+
"colossus-product",
|
|
59
|
+
"cost-receipt",
|
|
60
|
+
"cultural-icon-dressup",
|
|
61
|
+
"effect-field",
|
|
62
|
+
"fine-art-intervention",
|
|
63
|
+
"flavor-story",
|
|
64
|
+
"giant-tiny",
|
|
65
|
+
"half-face-reveal",
|
|
66
|
+
"hero-vs-villain",
|
|
67
|
+
"horror-illustration",
|
|
68
|
+
"horror-object-interior",
|
|
69
|
+
"illustrated-cutaway",
|
|
70
|
+
"impossible-physics",
|
|
71
|
+
"ingredient-warrior",
|
|
72
|
+
"lifestyle-colorway-array",
|
|
73
|
+
"material-state",
|
|
74
|
+
"mirror-reveal",
|
|
75
|
+
"object-launch",
|
|
76
|
+
"organ-buddy",
|
|
77
|
+
"polaroid-sticky",
|
|
78
|
+
"pop-art-comic",
|
|
79
|
+
"product-as-body",
|
|
80
|
+
"product-as-hero",
|
|
81
|
+
"retro-game-screen",
|
|
82
|
+
"sequential-state-strip",
|
|
83
|
+
"sketch-equation",
|
|
84
|
+
"spotlight-hero",
|
|
85
|
+
"stat-surround-flatlay",
|
|
86
|
+
"stat-surround-hero",
|
|
87
|
+
"street-find",
|
|
88
|
+
"street-poster",
|
|
89
|
+
"stuffed-container",
|
|
90
|
+
"surrogate-character",
|
|
91
|
+
"symbol-swap",
|
|
92
|
+
"symptom-collage",
|
|
93
|
+
"threat-map",
|
|
94
|
+
"tiny-universe",
|
|
95
|
+
"tiny-world",
|
|
96
|
+
"toxic-assemblage",
|
|
97
|
+
"toxic-emanation",
|
|
98
|
+
"trophy-moment",
|
|
99
|
+
"villain-pour",
|
|
100
|
+
"vintage-reframe",
|
|
101
|
+
"whiteboard",
|
|
102
|
+
"zipper-reveal",
|
|
52
103
|
];
|
|
53
104
|
export const AD_TYPE_NAMES = {
|
|
54
105
|
"before-after": "Before / After",
|
|
@@ -101,6 +152,57 @@ export const AD_TYPE_NAMES = {
|
|
|
101
152
|
thumbnail: "Thumbnail",
|
|
102
153
|
xray: "X-ray",
|
|
103
154
|
"zoom-bubble": "Zoom Bubble",
|
|
155
|
+
"advice-cacophony": "Advice Cacophony",
|
|
156
|
+
"annotated-testimonial": "Highlighted / Annotated Testimonial",
|
|
157
|
+
"body-morph": "Body Morph",
|
|
158
|
+
"body-portal": "Body Portal",
|
|
159
|
+
"bundle-showcase": "Bundle Showcase + Benefit Bar",
|
|
160
|
+
"clay-world": "Clay World",
|
|
161
|
+
"colossus-product": "Colossus Product",
|
|
162
|
+
"cost-receipt": "Cost Receipt",
|
|
163
|
+
"cultural-icon-dressup": "Cultural Icon Dress-Up",
|
|
164
|
+
"effect-field": "Effect Field",
|
|
165
|
+
"fine-art-intervention": "Fine Art Intervention",
|
|
166
|
+
"flavor-story": "Flavor Story / Tastes Like",
|
|
167
|
+
"giant-tiny": "Giant / Tiny",
|
|
168
|
+
"half-face-reveal": "Half-Face Reveal",
|
|
169
|
+
"hero-vs-villain": "Hero vs Villain",
|
|
170
|
+
"horror-illustration": "Horror Illustration",
|
|
171
|
+
"horror-object-interior": "Horror Object Interior",
|
|
172
|
+
"illustrated-cutaway": "Illustrated Cutaway",
|
|
173
|
+
"impossible-physics": "Impossible Physics",
|
|
174
|
+
"ingredient-warrior": "Ingredient Warrior",
|
|
175
|
+
"lifestyle-colorway-array": "Lifestyle Action + Product Colorway Array",
|
|
176
|
+
"material-state": "Material State",
|
|
177
|
+
"mirror-reveal": "Mirror Reveal",
|
|
178
|
+
"object-launch": "Object Launch",
|
|
179
|
+
"organ-buddy": "Organ Buddy",
|
|
180
|
+
"polaroid-sticky": "Polaroid + Sticky",
|
|
181
|
+
"pop-art-comic": "Pop Art / Comic",
|
|
182
|
+
"product-as-body": "Product as Body",
|
|
183
|
+
"product-as-hero": "Product as Hero",
|
|
184
|
+
"retro-game-screen": "Retro Game Screen",
|
|
185
|
+
"sequential-state-strip": "Sequential State Strip",
|
|
186
|
+
"sketch-equation": "Sketch Equation",
|
|
187
|
+
"spotlight-hero": "Spotlight Hero",
|
|
188
|
+
"stat-surround-flatlay": "Stat Surround / Callout Radial (Lifestyle Flatlay)",
|
|
189
|
+
"stat-surround-hero": "Stat Surround / Callout Radial (Product Hero)",
|
|
190
|
+
"street-find": "Street Find",
|
|
191
|
+
"street-poster": "Street Poster",
|
|
192
|
+
"stuffed-container": "Stuffed Container",
|
|
193
|
+
"surrogate-character": "Surrogate Character",
|
|
194
|
+
"symbol-swap": "Symbol Swap",
|
|
195
|
+
"symptom-collage": "Symptom Collage",
|
|
196
|
+
"threat-map": "Threat Map",
|
|
197
|
+
"tiny-universe": "Tiny Universe",
|
|
198
|
+
"tiny-world": "Tiny World",
|
|
199
|
+
"toxic-assemblage": "Toxic Assemblage",
|
|
200
|
+
"toxic-emanation": "Toxic Emanation",
|
|
201
|
+
"trophy-moment": "Trophy Moment",
|
|
202
|
+
"villain-pour": "Villain Pour",
|
|
203
|
+
"vintage-reframe": "Vintage Reframe",
|
|
204
|
+
"whiteboard": "Whiteboard",
|
|
205
|
+
"zipper-reveal": "Zipper Reveal",
|
|
104
206
|
};
|
|
105
207
|
export const REPTILE_TRIGGERS = [
|
|
106
208
|
"ultra-real",
|