@localpulse/cli 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -25,8 +25,17 @@ chmod +x localpulse
25
25
  # Authenticate
26
26
  localpulse auth login --token lp_cli_...
27
27
 
28
- # Search events
29
- localpulse search "techno amsterdam" --date weekend --tz Europe/Amsterdam
28
+ # Search upcoming events
29
+ localpulse search "techno amsterdam"
30
+
31
+ # Search with filters
32
+ localpulse search "festival" --date weekend --tz Europe/Amsterdam
33
+
34
+ # Include past events
35
+ localpulse search "amsterdam" --all
36
+
37
+ # Structured JSON output
38
+ localpulse search "berlin" --json
30
39
 
31
40
  # Ingest a poster (creates a draft for review)
32
41
  localpulse ingest poster.jpg --research metadata.json
@@ -47,11 +56,13 @@ Remove stored credentials.
47
56
 
48
57
  ### `ingest <file> --research <payload.json>`
49
58
 
50
- Upload an event poster with research metadata. By default creates a draft for review at `localpulse.nl/publish/edit/<id>`. Use `--force` to submit directly. Use `--generate-skeleton` to see the research JSON format.
59
+ Upload an event poster with research metadata. By default creates a draft for review at `localpulse.nl/publish/edit/<id>`. Use `--force` to submit directly. Use `--generate-skeleton` to see the research JSON format. Pass `--json` for structured output.
51
60
 
52
61
  ### `search <query>`
53
62
 
54
- Search events by text. Supports `--city`, `--date today|weekend`, `--tz`, `--limit`, `--cursor`.
63
+ Search upcoming events by default. Supports `--city`, `--date today|weekend|upcoming`, `--tz`, `--all` (include past), `--json`, `--limit`, `--cursor`.
64
+
65
+ All commands support `--json` for structured, machine-parseable output.
55
66
 
56
67
  ## Environment variables
57
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@localpulse/cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
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
@@ -24,12 +24,14 @@ function runCli(...args: string[]): { exitCode: number; stdout: string; stderr:
24
24
  }
25
25
 
26
26
  describe("localpulse", () => {
27
- it("shows root help with expected commands", () => {
27
+ it("shows root help with expected commands and quick start", () => {
28
28
  const { exitCode, stdout } = runCli("--help");
29
29
  expect(exitCode).toBe(0);
30
30
  expect(stdout).toContain("ingest");
31
31
  expect(stdout).toContain("search");
32
32
  expect(stdout).toContain("auth");
33
+ expect(stdout).toContain("--json");
34
+ expect(stdout).toContain("Quick start:");
33
35
  expect(stdout).not.toMatch(/^\s+directives\s/m);
34
36
  expect(stdout).not.toMatch(/^\s+event\s/m);
35
37
  expect(stdout).not.toMatch(/^\s+status\s/m);
@@ -43,12 +45,14 @@ describe("localpulse", () => {
43
45
  expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
44
46
  });
45
47
 
46
- it("shows ingest help with research as primary input", () => {
48
+ it("shows ingest help with research, --json, and examples", () => {
47
49
  const { exitCode, stdout } = runCli("ingest", "--help");
48
50
  expect(exitCode).toBe(0);
49
51
  expect(stdout).toContain("--research");
50
52
  expect(stdout).toContain("--generate-skeleton");
51
53
  expect(stdout).toContain("--dry-run");
54
+ expect(stdout).toContain("--json");
55
+ expect(stdout).toContain("Examples:");
52
56
  expect(stdout).not.toContain("--performers");
53
57
  expect(stdout).not.toContain("--genre");
54
58
  expect(stdout).not.toContain("--socials");
@@ -57,26 +61,41 @@ describe("localpulse", () => {
57
61
  expect(stdout).not.toContain("--batch");
58
62
  });
59
63
 
60
- it("shows search help with --date instead of --time", () => {
64
+ it("shows search help with --date, --all, --json, and examples", () => {
61
65
  const { exitCode, stdout } = runCli("search", "--help");
62
66
  expect(exitCode).toBe(0);
63
67
  expect(stdout).toContain("--date");
64
68
  expect(stdout).toContain("--city");
69
+ expect(stdout).toContain("--all");
70
+ expect(stdout).toContain("--json");
71
+ expect(stdout).toContain("upcoming");
72
+ expect(stdout).toContain("Examples:");
65
73
  expect(stdout).not.toContain("--time");
66
74
  });
67
75
 
68
- it("shows auth help", () => {
76
+ it("shows auth help with examples", () => {
69
77
  const { exitCode, stdout } = runCli("auth", "--help");
70
78
  expect(exitCode).toBe(0);
71
79
  expect(stdout).toContain("login");
72
80
  expect(stdout).toContain("logout");
81
+ expect(stdout).toContain("Examples:");
73
82
  });
74
83
 
75
- it("shows auth login help", () => {
84
+ it("shows drafts help with --status, --json, and examples", () => {
85
+ const { exitCode, stdout } = runCli("drafts", "--help");
86
+ expect(exitCode).toBe(0);
87
+ expect(stdout).toContain("--status");
88
+ expect(stdout).toContain("--json");
89
+ expect(stdout).toContain("Examples:");
90
+ });
91
+
92
+ it("shows auth login help with examples", () => {
76
93
  const { exitCode, stdout } = runCli("auth", "login", "--help");
77
94
  expect(exitCode).toBe(0);
78
95
  expect(stdout).toContain("--token");
79
96
  expect(stdout).toContain("--api-url");
97
+ expect(stdout).toContain("--json");
98
+ expect(stdout).toContain("Examples:");
80
99
  });
81
100
 
82
101
  it("rejects unknown commands", () => {
package/src/index.ts CHANGED
@@ -4,8 +4,10 @@ import { stdin, stdout } from "node:process";
4
4
 
5
5
  import { hasOption, parseArgv, readNumberOption, readStringArrayOption, readStringOption } from "./lib/argv";
6
6
  import { requireToken } from "./lib/auth";
7
- import { searchCliEvents } from "./lib/cli-read-client";
8
- import type { SearchEventCard } from "./lib/cli-read-types";
7
+ import { toFrontendBaseUrl } from "./lib/api-url";
8
+ import { fetchDrafts, searchCliEvents } from "./lib/cli-read-client";
9
+ import { DRAFT_STATUSES } from "./lib/cli-read-types";
10
+ import type { DraftListItem, DraftStatus, SearchEventCard } from "./lib/cli-read-types";
9
11
  import {
10
12
  deleteCredentials,
11
13
  getCredentialsPath,
@@ -60,6 +62,9 @@ async function main(argv: string[]): Promise<void> {
60
62
  case "search":
61
63
  await runSearch(parsed);
62
64
  break;
65
+ case "drafts":
66
+ await runDrafts(parsed);
67
+ break;
63
68
  default:
64
69
  throw new Error(`Unknown command: ${command}. Run \`localpulse --help\` for usage.`);
65
70
  }
@@ -83,7 +88,7 @@ async function runAuth(parsed: ReturnType<typeof parseArgv>): Promise<void> {
83
88
  if (subcommand === "login") {
84
89
  await runAuthLogin(parsed);
85
90
  } else {
86
- await runAuthLogout();
91
+ await runAuthLogout(parsed);
87
92
  }
88
93
  }
89
94
 
@@ -93,6 +98,7 @@ async function runAuthLogin(parsed: ReturnType<typeof parseArgv>): Promise<void>
93
98
  return;
94
99
  }
95
100
 
101
+ const jsonOutput = hasOption(parsed, "json");
96
102
  const apiUrl = readStringOption(parsed, "api-url")?.trim() || getDefaultApiUrl();
97
103
  const token = await resolveLoginToken(readStringOption(parsed, "token"));
98
104
  if (!isLikelyCliToken(token)) {
@@ -100,12 +106,19 @@ async function runAuthLogin(parsed: ReturnType<typeof parseArgv>): Promise<void>
100
106
  }
101
107
 
102
108
  const result = await loginWithToken(apiUrl, token);
103
- stdout.write(`Authenticated. Credentials saved to ${result.credentials_path}\n`);
109
+ if (jsonOutput) {
110
+ stdout.write(`${JSON.stringify({ authenticated: true, credentials_path: result.credentials_path })}\n`);
111
+ } else {
112
+ stdout.write(`Authenticated. Credentials saved to ${result.credentials_path}\n`);
113
+ }
104
114
  }
105
115
 
106
- async function runAuthLogout(): Promise<void> {
116
+ async function runAuthLogout(parsed: ReturnType<typeof parseArgv>): Promise<void> {
117
+ const jsonOutput = hasOption(parsed, "json");
107
118
  const deleted = await deleteCredentials();
108
- if (deleted) {
119
+ if (jsonOutput) {
120
+ stdout.write(`${JSON.stringify({ logged_out: deleted })}\n`);
121
+ } else if (deleted) {
109
122
  stdout.write("Logged out. Credentials removed.\n");
110
123
  } else {
111
124
  stdout.write("No credentials found.\n");
@@ -143,6 +156,7 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
143
156
  const apiUrl = await resolveApiUrl();
144
157
  const dryRun = hasOption(parsed, "dry-run");
145
158
  const force = hasOption(parsed, "force");
159
+ const jsonOutput = hasOption(parsed, "json");
146
160
  const token = dryRun ? "" : await requireToken();
147
161
 
148
162
  const uploadOptions = {
@@ -157,13 +171,16 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
157
171
  venue: readStringOption(parsed, "venue") ?? mapped.venue,
158
172
  extraMedia: readStringArrayOption(parsed, "extra-media"),
159
173
  dryRun,
160
- force,
161
174
  };
162
175
 
163
176
  if (dryRun) {
164
177
  const result = await uploadPoster(apiUrl, token, uploadOptions);
165
178
  if ("dry_run" in result) {
166
- stdout.write(`Dry run passed: ${result.file} (${result.extra_media_count} extra media)\n`);
179
+ if (jsonOutput) {
180
+ stdout.write(`${JSON.stringify(result)}\n`);
181
+ } else {
182
+ stdout.write(`Dry run passed: ${result.file} (${result.extra_media_count} extra media)\n`);
183
+ }
167
184
  }
168
185
  return;
169
186
  }
@@ -171,25 +188,39 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
171
188
  if (force) {
172
189
  const result = await uploadPoster(apiUrl, token, uploadOptions);
173
190
  if (!("dry_run" in result)) {
174
- printIngestResult(result);
191
+ if (jsonOutput) {
192
+ stdout.write(`${JSON.stringify(result)}\n`);
193
+ } else {
194
+ printIngestResult(result);
195
+ }
175
196
  }
176
197
  return;
177
198
  }
178
199
 
179
200
  // Default: draft flow
180
201
  const draft = await runDraftIngest(apiUrl, token, file, uploadOptions);
181
- printDraftResult(draft, apiUrl);
202
+ if (jsonOutput) {
203
+ const baseUrl = toFrontendBaseUrl(apiUrl);
204
+ stdout.write(`${JSON.stringify({ draft_id: draft.id, status: draft.status, edit_url: `${baseUrl}/publish/edit/${draft.id}` })}\n`);
205
+ } else {
206
+ printDraftResult(draft, apiUrl);
207
+ }
182
208
  }
183
209
 
184
210
  async function validateFilePaths(primaryFile: string, extraMedia?: string[]): Promise<void> {
185
- const { existsSync } = await import("node:fs");
186
- const missing: string[] = [];
187
- if (!existsSync(primaryFile)) missing.push(primaryFile);
188
- for (const p of extraMedia ?? []) {
189
- if (!existsSync(p)) missing.push(p);
211
+ const { access } = await import("node:fs/promises");
212
+
213
+ if (extraMedia && extraMedia.length > 2) {
214
+ throw new Error("`--extra-media` supports at most 2 files.");
190
215
  }
191
- if (missing.length) {
192
- throw new Error(`File(s) not found: ${missing.join(", ")}`);
216
+
217
+ const checkFile = async (path: string, label: string) => {
218
+ try { await access(path); } catch { throw new Error(`File not found: ${path} (${label})`); }
219
+ };
220
+
221
+ await checkFile(primaryFile, "file");
222
+ for (const [i, p] of (extraMedia ?? []).entries()) {
223
+ await checkFile(p, `extra-media[${i}]`);
193
224
  }
194
225
  }
195
226
 
@@ -201,11 +232,11 @@ async function runDraftIngest(
201
232
  ): Promise<DraftResult> {
202
233
  await validateFilePaths(primaryFile, options.extraMedia);
203
234
 
204
- stdout.write("Creating draft...\n");
235
+ process.stderr.write("Creating draft...\n");
205
236
  const draft = await createDraft(apiUrl, token, options);
206
237
 
207
- stdout.write(`Draft created: ${draft.id}\n`);
208
- stdout.write("Uploading primary media...\n");
238
+ process.stderr.write(`Draft created: ${draft.id}\n`);
239
+ process.stderr.write("Uploading primary media...\n");
209
240
  await uploadDraftMedia(apiUrl, token, draft.id, primaryFile, {
210
241
  mediaType: detectMediaType(primaryFile),
211
242
  sortOrder: 0,
@@ -214,7 +245,7 @@ async function runDraftIngest(
214
245
 
215
246
  if (options.extraMedia?.length) {
216
247
  for (const [index, path] of options.extraMedia.entries()) {
217
- stdout.write(`Uploading extra media ${index + 1}...\n`);
248
+ process.stderr.write(`Uploading extra media ${index + 1}...\n`);
218
249
  await uploadDraftMedia(apiUrl, token, draft.id, path, {
219
250
  mediaType: detectMediaType(path),
220
251
  sortOrder: index + 1,
@@ -227,7 +258,7 @@ async function runDraftIngest(
227
258
  }
228
259
 
229
260
  function printDraftResult(draft: DraftResult, apiUrl: string): void {
230
- const baseUrl = apiUrl.replace(/\/api\/?$/, "").replace(/\/$/, "");
261
+ const baseUrl = toFrontendBaseUrl(apiUrl);
231
262
  stdout.write(`\nDraft saved: ${draft.id}\n`);
232
263
  stdout.write("Open the publish dashboard to review and submit:\n");
233
264
  stdout.write(` ${baseUrl}/publish/edit/${draft.id}\n`);
@@ -254,19 +285,29 @@ async function runSearch(parsed: ReturnType<typeof parseArgv>): Promise<void> {
254
285
 
255
286
  const query = parsed.positionals.join(" ").trim();
256
287
  if (!query) {
257
- throw new Error("Search query is required. Run `localpulse search --help` for usage.");
288
+ throw new Error("Search query is required.\n localpulse search \"amsterdam\"\n localpulse search --help");
258
289
  }
259
290
 
291
+ const jsonOutput = hasOption(parsed, "json");
292
+ const allEvents = hasOption(parsed, "all");
293
+ const dateFilter = readDateFilterOption(parsed);
294
+ const time_intent = allEvents ? undefined : (dateFilter ?? "upcoming");
295
+
260
296
  const apiUrl = await resolveApiUrl();
261
297
  const result = await searchCliEvents(apiUrl, {
262
298
  query,
263
299
  city: readStringOption(parsed, "city"),
264
- time_intent: readDateFilterOption(parsed),
300
+ time_intent,
265
301
  timezone: readStringOption(parsed, "tz"),
266
302
  limit: readNumberOption(parsed, "limit") ?? 10,
267
303
  cursor: readNumberOption(parsed, "cursor") ?? 0,
268
304
  });
269
305
 
306
+ if (jsonOutput) {
307
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
308
+ return;
309
+ }
310
+
270
311
  if (result.results.length === 0) {
271
312
  stdout.write("No events found.\n");
272
313
  return;
@@ -295,6 +336,63 @@ function printSearchCard(card: SearchEventCard): void {
295
336
  stdout.write(` ${card.frontend_url}\n`);
296
337
  }
297
338
 
339
+ const VALID_DRAFT_STATUSES: ReadonlySet<string> = new Set<DraftStatus>(DRAFT_STATUSES);
340
+
341
+ function countByStatus(drafts: DraftListItem[]): Record<DraftStatus, number> {
342
+ const counts: Record<DraftStatus, number> = { uploading: 0, processing: 0, ready: 0, failed: 0 };
343
+ for (const d of drafts) counts[d.status]++;
344
+ return counts;
345
+ }
346
+
347
+ async function runDrafts(parsed: ReturnType<typeof parseArgv>): Promise<void> {
348
+ if (hasOption(parsed, "help")) {
349
+ stdout.write(draftsHelp());
350
+ return;
351
+ }
352
+
353
+ const jsonOutput = hasOption(parsed, "json");
354
+ const statusFilter = readStringOption(parsed, "status");
355
+
356
+ if (statusFilter && !VALID_DRAFT_STATUSES.has(statusFilter)) {
357
+ throw new Error(
358
+ `\`--status\` must be \`uploading\`, \`processing\`, \`ready\`, or \`failed\`.\n localpulse drafts --status failed`,
359
+ );
360
+ }
361
+
362
+ const apiUrl = await resolveApiUrl();
363
+ const token = await requireToken();
364
+ const drafts = await fetchDrafts(apiUrl, token, statusFilter as DraftStatus | undefined);
365
+ const counts = countByStatus(drafts);
366
+
367
+ if (jsonOutput) {
368
+ stdout.write(`${JSON.stringify({ total: drafts.length, counts, drafts }, null, 2)}\n`);
369
+ return;
370
+ }
371
+
372
+ if (drafts.length === 0) {
373
+ stdout.write("No drafts.\n");
374
+ return;
375
+ }
376
+
377
+ const summary = Object.entries(counts).filter(([, n]) => n > 0).map(([s, n]) => `${n} ${s}`).join(", ");
378
+ stdout.write(`Drafts: ${drafts.length} total (${summary})\n\n`);
379
+
380
+ const baseUrl = toFrontendBaseUrl(apiUrl);
381
+ for (const draft of drafts) {
382
+ printDraftListItem(draft, baseUrl);
383
+ }
384
+ }
385
+
386
+ function printDraftListItem(draft: DraftListItem, baseUrl: string): void {
387
+ const title = draft.metadata?.event_title ? `"${draft.metadata.event_title}"` : "";
388
+ const status = draft.status.padEnd(10);
389
+ stdout.write(`${draft.id} ${status} ${draft.updated_at} ${title}\n`);
390
+ if (draft.status === "failed" && draft.error_message) {
391
+ stdout.write(` Error: ${draft.error_message}\n`);
392
+ }
393
+ stdout.write(` ${baseUrl}/publish/edit/${draft.id}\n`);
394
+ }
395
+
298
396
  async function resolveLoginToken(explicitToken?: string): Promise<string> {
299
397
  if (explicitToken?.trim()) {
300
398
  return explicitToken.trim();
@@ -321,15 +419,15 @@ async function resolveLoginToken(explicitToken?: string): Promise<string> {
321
419
 
322
420
  function readDateFilterOption(
323
421
  parsed: ReturnType<typeof parseArgv>,
324
- ): "today" | "weekend" | undefined {
422
+ ): "today" | "weekend" | "upcoming" | undefined {
325
423
  const value = readStringOption(parsed, "date");
326
424
  if (!value) {
327
425
  return undefined;
328
426
  }
329
- if (value === "today" || value === "weekend") {
427
+ if (value === "today" || value === "weekend" || value === "upcoming") {
330
428
  return value;
331
429
  }
332
- throw new Error("`--date` must be `today` or `weekend`.");
430
+ throw new Error("`--date` must be `today`, `weekend`, or `upcoming`.\n localpulse search \"amsterdam\" --date today --tz Europe/Amsterdam");
333
431
  }
334
432
 
335
433
  function rootHelp(): string {
@@ -337,16 +435,23 @@ function rootHelp(): string {
337
435
 
338
436
  Commands:
339
437
  ingest Ingest an event poster into Local Pulse
340
- search Search existing events
438
+ search Search upcoming events
439
+ drafts List your submission drafts
341
440
  auth Login and logout
342
441
 
343
442
  Options:
344
443
  --help Show help
345
444
  --version Show version
445
+ --json Output structured JSON (supported by all commands)
346
446
 
347
447
  Environment:
348
448
  LP_TOKEN Override stored auth token
349
449
  LP_API_URL Override API base URL (default: https://localpulse.nl)
450
+
451
+ Quick start:
452
+ localpulse auth login --token <token>
453
+ localpulse search "amsterdam"
454
+ localpulse ingest poster.jpg --research data.json
350
455
  `;
351
456
  }
352
457
 
@@ -357,7 +462,10 @@ Commands:
357
462
  login Authenticate with a Local Pulse CLI token
358
463
  logout Remove stored credentials
359
464
 
360
- Run \`localpulse auth login --help\` for login options.
465
+ Examples:
466
+ localpulse auth login --token lp_cli_...
467
+ LP_TOKEN=lp_cli_... localpulse auth login
468
+ localpulse auth logout
361
469
  `;
362
470
  }
363
471
 
@@ -367,9 +475,15 @@ function authLoginHelp(): string {
367
475
  Options:
368
476
  --token <token> Local Pulse CLI token
369
477
  --api-url <url> Override API base URL
478
+ --json Output structured JSON
370
479
  --help Show this help
371
480
 
372
481
  Without --token, prompts interactively (visit https://localpulse.nl/dev to create a token).
482
+
483
+ Examples:
484
+ localpulse auth login --token lp_cli_...
485
+ localpulse auth login --token lp_cli_... --json
486
+ LP_TOKEN=lp_cli_... localpulse auth login
373
487
  `;
374
488
  }
375
489
 
@@ -441,29 +555,60 @@ Extra:
441
555
  --extra-media <file>... Up to 2 additional poster or flyer images
442
556
 
443
557
  Options:
558
+ --json Output structured JSON
444
559
  --help Show this help
445
560
 
446
- Example:
561
+ Examples:
447
562
  localpulse ingest --generate-skeleton > research.json
448
- # research and fill in the payload
449
- localpulse ingest ./poster.jpg --research research.json
563
+ localpulse ingest poster.jpg --research research.json
564
+ localpulse ingest poster.jpg --research research.json --force
565
+ localpulse ingest poster.jpg --research research.json --json
566
+ cat research.json | localpulse ingest poster.jpg --research -
450
567
  `;
451
568
  }
452
569
 
453
570
  function searchHelp(): string {
454
571
  return `Usage: localpulse search <query> [options]
455
572
 
456
- Search existing events on Local Pulse.
573
+ Search events on Local Pulse. Returns upcoming events by default.
457
574
 
458
575
  Arguments:
459
576
  query Free-text search query
460
577
 
461
578
  Options:
462
579
  --city <text> Filter by city
463
- --date <today|weekend> Time intent filter
580
+ --date <today|weekend|upcoming> Time intent filter (default: upcoming)
464
581
  --tz <timezone> IANA timezone (requires --date)
582
+ --all Include past events
583
+ --json Output structured JSON
465
584
  --limit <n> Results per page (1-25, default 10)
466
585
  --cursor <n> Pagination offset
467
586
  --help Show this help
587
+
588
+ Examples:
589
+ localpulse search "amsterdam"
590
+ localpulse search "techno" --city Amsterdam
591
+ localpulse search "festival" --date weekend --tz Europe/Amsterdam
592
+ localpulse search "amsterdam" --all
593
+ localpulse search "berlin" --json
594
+ localpulse search "amsterdam" --json | jq '.results[].frontend_url'
595
+ `;
596
+ }
597
+
598
+ function draftsHelp(): string {
599
+ return `Usage: localpulse drafts [options]
600
+
601
+ List your event submission drafts and their status.
602
+
603
+ Options:
604
+ --status <uploading|processing|ready|failed> Filter by status
605
+ --json Output structured JSON
606
+ --help Show this help
607
+
608
+ Examples:
609
+ localpulse drafts
610
+ localpulse drafts --status failed
611
+ localpulse drafts --json
612
+ localpulse drafts --json | jq '.counts'
468
613
  `;
469
614
  }
@@ -13,3 +13,7 @@ export function buildApiUrl(apiUrl: string, path: string): string {
13
13
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
14
14
  return `${normalizeApiUrl(apiUrl)}${normalizedPath}`;
15
15
  }
16
+
17
+ export function toFrontendBaseUrl(apiUrl: string): string {
18
+ return apiUrl.replace(/\/api\/?$/, "").replace(/\/$/, "");
19
+ }
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, mock } from "bun:test";
3
3
  import { CliApiError } from "./api-response";
4
4
  import {
5
5
  fetchCliInfo,
6
+ fetchDrafts,
6
7
  searchCliEvents,
7
8
  } from "./cli-read-client";
8
9
 
@@ -70,6 +71,53 @@ describe("cli-read-client", () => {
70
71
  });
71
72
  });
72
73
 
74
+ it("encodes upcoming time_intent in query params", async () => {
75
+ globalThis.fetch = mock(async (input) => {
76
+ expect(String(input)).toBe(
77
+ "https://localpulse.nl/api/cli/events/search?query=berlin&limit=10&cursor=0&time_intent=upcoming",
78
+ );
79
+ return new Response(
80
+ JSON.stringify({ results: [], cursor: 0, next_cursor: null }),
81
+ { status: 200, headers: { "Content-Type": "application/json" } },
82
+ );
83
+ }) as typeof fetch;
84
+
85
+ expect(
86
+ await searchCliEvents("https://localpulse.nl", {
87
+ query: "berlin",
88
+ time_intent: "upcoming",
89
+ limit: 10,
90
+ cursor: 0,
91
+ }),
92
+ ).toEqual({ results: [], cursor: 0, next_cursor: null });
93
+ });
94
+
95
+ it("fetches drafts with auth header", async () => {
96
+ globalThis.fetch = mock(async (input, init) => {
97
+ expect(String(input)).toBe("https://localpulse.nl/api/drafts");
98
+ expect(init?.headers).toMatchObject({ authorization: "Bearer test-token" });
99
+ return new Response(
100
+ JSON.stringify([
101
+ { id: "abc", status: "uploading", error_message: null, metadata: null, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" },
102
+ ]),
103
+ { status: 200, headers: { "Content-Type": "application/json" } },
104
+ );
105
+ }) as typeof fetch;
106
+
107
+ const result = await fetchDrafts("https://localpulse.nl", "test-token");
108
+ expect(result).toHaveLength(1);
109
+ expect(result[0].status).toBe("uploading");
110
+ });
111
+
112
+ it("passes status filter to drafts endpoint", async () => {
113
+ globalThis.fetch = mock(async (input) => {
114
+ expect(String(input)).toBe("https://localpulse.nl/api/drafts?status=failed");
115
+ return new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" } });
116
+ }) as typeof fetch;
117
+
118
+ expect(await fetchDrafts("https://localpulse.nl", "test-token", "failed")).toEqual([]);
119
+ });
120
+
73
121
  it("surfaces REST error responses", async () => {
74
122
  globalThis.fetch = mock(async () => {
75
123
  return new Response(
@@ -7,6 +7,8 @@ import {
7
7
  import { buildApiUrl } from "./api-url";
8
8
  import type {
9
9
  CliInfoResult,
10
+ DraftListItem,
11
+ DraftStatus,
10
12
  SearchEventsResult,
11
13
  } from "./cli-read-types";
12
14
 
@@ -20,7 +22,7 @@ export async function searchCliEvents(
20
22
  args: {
21
23
  query: string;
22
24
  city?: string;
23
- time_intent?: "today" | "weekend";
25
+ time_intent?: "today" | "weekend" | "upcoming";
24
26
  timezone?: string;
25
27
  limit: number;
26
28
  cursor: number;
@@ -84,6 +86,42 @@ function parseCliInfoResult(payload: ApiResponseBody): CliInfoResult {
84
86
  return result as CliInfoResult;
85
87
  }
86
88
 
89
+ export async function fetchDrafts(
90
+ apiUrl: string,
91
+ token: string,
92
+ status?: DraftStatus,
93
+ ): Promise<DraftListItem[]> {
94
+ const path = status ? `/api/drafts?status=${encodeURIComponent(status)}` : "/api/drafts";
95
+ const response = await fetch(buildApiUrl(apiUrl, path), {
96
+ method: "GET",
97
+ headers: {
98
+ accept: "application/json",
99
+ authorization: `Bearer ${token}`,
100
+ },
101
+ });
102
+
103
+ const text = await response.text();
104
+ let parsed: unknown;
105
+ try {
106
+ parsed = JSON.parse(text);
107
+ } catch {
108
+ throw new CliApiError("Drafts fetch returned non-JSON response", { httpStatus: response.status });
109
+ }
110
+
111
+ if (!response.ok) {
112
+ const body = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as ApiResponseBody : undefined;
113
+ throw new CliApiError(extractApiErrorMessage(body ?? {}, `Drafts fetch failed (${response.status})`), {
114
+ httpStatus: response.status,
115
+ body,
116
+ });
117
+ }
118
+
119
+ if (!Array.isArray(parsed)) {
120
+ throw new Error("Invalid API response: expected drafts array.");
121
+ }
122
+ return parsed as DraftListItem[];
123
+ }
124
+
87
125
  function parseSearchEventsResult(payload: ApiResponseBody): SearchEventsResult {
88
126
  const result = payload as Partial<SearchEventsResult>;
89
127
  if (
@@ -25,3 +25,15 @@ export type SearchEventsResult = {
25
25
  cursor: number;
26
26
  next_cursor: number | null;
27
27
  };
28
+
29
+ export const DRAFT_STATUSES = ["uploading", "processing", "ready", "failed"] as const;
30
+ export type DraftStatus = (typeof DRAFT_STATUSES)[number];
31
+
32
+ export type DraftListItem = {
33
+ id: string;
34
+ status: DraftStatus;
35
+ error_message: string | null;
36
+ metadata: { event_title?: string; venue_name?: string; poster_id?: string } | null;
37
+ created_at: string;
38
+ updated_at: string;
39
+ };
@@ -23,8 +23,6 @@ export type IngestUploadOptions = {
23
23
  venue?: string;
24
24
  extraMedia?: string[];
25
25
  dryRun?: boolean;
26
- /** When true, bypass drafts and upload directly to the ingestion pipeline. */
27
- force?: boolean;
28
26
  };
29
27
 
30
28
  export type UploadDryRunResult = {