@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@localpulse/cli",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Local Pulse CLI — ingest event posters, search events, manage credentials",
5
5
  "type": "module",
6
6
  "license": "MIT",
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("rejects thin research payload with audit findings", async () => {
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, stderr } = runCli("ingest", "../../README.md", "--research", researchFile, "--dry-run");
227
+ const { exitCode, stdout } = runCli("ingest", "../../README.md", "--research", researchFile, "--dry-run");
166
228
  expect(exitCode).toBe(1);
167
- expect(stderr).toContain("audit failed");
168
- expect(stderr).toContain("featured");
169
- expect(stderr).toContain("Fix these issues");
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 = dryRun ? "" : await requireToken();
247
+ const token = await requireToken();
215
248
 
216
249
  const uploadOptions = {
217
250
  file,
218
- urls: readStringArrayOption(parsed, "urls") ?? mapped.urls,
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 — performers, chefs, hosts, speakers
814
+ featured[] Who is featured (required, at least one)
575
815
  .name Full name (required per person)
576
- .type Their role: "DJ", "chef", "band", "host", "speaker",
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, Bandcamp, personal site
580
- .context Anything else: bio, notable work, affiliations
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 (skips venue matching if provided)
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", "exhibition",
597
- "workshop", "concert", "open air", "listening session",
598
- "pop-up dinner", "food event", "tasting", "market"
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 / purchase URL
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
+ };