@mirage-cli/contentdynamite-cli 0.0.0-stage → 1.0.0

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.
@@ -0,0 +1,2158 @@
1
+ // src/version.ts
2
+ var VERSION = "1.0.0";
3
+
4
+ // src/program.ts
5
+ import { Command, CommanderError as CommanderError2, Option as Option4 } from "commander";
6
+
7
+ // src/files.ts
8
+ import { readFileSync, writeFileSync } from "node:fs";
9
+ function bridgeFor(path) {
10
+ const bridge = globalThis.__MIRAGE_CLI_FILE_IO__;
11
+ return bridge?.canHandle(path) ? bridge : null;
12
+ }
13
+ function read(path) {
14
+ const bridge = bridgeFor(path);
15
+ if (!bridge)
16
+ return new Uint8Array(readFileSync(path));
17
+ const data = bridge.readFileSync(path);
18
+ if (data === null)
19
+ throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" });
20
+ return new Uint8Array(typeof data === "string" ? new TextEncoder().encode(data) : data);
21
+ }
22
+ function readBytes(path) {
23
+ try {
24
+ return read(path);
25
+ } catch (error) {
26
+ if (error.code === "ENOENT")
27
+ throw new Error(`file not found: ${path}`);
28
+ throw error;
29
+ }
30
+ }
31
+ var readText = (path) => new TextDecoder().decode(readBytes(path));
32
+ function writeFile(path, data) {
33
+ const bridge = bridgeFor(path);
34
+ if (!bridge) {
35
+ writeFileSync(path, data);
36
+ return;
37
+ }
38
+ if (!bridge.writeFileSync(path, data))
39
+ throw new Error(`could not write ${path}`);
40
+ }
41
+
42
+ // src/helpers.ts
43
+ import { basename } from "node:path";
44
+ import { CommanderError, InvalidArgumentError } from "commander";
45
+
46
+ // src/output.ts
47
+ var io = {
48
+ stdout: (text) => void process.stdout.write(text),
49
+ stderr: (text) => void process.stderr.write(text)
50
+ };
51
+ function out(data) {
52
+ io.stdout(`${JSON.stringify(data, null, 2)}
53
+ `);
54
+ }
55
+ function err(message) {
56
+ io.stderr(`${message}
57
+ `);
58
+ }
59
+ function line(message) {
60
+ io.stdout(`${message}
61
+ `);
62
+ }
63
+ function write(text) {
64
+ io.stdout(text.endsWith(`
65
+ `) || text === "" ? text : `${text}
66
+ `);
67
+ }
68
+ var isoNow = () => new Date().toISOString();
69
+ var isoFromMs = (ms) => new Date(ms).toISOString();
70
+
71
+ // src/wire.js
72
+ import { z as z4 } from "zod";
73
+ import { z as z2 } from "zod";
74
+ import { z } from "zod";
75
+ import { z as z3 } from "zod";
76
+ var USER_ROLES = ["admin", "normal"];
77
+ var ARTICLE_STATUSES = ["pending", "success", "failed"];
78
+ var ARTICLE_TYPES = ["listicle"];
79
+ var INTERACTIVE_TYPES = ["quiz", "game", "calculator", "chart"];
80
+ var GBP_POST_STATUSES = ["pending", "done", "failed"];
81
+ var GBP_IMAGE_MODES = ["none", "supplied", "article", "generate"];
82
+ var GBP_POST_SOURCES = ["article", "topic"];
83
+ var LANDING_DELIVERABLES = ["copy", "page"];
84
+ var LANDING_PAGE_STATUSES = ["pending", "success", "failed"];
85
+ var REVISION_PARTS = ["intro", "section", "image", "feat_image", "faq", "video", "interactive"];
86
+ var REVISION_STATUSES = ["pending", "ready", "failed"];
87
+ var REVISION_VERDICTS = ["better", "same", "worse"];
88
+ var REVISION_TARGETS = ["section", "heading", "passage", "question", "answer"];
89
+ var ICP_SECTIONS = [
90
+ "business_overview",
91
+ "customer_profile",
92
+ "channel_analysis",
93
+ "pain_points",
94
+ "intent_funnel",
95
+ "competitive_landscape"
96
+ ];
97
+ var WORD = "\\p{L}\\p{N}_";
98
+ var PY_SPACE = "\\t\\n\\v\\f\\r\\u001c-\\u001f \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
99
+ var BOUNDARY = `(?:(?<=[${WORD}])(?![${WORD}])|(?<![${WORD}])(?=[${WORD}]))`;
100
+ var NOT_BOUNDARY = `(?:(?<=[${WORD}])(?=[${WORD}])|(?<![${WORD}])(?![${WORD}]))`;
101
+ var SYNTAX = new Set(["^", "$", "\\", ".", "*", "+", "?", "(", ")", "[", "]", "{", "}", "|", "/"]);
102
+ var PY_SPACE_RUN_RE = new RegExp(`[${PY_SPACE}]+`, "u");
103
+ var PY_STRIP_RE = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, "g");
104
+ var pyStrip = (text) => text.replace(PY_STRIP_RE, "");
105
+ var PY_SPACE_RUNS_RE = new RegExp(`[${PY_SPACE}]+`, "gu");
106
+ function wireLandingStatus(status) {
107
+ if (status === null || status === undefined)
108
+ return;
109
+ let value = status.toLowerCase();
110
+ if (value === "sucess")
111
+ value = "success";
112
+ if (!LANDING_PAGE_STATUSES.includes(value))
113
+ throw new Error(`invalid status '${status}', valid values: ${LANDING_PAGE_STATUSES.join(", ")}`);
114
+ return value;
115
+ }
116
+ function wireDeliverable(deliverable) {
117
+ const value = pyStrip(deliverable.toLowerCase());
118
+ if (!LANDING_DELIVERABLES.includes(value))
119
+ throw new Error(`invalid deliverable '${deliverable}', valid values: ${LANDING_DELIVERABLES.join(", ")}`);
120
+ return value;
121
+ }
122
+ var TAG_BY_TYPE = {
123
+ quiz: "cg-quiz",
124
+ game: "cg-game",
125
+ calculator: "cg-calculator",
126
+ chart: "cg-chart"
127
+ };
128
+ var EMBED_RE = new RegExp(String.raw`[ \t]*(?:<script\s+src="[^"]*"\s+defer\s*>\s*</script>\s*)?<(${Object.values(TAG_BY_TYPE).join("|")})\b[^>]*>[\s\S]*?</\1>[ \t]*`, "gi");
129
+ var exportQuery = z.object({ format: z.enum(["docx", "md"]).default("docx") });
130
+ var PYTHON_TRUTHY = ["1", "on", "t", "true", "y", "yes"];
131
+ var PYTHON_FALSY = ["0", "off", "f", "false", "n", "no"];
132
+ var queryBool = z.stringbool({ truthy: PYTHON_TRUTHY, falsy: PYTHON_FALSY, error: "Input should be a valid boolean, unable to interpret input" });
133
+ function valueErrors(checks) {
134
+ return (_, context) => {
135
+ const failure = checks().find(([failed]) => failed);
136
+ if (failure)
137
+ context.addIssue({ code: "custom", message: `Value error, ${failure[1]}` });
138
+ };
139
+ }
140
+ var blank = (value) => value != null && !value.trim();
141
+ var optionalText = z2.string().nullable().optional();
142
+ var CLEARABLE_FIELDS = ["intro", "body", "faqs", "feat_image"];
143
+ var articleParam = z2.object({ article_id: z2.coerce.number().int() });
144
+ var batchParam = z2.object({ batch_id: z2.coerce.number().int() });
145
+ var articlesQuery = z2.object({
146
+ page: z2.coerce.number().int().min(1).default(1),
147
+ limit: z2.coerce.number().int().min(1).max(100).default(10),
148
+ company_name: z2.string().optional(),
149
+ status: z2.enum(ARTICLE_STATUSES).optional(),
150
+ batch_id: z2.coerce.number().int().optional()
151
+ });
152
+ var listicleResearchQuery = z2.object({
153
+ profile_id: z2.coerce.number().int(),
154
+ keyword: z2.string(),
155
+ title: z2.string().optional(),
156
+ location: z2.string().optional(),
157
+ item_count: z2.coerce.number().int().optional()
158
+ });
159
+ var updateArticleDto = z2.object({
160
+ title: optionalText,
161
+ slug: optionalText,
162
+ intro: optionalText,
163
+ body: optionalText,
164
+ faqs: optionalText,
165
+ feat_image: optionalText
166
+ }).superRefine((dto, context) => valueErrors(() => [
167
+ [dto.title === null || blank(dto.title), "title must not be empty if provided"],
168
+ [dto.slug === null || blank(dto.slug), "slug must not be empty if provided"],
169
+ ...CLEARABLE_FIELDS.map((field) => [blank(dto[field]), `${field} must not be empty if provided`])
170
+ ])(dto, context));
171
+ var optionalList = z2.array(z2.string()).nullable().default(null);
172
+ var articleJobItem = z2.object({
173
+ company_profile_id: z2.number().int(),
174
+ search_query: z2.string(),
175
+ primary_keywords: z2.string(),
176
+ secondary_keywords: z2.string().nullable().default(null),
177
+ location_to_rank_for: z2.string().nullable().default(null),
178
+ extra_guidelines: z2.string().nullable().default(null),
179
+ image_guideline: z2.string().nullable().default(null),
180
+ internal_links: optionalList,
181
+ external_links: optionalList,
182
+ competitor_links: optionalList,
183
+ infographic_feat_image: z2.boolean().default(false),
184
+ generate_video: z2.boolean().default(false),
185
+ generate_interactive: z2.boolean().default(false),
186
+ interactive_type: z2.enum(INTERACTIVE_TYPES).nullable().default(null),
187
+ gbp_post: z2.boolean().default(false),
188
+ dewatermark: z2.boolean().nullable().default(null),
189
+ article_type: z2.enum(ARTICLE_TYPES).nullable().default(null),
190
+ listicle_research: z2.string().nullable().default(null),
191
+ item_count: z2.number().int().nullable().default(null),
192
+ brand_facts: z2.string().nullable().default(null),
193
+ allowed_competitors: optionalList,
194
+ table_sources: z2.record(z2.string(), z2.string()).nullable().default(null)
195
+ });
196
+ var cleanList = (values) => values.filter((value) => value && value.trim()).map((value) => value.trim());
197
+ var isHttpUrl = (url) => /^https?:\/\//.test(url.toLowerCase());
198
+ var blankToNull = (value) => value !== null && !value.trim() ? null : value;
199
+ function cleanTableSources(sources) {
200
+ const pairs = [];
201
+ const seen = new Map;
202
+ for (const [rawName, rawUrl] of Object.entries(sources)) {
203
+ const name = (rawName ?? "").trim();
204
+ const url = (rawUrl ?? "").trim();
205
+ if (!name || !url)
206
+ return `table_sources: '${name || url}' needs both an option name and a URL`;
207
+ if (!isHttpUrl(url))
208
+ return `table_sources: '${url}' is not an http(s) URL`;
209
+ const key = name.toLowerCase();
210
+ if (seen.has(key))
211
+ return `table_sources: '${name}' and '${seen.get(key)}' name the same option`;
212
+ seen.set(key, name);
213
+ pairs.push([name, url]);
214
+ }
215
+ if (pairs.length > 10)
216
+ return "table_sources: maximum 10 entries allowed";
217
+ return pairs.length > 0 ? Object.fromEntries(pairs) : null;
218
+ }
219
+ function normaliseJobItem(item) {
220
+ const out2 = { ...item, extra_guidelines: blankToNull(item.extra_guidelines), image_guideline: blankToNull(item.image_guideline) };
221
+ if (out2.internal_links !== null) {
222
+ const cleaned = cleanList(out2.internal_links);
223
+ out2.internal_links = cleaned.length > 0 ? cleaned : null;
224
+ }
225
+ if (out2.external_links !== null) {
226
+ const cleaned = cleanList(out2.external_links);
227
+ if (cleaned.length > 5)
228
+ return "external_links: maximum 5 URLs allowed";
229
+ const invalid = cleaned.find((url) => !isHttpUrl(url));
230
+ if (invalid !== undefined)
231
+ return `external_links: '${invalid}' is not an http(s) URL`;
232
+ out2.external_links = cleaned.length > 0 ? cleaned : null;
233
+ }
234
+ if (out2.competitor_links !== null) {
235
+ const cleaned = cleanList(out2.competitor_links);
236
+ if (cleaned.length > 3)
237
+ return "competitor_links: maximum 3 URLs allowed";
238
+ out2.competitor_links = cleaned.length > 0 ? cleaned : null;
239
+ }
240
+ out2.listicle_research = blankToNull(out2.listicle_research);
241
+ if (out2.article_type === "listicle" && !out2.listicle_research)
242
+ return "listicle_research is required when article_type is 'listicle'";
243
+ if (out2.item_count !== null && !(out2.item_count >= 3 && out2.item_count <= 10))
244
+ return "item_count must be between 3 and 10";
245
+ out2.brand_facts = blankToNull(out2.brand_facts);
246
+ if (out2.allowed_competitors !== null) {
247
+ const cleaned = cleanList(out2.allowed_competitors);
248
+ if (cleaned.length > 12)
249
+ return "allowed_competitors: maximum 12 names allowed";
250
+ out2.allowed_competitors = cleaned.length > 0 ? cleaned : null;
251
+ }
252
+ if (out2.table_sources !== null) {
253
+ const sources = cleanTableSources(out2.table_sources);
254
+ if (typeof sources === "string")
255
+ return sources;
256
+ out2.table_sources = sources;
257
+ }
258
+ return out2;
259
+ }
260
+ var articleDto = articleJobItem.transform((item, context) => {
261
+ const normalised = normaliseJobItem(item);
262
+ if (typeof normalised !== "string")
263
+ return normalised;
264
+ context.addIssue({ code: "custom", message: `Value error, ${normalised}` });
265
+ return z2.NEVER;
266
+ });
267
+ var batchWriteQuery = z2.object({
268
+ batch_name: z2.string(),
269
+ extra_guidelines: z2.string().optional(),
270
+ image_guideline: z2.string().optional(),
271
+ infographic_feat_image: queryBool.default(false),
272
+ generate_video: queryBool.default(false),
273
+ generate_interactive: queryBool.default(false),
274
+ interactive_type: z2.enum(INTERACTIVE_TYPES).optional(),
275
+ gbp_post: queryBool.default(false),
276
+ dewatermark: queryBool.optional(),
277
+ article_type: z2.enum(ARTICLE_TYPES).optional()
278
+ });
279
+ var batchFileForm = z2.object({ file: z2.file() });
280
+ var editFeatImageDto = z2.object({ image_url: z2.string(), instruction: z2.string() }).superRefine((dto, context) => valueErrors(() => [
281
+ [!dto.image_url.trim(), "image_url must not be empty"],
282
+ [!dto.instruction.trim(), "instruction must not be empty"]
283
+ ])(dto, context));
284
+ var commitFeatImageDto = z2.object({ image_url: z2.string() }).superRefine((dto, context) => valueErrors(() => [[!dto.image_url.trim(), "image_url must not be empty"]])(dto, context));
285
+ var editJobParam = z2.object({ article_id: z2.coerce.number().int(), job_id: z2.string() });
286
+ var exportToDocsQuery = z2.object({ replace: queryBool.default(false) });
287
+ var auditPage = {
288
+ page: z2.coerce.number().int().min(1).default(1),
289
+ limit: z2.coerce.number().int().min(1).max(200).default(50)
290
+ };
291
+ var auditQuery = z2.object(auditPage);
292
+ var qaAuditQuery = z2.object({ ...auditPage, company_name: z2.string().optional(), images: queryBool.default(true) });
293
+ var articleQaQuery = z2.object({ model_checks: queryBool.default(false), store: queryBool.default(true) });
294
+ var cleanProvenanceQuery = z2.object({ rewrite: queryBool.default(false) });
295
+ var stripEmbedsBody = z2.array(z2.number().int()).nullable();
296
+ var optionalText2 = z3.string().nullable().default(null);
297
+ var gbpPostJobItem = z3.object({
298
+ company_profile_id: z3.number().int(),
299
+ topic: optionalText2,
300
+ article_id: z3.number().int().nullable().default(null),
301
+ location: optionalText2,
302
+ facts: optionalText2,
303
+ guidelines: optionalText2,
304
+ image_mode: z3.enum(GBP_IMAGE_MODES).nullable().default(null),
305
+ image_url: optionalText2,
306
+ image_guideline: optionalText2,
307
+ regenerate: z3.boolean().default(false)
308
+ });
309
+ var TEXT_FIELDS = ["topic", "location", "facts", "guidelines", "image_url", "image_guideline"];
310
+ var isHttpUrl2 = (url) => /^https?:\/\//.test(url.toLowerCase());
311
+ function normaliseGbpJob(item) {
312
+ const out2 = { ...item };
313
+ for (const field of TEXT_FIELDS) {
314
+ const value = out2[field];
315
+ if (value !== null)
316
+ out2[field] = value.trim() || null;
317
+ }
318
+ if (!out2.topic && out2.article_id === null)
319
+ return "a post needs either a topic or an article_id to repurpose";
320
+ const mode = out2.image_mode ?? (out2.image_url ? "supplied" : out2.article_id !== null ? "article" : "generate");
321
+ if (mode === "supplied" && !out2.image_url)
322
+ return "image_mode 'supplied' needs an image_url";
323
+ if (mode === "article" && out2.article_id === null)
324
+ return "image_mode 'article' needs an article_id to take the image from";
325
+ if (out2.image_url && !isHttpUrl2(out2.image_url))
326
+ return `image_url: '${out2.image_url}' is not an http(s) URL`;
327
+ if (out2.image_guideline && mode !== "generate")
328
+ return "image_guideline only applies when image_mode is 'generate'";
329
+ return { ...out2, image_mode: mode };
330
+ }
331
+ var gbpPostDto = gbpPostJobItem.transform((item, context) => {
332
+ const normalised = normaliseGbpJob(item);
333
+ if (typeof normalised !== "string")
334
+ return normalised;
335
+ context.addIssue({ code: "custom", message: `Value error, ${normalised}` });
336
+ return z3.NEVER;
337
+ });
338
+ var updateGbpPostDto = z3.object({
339
+ content: z3.string().nullable().optional(),
340
+ image_url: z3.string().nullable().optional(),
341
+ topic: z3.string().nullable().optional(),
342
+ status: z3.enum(GBP_POST_STATUSES).nullable().optional()
343
+ }).transform((dto, context) => {
344
+ if (dto.content != null && !dto.content.trim())
345
+ context.addIssue({ code: "custom", message: "Value error, content must not be empty if provided" });
346
+ else if (dto.topic != null && !dto.topic.trim())
347
+ context.addIssue({ code: "custom", message: "Value error, topic must not be empty if provided" });
348
+ else if (dto.image_url != null && dto.image_url.trim() && !isHttpUrl2(dto.image_url.trim())) {
349
+ context.addIssue({ code: "custom", message: `Value error, image_url: '${dto.image_url.trim()}' is not an http(s) URL` });
350
+ } else {
351
+ const out2 = {};
352
+ if (dto.content != null)
353
+ out2.content = dto.content;
354
+ if (dto.status != null)
355
+ out2.status = dto.status;
356
+ if (dto.topic !== undefined)
357
+ out2.topic = dto.topic;
358
+ if (dto.image_url !== undefined)
359
+ out2.image_url = dto.image_url?.trim() || null;
360
+ return out2;
361
+ }
362
+ return z3.NEVER;
363
+ });
364
+ var gbpPostParam = z3.object({ post_id: z3.coerce.number().int() });
365
+ var gbpPostsQuery = z3.object({
366
+ page: z3.coerce.number().int().min(1).default(1),
367
+ limit: z3.coerce.number().int().min(1).max(100).default(10),
368
+ company_name: z3.string().optional(),
369
+ status: z3.enum(GBP_POST_STATUSES).optional(),
370
+ source: z3.enum(GBP_POST_SOURCES).optional()
371
+ });
372
+ var legacyGbpPostsQuery = gbpPostsQuery.pick({ page: true, limit: true, status: true });
373
+ var batchGbpQuery = z3.object({
374
+ location: z3.string().optional(),
375
+ facts: z3.string().optional(),
376
+ guidelines: z3.string().optional(),
377
+ image_mode: z3.enum(GBP_IMAGE_MODES).optional(),
378
+ image_guideline: z3.string().optional()
379
+ });
380
+ var imageOptionsQuery = z3.object({ company_profile_id: z3.coerce.number().int() });
381
+ var STATUS_ALIASES = { pending: "pending", success: "success", sucess: "success", failed: "failed" };
382
+ var BATCH_WIDE_ONLY_FIELDS = ["extra_guidelines", "image_guideline"];
383
+ function withoutDefaults(shape) {
384
+ return Object.fromEntries(Object.entries(shape).map(([key, field]) => [key, field instanceof z4.ZodDefault ? field.unwrap().optional() : field]));
385
+ }
386
+ var batchArticleJob = z4.object(withoutDefaults(articleJobItem.shape));
387
+ var batchGbpJob = z4.object(withoutDefaults(gbpPostJobItem.shape));
388
+ function wireStatus(status) {
389
+ if (status == null)
390
+ return;
391
+ const mapped = STATUS_ALIASES[status.toLowerCase()];
392
+ if (!mapped)
393
+ throw new Error(`invalid status '${status}', valid values: ${ARTICLE_STATUSES.join(", ")}`);
394
+ return mapped;
395
+ }
396
+ function wireGbpStatus(status) {
397
+ if (status == null)
398
+ return;
399
+ const value = status.toLowerCase() === "success" ? "done" : status.toLowerCase();
400
+ if (!GBP_POST_STATUSES.includes(value))
401
+ throw new Error(`invalid status '${status}', valid values: ${GBP_POST_STATUSES.join(", ")}`);
402
+ return value;
403
+ }
404
+ function wireGbpSource(source) {
405
+ if (source == null)
406
+ return;
407
+ const value = source.toLowerCase();
408
+ if (!GBP_POST_SOURCES.includes(value))
409
+ throw new Error(`invalid source '${source}', valid values: ${GBP_POST_SOURCES.join(", ")}`);
410
+ return value;
411
+ }
412
+ function cell(value) {
413
+ if (value == null)
414
+ return "";
415
+ if (Array.isArray(value))
416
+ return value.join("|");
417
+ if (typeof value === "boolean")
418
+ return value ? "true" : "false";
419
+ if (typeof value === "object")
420
+ return Object.entries(value).map(([name, url]) => `${name}=${url}`).join("|");
421
+ return String(value);
422
+ }
423
+ function quote(value) {
424
+ return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
425
+ }
426
+ function toCsv(jobs, required, optional) {
427
+ const columns = [...required, ...optional.filter((field) => jobs.some((job) => job[field] != null))];
428
+ const lines = [columns, ...jobs.map((job) => columns.map((column) => cell(job[column])))];
429
+ return lines.map((line2) => line2.map(quote).join(",") + `
430
+ `).join("");
431
+ }
432
+ var ARTICLE_REQUIRED = ["company_profile_id", "search_query", "primary_keywords"];
433
+ var GBP_REQUIRED = ["company_profile_id"];
434
+ function articleJobsToCsv(jobs, guidelineHint) {
435
+ jobs.forEach((job, index) => {
436
+ if (BATCH_WIDE_ONLY_FIELDS.some((field) => job[field]))
437
+ throw new Error(`job ${index}: per job guidelines are not supported by the API, use ${guidelineHint}`);
438
+ });
439
+ const optional = Object.keys(articleJobItem.shape).filter((field) => !ARTICLE_REQUIRED.includes(field) && !BATCH_WIDE_ONLY_FIELDS.includes(field));
440
+ return new File([toCsv(jobs, ARTICLE_REQUIRED, optional)], "jobs.csv", { type: "text/csv" });
441
+ }
442
+ function gbpJobsToCsv(jobs) {
443
+ const optional = Object.keys(gbpPostJobItem.shape).filter((field) => !GBP_REQUIRED.includes(field));
444
+ return new File([toCsv(jobs, GBP_REQUIRED, optional)], "posts.csv", { type: "text/csv" });
445
+ }
446
+ var EOL = null;
447
+ function parseCsv(text) {
448
+ const records = [];
449
+ let fields = [];
450
+ let field = "";
451
+ let state = "start_record";
452
+ const saveField = () => {
453
+ fields.push(field);
454
+ field = "";
455
+ };
456
+ const endRecord = () => {
457
+ records.push(fields);
458
+ fields = [];
459
+ state = "start_record";
460
+ };
461
+ const endOfLine = (character) => {
462
+ saveField();
463
+ state = character === EOL ? "start_record" : "eat_crnl";
464
+ };
465
+ const process2 = (character) => {
466
+ switch (state) {
467
+ case "eat_crnl":
468
+ if (character === `
469
+ ` || character === "\r")
470
+ return;
471
+ if (character === EOL) {
472
+ state = "start_record";
473
+ return;
474
+ }
475
+ endRecord();
476
+ process2(character);
477
+ return;
478
+ case "start_record":
479
+ if (character === EOL)
480
+ return;
481
+ if (character === `
482
+ ` || character === "\r") {
483
+ state = "eat_crnl";
484
+ return;
485
+ }
486
+ state = "start_field";
487
+ process2(character);
488
+ return;
489
+ case "start_field":
490
+ if (character === `
491
+ ` || character === "\r" || character === EOL)
492
+ endOfLine(character);
493
+ else if (character === '"')
494
+ state = "in_quoted_field";
495
+ else if (character === ",")
496
+ saveField();
497
+ else {
498
+ field += character;
499
+ state = "in_field";
500
+ }
501
+ return;
502
+ case "in_field":
503
+ if (character === `
504
+ ` || character === "\r" || character === EOL)
505
+ endOfLine(character);
506
+ else if (character === ",") {
507
+ saveField();
508
+ state = "start_field";
509
+ } else
510
+ field += character;
511
+ return;
512
+ case "in_quoted_field":
513
+ if (character === EOL)
514
+ return;
515
+ if (character === '"')
516
+ state = "quote_in_quoted_field";
517
+ else
518
+ field += character;
519
+ return;
520
+ case "quote_in_quoted_field":
521
+ if (character === '"') {
522
+ field += character;
523
+ state = "in_quoted_field";
524
+ } else if (character === ",") {
525
+ saveField();
526
+ state = "start_field";
527
+ } else if (character === `
528
+ ` || character === "\r" || character === EOL)
529
+ endOfLine(character);
530
+ else {
531
+ field += character;
532
+ state = "in_field";
533
+ }
534
+ return;
535
+ }
536
+ };
537
+ for (const line2 of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
538
+ for (const character of line2)
539
+ process2(character);
540
+ process2(EOL);
541
+ if (state === "start_record")
542
+ endRecord();
543
+ }
544
+ if (field || state === "in_quoted_field") {
545
+ saveField();
546
+ endRecord();
547
+ }
548
+ return records;
549
+ }
550
+ function readCsvTable(text) {
551
+ const [header, ...records] = parseCsv(text);
552
+ if (header === undefined)
553
+ return { fieldnames: null, rows: [] };
554
+ const rows = records.filter((record) => record.length > 0).map((record) => {
555
+ const row = new Map;
556
+ header.forEach((name, index) => row.set(name, record[index] ?? ""));
557
+ return row;
558
+ });
559
+ return { fieldnames: header, rows };
560
+ }
561
+
562
+ // src/helpers.ts
563
+ class Exit extends CommanderError {
564
+ constructor(code) {
565
+ super(code, "commander.exit", "");
566
+ }
567
+ }
568
+ var clock = {
569
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
570
+ now: () => performance.now()
571
+ };
572
+ var fmtNum = (value) => String(value);
573
+ var splitCsv = (value) => value.split(",").map((part) => part.trim()).filter(Boolean);
574
+ function integer(value) {
575
+ if (!/^[+-]?\d+$/.test(value.trim()))
576
+ throw new InvalidArgumentError("Not a valid integer.");
577
+ return Number.parseInt(value, 10);
578
+ }
579
+ function decimal(value) {
580
+ const parsed = Number(value);
581
+ if (!value.trim() || !Number.isFinite(parsed))
582
+ throw new InvalidArgumentError("Not a valid number.");
583
+ return parsed;
584
+ }
585
+ function between(low, high) {
586
+ return (value) => {
587
+ const parsed = decimal(value);
588
+ if (parsed < low || parsed > high)
589
+ throw new InvalidArgumentError(`Must be between ${low} and ${high}.`);
590
+ return parsed;
591
+ };
592
+ }
593
+ function either(value, file, flags) {
594
+ if (value && file)
595
+ throw new Error(`pass either ${flags}, not both`);
596
+ return file ? readText(file) : value;
597
+ }
598
+ function documentOutput(command, cli) {
599
+ if (cli.mount)
600
+ return command.requiredOption("-o, --output <path>", "Where to write it: a path the host shares with you, such as /data/report.docx");
601
+ return command.option("-o, --output <path>", "Where to write it, defaults to the server's filename in the current directory");
602
+ }
603
+ async function downloadDocument(cli, path, format, output, fallback) {
604
+ if (format !== "docx" && format !== "md")
605
+ throw new Error("--format must be docx or md");
606
+ const { content, filename } = await cli.client().getDownload(path, { format });
607
+ const target = output ?? (filename ? basename(filename) : `${fallback}.${format}`);
608
+ writeFile(target, content);
609
+ return target;
610
+ }
611
+ function interactiveFields(value) {
612
+ if (value === undefined)
613
+ return {};
614
+ if (value === true || value === "")
615
+ return { generate_interactive: true };
616
+ if (!INTERACTIVE_TYPES.includes(value)) {
617
+ throw new Error(`invalid interactive type '${value}', valid values: ${INTERACTIVE_TYPES.join(", ")}`);
618
+ }
619
+ return { generate_interactive: true, interactive_type: value };
620
+ }
621
+ async function watchLoop({ interval, timeout, poll, progress, done, timedOut }) {
622
+ const deadline = timeout === undefined ? null : clock.now() + timeout * 1000;
623
+ for (;; ) {
624
+ const item = await poll();
625
+ err(progress(item));
626
+ if (done(item))
627
+ return item;
628
+ if (deadline !== null && clock.now() >= deadline)
629
+ throw new Error(timedOut(item));
630
+ await clock.sleep(Math.max(1, interval) * 1000);
631
+ }
632
+ }
633
+
634
+ // src/commands/articles.ts
635
+ var media = (article, field) => (article.status === "success" ? article[field] : null) ?? "null";
636
+ function isTerminal(article) {
637
+ if (article.status === "failed")
638
+ return true;
639
+ if (article.status !== "success")
640
+ return false;
641
+ return article.video_status !== "pending" && article.interactive_status !== "pending";
642
+ }
643
+ function limitedList(value, name, max) {
644
+ const items = splitCsv(value);
645
+ if (items.length > max)
646
+ throw new Error(`${name} max out at ${max}, got ${items.length}`);
647
+ return items;
648
+ }
649
+ async function everyArticle(client, filter) {
650
+ const articles = [];
651
+ for (let page = 1;; page++) {
652
+ const res = await client.get("content-writing/articles", { page, limit: 100, ...filter });
653
+ articles.push(...res.articles);
654
+ if (page >= res.total_pages || res.articles.length === 0)
655
+ return articles;
656
+ }
657
+ }
658
+ function writeBody(options) {
659
+ const body = { company_profile_id: options.profileId, search_query: options.query, primary_keywords: options.primaryKeywords };
660
+ if (options.secondaryKeywords)
661
+ body.secondary_keywords = options.secondaryKeywords;
662
+ if (options.location)
663
+ body.location_to_rank_for = options.location;
664
+ if (options.guidelines)
665
+ body.extra_guidelines = options.guidelines;
666
+ if (options.imageGuideline)
667
+ body.image_guideline = options.imageGuideline;
668
+ if (options.internalLinks)
669
+ body.internal_links = splitCsv(options.internalLinks);
670
+ if (options.externalLinks)
671
+ body.external_links = limitedList(options.externalLinks, "external links", 5);
672
+ if (options.competitorLinks)
673
+ body.competitor_links = limitedList(options.competitorLinks, "competitor links", 3);
674
+ if (options.brandFacts)
675
+ body.brand_facts = options.brandFacts;
676
+ if (options.infographic)
677
+ body.infographic_feat_image = true;
678
+ if (options.video)
679
+ body.generate_video = true;
680
+ Object.assign(body, interactiveFields(options.interactive));
681
+ if (options.dewatermark !== undefined) {
682
+ body.dewatermark = options.dewatermark;
683
+ if (options.dewatermark)
684
+ err("--dewatermark adds one rewrite call per article and trades some of Claude's voice for hygiene.");
685
+ }
686
+ if (options.listicle) {
687
+ if (options.researchFile === undefined)
688
+ throw new Error("--listicle needs --research-file: paste the research from `ve-dynamite articles research-prompt` into a file first");
689
+ body.article_type = "listicle";
690
+ body.listicle_research = readText(options.researchFile);
691
+ } else if (options.researchFile !== undefined) {
692
+ throw new Error("--research-file only applies with --listicle");
693
+ }
694
+ if (options.itemCount !== undefined)
695
+ body.item_count = options.itemCount;
696
+ if (options.competitors)
697
+ body.allowed_competitors = splitCsv(options.competitors);
698
+ return body;
699
+ }
700
+ function updateBody(options) {
701
+ const body = either(options.body, options.bodyFile, "--body or --body-file");
702
+ const faqs = either(options.faqs, options.faqsFile, "--faqs or --faqs-file");
703
+ const payload = {};
704
+ if (options.title)
705
+ payload.title = options.title;
706
+ if (options.slug)
707
+ payload.slug = options.slug;
708
+ if (options.intro)
709
+ payload.intro = options.intro;
710
+ if (body)
711
+ payload.body = body;
712
+ if (faqs)
713
+ payload.faqs = faqs;
714
+ if (options.featImage)
715
+ payload.feat_image = options.featImage;
716
+ if (Object.keys(payload).length === 0) {
717
+ throw new Error("nothing to update, pass at least one of --title, --slug, --intro, --body, --body-file, --faqs, --faqs-file, --feat-image");
718
+ }
719
+ return payload;
720
+ }
721
+ function registerArticles(program, cli) {
722
+ const articles = program.command("articles").description("Manage articles");
723
+ articles.command("write").description("Queue an article for generation (async, costs real money per article)").requiredOption("--profile-id <id>", "Company profile id", integer).requiredOption("--query <query>", "Search query the article should target").requiredOption("--primary-keywords <keywords>", "Primary keywords").option("--secondary-keywords <keywords>", "Secondary keywords").option("--location <location>", "Location to rank for").option("--guidelines <text>", "Extra writing guidelines").option("--image-guideline <text>", "Featured image guideline").option("--internal-links <urls>", "Comma separated URLs on the company's own site that must be linked").option("--external-links <urls>", "Comma separated outside sources the article must cite (max 5)").option("--competitor-links <urls>", "Comma separated competitor URLs (max 3)").option("--brand-facts <text>", "Verified facts about the client the article may state, such as the CLIENT FACTS block from research-prompt").option("--infographic", "Generate the featured image as an infographic").option("--video", "Generate a companion video").option("--interactive [type]", `Generate an interactive component, optionally ${INTERACTIVE_TYPES.join("|")} (bare flag lets the server auto select)`).option("--dewatermark", "Rewrite the finished prose through a non-Claude model. Omit to follow DEWATERMARK_REWRITE_ENABLED").option("--no-dewatermark", "Keep the prose as written, whatever DEWATERMARK_REWRITE_ENABLED says").option("--listicle", "Write a structured listicle. Requires --research-file").option("--research-file <path>", "File holding the research and source links a listicle is built from").option("--item-count <count>", "How many options the listicle should cover (3 to 10)", integer).option("--competitors <names>", "Comma separated list of the only competitors a listicle may name").action(async (options) => {
724
+ const res = await cli.client().post("content-writing/article", writeBody(options));
725
+ cli.billed(1);
726
+ err(`Generation is asynchronous and spends real money (LLM, SERP, crawling, images). Poll with ${cli.follow(`articles watch ${res.article_id}`, `articles get ${res.article_id}`)}`);
727
+ out(res);
728
+ });
729
+ articles.command("research-prompt").description("Print the research prompt to run in an external AI tool before writing a listicle").requiredOption("--profile-id <id>", "Company profile id, names the client in the prompt", integer).requiredOption("--keyword <keyword>", "The keyword the listicle targets").option("--title <title>", "Working title, defaults to the keyword").option("--location <location>", "Location the reader is in").option("--item-count <count>", "How many options to research (3 to 10)", integer).action(async (options) => {
730
+ const res = await cli.client().get("content-writing/listicle-research-prompt", {
731
+ keyword: options.keyword,
732
+ profile_id: options.profileId,
733
+ title: options.title,
734
+ location: options.location,
735
+ item_count: options.itemCount
736
+ });
737
+ err(`Research prompt for ${res.company_name}. Paste the CLIENT FACTS block into --brand-facts, the rest into --research-file.`);
738
+ line(res.prompt);
739
+ });
740
+ documentOutput(articles.command("download").description("Download a finished article as a document (docx imports straight into Google Docs)").argument("<ID>", "Article id", integer).option("--format <format>", "docx (opens in Google Docs) or md", "docx"), cli).action(async (articleId, options) => {
741
+ const target = await downloadDocument(cli, `content-writing/article/${articleId}/export`, options.format, options.output, `article-${articleId}`);
742
+ err(`exported article ${articleId} to ${target}`);
743
+ });
744
+ articles.command("get").description("Show one article (slim {article_id, status} shape until the article completes)").argument("<ID>", "Article id", integer).action(async (articleId) => out(await cli.client().get(`content-writing/article/${articleId}`)));
745
+ articles.command("list").description("List articles, newest first").option("--page <page>", "Page number", integer, 1).option("--limit <limit>", "Page size (max 100)", integer, 10).option("--company <name>", "Filter by exact company name").option("--status <status>", "Filter by status: pending, success, failed").option("--batch-id <id>", "Filter by batch id", integer).action(async (options) => {
746
+ const status = wireStatus(options.status);
747
+ out(await cli.client().get("content-writing/articles", { page: options.page, limit: options.limit, company_name: options.company, status, batch_id: options.batchId }));
748
+ });
749
+ if (!cli.mount) {
750
+ articles.command("watch").description("Poll an article until the write AND any requested video/interactive are terminal").argument("<ID>", "Article id", integer).option("--interval <seconds>", "Seconds between polls", decimal, 10).option("--timeout <seconds>", "Give up after this many seconds", decimal).action(async (articleId, options) => {
751
+ const client = cli.client();
752
+ const article = await watchLoop({
753
+ interval: options.interval,
754
+ timeout: options.timeout,
755
+ poll: () => client.get(`content-writing/article/${articleId}`),
756
+ progress: (item) => `[${isoNow()}] status=${item.status} video=${media(item, "video_status")} interactive=${media(item, "interactive_status")}`,
757
+ done: isTerminal,
758
+ timedOut: (item) => `timed out after ${fmtNum(options.timeout)}s, article ${articleId} still ${item.status}`
759
+ });
760
+ out(article);
761
+ if (article.status === "failed")
762
+ throw new Exit(1);
763
+ });
764
+ }
765
+ articles.command("update").description("Update article fields (409 while the article is still being written)").argument("<ID>", "Article id", integer).option("--title <title>", "New title").option("--slug <slug>", "New slug").option("--intro <intro>", "New intro").option("--body <markdown>", "New body markdown").option("--body-file <path>", "Read the new body from a file").option("--faqs <faqs>", "New FAQs").option("--faqs-file <path>", "Read the new FAQs from a file").option("--feat-image <url>", "New featured image URL").action(async (articleId, options) => out(await cli.client().put(`content-writing/article/${articleId}`, updateBody(options))));
766
+ articles.command("embed-audit").description("List completed articles whose body still has the interactive block welded in (read only)").option("--limit <limit>", "Articles to scan per page", integer, 50).option("--page <page>", "Page of completed articles to scan", integer, 1).action(async (options) => {
767
+ const res = await cli.client().get("content-writing/articles/interactive-embed-audit", { page: options.page, limit: options.limit });
768
+ if (res.articles.length === 0) {
769
+ err(`Scanned ${res.total_success} completed article(s), none carry a welded embed.`);
770
+ } else {
771
+ err(`${res.articles.length} article(s) still carry the block inside the body:`);
772
+ for (const item of res.articles) {
773
+ err(` #${item.article_id} [${item.company_name}] ${item.interactive_type} (${item.payload_stored ? "payload stored" : "NO PAYLOAD — leave alone"})`);
774
+ }
775
+ }
776
+ out(res);
777
+ });
778
+ articles.command("strip-embeds").summary("Take the interactive block out of shipped bodies, leaving the payload (no spend)").description(`Take the interactive block out of shipped bodies, leaving the payload (no spend)
779
+
780
+ ` + "The component then comes from `interactive_embed` on the API instead, which is what lets a site place it. " + "Run this only once whoever renders these articles reads that field: until then the block in the body is what a reader sees.").argument("[ID]", "Omit to strip every article you own", integer).option("--dry-run", "Only list what would be stripped, change nothing").action(async (articleId, options) => {
781
+ const client = cli.client();
782
+ if (options.dryRun) {
783
+ const res = await client.get("content-writing/articles/interactive-embed-audit", { limit: 200 });
784
+ const targets = res.articles.filter((item) => articleId === undefined || item.article_id === articleId);
785
+ err(`would strip ${targets.length} article(s): ${targets.map((item) => `#${item.article_id}`).join(", ") || "none"}`);
786
+ out({ would_strip: targets.map((item) => item.article_id) });
787
+ return;
788
+ }
789
+ out(await client.post("content-writing/articles/strip-interactive-embeds", articleId === undefined ? null : [articleId]));
790
+ });
791
+ articles.command("provenance-audit").description("List completed articles still carrying invisible characters (read only, no spend)").option("--limit <limit>", "Articles to scan per page", integer, 50).option("--page <page>", "Page of completed articles to scan", integer, 1).action(async (options) => {
792
+ const res = await cli.client().get("content-writing/articles/provenance-audit", { page: options.page, limit: options.limit });
793
+ if (res.articles.length === 0) {
794
+ err(`Scanned ${res.checked} completed article(s), none carry invisible characters.`);
795
+ } else {
796
+ err(`${res.articles.length} of ${res.checked} scanned article(s) carry invisible characters:`);
797
+ for (const item of res.articles) {
798
+ const classes = [...new Set(Object.values(item.findings).flatMap((counts) => Object.keys(counts)))].sort();
799
+ err(` #${item.article_id} [${item.company_name}] ${item.total_findings} char(s): ${classes.join(", ")}`);
800
+ }
801
+ }
802
+ out(res);
803
+ });
804
+ const clean = articles.command("clean-provenance").description("Strip invisible characters from shipped articles in place (deterministic, no spend without --rewrite)").argument("[ID]", "Omit to clean every flagged article", integer).option("--rewrite", "Also run the Layer B prose rewrite. Costs an LLM call and some voice").option("--dry-run", "Only list what would be cleaned, change nothing");
805
+ if (!cli.mount) {
806
+ clean.option("--watch", "After cleaning, poll the audit until it comes back clean").option("--interval <seconds>", "Seconds between audit polls with --watch", decimal, 15).option("--timeout <seconds>", "Give up polling after this many seconds with --watch", decimal, 300);
807
+ }
808
+ clean.action(async (articleId, options) => {
809
+ const client = cli.client();
810
+ const audit = { limit: 200 };
811
+ const rewrite = options.rewrite ? { rewrite: true } : undefined;
812
+ if (options.rewrite) {
813
+ err("--rewrite sends the prose to the configured non-Claude model. It costs a call per article and");
814
+ err("the rewrite model's word choices replace Claude's. It cannot be certified against any detector.");
815
+ }
816
+ if (articleId !== undefined) {
817
+ if (options.dryRun) {
818
+ err(`would clean article #${articleId}`);
819
+ return;
820
+ }
821
+ const res = await client.post(`content-writing/article/${articleId}/clean-provenance`, undefined, rewrite);
822
+ if (rewrite)
823
+ cli.billed(1);
824
+ out(res);
825
+ return;
826
+ }
827
+ const flagged = await client.get("content-writing/articles/provenance-audit", audit);
828
+ if (flagged.articles.length === 0) {
829
+ err(`Scanned ${flagged.checked} completed article(s), nothing to clean.`);
830
+ out(flagged);
831
+ return;
832
+ }
833
+ err(`${flagged.articles.length} article(s) carry invisible characters:`);
834
+ for (const item of flagged.articles)
835
+ err(` #${item.article_id} [${item.company_name}] ${item.total_findings} char(s)`);
836
+ if (options.dryRun) {
837
+ out(flagged);
838
+ return;
839
+ }
840
+ const cleaned = [];
841
+ try {
842
+ for (const item of flagged.articles) {
843
+ const res = await client.post(`content-writing/article/${item.article_id}/clean-provenance`, undefined, rewrite);
844
+ cleaned.push(res);
845
+ err(`cleaned #${item.article_id}: ${res.msg}`);
846
+ }
847
+ } finally {
848
+ if (rewrite)
849
+ cli.billed(cleaned.length);
850
+ }
851
+ if (!options.watch) {
852
+ out({ cleaned });
853
+ return;
854
+ }
855
+ out(await watchLoop({
856
+ interval: options.interval,
857
+ timeout: options.timeout,
858
+ poll: () => client.get("content-writing/articles/provenance-audit", audit),
859
+ progress: (res) => `[${isoNow()}] articles still carrying carriers: ${res.articles.length}`,
860
+ done: (res) => res.articles.length === 0,
861
+ timedOut: (res) => `timed out after ${fmtNum(options.timeout)}s, ${res.articles.length} article(s) still flagged`
862
+ }));
863
+ });
864
+ articles.command("delete").description("Delete an article permanently").argument("<ID>", "Article id", integer).option("--yes", "Confirm the deletion").action(async (articleId, options) => {
865
+ if (!options.yes)
866
+ throw new Error("deleting an article is permanent, re-run with --yes to confirm");
867
+ out(await cli.client().delete(`content-writing/article/${articleId}`));
868
+ });
869
+ articles.command("export").description("Export all matching articles via client side paging").option("--company <name>", "Filter by exact company name").option("--status <status>", "Filter by status: pending, success, failed").option("--batch-id <id>", "Filter by batch id", integer).option("--ndjson", "Newline delimited JSON instead of an array").option("-o, --output <path>", "Write to a file instead of stdout").action(async (options) => {
870
+ const status = wireStatus(options.status);
871
+ const found = await everyArticle(cli.client(), { company_name: options.company, status, batch_id: options.batchId });
872
+ const text = options.ndjson ? found.map((article) => `${JSON.stringify(article)}
873
+ `).join("") : JSON.stringify(found, null, 2);
874
+ if (!options.output) {
875
+ write(text);
876
+ return;
877
+ }
878
+ writeFile(options.output, text);
879
+ err(`exported ${found.length} articles to ${options.output}`);
880
+ });
881
+ return articles;
882
+ }
883
+
884
+ // src/client.ts
885
+ class ApiError extends Error {
886
+ status;
887
+ constructor(status, message) {
888
+ super(message);
889
+ this.status = status;
890
+ }
891
+ }
892
+ function describeDetail(text) {
893
+ try {
894
+ const detail = JSON.parse(text).detail;
895
+ if (typeof detail === "string")
896
+ return detail;
897
+ if (Array.isArray(detail))
898
+ return detail.map((item) => `${(item.loc ?? []).slice(1).join(".")}: ${item.msg}`).join("; ");
899
+ return null;
900
+ } catch {
901
+ return null;
902
+ }
903
+ }
904
+ async function apiError(response) {
905
+ const text = await response.text();
906
+ return new ApiError(response.status, `${response.status}: ${describeDetail(text) ?? (text || response.statusText)}`);
907
+ }
908
+
909
+ class ApiClient {
910
+ baseUrl;
911
+ token;
912
+ constructor(baseUrl, token) {
913
+ this.baseUrl = baseUrl;
914
+ this.token = token;
915
+ }
916
+ get(path, params) {
917
+ return this.json("GET", path, { params });
918
+ }
919
+ async getText(path, params) {
920
+ return (await this.request("GET", path, { params })).text();
921
+ }
922
+ async getDownload(path, params) {
923
+ const response = await this.request("GET", path, { params });
924
+ const filename = /filename="([^"]+)"/.exec(response.headers.get("content-disposition") ?? "")?.[1] ?? null;
925
+ return { content: new Uint8Array(await response.arrayBuffer()), filename };
926
+ }
927
+ post(path, body, params) {
928
+ return this.json("POST", path, { body, params });
929
+ }
930
+ postForm(path, file, filename, params) {
931
+ const form = new FormData;
932
+ form.append("file", file, filename);
933
+ return this.json("POST", path, { form, params });
934
+ }
935
+ put(path, body) {
936
+ return this.json("PUT", path, { body });
937
+ }
938
+ patch(path, body) {
939
+ return this.json("PATCH", path, { body });
940
+ }
941
+ delete(path) {
942
+ return this.json("DELETE", path, {});
943
+ }
944
+ async json(method, path, payload) {
945
+ return await (await this.request(method, path, payload)).json();
946
+ }
947
+ async request(method, path, { params, body, form }) {
948
+ const headers = this.token ? { Authorization: `Bearer ${this.token}` } : {};
949
+ let content = form;
950
+ if (!form && method !== "GET" && method !== "DELETE") {
951
+ headers["Content-Type"] = "application/json";
952
+ content = body == null ? undefined : JSON.stringify(body);
953
+ }
954
+ const response = await fetch(this.url(path, params), { method, headers, body: content, redirect: "manual" });
955
+ if (!response.ok)
956
+ throw await apiError(response);
957
+ return response;
958
+ }
959
+ url(path, params) {
960
+ const query = new URLSearchParams;
961
+ for (const [key, value] of Object.entries(params ?? {})) {
962
+ if (value !== null && value !== undefined && value !== "")
963
+ query.set(key, String(value));
964
+ }
965
+ const search = query.toString();
966
+ return `${this.baseUrl}/api/v1/${path}${search ? `?${search}` : ""}`;
967
+ }
968
+ }
969
+
970
+ // src/credentials.ts
971
+ import { chmodSync, mkdirSync, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
972
+ import { z as z5 } from "zod";
973
+
974
+ // src/config.ts
975
+ import { homedir } from "node:os";
976
+ import { join } from "node:path";
977
+ var DEFAULT_API_URL = "https://content-dynamite.alexthecomputer1.workers.dev";
978
+ function configDir() {
979
+ const override = process.env.VE_DYNAMITE_CONFIG_DIR;
980
+ if (override)
981
+ return override;
982
+ const xdg = process.env.XDG_CONFIG_HOME;
983
+ return xdg ? join(xdg, "ve-dynamite") : join(homedir(), ".config", "ve-dynamite");
984
+ }
985
+ var credentialsPath = () => join(configDir(), "credentials.json");
986
+ var imageJobsPath = () => join(configDir(), "image-jobs.json");
987
+ function defaultApiUrl() {
988
+ return process.env.VE_DYNAMITE_API_URL || DEFAULT_API_URL;
989
+ }
990
+ function envToken() {
991
+ return process.env.VE_DYNAMITE_TOKEN || undefined;
992
+ }
993
+
994
+ // src/credentials.ts
995
+ var PAT_PREFIX = "ved_";
996
+ var EXPIRY_SKEW_MS = 60000;
997
+ var storedCredentials = z5.object({
998
+ authToken: z5.string(),
999
+ email: z5.string(),
1000
+ username: z5.string(),
1001
+ profile: z5.string().nullable(),
1002
+ expiresAt: z5.number().int().nullable()
1003
+ });
1004
+ var isPat = (token) => token.startsWith(PAT_PREFIX);
1005
+ function loadCredentials() {
1006
+ try {
1007
+ return storedCredentials.parse(JSON.parse(readFileSync2(credentialsPath(), "utf8")));
1008
+ } catch {
1009
+ return null;
1010
+ }
1011
+ }
1012
+ function saveCredentials({ authToken, email, username, profile, expiresAt }) {
1013
+ mkdirSync(configDir(), { recursive: true, mode: 448 });
1014
+ const path = credentialsPath();
1015
+ writeFileSync2(path, JSON.stringify({ authToken, email, username, profile, expiresAt }, null, 2), { mode: 384 });
1016
+ chmodSync(path, 384);
1017
+ }
1018
+ function clearCredentials() {
1019
+ try {
1020
+ unlinkSync(credentialsPath());
1021
+ } catch {
1022
+ return;
1023
+ }
1024
+ }
1025
+ function decodeJwtPayload(token) {
1026
+ try {
1027
+ const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
1028
+ return payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : null;
1029
+ } catch {
1030
+ return null;
1031
+ }
1032
+ }
1033
+ function decodeJwtExp(token) {
1034
+ const exp = decodeJwtPayload(token)?.exp;
1035
+ return typeof exp === "number" ? exp * 1000 : null;
1036
+ }
1037
+ function resolveToken(flagToken, stored = true) {
1038
+ if (flagToken)
1039
+ return { token: flagToken, source: "flag", credentials: null };
1040
+ const env = envToken();
1041
+ if (env)
1042
+ return { token: env, source: "env", credentials: null };
1043
+ if (!stored)
1044
+ throw new Error("no API token, set VE_DYNAMITE_TOKEN or pass --token");
1045
+ const credentials = loadCredentials();
1046
+ if (!credentials)
1047
+ throw new Error("not logged in, run `ve-dynamite login` or set VE_DYNAMITE_TOKEN");
1048
+ if (credentials.expiresAt !== null && Date.now() >= credentials.expiresAt - EXPIRY_SKEW_MS)
1049
+ throw new Error("token expired, run `ve-dynamite login`");
1050
+ return { token: credentials.authToken, source: "stored", credentials };
1051
+ }
1052
+
1053
+ // src/commands/auth.ts
1054
+ var DAY_MS = 24 * 60 * 60 * 1000;
1055
+ async function signedIn(client, rejected) {
1056
+ try {
1057
+ return await client.get("auth/me");
1058
+ } catch (error) {
1059
+ if (error instanceof ApiError && error.status === 401)
1060
+ throw new Error(rejected);
1061
+ throw error;
1062
+ }
1063
+ }
1064
+ function registerAuth(program, cli) {
1065
+ if (!cli.mount)
1066
+ registerLogin(program, cli);
1067
+ program.command("whoami").description("Print the signed in identity and its role, checking the token against the API").action(async () => {
1068
+ const resolved = cli.token();
1069
+ const rejected = cli.mount ? "not authenticated, the API rejected the token" : "not authenticated, run `ve-dynamite login`";
1070
+ const me = await signedIn(new ApiClient(cli.baseUrl(), resolved.token), rejected);
1071
+ const expiresAt = decodeJwtExp(resolved.token);
1072
+ out({
1073
+ email: me.email,
1074
+ username: me.username,
1075
+ profile: me.profile,
1076
+ role: me.role,
1077
+ user_id: me.user_id,
1078
+ token_source: resolved.source,
1079
+ token_type: isPat(resolved.token) ? "pat" : "jwt",
1080
+ expires_at: expiresAt ? isoFromMs(expiresAt) : null
1081
+ });
1082
+ });
1083
+ }
1084
+ function registerLogin(program, cli) {
1085
+ program.command("login").description("Sign in with your Content Dynamite account and store the 24h token locally").requiredOption("--email <email>", "Account email").requiredOption("--password <password>", "Account password").action(async (options) => {
1086
+ const res = await new ApiClient(cli.baseUrl(), "").post("auth/login", { email: options.email, passwd: options.password });
1087
+ const expiresAt = decodeJwtExp(res.auth_token) ?? Date.now() + DAY_MS;
1088
+ saveCredentials({ authToken: res.auth_token, email: res.email, username: res.username, profile: res.profile, expiresAt });
1089
+ err(`✓ Logged in as ${res.username} (${res.email}), token valid until ${isoFromMs(expiresAt)}`);
1090
+ out({ email: res.email, username: res.username, role: res.role });
1091
+ });
1092
+ program.command("logout").description("Delete the locally stored credential").action(() => {
1093
+ clearCredentials();
1094
+ err("✓ Logged out");
1095
+ });
1096
+ }
1097
+
1098
+ // src/commands/batches.ts
1099
+ import { basename as basename2 } from "node:path";
1100
+
1101
+ // src/jobs.ts
1102
+ function valueAt(input, path) {
1103
+ let value = input;
1104
+ for (const key of path) {
1105
+ if (value === null || typeof value !== "object")
1106
+ return;
1107
+ value = value[key];
1108
+ }
1109
+ return value;
1110
+ }
1111
+ function readJobs(path) {
1112
+ const raw = JSON.parse(readText(path));
1113
+ if (!Array.isArray(raw) || raw.length === 0)
1114
+ throw new Error("--jobs file must be a non empty JSON array");
1115
+ return raw;
1116
+ }
1117
+ function parseJobs(raw, schema) {
1118
+ const fields = Object.keys(schema.shape);
1119
+ return raw.map((item, index) => {
1120
+ if (item === null || typeof item !== "object" || Array.isArray(item))
1121
+ throw new Error(`job ${index}: must be a JSON object`);
1122
+ const unknown = Object.keys(item).find((key) => !fields.includes(key));
1123
+ if (unknown !== undefined)
1124
+ throw new Error(`job ${index}: unknown field '${unknown}'`);
1125
+ const parsed = schema.safeParse(item);
1126
+ if (parsed.success)
1127
+ return parsed.data;
1128
+ const issue = parsed.error.issues[0];
1129
+ const message = issue.code === "invalid_type" && valueAt(item, issue.path) === undefined ? "Field required" : issue.message;
1130
+ const location = issue.path.map(String).join(".");
1131
+ throw new Error(`job ${index}: ${location ? `${location}: ${message}` : message}`);
1132
+ });
1133
+ }
1134
+
1135
+ // src/commands/batches.ts
1136
+ var GUIDELINE_HINT = "the batch wide --guidelines / --image-guideline flags";
1137
+ var TRUTHY = ["true", "1", "yes"];
1138
+ async function findBatch(client, batchId) {
1139
+ const batch = (await client.get("content-writing/batches")).find((item) => item.batch_id === batchId);
1140
+ if (!batch)
1141
+ throw new Error(`batch ${batchId} not found, run \`ve-dynamite batches list\``);
1142
+ return batch;
1143
+ }
1144
+ async function jobsCsv(options) {
1145
+ if (Boolean(options.csv) === Boolean(options.jobs))
1146
+ throw new Error("pass exactly one of --csv or --jobs");
1147
+ if (options.csv)
1148
+ return { csv: readText(options.csv), filename: options.csv.endsWith(".csv") ? basename2(options.csv) : "batch.csv" };
1149
+ const jobs = parseJobs(readJobs(options.jobs), batchArticleJob);
1150
+ return { csv: await articleJobsToCsv(jobs, GUIDELINE_HINT).text(), filename: "batch.csv" };
1151
+ }
1152
+ var gbpPostsAsked = (csv) => readCsvTable(csv).rows.filter((row) => TRUTHY.includes((row.get("gbp_post") ?? "").trim().toLowerCase())).length;
1153
+ function batchParams(options) {
1154
+ const params = { batch_name: options.name };
1155
+ if (options.guidelines)
1156
+ params.extra_guidelines = options.guidelines;
1157
+ if (options.imageGuideline)
1158
+ params.image_guideline = options.imageGuideline;
1159
+ if (options.infographic)
1160
+ params.infographic_feat_image = true;
1161
+ if (options.video)
1162
+ params.generate_video = true;
1163
+ Object.assign(params, interactiveFields(options.interactive));
1164
+ if (options.dewatermark !== undefined) {
1165
+ params.dewatermark = options.dewatermark;
1166
+ if (options.dewatermark)
1167
+ err("--dewatermark adds one rewrite call PER ARTICLE and trades some of Claude's voice for hygiene.");
1168
+ }
1169
+ return params;
1170
+ }
1171
+ function registerBatches(program, cli) {
1172
+ const batches = program.command("batches").description("Manage article batches");
1173
+ batches.command("create").description("Queue a batch of articles (async, costs real money PER ARTICLE)").requiredOption("--name <name>", "Batch name (unique per user)").option("--csv <path>", "CSV file of jobs").option("--jobs <path>", "JSON file with an array of job objects").option("--guidelines <text>", "Batch wide extra writing guidelines").option("--image-guideline <text>", "Batch wide featured image guideline").option("--infographic", "Batch wide infographic featured images").option("--video", "Batch wide companion videos").option("--interactive [type]", `Batch wide interactive components, optionally ${INTERACTIVE_TYPES.join("|")}`).option("--dewatermark", "Batch wide prose rewrite through a non-Claude model. Omit to follow DEWATERMARK_REWRITE_ENABLED. A per row dewatermark column still wins").option("--no-dewatermark", "Batch wide: keep the prose as written. A per row dewatermark column still wins").action(async (options) => {
1174
+ const { csv, filename } = await jobsCsv(options);
1175
+ const params = batchParams(options);
1176
+ const res = await cli.client().postForm("content-writing/articles/batch", new Blob([csv], { type: "text/csv" }), filename, params);
1177
+ cli.billed(res.total + gbpPostsAsked(csv));
1178
+ err(`Queued ${res.total} articles, each spends real money (LLM, SERP, crawling, images). Poll with ${cli.follow(`batches watch ${res.batch_id}`, `batches get ${res.batch_id}`)}`);
1179
+ out(res);
1180
+ });
1181
+ batches.command("list").description("List your batches with progress counters").action(async () => out(await cli.client().get("content-writing/batches")));
1182
+ batches.command("get").description("Show one batch with its member articles").argument("<ID>", "Batch id", integer).action(async (batchId) => {
1183
+ const client = cli.client();
1184
+ const batch = await findBatch(client, batchId);
1185
+ out({ ...batch, articles: await everyArticle(client, { batch_id: batchId }) });
1186
+ });
1187
+ if (!cli.mount) {
1188
+ batches.command("watch").description("Poll a batch until every article is completed or failed").argument("<ID>", "Batch id", integer).option("--interval <seconds>", "Seconds between polls", decimal, 10).option("--timeout <seconds>", "Give up after this many seconds", decimal).action(async (batchId, options) => {
1189
+ const client = cli.client();
1190
+ const settled = (batch2) => batch2.completed_articles + batch2.failed_articles;
1191
+ const batch = await watchLoop({
1192
+ interval: options.interval,
1193
+ timeout: options.timeout,
1194
+ poll: () => findBatch(client, batchId),
1195
+ progress: (item) => `[${isoNow()}] completed=${item.completed_articles} failed=${item.failed_articles} total=${item.total_articles}`,
1196
+ done: (item) => settled(item) >= item.total_articles,
1197
+ timedOut: (item) => `timed out after ${fmtNum(options.timeout)}s, batch ${batchId} at ${settled(item)}/${item.total_articles}`
1198
+ });
1199
+ out(batch);
1200
+ if (batch.failed_articles > 0)
1201
+ throw new Exit(1);
1202
+ });
1203
+ }
1204
+ batches.command("delete").description("Delete a batch permanently").argument("<ID>", "Batch id", integer).option("--yes", "Confirm the deletion").action(async (batchId, options) => {
1205
+ if (!options.yes)
1206
+ throw new Error("deleting a batch is permanent, re-run with --yes to confirm");
1207
+ out(await cli.client().delete(`content-writing/batch/${batchId}`));
1208
+ });
1209
+ }
1210
+
1211
+ // src/commands/evals.ts
1212
+ var DEFAULT_THRESHOLD = 7;
1213
+ var ORIGIN_ORDER = ["copy", "inputs", "age", "blocked"];
1214
+ var articleIds = (value, previous = []) => [...previous, integer(value)];
1215
+ function originLine(lost) {
1216
+ const origins = Object.keys(lost);
1217
+ if (origins.length === 0)
1218
+ return "nothing lost";
1219
+ const ordered = [...ORIGIN_ORDER.filter((origin) => (origin in lost)), ...origins.filter((origin) => !ORIGIN_ORDER.includes(origin))];
1220
+ return ordered.map((origin) => `${origin} ${lost[origin]}`).join(", ");
1221
+ }
1222
+ function exitOn(rows, threshold) {
1223
+ const low = rows.filter((row) => row.score !== null && row.score < threshold);
1224
+ if (low.length === 0)
1225
+ return;
1226
+ err(`${low.length} article(s) below ${threshold}`);
1227
+ throw new Exit(1);
1228
+ }
1229
+ function printAudit(res) {
1230
+ const summary = res.summary;
1231
+ line(`${summary.count} article(s) of ${res.total_success} shipped mean ${summary.mean ?? null} range ${summary.min ?? null} to ${summary.max ?? null}` + ` ${summary.below_threshold ?? 0} below ${summary.threshold ?? null}`);
1232
+ line(`points lost by origin: ${originLine(summary.points_lost_by_origin ?? {})}`);
1233
+ line(`compliance mean counting COPY faults only: ${summary.mean_compliance_excluding_non_copy ?? null}`);
1234
+ line("");
1235
+ line(`${"score".padStart(7)} ${"content".padStart(8)} ${"image".padStart(7)} article`);
1236
+ for (const row of res.articles) {
1237
+ line(`${String(row.score).padStart(7)} ${String(row.content_score).padStart(8)} ${String(row.image_score ?? "-").padStart(7)} #${String(row.article_id).padEnd(5)} ${row.title.slice(0, 46)}`);
1238
+ if (Object.keys(row.lost_by_origin).length > 0)
1239
+ line(`${"".padStart(24)} lost: ${originLine(row.lost_by_origin)}`);
1240
+ }
1241
+ }
1242
+ function printPart(name, part) {
1243
+ line(` ${name} ${part.score}/${part.budget}${part.trusted === false ? " (reported, NOT counted in the score)" : ""}`);
1244
+ for (const deduction of part.deductions) {
1245
+ line(` -${String(deduction.points).padEnd(5)} ${deduction.origin.padEnd(7)} ${deduction.code.padEnd(33)} ${deduction.message}`);
1246
+ if (deduction.evidence)
1247
+ line(` evidence: ${deduction.evidence.slice(0, 110)}`);
1248
+ }
1249
+ for (const skipped of part.skipped)
1250
+ line(` skipped: ${skipped}`);
1251
+ }
1252
+ function printReport(articleId, res) {
1253
+ const { content, image } = res.report;
1254
+ const blended = content.score_with_model ?? content.score;
1255
+ const contentColumn = blended !== content.score ? `content ${blended} (code ${content.score})` : `content ${content.score}`;
1256
+ line("");
1257
+ line(`#${articleId} SCORE ${res.score} ${contentColumn} image ${image?.scaled_score ?? "-"}${res.stored ? " [stored]" : ""}`);
1258
+ const verdict = content.judge;
1259
+ if (verdict) {
1260
+ line(` judge ${verdict.total}/${verdict.ceiling} -> ${verdict.score}/10 ${verdict.verdict.toUpperCase()} (${verdict.model})`);
1261
+ for (const [dimension, score] of Object.entries(verdict.scores))
1262
+ line(` ${String(score).padStart(2)}/10 ${dimension}`);
1263
+ for (const violation of verdict.violations.slice(0, 5))
1264
+ line(` ! ${violation.slice(0, 140)}`);
1265
+ for (const fix of verdict.top_fixes)
1266
+ line(` fix: ${fix.slice(0, 130)}`);
1267
+ } else if (content.judge_error) {
1268
+ err(` judge did not run: ${content.judge_error}`);
1269
+ }
1270
+ printPart("compliance", content.compliance);
1271
+ printPart("craft", content.craft);
1272
+ if (image)
1273
+ printPart("image", image);
1274
+ }
1275
+ function registerEvals(program, cli) {
1276
+ const evals = program.command("evals").description("Score finished articles against the deterministic quality checks");
1277
+ evals.command("run").description("Score one or more articles. Free: no model is called.").argument("[ID...]", "Article ids. Omit with --all.", articleIds).option("--all", "Score every shipped article").option("--company <name>", "Only this company's articles (with --all)").option("--no-images", "Skip the per image fetch").option("--judge", "Also run the model judge. Costs a model call per article.").option("--store", "Keep the result on article.qa_report (the default)").option("--no-store", "Score without keeping the result").option("--threshold <score>", "Exit 1 if any article scores below this", decimal, DEFAULT_THRESHOLD).option("--limit <limit>", "Page size for --all", integer, 200).option("--json", "Emit the full report instead of a summary").action(async (ids, options) => {
1278
+ const articles = ids ?? [];
1279
+ if (options.all && articles.length > 0)
1280
+ throw new Error("pass article ids or --all, not both");
1281
+ if (!options.all && articles.length === 0)
1282
+ throw new Error("pass at least one article id, or --all");
1283
+ if (options.company && !options.all)
1284
+ throw new Error("--company only applies with --all");
1285
+ const client = cli.client();
1286
+ err(`scoring against ${cli.baseUrl()}`);
1287
+ if (options.all) {
1288
+ const params = { limit: options.limit, images: options.images ? "true" : "false", company_name: options.company };
1289
+ const res = await client.get("content-writing/articles/qa-audit", params);
1290
+ if (options.json)
1291
+ out(res);
1292
+ else
1293
+ printAudit(res);
1294
+ exitOn(res.articles, options.threshold);
1295
+ return;
1296
+ }
1297
+ const rows = [];
1298
+ try {
1299
+ for (const articleId of articles) {
1300
+ const res = await client.post(`content-writing/article/${articleId}/qa`, undefined, {
1301
+ model_checks: options.judge ? "true" : "false",
1302
+ store: options.store === false ? "false" : "true"
1303
+ });
1304
+ rows.push({ article_id: articleId, score: res.score, report: res.report });
1305
+ if (!options.json)
1306
+ printReport(articleId, res);
1307
+ }
1308
+ } finally {
1309
+ cli.billed(rows.filter((row) => row.report.content.judge).length);
1310
+ }
1311
+ if (options.json)
1312
+ out(rows);
1313
+ exitOn(rows, options.threshold);
1314
+ });
1315
+ }
1316
+
1317
+ // src/commands/gbp.ts
1318
+ import { basename as basename3 } from "node:path";
1319
+ var isTerminal2 = (post) => post.status === "done" || post.status === "failed";
1320
+ function writeBody2(options) {
1321
+ const facts = either(options.facts, options.factsFile, "--facts or --facts-file");
1322
+ const body = { company_profile_id: options.profileId };
1323
+ if (options.topic)
1324
+ body.topic = options.topic;
1325
+ if (options.articleId !== undefined)
1326
+ body.article_id = options.articleId;
1327
+ if (options.location)
1328
+ body.location = options.location;
1329
+ if (facts)
1330
+ body.facts = facts;
1331
+ if (options.guidelines)
1332
+ body.guidelines = options.guidelines;
1333
+ if (options.image)
1334
+ body.image_mode = options.image;
1335
+ if (options.imageUrl)
1336
+ body.image_url = options.imageUrl;
1337
+ if (options.imageGuideline)
1338
+ body.image_guideline = options.imageGuideline;
1339
+ if (options.regenerate)
1340
+ body.regenerate = true;
1341
+ return body;
1342
+ }
1343
+ async function postsCsv(options) {
1344
+ if (Boolean(options.csv) === Boolean(options.jobs))
1345
+ throw new Error("pass exactly one of --csv or --jobs");
1346
+ if (options.csv)
1347
+ return { csv: readText(options.csv), filename: options.csv.endsWith(".csv") ? basename3(options.csv) : "gbp.csv" };
1348
+ return { csv: await gbpJobsToCsv(parseJobs(readJobs(options.jobs), batchGbpJob)).text(), filename: "gbp.csv" };
1349
+ }
1350
+ function batchParams2(options) {
1351
+ const facts = either(options.facts, options.factsFile, "--facts or --facts-file");
1352
+ const params = {};
1353
+ if (options.location)
1354
+ params.location = options.location;
1355
+ if (facts)
1356
+ params.facts = facts;
1357
+ if (options.guidelines)
1358
+ params.guidelines = options.guidelines;
1359
+ if (options.image)
1360
+ params.image_mode = options.image;
1361
+ if (options.imageGuideline)
1362
+ params.image_guideline = options.imageGuideline;
1363
+ return params;
1364
+ }
1365
+ function updateBody2(options) {
1366
+ const content = either(options.content, options.contentFile, "--content or --content-file");
1367
+ const body = {};
1368
+ if (content)
1369
+ body.content = content;
1370
+ if (options.imageUrl)
1371
+ body.image_url = options.imageUrl;
1372
+ if (options.topic)
1373
+ body.topic = options.topic;
1374
+ if (options.status)
1375
+ body.status = wireGbpStatus(options.status);
1376
+ if (Object.keys(body).length === 0)
1377
+ throw new Error("nothing to update");
1378
+ return body;
1379
+ }
1380
+ function registerGbp(program, cli) {
1381
+ const gbp = program.command("gbp").description("Manage Google Business Profile posts");
1382
+ gbp.command("write").description("Queue one GBP post (async, spends an LLM call and, unless --image none, an image credit)").requiredOption("--profile-id <id>", "Company profile id", integer).option("--topic <topic>", "What the post is about. Required unless --article-id is given").option("--article-id <id>", "Repurpose this finished article instead of writing from a topic", integer).option("--location <location>", "Service area to frame the post around").option("--facts <text>", "The only specifics the post may state about this company").option("--facts-file <path>", "Read the facts from a file").option("--guidelines <text>", "A one off instruction for this post").option("--image <mode>", "none | supplied | article | generate. Defaults to article when repurposing and generate otherwise").option("--image-url <url>", "The picture to use. Upload one with `ve-dynamite upload`, or take one from `ve-dynamite gbp image-options`").option("--image-guideline <text>", "Style guidance, for a generated image only").option("--regenerate", "Overwrite the post this article already has").action(async (options) => {
1383
+ const res = await cli.client().post("gbp-posts/", writeBody2(options));
1384
+ cli.billed(1);
1385
+ err(`Poll with ${cli.follow(`gbp watch ${res.gbp_post_id}`, `gbp get ${res.gbp_post_id}`)}`);
1386
+ out(res);
1387
+ });
1388
+ gbp.command("batch").description("Queue a list of GBP posts (async, spends real money PER POST)").option("--csv <path>", "CSV file of posts (columns: company_profile_id, topic, article_id, location, facts, guidelines, image_mode, image_url, image_guideline, regenerate)").option("--jobs <path>", "JSON file with an array of post objects").option("--location <location>", "Batch wide service area").option("--facts <text>", "Batch wide brand facts").option("--facts-file <path>", "Read the batch wide facts from a file").option("--guidelines <text>", "Batch wide writing instruction").option("--image <mode>", "Batch wide image mode: none | supplied | article | generate").option("--image-guideline <text>", "Batch wide image style guidance").action(async (options) => {
1389
+ const { csv, filename } = await postsCsv(options);
1390
+ const params = batchParams2(options);
1391
+ const res = await cli.client().postForm("gbp-posts/batch", new Blob([csv], { type: "text/csv" }), filename, params);
1392
+ cli.billed(res.total);
1393
+ err(`Queued ${res.total} GBP posts, each spends real money. Poll with \`ve-dynamite gbp list\``);
1394
+ out(res);
1395
+ });
1396
+ gbp.command("get").description("Show one GBP post").argument("<ID>", "GBP post id", integer).action(async (postId) => out(await cli.client().get(`gbp-posts/${postId}`)));
1397
+ gbp.command("list").description("List GBP posts, newest first").option("--page <page>", "Page number", integer, 1).option("--limit <limit>", "Page size (max 100)", integer, 10).option("--company <name>", "Filter by exact company name").option("--status <status>", "Filter by status: pending, done, failed").option("--source <source>", "Filter by where the post came from: article or topic").action(async (options) => {
1398
+ const filter = { status: wireGbpStatus(options.status), source: wireGbpSource(options.source) };
1399
+ out(await cli.client().get("gbp-posts/", { page: options.page, limit: options.limit, company_name: options.company, ...filter }));
1400
+ });
1401
+ if (!cli.mount) {
1402
+ gbp.command("watch").description("Poll a GBP post until it is written (done or failed)").argument("<ID>", "GBP post id", integer).option("--interval <seconds>", "Seconds between polls", decimal, 5).option("--timeout <seconds>", "Give up after this many seconds", decimal).action(async (postId, options) => {
1403
+ const client = cli.client();
1404
+ const post = await watchLoop({
1405
+ interval: options.interval,
1406
+ timeout: options.timeout,
1407
+ poll: () => client.get(`gbp-posts/${postId}`),
1408
+ progress: (item) => `[${isoNow()}] status=${item.status} image=${item.image_url ? "yes" : "no"}`,
1409
+ done: isTerminal2,
1410
+ timedOut: (item) => `timed out after ${fmtNum(options.timeout)}s, GBP post ${postId} still ${item.status}`
1411
+ });
1412
+ out(post);
1413
+ if (post.status === "failed")
1414
+ throw new Exit(1);
1415
+ });
1416
+ }
1417
+ gbp.command("image-options").description("Images this client already has, any of which can be passed to --image-url").requiredOption("--profile-id <id>", "Company profile id", integer).action(async (options) => out(await cli.client().get("gbp-posts/image-options", { company_profile_id: options.profileId })));
1418
+ gbp.command("update").description("Edit a written GBP post by hand before it goes live").argument("<ID>", "GBP post id", integer).option("--content <text>", "Replace the post text").option("--content-file <path>", "Read the replacement text from a file").option("--image-url <url>", "Replace the image").option("--topic <topic>", "Correct the recorded topic").option("--status <status>", "Set the status: pending, done, failed").action(async (postId, options) => out(await cli.client().put(`gbp-posts/${postId}`, updateBody2(options))));
1419
+ gbp.command("regenerate").description("Write an existing GBP post again, on the request it already carries").argument("<ID>", "GBP post id", integer).action(async (postId) => {
1420
+ const res = await cli.client().post(`gbp-posts/${postId}/regenerate`);
1421
+ cli.billed(1);
1422
+ err(`Poll with ${cli.follow(`gbp watch ${postId}`, `gbp get ${postId}`)}`);
1423
+ out(res);
1424
+ });
1425
+ gbp.command("delete").description("Delete a GBP post permanently").argument("<ID>", "GBP post id", integer).option("--yes", "Confirm the deletion").action(async (postId, options) => {
1426
+ if (!options.yes)
1427
+ throw new Error("deleting a GBP post is permanent, re-run with --yes to confirm");
1428
+ out(await cli.client().delete(`gbp-posts/${postId}`));
1429
+ });
1430
+ }
1431
+
1432
+ // src/commands/images.ts
1433
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
1434
+ import { basename as basename4 } from "node:path";
1435
+ var FILE_TYPES = ["image", "csv"];
1436
+ var MAX_CACHED_JOBS = 50;
1437
+ function loadJobCache() {
1438
+ try {
1439
+ return JSON.parse(readFileSync3(imageJobsPath(), "utf8"));
1440
+ } catch {
1441
+ return {};
1442
+ }
1443
+ }
1444
+ function cacheJob(key, job) {
1445
+ const cache = loadJobCache();
1446
+ cache[key] = job;
1447
+ const keys = Object.keys(cache);
1448
+ for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_CACHED_JOBS)))
1449
+ delete cache[stale];
1450
+ mkdirSync2(configDir(), { recursive: true, mode: 448 });
1451
+ writeFileSync3(imageJobsPath(), JSON.stringify(cache, null, 2));
1452
+ }
1453
+ function keepJob(key, job) {
1454
+ try {
1455
+ cacheJob(key, job);
1456
+ } catch (error) {
1457
+ err(`could not keep the job in the local cache (${error instanceof Error ? error.message : String(error)}), the server will not show it again`);
1458
+ }
1459
+ }
1460
+ async function fetchStatus(client, articleId, jobId, cached = true) {
1461
+ const key = `${articleId}:${jobId}`;
1462
+ const hit = cached ? loadJobCache()[key] : undefined;
1463
+ if (hit) {
1464
+ err("served from local cache, the server forgets jobs after the first terminal read");
1465
+ return hit;
1466
+ }
1467
+ const job = await client.get(`content-writing/article/${articleId}/edit-feat-image/status/${jobId}`);
1468
+ if (cached && job.status !== "pending")
1469
+ keepJob(key, job);
1470
+ return job;
1471
+ }
1472
+ function registerImages(program, cli) {
1473
+ const images = program.command("images").description("Manage featured image edit jobs");
1474
+ images.command("edit").description("Start a featured image edit job (async, costs real money)").argument("<ARTICLE-ID>", "Article id", integer).requiredOption("--image-url <url>", "URL of the image to edit").requiredOption("--instruction <text>", "Edit instruction").action(async (articleId, options) => {
1475
+ const res = await cli.client().post(`content-writing/article/${articleId}/edit-feat-image`, { image_url: options.imageUrl, instruction: options.instruction });
1476
+ cli.billed(1);
1477
+ err(`Edit job submitted (costs real money). Poll with ${cli.follow(`images watch ${articleId} ${res.job_id}`, `images status ${articleId} ${res.job_id}`)}, the job expires after 1h`);
1478
+ out(res);
1479
+ });
1480
+ images.command("status").description(cli.mount ? "Check an edit job. The server forgets a finished job after the first read that shows it, so keep what this prints" : "Check an edit job (terminal results are cached locally so re-polls never 404)").argument("<ARTICLE-ID>", "Article id", integer).argument("<JOB-ID>", "Edit job id").action(async (articleId, jobId) => {
1481
+ const job = await fetchStatus(cli.client(), articleId, jobId, !cli.mount);
1482
+ if (cli.mount && job.status !== "pending")
1483
+ err("This was the only read of the finished job, the server has now forgotten it, so keep this output");
1484
+ out(job);
1485
+ });
1486
+ if (!cli.mount) {
1487
+ images.command("watch").description("Poll an edit job until it is done or failed").argument("<ARTICLE-ID>", "Article id", integer).argument("<JOB-ID>", "Edit job id").option("--interval <seconds>", "Seconds between polls", decimal, 3).option("--timeout <seconds>", "Give up after this many seconds", decimal).action(async (articleId, jobId, options) => {
1488
+ const client = cli.client();
1489
+ const job = await watchLoop({
1490
+ interval: options.interval,
1491
+ timeout: options.timeout,
1492
+ poll: () => fetchStatus(client, articleId, jobId),
1493
+ progress: (item) => `[${isoNow()}] status=${item.status}`,
1494
+ done: (item) => item.status !== "pending",
1495
+ timedOut: () => `timed out after ${fmtNum(options.timeout)}s, job ${jobId} still pending`
1496
+ });
1497
+ out(job);
1498
+ if (job.status === "failed")
1499
+ throw new Exit(1);
1500
+ });
1501
+ }
1502
+ images.command("commit").description("Persist an edited image as the article's featured image (arbitrary URLs go through `articles update --feat-image`)").argument("<ARTICLE-ID>", "Article id", integer).requiredOption("--image-url <url>", "Image URL from a done edit job in the last 24h").action(async (articleId, options) => {
1503
+ out(await cli.client().post(`content-writing/article/${articleId}/commit-feat-image`, { image_url: options.imageUrl }));
1504
+ });
1505
+ }
1506
+ function registerUpload(program, cli) {
1507
+ program.command("upload").description("Upload a file to the CDN").argument("<FILE>", "The file to upload").requiredOption("--type <type>", "File type: image or csv").action(async (file, options) => {
1508
+ if (!FILE_TYPES.includes(options.type))
1509
+ throw new Error(`invalid type '${options.type}', valid values: ${FILE_TYPES.join(", ")}`);
1510
+ const content = readBytes(file);
1511
+ if (!basename4(file).includes("."))
1512
+ throw new Error("file needs an extension, the CDN key is derived from it");
1513
+ out(await cli.client().postForm("upload/", new Blob([content]), basename4(file), { file_type: options.type }));
1514
+ });
1515
+ }
1516
+
1517
+ // src/commands/landing-pages.ts
1518
+ import { basename as basename5 } from "node:path";
1519
+ import { Option } from "commander";
1520
+ var isTerminal3 = (page) => page.status === "success" || page.status === "failed";
1521
+ var evalOf = (page) => ("eval_status" in page ? page.eval_status : null) ?? "null";
1522
+ var progress = (page) => `[${isoNow()}] status=${page.status} eval=${evalOf(page)}`;
1523
+ var criteriaOf = (page) => ("eval_report" in page ? page.eval_report?.reader_criteria : null) ?? {};
1524
+ function followPage(client, lpId) {
1525
+ return watchLoop({
1526
+ interval: 10,
1527
+ timeout: 900,
1528
+ poll: () => client.get(`landing-pages/${lpId}`),
1529
+ progress,
1530
+ done: isTerminal3,
1531
+ timedOut: (page) => `timed out, landing page ${lpId} still ${page.status}`
1532
+ });
1533
+ }
1534
+ async function writeBody3(client, options) {
1535
+ const guide = either(options.writingGuide, options.writingGuideFile, "--writing-guide or --writing-guide-file");
1536
+ const brief = either(options.brief, options.briefFile, "--brief or --brief-file");
1537
+ const body = { company_profile_id: options.profileId, keyword: options.keyword, deliverable: wireDeliverable(options.deliverable) };
1538
+ if (brief)
1539
+ body.page_brief = brief;
1540
+ if (options.ctaLabel)
1541
+ body.cta_label = options.ctaLabel;
1542
+ if (options.ctaUrl)
1543
+ body.cta_url = options.ctaUrl;
1544
+ if (options.secondaryKeywords) {
1545
+ const keywords = splitCsv(options.secondaryKeywords);
1546
+ if (keywords.length > 2)
1547
+ throw new Error(`secondary keywords max out at 2, got ${keywords.length}`);
1548
+ body.secondary_keywords = keywords;
1549
+ }
1550
+ const intent = { who: options.intentWho, want: options.intentWant, achieve: options.intentAchieve };
1551
+ if (Object.values(intent).some(Boolean)) {
1552
+ if (!Object.values(intent).every(Boolean))
1553
+ throw new Error("pass all three of --intent-who, --intent-want, --intent-achieve or none");
1554
+ body.intent = intent;
1555
+ }
1556
+ if (options.brandFacts)
1557
+ body.brand_facts = options.brandFacts;
1558
+ if (guide)
1559
+ body.writing_guide = guide;
1560
+ if (options.imageUrls)
1561
+ body.image_urls = splitCsv(options.imageUrls);
1562
+ body.generate_images = options.images !== false;
1563
+ if (options.allowedTerms)
1564
+ body.allowed_terms = splitCsv(options.allowedTerms);
1565
+ if (options.goldenUrl)
1566
+ body.golden_url = options.goldenUrl;
1567
+ if (options.socialProof)
1568
+ body.social_proof = { text: options.socialProof, rating: options.socialProofRating ?? null };
1569
+ else if (options.socialProofRating !== undefined)
1570
+ throw new Error("--social-proof-rating needs --social-proof: a rating with no claim beside it is meaningless");
1571
+ if (options.designZip && options.designZipUrl)
1572
+ throw new Error("pass either --design-zip or --design-zip-url, not both");
1573
+ if (options.designZip) {
1574
+ const zip = new Blob([readBytes(options.designZip)], { type: "application/zip" });
1575
+ const stored = await client.postForm("landing-pages/design-zips", zip, basename5(options.designZip));
1576
+ body.design_zip_key = stored.design_zip_key;
1577
+ body.design_zip_name = stored.design_zip_name;
1578
+ } else if (options.designZipUrl) {
1579
+ body.design_zip_url = options.designZipUrl;
1580
+ }
1581
+ return body;
1582
+ }
1583
+ function updateBody3(options) {
1584
+ const payload = {};
1585
+ if (options.slug)
1586
+ payload.slug = options.slug;
1587
+ if (options.titleTag)
1588
+ payload.title_tag = options.titleTag;
1589
+ if (options.metaDescription)
1590
+ payload.meta_description = options.metaDescription;
1591
+ if (options.h1)
1592
+ payload.h1 = options.h1;
1593
+ if (options.ctaLabel)
1594
+ payload.cta_label = options.ctaLabel;
1595
+ if (options.ctaUrl)
1596
+ payload.cta_url = options.ctaUrl;
1597
+ if (options.copyFile)
1598
+ payload.copy_payload = JSON.parse(readText(options.copyFile));
1599
+ if (Object.keys(payload).length === 0) {
1600
+ throw new Error("nothing to update, pass at least one of --slug, --title-tag, --meta-description, --h1, --cta-label, --cta-url, --copy-file");
1601
+ }
1602
+ return payload;
1603
+ }
1604
+ function auditWatch(client, path, params, options, still) {
1605
+ return watchLoop({
1606
+ interval: options.interval,
1607
+ timeout: options.timeout,
1608
+ poll: () => client.get(path, params),
1609
+ progress: (audit) => `[${isoNow()}] pages still ${still}: ${audit.landing_pages.length}`,
1610
+ done: (audit) => audit.landing_pages.length === 0,
1611
+ timedOut: (audit) => `timed out after ${fmtNum(options.timeout)}s, ${audit.landing_pages.length} page(s) still ${still}`
1612
+ });
1613
+ }
1614
+ function writeText(text, output, lpId) {
1615
+ if (!output) {
1616
+ write(text);
1617
+ return;
1618
+ }
1619
+ writeFile(output, text);
1620
+ err(`exported landing page ${lpId} to ${output}`);
1621
+ }
1622
+ function registerLandingPages(program, cli) {
1623
+ const landing = program.command("landing-pages").description("Manage BOFU SEO landing pages");
1624
+ landing.command("write").description("Queue a landing page for generation (async, costs real money per page)").requiredOption("--profile-id <id>", "Company profile id", integer).requiredOption("--keyword <keyword>", "The purchase intent keyword this page targets").option("--brief <text>", "What this page should do, in your own words. The angle, a section it must carry, a client correction that outranks their live site").option("--brief-file <path>", "Read the page brief from a file").option("--deliverable <deliverable>", "copy (a copy deck, the default) | page (also build and host the web page)", "copy").option("--cta-label <label>", "Call to action button label. Left out, the engine writes one from the keyword and the ICP").option("--cta-url <url>", "Call to action destination URL").option("--secondary-keywords <keywords>", "Comma separated secondary keywords (max 2)").option("--intent-who <text>", "Who is searching this keyword (skips the intent LLM step when all three intent flags are set)").option("--intent-want <text>", "What the searcher wants").option("--intent-achieve <text>", "What the searcher is trying to achieve").option("--brand-facts <text>", "Verified brand facts the writer may state").option("--writing-guide <text>", "Brand writing guide (source of truth over generic style rules)").option("--writing-guide-file <path>", "Read the writing guide from a file").option("--image-urls <urls>", "Comma separated image URLs, taking the slots in order. Each is rehosted to the CDN. Remaining slots are filled from the client's design system and their own site").option("--images", "Has no effect: the Worker never generates a landing page image. Kept so older scripts still run").option("--no-images", "Has no effect, see --images").option("--design-zip <path>", "The client's design system zip, for this page only. Stored privately, then the page renders in it. The profile's own design system is used when this is left out").option("--design-zip-url <url>", "An https link the worker downloads the design system zip from").option("--allowed-terms <terms>", "Comma separated banned-list exceptions for this page").option("--golden-url <url>", "URL of a golden reference page for the judge").option("--social-proof <text>", "A real proof line beside the hero CTA, e.g. 'trusted by 400 contractors'").option("--social-proof-rating <rating>", "A real average rating, 1 to 5. Stars render only when this is given", between(1, 5)).addOption(new Option("--page-type <type>", "Has no effect: the Worker chooses the layout. Kept so older scripts still run").hideHelp()).action(async (options) => {
1625
+ if (options.pageType)
1626
+ err("--page-type has no effect, the Worker chooses the layout");
1627
+ const client = cli.client();
1628
+ const res = await client.post("landing-pages/", await writeBody3(client, options));
1629
+ cli.billed(1);
1630
+ err(`Generation is asynchronous and spends real money (LLM, SERP). Poll with ${cli.follow(`landing-pages watch ${res.landing_page_id}`, `landing-pages get ${res.landing_page_id}`)}`);
1631
+ out(res);
1632
+ });
1633
+ landing.command("research").description("Show the SERP research a page's FAQs were chosen from").argument("<ID>", "Landing page id", integer).option("--json", "Emit the raw pool instead of the summary").action(async (lpId, options) => {
1634
+ const page = await cli.client().get(`landing-pages/${lpId}`);
1635
+ const pool = "research_pool" in page ? page.research_pool : null;
1636
+ if (!pool || Object.keys(pool).length === 0) {
1637
+ err(`Landing page ${lpId} carries no research (generated before research was stored, or the SERP pull failed)`);
1638
+ return;
1639
+ }
1640
+ if (options.json) {
1641
+ out(pool);
1642
+ return;
1643
+ }
1644
+ const spend = pool.spend ?? {};
1645
+ err(`keyword: ${pool.keyword} · ${pool.questions?.length ?? 0} questions from ${pool.seeds?.length ?? 0} seed searches · pulled ${pool.fetched_at}`);
1646
+ err(`spend: ${spend.calls ?? 0} billed, ${spend.cached ?? 0} cached, ${spend.skipped ?? 0} skipped, ${spend.failed ?? 0} failed${pool.degraded ? " (DEGRADED)" : ""}`);
1647
+ for (const question of pool.questions ?? []) {
1648
+ const freq = question.freq ?? 1;
1649
+ line(` ${freq > 1 ? `x${freq}` : " "} ${question.q}`);
1650
+ }
1651
+ });
1652
+ landing.command("get").description("Show one landing page (slim {landing_page_id, status} shape until it completes)").argument("<ID>", "Landing page id", integer).action(async (lpId) => out(await cli.client().get(`landing-pages/${lpId}`)));
1653
+ landing.command("list").description("List landing pages, newest first").option("--page <page>", "Page number", integer, 1).option("--limit <limit>", "Page size (max 100)", integer, 10).option("--company <name>", "Filter by exact company name").option("--status <status>", "Filter by status: pending, success, failed").action(async (options) => {
1654
+ const status = wireLandingStatus(options.status);
1655
+ out(await cli.client().get("landing-pages/", { page: options.page, limit: options.limit, company_name: options.company, status }));
1656
+ });
1657
+ if (!cli.mount) {
1658
+ landing.command("watch").description("Poll a landing page until generation is terminal (success or failed)").argument("<ID>", "Landing page id", integer).option("--interval <seconds>", "Seconds between polls", decimal, 10).option("--timeout <seconds>", "Give up after this many seconds", decimal).action(async (lpId, options) => {
1659
+ const client = cli.client();
1660
+ const page = await watchLoop({
1661
+ interval: options.interval,
1662
+ timeout: options.timeout,
1663
+ poll: () => client.get(`landing-pages/${lpId}`),
1664
+ progress,
1665
+ done: isTerminal3,
1666
+ timedOut: (item) => `timed out after ${fmtNum(options.timeout)}s, landing page ${lpId} still ${item.status}`
1667
+ });
1668
+ out(page);
1669
+ if (page.status === "failed")
1670
+ throw new Exit(1);
1671
+ });
1672
+ }
1673
+ landing.command("update").description("Update copy fields and re-render the HTML in place (409 while still generating)").argument("<ID>", "Landing page id", integer).option("--slug <slug>", "New slug").option("--title-tag <text>", "New title tag").option("--meta-description <text>", "New meta description").option("--h1 <text>", "New H1").option("--cta-label <label>", "New CTA label").option("--cta-url <url>", "New CTA URL").option("--copy-file <path>", "Replace the copy deck from a JSON file (the shape returned by export --copy)").action(async (lpId, options) => out(await cli.client().put(`landing-pages/${lpId}`, updateBody3(options))));
1674
+ landing.command("audit").description("List completed pages whose deliverable still fails the image gate or carries placeholder markers").option("--checks <checks>", "Comma separated subset of: images, placeholders", "images,placeholders").option("--limit <limit>", "How many completed pages to scan", integer, 100).action(async (options) => out(await cli.client().get("landing-pages/audit", { checks: options.checks, limit: options.limit })));
1675
+ const fixPlaceholders = landing.command("fix-placeholders").description("Strip unresolved placeholder markers from completed pages and re-render them (deterministic, no LLM, no spend)").option("--dry-run", "Only list the pages carrying placeholder markers, change nothing");
1676
+ if (!cli.mount) {
1677
+ fixPlaceholders.option("--watch", "After fixing, poll the audit until it comes back clean").option("--interval <seconds>", "Seconds between audit polls with --watch", decimal, 15).option("--timeout <seconds>", "Give up polling after this many seconds with --watch", decimal, 300);
1678
+ }
1679
+ fixPlaceholders.action(async (options) => {
1680
+ const client = cli.client();
1681
+ const params = { checks: "placeholders", limit: 100 };
1682
+ const audit = await client.get("landing-pages/audit", params);
1683
+ if (audit.landing_pages.length === 0) {
1684
+ err("All completed landing pages are free of placeholder markers. Nothing to fix.");
1685
+ out(audit);
1686
+ return;
1687
+ }
1688
+ err(`${audit.landing_pages.length} landing page(s) carry unresolved placeholder markers:`);
1689
+ for (const item of audit.landing_pages)
1690
+ err(` #${item.landing_page_id} [${item.company_name}] ${item.keyword} (${item.placeholder_failures.length} marker(s))`);
1691
+ if (options.dryRun) {
1692
+ out(audit);
1693
+ return;
1694
+ }
1695
+ const fixed = [];
1696
+ for (const item of audit.landing_pages) {
1697
+ const res = await client.post(`landing-pages/${item.landing_page_id}/fix-placeholders`);
1698
+ fixed.push(res);
1699
+ err(`cleaned #${item.landing_page_id}: ${res.removed?.length ?? 0} unit(s) rewritten or dropped`);
1700
+ }
1701
+ if (!options.watch) {
1702
+ out({ fixed });
1703
+ return;
1704
+ }
1705
+ out(await auditWatch(client, "landing-pages/audit", params, options, "carrying markers"));
1706
+ });
1707
+ landing.command("rebrand").description("Re-render completed pages in their company's current brand (deterministic, no LLM, no spend)").argument("[ID]", "One landing page. Omit and pass --company to do a whole company", integer).option("--company <name>", "Rebrand every completed page for this exact company name").option("--limit <limit>", "Page size when listing a company's pages", integer, 100).option("--dry-run", "List what would be re-rendered, change nothing").action(async (lpId, options) => {
1708
+ if (lpId === undefined && !options.company) {
1709
+ err("Pass a landing page ID or --company.");
1710
+ throw new Exit(1);
1711
+ }
1712
+ const client = cli.client();
1713
+ let targets;
1714
+ if (lpId !== undefined) {
1715
+ targets = [{ landing_page_id: lpId, keyword: "", company_name: options.company ?? "" }];
1716
+ } else {
1717
+ const listing = await client.get("landing-pages/", { page: 1, limit: options.limit, company_name: options.company, status: "success" });
1718
+ targets = listing.landing_pages.filter((page) => page.status === "success").map((page) => ({ landing_page_id: page.landing_page_id, keyword: page.keyword ?? "", company_name: options.company }));
1719
+ if (targets.length === 0) {
1720
+ err(`No completed landing pages found for '${options.company}'.`);
1721
+ throw new Exit(1);
1722
+ }
1723
+ err(`${targets.length} completed page(s) for ${options.company}:`);
1724
+ for (const item of targets)
1725
+ err(` #${item.landing_page_id} ${item.keyword}`);
1726
+ }
1727
+ if (options.dryRun) {
1728
+ out({ would_rebrand: targets });
1729
+ return;
1730
+ }
1731
+ const rebranded = [];
1732
+ for (const item of targets) {
1733
+ const res = await client.post(`landing-pages/${item.landing_page_id}/rebrand`);
1734
+ rebranded.push(res);
1735
+ err(`#${item.landing_page_id}: ${res.msg}`);
1736
+ }
1737
+ out({ rebranded });
1738
+ });
1739
+ const fixImages = landing.command("fix-images").description("Find completed pages whose images fail the CDN gate and queue real image regeneration for each (spends real money per page)").option("--dry-run", "Only list the pages with placeholder or non CDN images, queue nothing");
1740
+ if (!cli.mount) {
1741
+ fixImages.option("--watch", "After queueing, poll the audit until it comes back clean").option("--interval <seconds>", "Seconds between audit polls with --watch", decimal, 30).option("--timeout <seconds>", "Give up polling after this many seconds with --watch", decimal, 900);
1742
+ }
1743
+ fixImages.action(async (options) => {
1744
+ const client = cli.client();
1745
+ const params = { limit: 100 };
1746
+ const audit = await client.get("landing-pages/image-audit", params);
1747
+ if (audit.landing_pages.length === 0) {
1748
+ err("All completed landing pages pass the image gate. Nothing to fix.");
1749
+ out(audit);
1750
+ return;
1751
+ }
1752
+ err(`${audit.landing_pages.length} landing page(s) contain placeholder or non CDN images:`);
1753
+ for (const item of audit.landing_pages)
1754
+ err(` #${item.landing_page_id} [${item.company_name}] ${item.keyword} (${item.image_failures.length} failure(s))`);
1755
+ if (options.dryRun) {
1756
+ out(audit);
1757
+ return;
1758
+ }
1759
+ const queued = [];
1760
+ try {
1761
+ for (const item of audit.landing_pages) {
1762
+ queued.push(await client.post(`landing-pages/${item.landing_page_id}/regenerate-images`));
1763
+ err(`queued image regeneration for #${item.landing_page_id}`);
1764
+ }
1765
+ } finally {
1766
+ cli.billed(queued.length);
1767
+ }
1768
+ if (!options.watch) {
1769
+ out({ queued });
1770
+ return;
1771
+ }
1772
+ out(await auditWatch(client, "landing-pages/image-audit", params, options, "failing the image gate"));
1773
+ });
1774
+ landing.command("delete").description("Delete a landing page permanently").argument("<ID>", "Landing page id", integer).option("--yes", "Confirm the deletion").action(async (lpId, options) => {
1775
+ if (!options.yes)
1776
+ throw new Error("deleting a landing page is permanent, re-run with --yes to confirm");
1777
+ out(await cli.client().delete(`landing-pages/${lpId}`));
1778
+ });
1779
+ landing.command("export").description("Export a completed landing page as a copy deck, as HTML, or as its JSON copy deck").argument("<ID>", "Landing page id", integer).option("--deck", "Export the copy deck as markdown, the shape that goes into the Google Doc").option("--html", "Export the rendered standalone HTML with CDN image URLs (page deliverable only; refused while the page still has placeholder images or placeholder markers in the copy)").option("--copy", "Export the structured copy deck as JSON").option("--allow-placeholders", "Export even when the page still contains placeholder images or unresolved placeholder markers").option("--inline", "Inline every image as a base64 data URI for a fully self contained file").option("-o, --output <path>", "Write to a file instead of stdout").action(async (lpId, options) => {
1780
+ if ([options.deck, options.html, options.copy].filter(Boolean).length !== 1)
1781
+ throw new Error("pass exactly one of --deck, --html or --copy");
1782
+ const client = cli.client();
1783
+ if (options.deck || options.html) {
1784
+ const text = await client.getText(`landing-pages/${lpId}/export`, {
1785
+ format: options.deck ? "deck_md" : "html",
1786
+ allow_placeholders: options.allowPlaceholders ? "true" : undefined,
1787
+ inline: options.inline ? "true" : undefined
1788
+ });
1789
+ writeText(text, options.output, lpId);
1790
+ return;
1791
+ }
1792
+ const page = await client.get(`landing-pages/${lpId}`);
1793
+ if (page.status !== "success" || !("copy_payload" in page))
1794
+ throw new Error(`landing page ${lpId} is still ${page.status}, nothing to export yet`);
1795
+ writeText(JSON.stringify(page.copy_payload, null, 2), options.output, lpId);
1796
+ });
1797
+ const reiterate = landing.command("reiterate").summary("Check a page against what a buyer needs to see, and fix what a source can carry").description(`Check a page against what a buyer needs to see, and fix what a source can carry
1798
+
1799
+ ` + "The second half of the SOP. Ask in a SEPARATE chat, as the customer who typed this keyword and who knows nothing about this company, " + `what would have to be on the page for them to believe it. Pass that answer here and it wins over a generated one.
1800
+
1801
+ ` + "Anything the checklist asks for that no source supports is not written around: it lands in missing_facts as something to get from the client.").argument("<ID>", "Landing page id", integer).option("--criteria <text>", "What a buyer told you they need to see, in their words").option("--criteria-file <path>", "Read that answer from a file").option("--rescore", "Also pay the reviewer to score the page again afterwards");
1802
+ if (!cli.mount)
1803
+ reiterate.option("--watch", "Follow until the page is saved");
1804
+ reiterate.action(async (lpId, options) => {
1805
+ const text = either(options.criteria, options.criteriaFile, "--criteria or --criteria-file");
1806
+ const body = { rescore: Boolean(options.rescore) };
1807
+ if (text)
1808
+ body.criteria_text = text;
1809
+ const client = cli.client();
1810
+ const res = await client.post(`landing-pages/${lpId}/re-iterate`, body);
1811
+ cli.billed(1);
1812
+ err(`Using ${res.criteria_source} criteria. This costs a couple of model calls and one writer pass.`);
1813
+ if (!options.watch) {
1814
+ out(res);
1815
+ return;
1816
+ }
1817
+ const final = await followPage(client, lpId);
1818
+ const report = criteriaOf(final);
1819
+ if (report.total)
1820
+ err(`${report.met ?? 0} of ${report.total} criteria met`);
1821
+ if (report.research?.added?.length)
1822
+ err(` found ${report.research.added.length} new source(s) for the writer to cite`);
1823
+ for (const gap of report.unmet_unsupported ?? [])
1824
+ err(` needs the client: ${gap}`);
1825
+ out(final);
1826
+ });
1827
+ landing.command("criteria").summary("The buyer's checklist for a page, and setting a line aside").description(`The buyer's checklist for a page, and setting a line aside
1828
+
1829
+ ` + "With no option, prints every line with its status. A set aside line stops counting: it leaves the score, the writer's brief, the client asks and the pass decision. " + "It never leaves the list. Free, synchronous, no model.").argument("<ID>", "Landing page id", integer).option("--set-aside <criterion>", "Set this line aside as not needed (r1, r2, ...)").option("--restore <criterion>", "Bring a set aside line back").option("--reason <text>", "Why it is not needed, shown beside the line", "").action(async (lpId, options) => {
1830
+ if (options.setAside && options.restore)
1831
+ throw new Error("pass either --set-aside or --restore, not both");
1832
+ const client = cli.client();
1833
+ const criterion = options.setAside ?? options.restore;
1834
+ if (criterion) {
1835
+ const res = await client.patch(`landing-pages/${lpId}/criteria/${encodeURIComponent(criterion)}`, { dismissed: Boolean(options.setAside), reason: options.reason || null });
1836
+ err(`${res.msg}. Now ${res.met} of ${res.total}, page ${res.eval_status}.`);
1837
+ out(res);
1838
+ return;
1839
+ }
1840
+ const report = criteriaOf(await client.get(`landing-pages/${lpId}`));
1841
+ if (!report.criteria?.length)
1842
+ throw new Error(`landing page ${lpId} has no buyer checklist yet`);
1843
+ const verdicts = new Map((report.coverage ?? []).map((verdict) => [verdict.id, verdict]));
1844
+ const aside = new Set(report.dismissed_ids ?? []);
1845
+ err(`${report.met ?? 0} of ${report.total ?? 0} met, ${aside.size} set aside`);
1846
+ for (const item of report.criteria) {
1847
+ const mark = aside.has(item.id) ? "set aside" : verdicts.get(item.id)?.status || "missing";
1848
+ const flag = item.deal_breaker && mark !== "met" && mark !== "set aside" ? " (they would leave without this)" : "";
1849
+ err(` [${item.id}] ${mark}: ${item.need}${flag}`);
1850
+ }
1851
+ out(report);
1852
+ });
1853
+ landing.command("versions").description("Every take on this keyword, oldest first").argument("<ID>", "Landing page id", integer).action(async (lpId) => out(await cli.client().get(`landing-pages/${lpId}/versions`)));
1854
+ const newVersion = landing.command("new-version").summary("Write another version of a page, inheriting everything the first run was given").description(`Write another version of a page, inheriting everything the first run was given
1855
+
1856
+ ` + "Costs what a normal run costs, because it is one. Send only what should change.").argument("<ID>", "Landing page id", integer).option("--brief <text>", "A different angle. Everything else is inherited").option("--brief-file <path>", "Read the brief from a file").option("--keyword <keyword>", "A different search phrase").option("--criteria-file <path>", "A buyer's checklist to build to from the start");
1857
+ if (!cli.mount)
1858
+ newVersion.option("--watch", "Follow until the new version is saved");
1859
+ newVersion.action(async (lpId, options) => {
1860
+ const brief = either(options.brief, options.briefFile, "--brief or --brief-file");
1861
+ const body = {};
1862
+ if (brief)
1863
+ body.page_brief = brief;
1864
+ if (options.keyword)
1865
+ body.keyword = options.keyword;
1866
+ if (options.criteriaFile)
1867
+ body.reader_criteria = readText(options.criteriaFile);
1868
+ const client = cli.client();
1869
+ const res = await client.post(`landing-pages/${lpId}/versions`, body);
1870
+ cli.billed(1);
1871
+ err(`Version ${res.version} queued as landing page ${res.landing_page_id}. This spends real money.`);
1872
+ out(options.watch ? await followPage(client, res.landing_page_id) : res);
1873
+ });
1874
+ landing.command("deck").summary("The copy deck as markdown, in the house Google Doc template").description(`The copy deck as markdown, in the house Google Doc template
1875
+
1876
+ ` + "The same document `export --deck` builds, so what you paste and what you download are the same thing. " + "Paste it into Drive as text/markdown and it becomes a Google Doc.").argument("<ID>", "Landing page id", integer).option("-o, --output <path>", "Write it to a file instead of stdout").action(async (lpId, options) => {
1877
+ const text = await cli.client().getText(`landing-pages/${lpId}/deck`);
1878
+ if (!options.output) {
1879
+ line(text);
1880
+ return;
1881
+ }
1882
+ writeFile(options.output, text);
1883
+ err(`Wrote ${options.output}`);
1884
+ });
1885
+ }
1886
+
1887
+ // src/commands/profiles.ts
1888
+ import { basename as basename6, extname } from "node:path";
1889
+ var stripScheme = (url) => url.replace(/^https?:\/\//i, "");
1890
+ var flagOf = (field) => `--${field.replaceAll("_", "-")}`;
1891
+ var optionKeyOf = (field) => field.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
1892
+ var sectionLabel = (field) => `${field.charAt(0).toUpperCase()}${field.slice(1).replaceAll("_", " ")} section`;
1893
+ async function findProfile(client, profileId) {
1894
+ const profile = (await client.get("company-profile/")).find((item) => item.id === profileId);
1895
+ if (!profile)
1896
+ throw new Error(`profile ${profileId} not found, run \`ve-dynamite profiles list\``);
1897
+ return profile;
1898
+ }
1899
+ function parseJson(value) {
1900
+ try {
1901
+ return JSON.parse(value);
1902
+ } catch {
1903
+ return value;
1904
+ }
1905
+ }
1906
+ function parseIcp(icp) {
1907
+ let sections;
1908
+ try {
1909
+ sections = JSON.parse(icp);
1910
+ } catch {
1911
+ return null;
1912
+ }
1913
+ if (sections === null || typeof sections !== "object" || Array.isArray(sections))
1914
+ return null;
1915
+ return Object.fromEntries(Object.entries(sections).map(([key, value]) => [key, typeof value === "string" ? parseJson(value) : value]));
1916
+ }
1917
+ function profileBody(options) {
1918
+ const body = {};
1919
+ if (options.name)
1920
+ body.name = options.name;
1921
+ if (options.website)
1922
+ body.website_url = stripScheme(options.website);
1923
+ if (options.sitemap)
1924
+ body.sitemap_url = stripScheme(options.sitemap);
1925
+ if (options.writingGuide)
1926
+ body.writing_guide = options.writingGuide;
1927
+ return body;
1928
+ }
1929
+ function registerProfiles(program, cli) {
1930
+ const profiles = program.command("profiles").description("Manage company profiles");
1931
+ profiles.command("create").description("Create a company profile (also starts async ICP generation, which costs money)").requiredOption("--name <name>", "Company name").requiredOption("--website <host>", "Company website, bare host like example.com (scheme is stripped)").option("--sitemap <host>", "Sitemap URL, bare host form").option("--writing-guide <text>", "Writing style guide").action(async (options) => {
1932
+ const res = await cli.client().post("company-profile/create", profileBody(options));
1933
+ cli.billed(1);
1934
+ err("ICP generation started in the background, check progress with `ve-dynamite icp show <id>`");
1935
+ out(res);
1936
+ });
1937
+ profiles.command("list").description("List all company profiles").action(async () => out(await cli.client().get("company-profile/")));
1938
+ profiles.command("get").description("Show one company profile").argument("<ID>", "Company profile id", integer).action(async (profileId) => out(await findProfile(cli.client(), profileId)));
1939
+ profiles.command("update").description("Update profile fields (only the flags you pass are changed)").argument("<ID>", "Company profile id", integer).option("--name <name>", "New company name").option("--website <host>", "New website, bare host form (scheme is stripped)").option("--sitemap <host>", "New sitemap URL").option("--writing-guide <text>", "New writing style guide").action(async (profileId, options) => {
1940
+ const body = profileBody(options);
1941
+ if (Object.keys(body).length === 0)
1942
+ throw new Error("nothing to update, pass at least one of --name, --website, --sitemap, --writing-guide");
1943
+ out(await cli.client().put(`company-profile/${profileId}`, body));
1944
+ });
1945
+ profiles.command("delete").description("Delete a profile and everything under it (articles, ICP, categories)").argument("<ID>", "Company profile id", integer).option("--yes", "Confirm the deletion").action(async (profileId, options) => {
1946
+ if (!options.yes)
1947
+ throw new Error("deleting a profile also deletes its articles, re-run with --yes to confirm");
1948
+ out(await cli.client().delete(`company-profile/${profileId}`));
1949
+ });
1950
+ const icp = program.command("icp").description("Manage a profile's ICP (ideal customer profile)");
1951
+ icp.command("show").description("Show the ICP, or its generation status when not ready").argument("<PROFILE-ID>", "Company profile id", integer).action(async (profileId) => {
1952
+ const profile = await findProfile(cli.client(), profileId);
1953
+ const parsed = parseIcp(profile.icp);
1954
+ out(parsed && Object.keys(parsed).length > 0 ? parsed : { status: profile.icp });
1955
+ });
1956
+ documentOutput(icp.command("export").description("Download a profile's ICP as a document (docx imports straight into Google Docs)").argument("<PROFILE-ID>", "Company profile id", integer).option("--format <format>", "docx (opens in Google Docs) or md", "docx"), cli).action(async (profileId, options) => {
1957
+ const target = await downloadDocument(cli, `company-profile/${profileId}/icp/export`, options.format, options.output, `company-${profileId}-icp`);
1958
+ err(`exported the ICP for profile ${profileId} to ${target}`);
1959
+ });
1960
+ icp.command("regenerate").description("Regenerate the ICP from the website (async, costs money)").argument("<PROFILE-ID>", "Company profile id", integer).action(async (profileId) => {
1961
+ const res = await cli.client().post(`company-profile/${profileId}/regenerate-icp`);
1962
+ cli.billed(1);
1963
+ err("ICP regeneration started in the background, check progress with `ve-dynamite icp show <id>`");
1964
+ out(res);
1965
+ });
1966
+ const updateIcp = icp.command("update").description("Edit ICP sections directly (only the flags you pass are changed)").argument("<PROFILE-ID>", "Company profile id", integer);
1967
+ for (const section of ICP_SECTIONS)
1968
+ updateIcp.option(`${flagOf(section)} <text>`, sectionLabel(section));
1969
+ updateIcp.action(async (profileId, options) => {
1970
+ const body = Object.fromEntries(ICP_SECTIONS.flatMap((section) => options[optionKeyOf(section)] === undefined ? [] : [[section, options[optionKeyOf(section)]]]));
1971
+ if (Object.keys(body).length === 0)
1972
+ throw new Error(`nothing to update, pass at least one of ${ICP_SECTIONS.map(flagOf).join(", ")}`);
1973
+ out(await cli.client().put(`company-profile/${profileId}/icp`, body));
1974
+ });
1975
+ const categories = program.command("categories").description("Manage a profile's article categories");
1976
+ categories.command("list").description("List categories with article counts").argument("<PROFILE-ID>", "Company profile id", integer).action(async (profileId) => out(await cli.client().get(`company-profile/${profileId}/categories`)));
1977
+ categories.command("add").description("Add one or more categories (no commas in names)").argument("<PROFILE-ID>", "Company profile id", integer).argument("<NAMES...>", "Category names").action(async (profileId, names) => out(await cli.client().post(`company-profile/${profileId}/categories`, { categories: names })));
1978
+ const design = program.command("design-system").description("The client's own design system: the colours, type and photography every page for them renders in");
1979
+ design.command("show").description("Show the stored design system: its tokens and every image with the alt written for it").argument("<PROFILE-ID>", "Company profile id", integer).action(async (profileId) => out(await cli.client().get(`company-profile/${profileId}/design-system`)));
1980
+ design.command("set").description("Upload a design system zip and read it (private storage, one mapping call, one vision call per image)").argument("<PROFILE-ID>", "Company profile id", integer).argument("<ZIP>", "The design system zip the client's designer exported").action(async (profileId, zipPath) => {
1981
+ if (extname(zipPath).toLowerCase() !== ".zip")
1982
+ throw new Error("a design system is uploaded as a zip archive");
1983
+ const zip = new Blob([readBytes(zipPath)], { type: "application/zip" });
1984
+ err("Reading the zip: publishing its fonts and images, mapping its tokens and describing each image. This takes a moment and spends a little.");
1985
+ const res = await cli.client().postForm(`company-profile/${profileId}/design-system`, zip, basename6(zipPath));
1986
+ cli.billed(1);
1987
+ out(res);
1988
+ });
1989
+ design.command("rescan").description("Re-read the stored zip, for when the token mapping or an image description was wrong").argument("<PROFILE-ID>", "Company profile id", integer).action(async (profileId) => {
1990
+ const res = await cli.client().post(`company-profile/${profileId}/design-system/rescan`);
1991
+ cli.billed(1);
1992
+ out(res);
1993
+ });
1994
+ design.command("clear").description("Remove the design system. Later pages fall back to the brand kit scraped from the client's site").argument("<PROFILE-ID>", "Company profile id", integer).action(async (profileId) => out(await cli.client().delete(`company-profile/${profileId}/design-system`)));
1995
+ }
1996
+
1997
+ // src/commands/revisions.ts
1998
+ import { Option as Option2 } from "commander";
1999
+ var REJECT_HELP = [
2000
+ "Reject one part of a finished article because a writer called it bad, and have it made again. Asynchronous and spends real money: the writer model, plus fal for an image or the renderer for a video.",
2001
+ "--index is required for a section or an image, optional for the FAQ and ignored otherwise. `ve-dynamite articles parts <id>` lists the indexes.",
2002
+ "For a section, --target picks what to remake: section (the default, the whole section), heading (only the ## line, the text under it stays) or passage (only --text, which must be copied character for character from the article's body markdown, appear once in that section and hold whole links and images).",
2003
+ "For the intro, leave --target out to remake all of it, or use passage with --text copied the same way from the intro.",
2004
+ "For the FAQ, leave --index out to remake all of it. With an item's index, --target picks answer (the default, the question stays), question (only the question) or passage (--text copied from that item's answer in the FAQ HTML, holding whole tags).",
2005
+ "--reason is the writer's own words about what is wrong. It briefs the rebuild, so pass it verbatim. The article is not changed: the replacement waits beside the refused part until a verdict."
2006
+ ].join(`
2007
+
2008
+ `);
2009
+ var VERDICT_HELP = [
2010
+ "Record the writer's verdict on a ready revision: better, same or worse.",
2011
+ "Pass only what the writer said: the verdict is the record of what a person calls bad. better puts the replacement into the article, unless the article changed since the rejection (applied comes back false). same and worse keep the original. One verdict per revision; to try again, reject the part again."
2012
+ ].join(`
2013
+
2014
+ `);
2015
+ function registerArticleRevisions(articles, cli) {
2016
+ articles.command("parts").description("List the parts of a finished article a writer can reject, with the indexes reject takes").argument("<ID>", "Article id", integer).action(async (articleId) => out(await cli.client().get(`content-writing/article/${articleId}/parts`)));
2017
+ articles.command("reject").summary("Reject one part of a finished article and have it remade (async, spends real money)").description(REJECT_HELP).argument("<ID>", "Article id", integer).addOption(new Option2("--part <part>", "The part the writer called bad").choices(REVISION_PARTS).makeOptionMandatory()).option("--index <n>", "Which section, image or FAQ item, counting from 0", integer).addOption(new Option2("--target <target>", "What to remake inside a section, the intro or an FAQ item").choices(REVISION_TARGETS)).option("--text <passage>", "With --target passage: the passage, copied character for character").option("--text-file <path>", "Read the passage from a file").option("--reason <text>", "The writer's own words about what is wrong").option("--reason-file <path>", "Read the reason from a file").action(async (articleId, options) => {
2018
+ const text = either(options.text, options.textFile, "--text or --text-file");
2019
+ const reason = either(options.reason, options.reasonFile, "--reason or --reason-file");
2020
+ const body = { part: options.part };
2021
+ if (options.index !== undefined)
2022
+ body.index = options.index;
2023
+ if (options.target)
2024
+ body.target = options.target;
2025
+ if (text)
2026
+ body.text = text;
2027
+ if (reason)
2028
+ body.reason = reason;
2029
+ const revision = await cli.client().post(`content-writing/article/${articleId}/revisions`, body);
2030
+ cli.billed(1);
2031
+ err(`The ${options.part} is being remade, which spends real money. The article stays as it is until a verdict. ` + `Poll with ${cli.follow(`revisions watch ${articleId} ${revision.revision_id}`, `revisions get ${articleId} ${revision.revision_id}`)}`);
2032
+ out(revision);
2033
+ });
2034
+ articles.command("revisions").description("Every revision of one article, newest first").argument("<ID>", "Article id", integer).action(async (articleId) => out(await cli.client().get(`content-writing/article/${articleId}/revisions`)));
2035
+ }
2036
+ function registerRevisions(program, cli) {
2037
+ const revisions = program.command("revisions").description("Parts of articles writers rejected, the replacements and the verdicts");
2038
+ revisions.command("list").summary("List every revision you can see, newest first").description("Every revision you can see, newest first: what writers rejected and whether the replacement was better. An admin sees every account's").addOption(new Option2("--part <part>", "Only this part").choices(REVISION_PARTS)).addOption(new Option2("--target <target>", "Only this target").choices(REVISION_TARGETS)).addOption(new Option2("--status <status>", "Only this status").choices(REVISION_STATUSES)).addOption(new Option2("--verdict <verdict>", "Only this verdict").choices(REVISION_VERDICTS)).option("--profile-id <id>", "Only revisions of this company profile's articles", integer).option("--page <page>", "Page number", integer, 1).option("--limit <limit>", "Page size (max 100)", integer, 20).action(async (options) => {
2039
+ const { page, limit, part, target, status, verdict } = options;
2040
+ out(await cli.client().get("content-writing/revisions", { page, limit, part, target, status, verdict, company_profile_id: options.profileId }));
2041
+ });
2042
+ revisions.command("get").summary("Show one revision: both versions, their scores and the verdict").description("One revision: the refused part, the replacement once it is ready, the article's score with each, both score reports and the verdict").argument("<ARTICLE-ID>", "Article id", integer).argument("<REVISION-ID>", "Revision id", integer).action(async (articleId, revisionId) => out(await cli.client().get(`content-writing/article/${articleId}/revisions/${revisionId}`)));
2043
+ if (!cli.mount) {
2044
+ revisions.command("watch").description("Poll a revision until the replacement is ready or the remake failed").argument("<ARTICLE-ID>", "Article id", integer).argument("<REVISION-ID>", "Revision id", integer).option("--interval <seconds>", "Seconds between polls", decimal, 10).option("--timeout <seconds>", "Give up after this many seconds", decimal).action(async (articleId, revisionId, options) => {
2045
+ const client = cli.client();
2046
+ const revision = await watchLoop({
2047
+ interval: options.interval,
2048
+ timeout: options.timeout,
2049
+ poll: () => client.get(`content-writing/article/${articleId}/revisions/${revisionId}`),
2050
+ progress: (item) => `[${isoNow()}] status=${item.status}`,
2051
+ done: (item) => item.status !== "pending",
2052
+ timedOut: (item) => `timed out after ${fmtNum(options.timeout)}s, revision ${revisionId} still ${item.status}`
2053
+ });
2054
+ out(revision);
2055
+ if (revision.status === "failed")
2056
+ throw new Exit(1);
2057
+ });
2058
+ }
2059
+ revisions.command("verdict").summary("Record the writer's verdict on a ready revision: better, same or worse").description(VERDICT_HELP).argument("<ARTICLE-ID>", "Article id", integer).argument("<REVISION-ID>", "Revision id", integer).addOption(new Option2("--verdict <verdict>", "The writer's verdict").choices(REVISION_VERDICTS).makeOptionMandatory()).option("--note <text>", "The writer's note on the verdict").option("--note-file <path>", "Read the note from a file").action(async (articleId, revisionId, options) => {
2060
+ const note = either(options.note, options.noteFile, "--note or --note-file");
2061
+ const res = await cli.client().post(`content-writing/article/${articleId}/revisions/${revisionId}/verdict`, { verdict: options.verdict, note: note ?? null });
2062
+ err(res.msg);
2063
+ out(res);
2064
+ });
2065
+ }
2066
+
2067
+ // src/commands/tokens.ts
2068
+ function registerTokens(program, cli) {
2069
+ const tokens = program.command("tokens").description("Manage long lived API tokens for MCP and CI usage");
2070
+ tokens.command("create").description("Create a long lived API token (the raw token is shown once, store it now)").requiredOption("--name <name>", "Token label, e.g. claude-mcp").option("--expires-days <days>", "Days until the token expires (omit for a token that never expires)", integer).action(async (options) => {
2071
+ const body = { name: options.name };
2072
+ if (options.expiresDays !== undefined)
2073
+ body.expires_in_days = options.expiresDays;
2074
+ const res = await cli.client().post("api-tokens/", body);
2075
+ err(cli.mount ? "✓ Token created. Its token field is the only time it is shown, store it now" : `✓ Token created. This is the only time it is shown, store it now:
2076
+
2077
+ ${res.token}
2078
+ `);
2079
+ out(res);
2080
+ });
2081
+ tokens.command("list").description("List your API tokens (masked, includes revoked ones)").action(async () => out(await cli.client().get("api-tokens/")));
2082
+ tokens.command("revoke").description("Revoke an API token by id").argument("<ID>", "Token id", integer).action(async (tokenId) => out(await cli.client().delete(`api-tokens/${tokenId}`)));
2083
+ if (cli.mount)
2084
+ return;
2085
+ tokens.command("use").description("Store a ved_ API token as the local credential (replaces the stored login)").argument("<TOKEN>", "The ved_ token").action(async (token) => {
2086
+ if (!isPat(token))
2087
+ throw new Error("not an API token, expected a ved_ prefix; for a password login run `ve-dynamite login`");
2088
+ const me = await signedIn(new ApiClient(cli.baseUrl(), token), "token rejected by the API, it may be revoked or expired");
2089
+ saveCredentials({ authToken: token, email: me.email, username: me.username, profile: me.profile, expiresAt: null });
2090
+ err(`✓ API token for ${me.username} stored, it will be used for all commands until you run \`login\` or \`logout\``);
2091
+ });
2092
+ }
2093
+
2094
+ // src/commands/users.ts
2095
+ import { Option as Option3 } from "commander";
2096
+ function registerUsers(program, cli) {
2097
+ const users = program.command("users").description("Every account in the system and its role (admins only)");
2098
+ users.command("list").description("List accounts, newest first, with their roles").addOption(new Option3("--role <role>", "Only accounts with this role").choices(USER_ROLES)).option("--page <page>", "Page number", integer, 1).option("--limit <limit>", "Page size (max 100)", integer, 20).action(async (options) => {
2099
+ out(await cli.client().get("users/", { page: options.page, limit: options.limit, role: options.role }));
2100
+ });
2101
+ users.command("set-role").description("Make another account an admin or a normal user. Nobody can change their own role, so an admin always remains").argument("<USER-ID>", "Account id", integer).addOption(new Option3("--role <role>", "The new role").choices(USER_ROLES).makeOptionMandatory()).action(async (userId, options) => {
2102
+ const res = await cli.client().patch(`users/${userId}/role`, { role: options.role });
2103
+ err(res.msg);
2104
+ out(res);
2105
+ });
2106
+ }
2107
+
2108
+ // src/context.ts
2109
+ function cliFor(program, { mount = false, reportCost }) {
2110
+ const flags = () => program.opts();
2111
+ const cli = {
2112
+ mount,
2113
+ baseUrl: () => flags().url || defaultApiUrl(),
2114
+ token: () => resolveToken(flags().token, !mount),
2115
+ client: () => new ApiClient(cli.baseUrl(), cli.token().token),
2116
+ follow: (watch, get) => `\`ve-dynamite ${mount ? get : watch}\``,
2117
+ billed: (units) => {
2118
+ if (units > 0)
2119
+ reportCost?.({ provider: "contentdynamite", units });
2120
+ }
2121
+ };
2122
+ return cli;
2123
+ }
2124
+
2125
+ // src/program.ts
2126
+ function buildProgram(options = {}) {
2127
+ const program = new Command("ve-dynamite").description("Content Dynamite — content pipeline CLI").version(VERSION, "-V, --version", "Print the version and exit").option("--token <token>", "Access token override (or set VE_DYNAMITE_TOKEN env)").addOption(new Option4("--pretty", "Has no effect: output is always indented JSON. Kept so older scripts still run").hideHelp());
2128
+ if (!options.mount)
2129
+ program.option("--url <url>", "API base URL (or set VE_DYNAMITE_API_URL env)");
2130
+ program.exitOverride().configureOutput({ writeOut: (text) => io.stdout(text), writeErr: (text) => io.stderr(text) }).showHelpAfterError("(add --help for additional information)");
2131
+ const cli = cliFor(program, options);
2132
+ registerAuth(program, cli);
2133
+ registerTokens(program, cli);
2134
+ registerUsers(program, cli);
2135
+ registerProfiles(program, cli);
2136
+ registerArticleRevisions(registerArticles(program, cli), cli);
2137
+ registerRevisions(program, cli);
2138
+ registerBatches(program, cli);
2139
+ registerGbp(program, cli);
2140
+ registerLandingPages(program, cli);
2141
+ registerEvals(program, cli);
2142
+ registerImages(program, cli);
2143
+ registerUpload(program, cli);
2144
+ return program;
2145
+ }
2146
+ async function run(argv, options = {}) {
2147
+ try {
2148
+ await buildProgram(options).parseAsync(argv, { from: "user" });
2149
+ return 0;
2150
+ } catch (error) {
2151
+ if (error instanceof CommanderError2)
2152
+ return error.exitCode;
2153
+ err(error instanceof Error ? error.message : String(error));
2154
+ return 1;
2155
+ }
2156
+ }
2157
+
2158
+ export { VERSION, buildProgram, run };