@localpulse/cli 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.test.ts +69 -5
- package/src/index.ts +349 -42
- package/src/lib/cli-read-client.ts +73 -0
- package/src/lib/cli-read-types.ts +54 -0
package/package.json
CHANGED
package/src/index.test.ts
CHANGED
|
@@ -153,20 +153,84 @@ describe("localpulse", () => {
|
|
|
153
153
|
const { exitCode, stdout } = runCli("ingest", "../../README.md", "--research", researchFile, "--dry-run");
|
|
154
154
|
expect(exitCode).toBe(0);
|
|
155
155
|
expect(stdout).toContain("Dry run passed");
|
|
156
|
+
expect(stdout).toContain("Title: Test Night");
|
|
157
|
+
expect(stdout).toContain("Date: 2026-03-14T22:00:00+01:00");
|
|
158
|
+
expect(stdout).toContain("Venue: Shelter (Amsterdam) [ChIJ123]");
|
|
159
|
+
expect(stdout).toContain("Featured: 1 (DJ Nobu)");
|
|
160
|
+
expect(stdout).toContain("Organizer: Dekmantel");
|
|
161
|
+
expect(stdout).toContain("URLs: 2 source(s)");
|
|
162
|
+
expect(stdout).toContain("Ticket: https://tickets.example.com");
|
|
163
|
+
expect(stdout).toContain("Audit: passed (0 issues)");
|
|
156
164
|
});
|
|
157
165
|
|
|
158
|
-
it("
|
|
166
|
+
it("includes parsed fields in --dry-run --json output", async () => {
|
|
167
|
+
const researchFile = join(tmpdir(), `lp-test-${Date.now()}.json`);
|
|
168
|
+
await writeFile(researchFile, JSON.stringify({
|
|
169
|
+
featured: [
|
|
170
|
+
{ name: "DJ Nobu", type: "DJ", socials: ["https://instagram.com/djnobu"], context: "Berlin-based" },
|
|
171
|
+
{ name: "Nene H", type: "DJ", socials: ["https://instagram.com/neneh"], context: "Tehran-born" },
|
|
172
|
+
],
|
|
173
|
+
organizer: { name: "Dekmantel", socials: ["https://instagram.com/dekmantel"] },
|
|
174
|
+
venue: { name: "Shelter", city: "Amsterdam", google_place_id: "ChIJ123" },
|
|
175
|
+
event: {
|
|
176
|
+
title: "Test Night",
|
|
177
|
+
date: "2026-03-14T22:00:00+01:00",
|
|
178
|
+
type: "club night",
|
|
179
|
+
urls: ["https://ra.co/events/123", "https://example.com/event"],
|
|
180
|
+
ticket_url: "https://tickets.example.com",
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
const { exitCode, stdout } = runCli("ingest", "../../README.md", "--research", researchFile, "--dry-run", "--json");
|
|
185
|
+
expect(exitCode).toBe(0);
|
|
186
|
+
const result = JSON.parse(stdout);
|
|
187
|
+
expect(result.dry_run).toBe(true);
|
|
188
|
+
expect(result.valid).toBe(true);
|
|
189
|
+
expect(result.parsed.title).toBe("Test Night");
|
|
190
|
+
expect(result.parsed.datetime).toBe("2026-03-14T22:00:00+01:00");
|
|
191
|
+
expect(result.parsed.venue).toBe("Shelter");
|
|
192
|
+
expect(result.parsed.city).toBe("Amsterdam");
|
|
193
|
+
expect(result.parsed.venue_place_id).toBe("ChIJ123");
|
|
194
|
+
expect(result.parsed.featured_count).toBe(2);
|
|
195
|
+
expect(result.parsed.featured_names).toEqual(["DJ Nobu", "Nene H"]);
|
|
196
|
+
expect(result.parsed.organizer).toBe("Dekmantel");
|
|
197
|
+
expect(result.parsed.urls_count).toBe(2);
|
|
198
|
+
expect(result.parsed.ticket_url).toBe("https://tickets.example.com");
|
|
199
|
+
expect(result.audit.pass).toBe(true);
|
|
200
|
+
expect(result.audit.issues).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("shows required annotations in ingest help", () => {
|
|
204
|
+
const { exitCode, stdout } = runCli("ingest", "--help");
|
|
205
|
+
expect(exitCode).toBe(0);
|
|
206
|
+
expect(stdout).toContain("Who is featured (required, at least one)");
|
|
207
|
+
expect(stdout).toContain("Their role (required per person)");
|
|
208
|
+
expect(stdout).toContain("Profile URLs (required per person)");
|
|
209
|
+
expect(stdout).toContain("Bio and background (required per person)");
|
|
210
|
+
expect(stdout).toContain("Venue name (required)");
|
|
211
|
+
expect(stdout).toContain("City name (required)");
|
|
212
|
+
expect(stdout).toContain("Google Places ID (required)");
|
|
213
|
+
expect(stdout).toContain("Event name (required)");
|
|
214
|
+
expect(stdout).toContain("ISO 8601 datetime (required)");
|
|
215
|
+
expect(stdout).toContain("Kind of event (required)");
|
|
216
|
+
expect(stdout).toContain("Source pages (required, 2+)");
|
|
217
|
+
expect(stdout).toContain('required unless price is "Free"');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("rejects thin research payload with audit findings in dry-run", async () => {
|
|
159
221
|
const researchFile = join(tmpdir(), `lp-test-${Date.now()}.json`);
|
|
160
222
|
await writeFile(researchFile, JSON.stringify({
|
|
161
223
|
featured: [],
|
|
162
224
|
event: { title: "Test" },
|
|
163
225
|
}));
|
|
164
226
|
|
|
165
|
-
const { exitCode,
|
|
227
|
+
const { exitCode, stdout } = runCli("ingest", "../../README.md", "--research", researchFile, "--dry-run");
|
|
166
228
|
expect(exitCode).toBe(1);
|
|
167
|
-
expect(
|
|
168
|
-
expect(
|
|
169
|
-
expect(
|
|
229
|
+
expect(stdout).toContain("Dry run failed");
|
|
230
|
+
expect(stdout).toContain("Title: Test");
|
|
231
|
+
expect(stdout).toContain("audit failed");
|
|
232
|
+
expect(stdout).toContain("featured");
|
|
233
|
+
expect(stdout).toContain("Fix these issues");
|
|
170
234
|
});
|
|
171
235
|
|
|
172
236
|
it("outputs structured audit findings with --json", async () => {
|
package/src/index.ts
CHANGED
|
@@ -5,9 +5,9 @@ import { stdin, stdout } from "node:process";
|
|
|
5
5
|
import { hasOption, parseArgv, readNumberOption, readStringArrayOption, readStringOption } from "./lib/argv";
|
|
6
6
|
import { requireToken } from "./lib/auth";
|
|
7
7
|
import { toFrontendBaseUrl } from "./lib/api-url";
|
|
8
|
-
import { fetchDrafts, searchCliEvents } from "./lib/cli-read-client";
|
|
8
|
+
import { fetchDrafts, fetchEditable, patchEntity, patchEvent, searchCliEvents } from "./lib/cli-read-client";
|
|
9
9
|
import { DRAFT_STATUSES } from "./lib/cli-read-types";
|
|
10
|
-
import type { DraftListItem, DraftStatus, SearchEventCard } from "./lib/cli-read-types";
|
|
10
|
+
import type { DraftListItem, DraftStatus, EditableEntity, EditableResult, SearchEventCard } from "./lib/cli-read-types";
|
|
11
11
|
import {
|
|
12
12
|
deleteCredentials,
|
|
13
13
|
getCredentialsPath,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./lib/credentials";
|
|
18
18
|
import { loginWithToken } from "./lib/login";
|
|
19
19
|
import { exitCodeForError, printError } from "./lib/output";
|
|
20
|
-
import { auditResearchPayload, formatAuditFindings } from "./lib/research-audit";
|
|
20
|
+
import { type AuditResult, auditResearchPayload, formatAuditFindings } from "./lib/research-audit";
|
|
21
21
|
import { readResearchPayload } from "./lib/research-reader";
|
|
22
22
|
import {
|
|
23
23
|
generateResearchSkeleton,
|
|
@@ -25,8 +25,10 @@ import {
|
|
|
25
25
|
stitchResearchContext,
|
|
26
26
|
} from "./lib/research-schema";
|
|
27
27
|
import { isLikelyCliToken } from "./lib/token";
|
|
28
|
+
import type { ResearchPayload } from "./lib/research-schema";
|
|
28
29
|
import {
|
|
29
30
|
type UploadPosterResult,
|
|
31
|
+
type UploadDryRunResult,
|
|
30
32
|
type DraftResult,
|
|
31
33
|
uploadPoster,
|
|
32
34
|
createDraft,
|
|
@@ -66,6 +68,9 @@ async function main(argv: string[]): Promise<void> {
|
|
|
66
68
|
case "drafts":
|
|
67
69
|
await runDrafts(parsed);
|
|
68
70
|
break;
|
|
71
|
+
case "edit":
|
|
72
|
+
await runEdit(parsed);
|
|
73
|
+
break;
|
|
69
74
|
default:
|
|
70
75
|
throw new Error(`Unknown command: ${command}. Run \`localpulse --help\` for usage.`);
|
|
71
76
|
}
|
|
@@ -191,8 +196,38 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
|
|
|
191
196
|
|
|
192
197
|
const payload = await readResearchPayload(researchPath);
|
|
193
198
|
const jsonOutput = hasOption(parsed, "json");
|
|
199
|
+
const dryRun = hasOption(parsed, "dry-run");
|
|
200
|
+
|
|
201
|
+
const mapped = mapResearchToUploadFields(payload);
|
|
202
|
+
const uploadFields = {
|
|
203
|
+
urls: readStringArrayOption(parsed, "urls") ?? mapped.urls,
|
|
204
|
+
city: readStringOption(parsed, "city") ?? mapped.city,
|
|
205
|
+
venuePlaceId: readStringOption(parsed, "google-place-id") ?? mapped.venuePlaceId,
|
|
206
|
+
ticketUrl: readStringOption(parsed, "ticket-url") ?? mapped.ticketUrl,
|
|
207
|
+
title: readStringOption(parsed, "title") ?? mapped.title,
|
|
208
|
+
datetime: readStringOption(parsed, "date") ?? mapped.datetime,
|
|
209
|
+
venue: readStringOption(parsed, "venue") ?? mapped.venue,
|
|
210
|
+
};
|
|
194
211
|
|
|
195
212
|
const audit = auditResearchPayload(payload);
|
|
213
|
+
|
|
214
|
+
if (dryRun) {
|
|
215
|
+
const result = {
|
|
216
|
+
dry_run: true as const,
|
|
217
|
+
valid: true as const,
|
|
218
|
+
file,
|
|
219
|
+
extra_media_count: readStringArrayOption(parsed, "extra-media")?.length ?? 0,
|
|
220
|
+
api_url: (await resolveApiUrl()),
|
|
221
|
+
};
|
|
222
|
+
if (jsonOutput) {
|
|
223
|
+
stdout.write(`${JSON.stringify(buildDryRunJsonResult(payload, uploadFields, result, audit))}\n`);
|
|
224
|
+
} else {
|
|
225
|
+
stdout.write(formatDryRunSummary(payload, uploadFields, result, audit));
|
|
226
|
+
}
|
|
227
|
+
if (!audit.pass) process.exitCode = 1;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
196
231
|
if (!audit.pass) {
|
|
197
232
|
if (jsonOutput) {
|
|
198
233
|
stdout.write(`${JSON.stringify({ audit_failed: true, findings: audit.findings })}\n`);
|
|
@@ -203,42 +238,21 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
|
|
|
203
238
|
return;
|
|
204
239
|
}
|
|
205
240
|
|
|
206
|
-
const mapped = mapResearchToUploadFields(payload);
|
|
207
241
|
const stitched = stitchResearchContext(payload);
|
|
208
242
|
const explicitContext = readStringOption(parsed, "context");
|
|
209
243
|
const context = [stitched, explicitContext].filter(Boolean).join("\n\n") || undefined;
|
|
210
244
|
|
|
211
245
|
const apiUrl = await resolveApiUrl();
|
|
212
|
-
const dryRun = hasOption(parsed, "dry-run");
|
|
213
246
|
const force = hasOption(parsed, "force");
|
|
214
|
-
const token =
|
|
247
|
+
const token = await requireToken();
|
|
215
248
|
|
|
216
249
|
const uploadOptions = {
|
|
217
250
|
file,
|
|
218
|
-
|
|
219
|
-
city: readStringOption(parsed, "city") ?? mapped.city,
|
|
220
|
-
venuePlaceId: readStringOption(parsed, "google-place-id") ?? mapped.venuePlaceId,
|
|
251
|
+
...uploadFields,
|
|
221
252
|
context,
|
|
222
|
-
ticketUrl: readStringOption(parsed, "ticket-url") ?? mapped.ticketUrl,
|
|
223
|
-
title: readStringOption(parsed, "title") ?? mapped.title,
|
|
224
|
-
datetime: readStringOption(parsed, "date") ?? mapped.datetime,
|
|
225
|
-
venue: readStringOption(parsed, "venue") ?? mapped.venue,
|
|
226
253
|
extraMedia: readStringArrayOption(parsed, "extra-media"),
|
|
227
|
-
dryRun,
|
|
228
254
|
};
|
|
229
255
|
|
|
230
|
-
if (dryRun) {
|
|
231
|
-
const result = await uploadPoster(apiUrl, token, uploadOptions);
|
|
232
|
-
if ("dry_run" in result) {
|
|
233
|
-
if (jsonOutput) {
|
|
234
|
-
stdout.write(`${JSON.stringify(result)}\n`);
|
|
235
|
-
} else {
|
|
236
|
-
stdout.write(`Dry run passed: ${result.file} (${result.extra_media_count} extra media)\n`);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
256
|
if (force) {
|
|
243
257
|
const result = await uploadPoster(apiUrl, token, uploadOptions);
|
|
244
258
|
if (!("dry_run" in result)) {
|
|
@@ -309,6 +323,92 @@ function printIngestResult(result: UploadPosterResult): void {
|
|
|
309
323
|
}
|
|
310
324
|
}
|
|
311
325
|
|
|
326
|
+
type DryRunUploadOptions = {
|
|
327
|
+
title?: string;
|
|
328
|
+
datetime?: string;
|
|
329
|
+
venue?: string;
|
|
330
|
+
city?: string;
|
|
331
|
+
venuePlaceId?: string;
|
|
332
|
+
urls?: string[];
|
|
333
|
+
ticketUrl?: string;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
function formatDryRunSummary(
|
|
337
|
+
payload: ResearchPayload,
|
|
338
|
+
opts: DryRunUploadOptions,
|
|
339
|
+
result: UploadDryRunResult,
|
|
340
|
+
audit: AuditResult,
|
|
341
|
+
): string {
|
|
342
|
+
const header = audit.pass
|
|
343
|
+
? `Dry run passed: ${result.file} (${result.extra_media_count} extra media)`
|
|
344
|
+
: `Dry run failed: ${result.file} (${audit.findings.length} issue${audit.findings.length === 1 ? "" : "s"})`;
|
|
345
|
+
|
|
346
|
+
const lines: string[] = [header];
|
|
347
|
+
|
|
348
|
+
if (opts.title) lines.push(` Title: ${opts.title}`);
|
|
349
|
+
if (opts.datetime) lines.push(` Date: ${opts.datetime}`);
|
|
350
|
+
|
|
351
|
+
if (opts.venue) {
|
|
352
|
+
let venueStr = opts.venue;
|
|
353
|
+
if (opts.city) venueStr += ` (${opts.city})`;
|
|
354
|
+
if (opts.venuePlaceId) venueStr += ` [${opts.venuePlaceId}]`;
|
|
355
|
+
lines.push(` Venue: ${venueStr}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (payload.featured?.length) {
|
|
359
|
+
const names = payload.featured.map((p) => p.name).join(", ");
|
|
360
|
+
lines.push(` Featured: ${payload.featured.length} (${names})`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (payload.organizer?.name) {
|
|
364
|
+
lines.push(` Organizer: ${payload.organizer.name}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (opts.urls?.length) {
|
|
368
|
+
lines.push(` URLs: ${opts.urls.length} source(s)`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (opts.ticketUrl) lines.push(` Ticket: ${opts.ticketUrl}`);
|
|
372
|
+
|
|
373
|
+
if (audit.pass) {
|
|
374
|
+
lines.push(" Audit: passed (0 issues)");
|
|
375
|
+
} else {
|
|
376
|
+
lines.push("");
|
|
377
|
+
lines.push(formatAuditFindings(audit.findings));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
lines.push("");
|
|
381
|
+
return lines.join("\n");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function buildDryRunJsonResult(
|
|
385
|
+
payload: ResearchPayload,
|
|
386
|
+
opts: DryRunUploadOptions,
|
|
387
|
+
result: UploadDryRunResult,
|
|
388
|
+
audit: AuditResult,
|
|
389
|
+
): Record<string, unknown> {
|
|
390
|
+
return {
|
|
391
|
+
...result,
|
|
392
|
+
parsed: {
|
|
393
|
+
title: opts.title ?? null,
|
|
394
|
+
datetime: opts.datetime ?? null,
|
|
395
|
+
venue: opts.venue ?? null,
|
|
396
|
+
city: opts.city ?? null,
|
|
397
|
+
venue_place_id: opts.venuePlaceId ?? null,
|
|
398
|
+
featured_count: payload.featured?.length ?? 0,
|
|
399
|
+
featured_names: payload.featured?.map((p) => p.name) ?? [],
|
|
400
|
+
organizer: payload.organizer?.name ?? null,
|
|
401
|
+
urls_count: opts.urls?.length ?? 0,
|
|
402
|
+
ticket_url: opts.ticketUrl ?? null,
|
|
403
|
+
},
|
|
404
|
+
audit: {
|
|
405
|
+
pass: audit.pass,
|
|
406
|
+
issues: audit.findings.length,
|
|
407
|
+
...(audit.findings.length ? { findings: audit.findings } : {}),
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
312
412
|
async function runSearch(parsed: ReturnType<typeof parseArgv>): Promise<void> {
|
|
313
413
|
if (hasOption(parsed, "help")) {
|
|
314
414
|
stdout.write(searchHelp());
|
|
@@ -425,6 +525,143 @@ function printDraftListItem(draft: DraftListItem, baseUrl: string): void {
|
|
|
425
525
|
stdout.write(` ${baseUrl}/publish/edit/${draft.id}\n`);
|
|
426
526
|
}
|
|
427
527
|
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// edit command
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
async function runEdit(parsed: ReturnType<typeof parseArgv>): Promise<void> {
|
|
533
|
+
if (hasOption(parsed, "help")) {
|
|
534
|
+
stdout.write(editHelp());
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const subcommand = parsed.positionals[0];
|
|
539
|
+
|
|
540
|
+
if (!subcommand) {
|
|
541
|
+
throw new Error("Usage: localpulse edit <poster_id> [options]. Run `localpulse edit --help` for details.");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const jsonOutput = hasOption(parsed, "json");
|
|
545
|
+
const apiUrl = await resolveApiUrl();
|
|
546
|
+
const token = await requireToken();
|
|
547
|
+
const posterId = subcommand;
|
|
548
|
+
|
|
549
|
+
// If --set is present, this is an update; otherwise show editable fields
|
|
550
|
+
const setValues = readStringArrayOption(parsed, "set");
|
|
551
|
+
const entityId = readStringOption(parsed, "entity");
|
|
552
|
+
|
|
553
|
+
if (setValues && setValues.length > 0) {
|
|
554
|
+
const fields = parseSetFlags(setValues);
|
|
555
|
+
|
|
556
|
+
if (entityId) {
|
|
557
|
+
const result = await patchEntity(apiUrl, token, posterId, entityId, fields);
|
|
558
|
+
if (jsonOutput) {
|
|
559
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
560
|
+
} else {
|
|
561
|
+
stdout.write(`Entity ${entityId} updated.\n`);
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
const result = await patchEvent(apiUrl, token, posterId, fields);
|
|
565
|
+
if (jsonOutput) {
|
|
566
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
567
|
+
} else {
|
|
568
|
+
stdout.write("Event updated.\n");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Default: show editable fields
|
|
575
|
+
const editable = await fetchEditable(apiUrl, token, posterId);
|
|
576
|
+
if (jsonOutput) {
|
|
577
|
+
stdout.write(`${JSON.stringify(editable, null, 2)}\n`);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
printEditable(editable);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function parseSetFlags(pairs: string[]): Record<string, unknown> {
|
|
584
|
+
const fields: Record<string, unknown> = {};
|
|
585
|
+
for (const pair of pairs) {
|
|
586
|
+
const eqIdx = pair.indexOf("=");
|
|
587
|
+
if (eqIdx === -1) {
|
|
588
|
+
throw new Error(`Invalid --set format: "${pair}". Expected key=value (e.g. --set title="New Title").`);
|
|
589
|
+
}
|
|
590
|
+
const key = pair.slice(0, eqIdx).trim();
|
|
591
|
+
const rawValue = pair.slice(eqIdx + 1);
|
|
592
|
+
if (!key) {
|
|
593
|
+
throw new Error(`Empty key in --set: "${pair}".`);
|
|
594
|
+
}
|
|
595
|
+
fields[key] = parseFieldValue(rawValue);
|
|
596
|
+
}
|
|
597
|
+
return fields;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function parseFieldValue(raw: string): unknown {
|
|
601
|
+
// Boolean
|
|
602
|
+
if (raw === "true") return true;
|
|
603
|
+
if (raw === "false") return false;
|
|
604
|
+
// Null
|
|
605
|
+
if (raw === "null") return null;
|
|
606
|
+
// JSON array or object
|
|
607
|
+
if ((raw.startsWith("[") && raw.endsWith("]")) || (raw.startsWith("{") && raw.endsWith("}"))) {
|
|
608
|
+
try { return JSON.parse(raw); } catch { /* fall through to string */ }
|
|
609
|
+
}
|
|
610
|
+
// String (default)
|
|
611
|
+
return raw;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function printEditable(editable: EditableResult): void {
|
|
615
|
+
const { event, entities, media } = editable;
|
|
616
|
+
|
|
617
|
+
stdout.write(`Event: ${event.title ?? "(no title)"}\n`);
|
|
618
|
+
stdout.write(`Poster: ${editable.poster_id}\n\n`);
|
|
619
|
+
|
|
620
|
+
stdout.write("Event fields:\n");
|
|
621
|
+
const fieldOrder: (keyof typeof event)[] = [
|
|
622
|
+
"title", "description", "event_datetime", "event_datetimes",
|
|
623
|
+
"location", "venue_city", "ticketing_url", "pricing",
|
|
624
|
+
"is_cancelled", "is_sold_out", "is_postponed",
|
|
625
|
+
"event_type", "overall_theme",
|
|
626
|
+
"atmosphere_tags", "genre_tags", "vibe_tags", "type_tags",
|
|
627
|
+
"lineup_text", "description_paragraphs", "embed_urls",
|
|
628
|
+
];
|
|
629
|
+
for (const key of fieldOrder) {
|
|
630
|
+
const value = event[key];
|
|
631
|
+
if (value === null || value === undefined) continue;
|
|
632
|
+
const display = Array.isArray(value) ? JSON.stringify(value) :
|
|
633
|
+
typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
634
|
+
// Truncate long values for readability
|
|
635
|
+
const truncated = display.length > 120 ? `${display.slice(0, 117)}...` : display;
|
|
636
|
+
stdout.write(` ${key}: ${truncated}\n`);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (entities.length > 0) {
|
|
640
|
+
stdout.write("\nEntities:\n");
|
|
641
|
+
for (const entity of entities) {
|
|
642
|
+
const role = entity.event_role ? ` (${entity.event_role})` : "";
|
|
643
|
+
stdout.write(` ${entity.entity_id} ${entity.name}${role} [${entity.entity_type}]\n`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (media.length > 0) {
|
|
648
|
+
stdout.write("\nMedia:\n");
|
|
649
|
+
for (const item of media) {
|
|
650
|
+
const primary = item.is_primary ? " [primary]" : "";
|
|
651
|
+
stdout.write(` ${item.id} ${item.media_type}${primary}\n`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
stdout.write("\nTo update, use --set:\n");
|
|
656
|
+
stdout.write(` localpulse edit ${editable.poster_id} --set title="New Title"\n`);
|
|
657
|
+
stdout.write(` localpulse edit ${editable.poster_id} --set is_sold_out=true\n`);
|
|
658
|
+
stdout.write(` localpulse edit ${editable.poster_id} --set 'genre_tags=["techno","house"]'\n`);
|
|
659
|
+
if (entities.length > 0) {
|
|
660
|
+
const first = entities[0];
|
|
661
|
+
stdout.write(` localpulse edit ${editable.poster_id} --entity ${first.entity_id} --set event_role="headliner"\n`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
428
665
|
async function resolveLoginToken(explicitToken?: string): Promise<string> {
|
|
429
666
|
if (explicitToken?.trim()) {
|
|
430
667
|
return explicitToken.trim();
|
|
@@ -469,6 +706,7 @@ Commands:
|
|
|
469
706
|
ingest Ingest an event poster into Local Pulse
|
|
470
707
|
search Search upcoming events
|
|
471
708
|
drafts List your submission drafts
|
|
709
|
+
edit View and update published event fields
|
|
472
710
|
auth Login and logout
|
|
473
711
|
|
|
474
712
|
Options:
|
|
@@ -484,6 +722,8 @@ Quick start:
|
|
|
484
722
|
localpulse auth login --token <token>
|
|
485
723
|
localpulse search "amsterdam"
|
|
486
724
|
localpulse ingest poster.jpg --research data.json
|
|
725
|
+
localpulse edit <poster_id>
|
|
726
|
+
localpulse edit <poster_id> --set title="New Title"
|
|
487
727
|
`;
|
|
488
728
|
}
|
|
489
729
|
|
|
@@ -571,34 +811,38 @@ Research payload:
|
|
|
571
811
|
✓ All featured people as separate entries (support acts, guest chefs, co-hosts)
|
|
572
812
|
✓ Discover unlisted performers via Instagram tags (support acts, guest hosts)
|
|
573
813
|
|
|
574
|
-
featured[] Who is featured
|
|
814
|
+
featured[] Who is featured (required, at least one)
|
|
575
815
|
.name Full name (required per person)
|
|
576
|
-
.type Their role: "DJ", "chef", "band",
|
|
577
|
-
"visual artist", "cook"
|
|
816
|
+
.type Their role (required per person): "DJ", "chef", "band",
|
|
817
|
+
"host", "speaker", "visual artist", "cook"
|
|
578
818
|
.genre Style, genre, or cuisine: "techno", "jazz", "Kerala"
|
|
579
|
-
.socials[] Profile URLs: Instagram, RA,
|
|
580
|
-
|
|
819
|
+
.socials[] Profile URLs (required per person): Instagram, RA,
|
|
820
|
+
Bandcamp, personal site
|
|
821
|
+
.context Bio and background (required per person): notable work,
|
|
822
|
+
affiliations, links from their profiles
|
|
581
823
|
|
|
582
824
|
organizer Who is putting on the event
|
|
583
825
|
.name Collective, promoter, or brand name (required)
|
|
584
|
-
.socials[] Profile URLs
|
|
826
|
+
.socials[] Profile URLs (required)
|
|
585
827
|
.context History, reputation, previous events, affiliation
|
|
586
828
|
|
|
587
829
|
venue Where the event happens
|
|
588
|
-
.name Venue name
|
|
589
|
-
.city City name
|
|
590
|
-
.google_place_id Google Places ID (
|
|
830
|
+
.name Venue name (required)
|
|
831
|
+
.city City name (required)
|
|
832
|
+
.google_place_id Google Places ID (required); search Google Maps for
|
|
833
|
+
"{venue} {city}" and extract place ID from share URL
|
|
591
834
|
.context Capacity, room layout, vibe, accessibility, location tips
|
|
592
835
|
|
|
593
836
|
event What the event is
|
|
594
|
-
.title Event name
|
|
595
|
-
.date ISO 8601 datetime (e.g. "2026-03-14T22:00:00+01:00"
|
|
596
|
-
.type Kind of event: "club night", "festival",
|
|
597
|
-
"workshop", "concert", "open air",
|
|
598
|
-
"pop-up dinner", "food event",
|
|
837
|
+
.title Event name (required)
|
|
838
|
+
.date ISO 8601 datetime (required), e.g. "2026-03-14T22:00:00+01:00"
|
|
839
|
+
.type Kind of event (required): "club night", "festival",
|
|
840
|
+
"exhibition", "workshop", "concert", "open air",
|
|
841
|
+
"listening session", "pop-up dinner", "food event",
|
|
842
|
+
"tasting", "market"
|
|
599
843
|
.price Ticket price info: "€15-25", "Free", "Sold out"
|
|
600
|
-
.urls[] Source pages: RA, venue site, Facebook event
|
|
601
|
-
.ticket_url Direct ticketing
|
|
844
|
+
.urls[] Source pages (required, 2+): RA, venue site, Facebook event
|
|
845
|
+
.ticket_url Direct ticketing URL (required unless price is "Free")
|
|
602
846
|
.context Schedule, door policy, age restrictions, special notes
|
|
603
847
|
|
|
604
848
|
context Anything that doesn't fit above: background on the
|
|
@@ -664,6 +908,69 @@ Examples:
|
|
|
664
908
|
`;
|
|
665
909
|
}
|
|
666
910
|
|
|
911
|
+
function editHelp(): string {
|
|
912
|
+
return `Usage: localpulse edit <poster_id> [options]
|
|
913
|
+
|
|
914
|
+
View and update published event fields. Without --set, prints the current
|
|
915
|
+
editable values. With --set, applies partial updates to the event or entity.
|
|
916
|
+
|
|
917
|
+
Arguments:
|
|
918
|
+
poster_id Poster UUID of the published event
|
|
919
|
+
|
|
920
|
+
View current fields:
|
|
921
|
+
localpulse edit <poster_id> Show all editable fields
|
|
922
|
+
localpulse edit <poster_id> --json Output as structured JSON
|
|
923
|
+
|
|
924
|
+
Update event fields:
|
|
925
|
+
--set key=value Set a field (repeatable for multiple fields)
|
|
926
|
+
--json Output full updated event detail as JSON
|
|
927
|
+
|
|
928
|
+
Values are auto-parsed: true/false → boolean, null → null,
|
|
929
|
+
JSON arrays/objects → parsed, everything else → string.
|
|
930
|
+
|
|
931
|
+
Update entity fields:
|
|
932
|
+
--entity <entity_id> Target a specific entity (performer/venue)
|
|
933
|
+
--set key=value Set entity field (description, event_role, social_media)
|
|
934
|
+
|
|
935
|
+
Options:
|
|
936
|
+
--json Output structured JSON
|
|
937
|
+
--help Show this help
|
|
938
|
+
|
|
939
|
+
Event fields:
|
|
940
|
+
title, description, event_datetime, event_datetimes, location,
|
|
941
|
+
ticketing_url, pricing, is_cancelled, is_sold_out, is_postponed,
|
|
942
|
+
atmosphere_tags, genre_tags, vibe_tags, type_tags, event_type,
|
|
943
|
+
overall_theme, venue_city, lineup_text, embed_urls,
|
|
944
|
+
description_paragraphs
|
|
945
|
+
|
|
946
|
+
Entity fields:
|
|
947
|
+
description, event_role, social_media
|
|
948
|
+
|
|
949
|
+
Examples:
|
|
950
|
+
# View editable fields
|
|
951
|
+
localpulse edit abc-123
|
|
952
|
+
|
|
953
|
+
# Update event title
|
|
954
|
+
localpulse edit abc-123 --set title="Updated Event Name"
|
|
955
|
+
|
|
956
|
+
# Mark as sold out
|
|
957
|
+
localpulse edit abc-123 --set is_sold_out=true
|
|
958
|
+
|
|
959
|
+
# Update multiple fields
|
|
960
|
+
localpulse edit abc-123 --set title="New Name" --set is_cancelled=false
|
|
961
|
+
|
|
962
|
+
# Set genre tags (JSON array)
|
|
963
|
+
localpulse edit abc-123 --set 'genre_tags=["techno","house"]'
|
|
964
|
+
|
|
965
|
+
# Update entity
|
|
966
|
+
localpulse edit abc-123 --entity 42 --set event_role="headliner"
|
|
967
|
+
|
|
968
|
+
# JSON output for programmatic use
|
|
969
|
+
localpulse edit abc-123 --json
|
|
970
|
+
localpulse edit abc-123 --set title="X" --json
|
|
971
|
+
`;
|
|
972
|
+
}
|
|
973
|
+
|
|
667
974
|
function draftsHelp(): string {
|
|
668
975
|
return `Usage: localpulse drafts [options]
|
|
669
976
|
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
CliInfoResult,
|
|
10
10
|
DraftListItem,
|
|
11
11
|
DraftStatus,
|
|
12
|
+
EditableResult,
|
|
12
13
|
SearchEventsResult,
|
|
13
14
|
} from "./cli-read-types";
|
|
14
15
|
|
|
@@ -122,6 +123,78 @@ export async function fetchDrafts(
|
|
|
122
123
|
return parsed as DraftListItem[];
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
export async function fetchEditable(
|
|
127
|
+
apiUrl: string,
|
|
128
|
+
token: string,
|
|
129
|
+
posterId: string,
|
|
130
|
+
): Promise<EditableResult> {
|
|
131
|
+
const payload = await authedJson(
|
|
132
|
+
apiUrl, token, "GET",
|
|
133
|
+
`/api/cli/events/${encodeURIComponent(posterId)}/editable`,
|
|
134
|
+
"Fetch editable failed",
|
|
135
|
+
);
|
|
136
|
+
return payload as unknown as EditableResult;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function patchEvent(
|
|
140
|
+
apiUrl: string,
|
|
141
|
+
token: string,
|
|
142
|
+
posterId: string,
|
|
143
|
+
fields: Record<string, unknown>,
|
|
144
|
+
): Promise<ApiResponseBody> {
|
|
145
|
+
return authedJson(
|
|
146
|
+
apiUrl, token, "PATCH",
|
|
147
|
+
`/api/events/${encodeURIComponent(posterId)}`,
|
|
148
|
+
"Patch event failed",
|
|
149
|
+
fields,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function patchEntity(
|
|
154
|
+
apiUrl: string,
|
|
155
|
+
token: string,
|
|
156
|
+
posterId: string,
|
|
157
|
+
entityId: string,
|
|
158
|
+
fields: Record<string, unknown>,
|
|
159
|
+
): Promise<ApiResponseBody> {
|
|
160
|
+
return authedJson(
|
|
161
|
+
apiUrl, token, "PATCH",
|
|
162
|
+
`/api/poster/${encodeURIComponent(posterId)}/entity/${encodeURIComponent(entityId)}`,
|
|
163
|
+
"Patch entity failed",
|
|
164
|
+
fields,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function authedJson(
|
|
169
|
+
apiUrl: string,
|
|
170
|
+
token: string,
|
|
171
|
+
method: string,
|
|
172
|
+
path: string,
|
|
173
|
+
fallbackMessage: string,
|
|
174
|
+
body?: Record<string, unknown>,
|
|
175
|
+
): Promise<ApiResponseBody> {
|
|
176
|
+
const headers: Record<string, string> = {
|
|
177
|
+
accept: "application/json",
|
|
178
|
+
authorization: `Bearer ${token}`,
|
|
179
|
+
};
|
|
180
|
+
if (body) headers["content-type"] = "application/json";
|
|
181
|
+
|
|
182
|
+
const response = await fetch(buildApiUrl(apiUrl, path), {
|
|
183
|
+
method,
|
|
184
|
+
headers,
|
|
185
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const payload = await parseApiJsonBody(response);
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
throw new CliApiError(
|
|
191
|
+
extractApiErrorMessage(payload, `${fallbackMessage} (${response.status})`),
|
|
192
|
+
{ httpStatus: response.status, body: payload },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return payload;
|
|
196
|
+
}
|
|
197
|
+
|
|
125
198
|
function parseSearchEventsResult(payload: ApiResponseBody): SearchEventsResult {
|
|
126
199
|
const result = payload as Partial<SearchEventsResult>;
|
|
127
200
|
if (
|
|
@@ -37,3 +37,57 @@ export type DraftListItem = {
|
|
|
37
37
|
created_at: string;
|
|
38
38
|
updated_at: string;
|
|
39
39
|
};
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Edit types
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export type EditableEventFields = {
|
|
46
|
+
title: string | null;
|
|
47
|
+
description: string | null;
|
|
48
|
+
event_datetime: string | null;
|
|
49
|
+
event_datetimes: string[] | null;
|
|
50
|
+
location: string | null;
|
|
51
|
+
ticketing_url: string | null;
|
|
52
|
+
pricing: Record<string, unknown> | null;
|
|
53
|
+
is_cancelled: boolean;
|
|
54
|
+
is_sold_out: boolean;
|
|
55
|
+
is_postponed: boolean;
|
|
56
|
+
atmosphere_tags: string[] | null;
|
|
57
|
+
genre_tags: string[] | null;
|
|
58
|
+
vibe_tags: string[] | null;
|
|
59
|
+
type_tags: string[] | null;
|
|
60
|
+
event_type: string | null;
|
|
61
|
+
overall_theme: string | null;
|
|
62
|
+
venue_city: string | null;
|
|
63
|
+
description_paragraphs: string[] | null;
|
|
64
|
+
lineup_text: string | null;
|
|
65
|
+
embed_urls: Array<{ url: string; platform: string; context?: string }> | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type EditableEntity = {
|
|
69
|
+
entity_id: string | number;
|
|
70
|
+
name: string;
|
|
71
|
+
entity_type: string;
|
|
72
|
+
description: string | null;
|
|
73
|
+
event_role: string | null;
|
|
74
|
+
social_media: Record<string, string> | null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type EditableMediaItem = {
|
|
78
|
+
id: string;
|
|
79
|
+
poster_id: string;
|
|
80
|
+
media_type: string;
|
|
81
|
+
sort_order: number;
|
|
82
|
+
is_primary: boolean;
|
|
83
|
+
media_url: string | null;
|
|
84
|
+
preview_url: string | null;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type EditableResult = {
|
|
88
|
+
poster_id: string;
|
|
89
|
+
event: EditableEventFields;
|
|
90
|
+
entities: EditableEntity[];
|
|
91
|
+
media: EditableMediaItem[];
|
|
92
|
+
endpoints: Record<string, string>;
|
|
93
|
+
};
|