@localpulse/cli 0.0.1 → 0.0.3

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.3",
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,13 +4,16 @@ 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,
12
14
  getDefaultApiUrl,
13
15
  resolveApiUrl,
16
+ resolveToken,
14
17
  } from "./lib/credentials";
15
18
  import { loginWithToken } from "./lib/login";
16
19
  import { exitCodeForError, printError } from "./lib/output";
@@ -26,8 +29,7 @@ import {
26
29
  type DraftResult,
27
30
  uploadPoster,
28
31
  createDraft,
29
- uploadDraftMedia,
30
- detectMediaType,
32
+ verifyCliToken,
31
33
  } from "./lib/upload-client";
32
34
  import packageJson from "../package.json";
33
35
 
@@ -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
  }
@@ -76,14 +81,16 @@ async function runAuth(parsed: ReturnType<typeof parseArgv>): Promise<void> {
76
81
  }
77
82
 
78
83
  const subcommand = parsed.positionals[0];
79
- if (!subcommand || (subcommand !== "login" && subcommand !== "logout")) {
80
- throw new Error("Usage: localpulse auth <login|logout>. Run `localpulse auth --help` for details.");
84
+ if (!subcommand || !["login", "logout", "status"].includes(subcommand)) {
85
+ throw new Error("Usage: localpulse auth <login|logout|status>. Run `localpulse auth --help` for details.");
81
86
  }
82
87
 
83
88
  if (subcommand === "login") {
84
89
  await runAuthLogin(parsed);
90
+ } else if (subcommand === "status") {
91
+ await runAuthStatus(parsed);
85
92
  } else {
86
- await runAuthLogout();
93
+ await runAuthLogout(parsed);
87
94
  }
88
95
  }
89
96
 
@@ -93,6 +100,7 @@ async function runAuthLogin(parsed: ReturnType<typeof parseArgv>): Promise<void>
93
100
  return;
94
101
  }
95
102
 
103
+ const jsonOutput = hasOption(parsed, "json");
96
104
  const apiUrl = readStringOption(parsed, "api-url")?.trim() || getDefaultApiUrl();
97
105
  const token = await resolveLoginToken(readStringOption(parsed, "token"));
98
106
  if (!isLikelyCliToken(token)) {
@@ -100,18 +108,64 @@ async function runAuthLogin(parsed: ReturnType<typeof parseArgv>): Promise<void>
100
108
  }
101
109
 
102
110
  const result = await loginWithToken(apiUrl, token);
103
- stdout.write(`Authenticated. Credentials saved to ${result.credentials_path}\n`);
111
+ if (jsonOutput) {
112
+ stdout.write(`${JSON.stringify({ authenticated: true, credentials_path: result.credentials_path })}\n`);
113
+ } else {
114
+ stdout.write(`Authenticated. Credentials saved to ${result.credentials_path}\n`);
115
+ }
104
116
  }
105
117
 
106
- async function runAuthLogout(): Promise<void> {
118
+ async function runAuthLogout(parsed: ReturnType<typeof parseArgv>): Promise<void> {
119
+ const jsonOutput = hasOption(parsed, "json");
107
120
  const deleted = await deleteCredentials();
108
- if (deleted) {
121
+ if (jsonOutput) {
122
+ stdout.write(`${JSON.stringify({ logged_out: deleted })}\n`);
123
+ } else if (deleted) {
109
124
  stdout.write("Logged out. Credentials removed.\n");
110
125
  } else {
111
126
  stdout.write("No credentials found.\n");
112
127
  }
113
128
  }
114
129
 
130
+ async function runAuthStatus(parsed: ReturnType<typeof parseArgv>): Promise<void> {
131
+ const jsonOutput = hasOption(parsed, "json");
132
+ const token = await resolveToken();
133
+ const apiUrl = await resolveApiUrl();
134
+ const credentialsPath = getCredentialsPath();
135
+ const source = process.env.LP_TOKEN?.trim() ? "LP_TOKEN" : credentialsPath;
136
+
137
+ if (!token) {
138
+ if (jsonOutput) {
139
+ stdout.write(`${JSON.stringify({ authenticated: false, source: null, reason: "no_token" })}\n`);
140
+ } else {
141
+ stdout.write("Not authenticated. Run `localpulse auth login` or set LP_TOKEN.\n");
142
+ }
143
+ process.exitCode = 1;
144
+ return;
145
+ }
146
+
147
+ try {
148
+ const result = await verifyCliToken(apiUrl, token);
149
+ if (jsonOutput) {
150
+ stdout.write(`${JSON.stringify({ authenticated: true, email: result.email, api_url: apiUrl, source })}\n`);
151
+ } else {
152
+ stdout.write(`Authenticated as ${result.email}\n`);
153
+ stdout.write(` API: ${apiUrl}\n`);
154
+ stdout.write(` Credentials: ${source}\n`);
155
+ }
156
+ } catch (error) {
157
+ const message = error instanceof Error ? error.message : "Token verification failed";
158
+ if (jsonOutput) {
159
+ stdout.write(`${JSON.stringify({ authenticated: false, api_url: apiUrl, source, error: message })}\n`);
160
+ } else {
161
+ stdout.write(`Authentication failed: ${message}\n`);
162
+ stdout.write(` API: ${apiUrl}\n`);
163
+ stdout.write(` Credentials: ${source}\n`);
164
+ }
165
+ process.exitCode = 1;
166
+ }
167
+ }
168
+
115
169
  async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
116
170
  if (hasOption(parsed, "help")) {
117
171
  stdout.write(ingestHelp());
@@ -143,6 +197,7 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
143
197
  const apiUrl = await resolveApiUrl();
144
198
  const dryRun = hasOption(parsed, "dry-run");
145
199
  const force = hasOption(parsed, "force");
200
+ const jsonOutput = hasOption(parsed, "json");
146
201
  const token = dryRun ? "" : await requireToken();
147
202
 
148
203
  const uploadOptions = {
@@ -157,13 +212,16 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
157
212
  venue: readStringOption(parsed, "venue") ?? mapped.venue,
158
213
  extraMedia: readStringArrayOption(parsed, "extra-media"),
159
214
  dryRun,
160
- force,
161
215
  };
162
216
 
163
217
  if (dryRun) {
164
218
  const result = await uploadPoster(apiUrl, token, uploadOptions);
165
219
  if ("dry_run" in result) {
166
- stdout.write(`Dry run passed: ${result.file} (${result.extra_media_count} extra media)\n`);
220
+ if (jsonOutput) {
221
+ stdout.write(`${JSON.stringify(result)}\n`);
222
+ } else {
223
+ stdout.write(`Dry run passed: ${result.file} (${result.extra_media_count} extra media)\n`);
224
+ }
167
225
  }
168
226
  return;
169
227
  }
@@ -171,25 +229,42 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
171
229
  if (force) {
172
230
  const result = await uploadPoster(apiUrl, token, uploadOptions);
173
231
  if (!("dry_run" in result)) {
174
- printIngestResult(result);
232
+ if (jsonOutput) {
233
+ stdout.write(`${JSON.stringify(result)}\n`);
234
+ } else {
235
+ printIngestResult(result);
236
+ }
175
237
  }
176
238
  return;
177
239
  }
178
240
 
179
- // Default: draft flow
241
+ // Default: draft flow — create draft then submit to ingestion pipeline
180
242
  const draft = await runDraftIngest(apiUrl, token, file, uploadOptions);
181
- printDraftResult(draft, apiUrl);
243
+ process.stderr.write("Submitting to ingestion pipeline...\n");
244
+ const result = await uploadPoster(apiUrl, token, { ...uploadOptions, draftId: draft.id }) as UploadPosterResult;
245
+ if (jsonOutput) {
246
+ stdout.write(`${JSON.stringify({ draft_id: draft.id, ...result })}\n`);
247
+ } else {
248
+ printIngestResult(result);
249
+ const baseUrl = toFrontendBaseUrl(apiUrl);
250
+ stdout.write(`Draft: ${baseUrl}/publish/edit/${draft.id}\n`);
251
+ }
182
252
  }
183
253
 
184
254
  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);
255
+ const { access } = await import("node:fs/promises");
256
+
257
+ if (extraMedia && extraMedia.length > 2) {
258
+ throw new Error("`--extra-media` supports at most 2 files.");
190
259
  }
191
- if (missing.length) {
192
- throw new Error(`File(s) not found: ${missing.join(", ")}`);
260
+
261
+ const checkFile = async (path: string, label: string) => {
262
+ try { await access(path); } catch { throw new Error(`File not found: ${path} (${label})`); }
263
+ };
264
+
265
+ await checkFile(primaryFile, "file");
266
+ for (const [i, p] of (extraMedia ?? []).entries()) {
267
+ await checkFile(p, `extra-media[${i}]`);
193
268
  }
194
269
  }
195
270
 
@@ -201,38 +276,13 @@ async function runDraftIngest(
201
276
  ): Promise<DraftResult> {
202
277
  await validateFilePaths(primaryFile, options.extraMedia);
203
278
 
204
- stdout.write("Creating draft...\n");
279
+ process.stderr.write("Creating draft...\n");
205
280
  const draft = await createDraft(apiUrl, token, options);
206
-
207
- stdout.write(`Draft created: ${draft.id}\n`);
208
- stdout.write("Uploading primary media...\n");
209
- await uploadDraftMedia(apiUrl, token, draft.id, primaryFile, {
210
- mediaType: detectMediaType(primaryFile),
211
- sortOrder: 0,
212
- isPrimary: true,
213
- });
214
-
215
- if (options.extraMedia?.length) {
216
- for (const [index, path] of options.extraMedia.entries()) {
217
- stdout.write(`Uploading extra media ${index + 1}...\n`);
218
- await uploadDraftMedia(apiUrl, token, draft.id, path, {
219
- mediaType: detectMediaType(path),
220
- sortOrder: index + 1,
221
- isPrimary: false,
222
- });
223
- }
224
- }
281
+ process.stderr.write(`Draft created: ${draft.id}\n`);
225
282
 
226
283
  return draft;
227
284
  }
228
285
 
229
- function printDraftResult(draft: DraftResult, apiUrl: string): void {
230
- const baseUrl = apiUrl.replace(/\/api\/?$/, "").replace(/\/$/, "");
231
- stdout.write(`\nDraft saved: ${draft.id}\n`);
232
- stdout.write("Open the publish dashboard to review and submit:\n");
233
- stdout.write(` ${baseUrl}/publish/edit/${draft.id}\n`);
234
- }
235
-
236
286
  function printIngestResult(result: UploadPosterResult): void {
237
287
  stdout.write(`${result.message}\n`);
238
288
  if (result.run_id) {
@@ -254,19 +304,29 @@ async function runSearch(parsed: ReturnType<typeof parseArgv>): Promise<void> {
254
304
 
255
305
  const query = parsed.positionals.join(" ").trim();
256
306
  if (!query) {
257
- throw new Error("Search query is required. Run `localpulse search --help` for usage.");
307
+ throw new Error("Search query is required.\n localpulse search \"amsterdam\"\n localpulse search --help");
258
308
  }
259
309
 
310
+ const jsonOutput = hasOption(parsed, "json");
311
+ const allEvents = hasOption(parsed, "all");
312
+ const dateFilter = readDateFilterOption(parsed);
313
+ const time_intent = allEvents ? undefined : (dateFilter ?? "upcoming");
314
+
260
315
  const apiUrl = await resolveApiUrl();
261
316
  const result = await searchCliEvents(apiUrl, {
262
317
  query,
263
318
  city: readStringOption(parsed, "city"),
264
- time_intent: readDateFilterOption(parsed),
319
+ time_intent,
265
320
  timezone: readStringOption(parsed, "tz"),
266
321
  limit: readNumberOption(parsed, "limit") ?? 10,
267
322
  cursor: readNumberOption(parsed, "cursor") ?? 0,
268
323
  });
269
324
 
325
+ if (jsonOutput) {
326
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
327
+ return;
328
+ }
329
+
270
330
  if (result.results.length === 0) {
271
331
  stdout.write("No events found.\n");
272
332
  return;
@@ -295,6 +355,63 @@ function printSearchCard(card: SearchEventCard): void {
295
355
  stdout.write(` ${card.frontend_url}\n`);
296
356
  }
297
357
 
358
+ const VALID_DRAFT_STATUSES: ReadonlySet<string> = new Set<DraftStatus>(DRAFT_STATUSES);
359
+
360
+ function countByStatus(drafts: DraftListItem[]): Record<DraftStatus, number> {
361
+ const counts: Record<DraftStatus, number> = { uploading: 0, processing: 0, ready: 0, failed: 0 };
362
+ for (const d of drafts) counts[d.status]++;
363
+ return counts;
364
+ }
365
+
366
+ async function runDrafts(parsed: ReturnType<typeof parseArgv>): Promise<void> {
367
+ if (hasOption(parsed, "help")) {
368
+ stdout.write(draftsHelp());
369
+ return;
370
+ }
371
+
372
+ const jsonOutput = hasOption(parsed, "json");
373
+ const statusFilter = readStringOption(parsed, "status");
374
+
375
+ if (statusFilter && !VALID_DRAFT_STATUSES.has(statusFilter)) {
376
+ throw new Error(
377
+ `\`--status\` must be \`uploading\`, \`processing\`, \`ready\`, or \`failed\`.\n localpulse drafts --status failed`,
378
+ );
379
+ }
380
+
381
+ const apiUrl = await resolveApiUrl();
382
+ const token = await requireToken();
383
+ const drafts = await fetchDrafts(apiUrl, token, statusFilter as DraftStatus | undefined);
384
+ const counts = countByStatus(drafts);
385
+
386
+ if (jsonOutput) {
387
+ stdout.write(`${JSON.stringify({ total: drafts.length, counts, drafts }, null, 2)}\n`);
388
+ return;
389
+ }
390
+
391
+ if (drafts.length === 0) {
392
+ stdout.write("No drafts.\n");
393
+ return;
394
+ }
395
+
396
+ const summary = Object.entries(counts).filter(([, n]) => n > 0).map(([s, n]) => `${n} ${s}`).join(", ");
397
+ stdout.write(`Drafts: ${drafts.length} total (${summary})\n\n`);
398
+
399
+ const baseUrl = toFrontendBaseUrl(apiUrl);
400
+ for (const draft of drafts) {
401
+ printDraftListItem(draft, baseUrl);
402
+ }
403
+ }
404
+
405
+ function printDraftListItem(draft: DraftListItem, baseUrl: string): void {
406
+ const title = draft.metadata?.event_title ? `"${draft.metadata.event_title}"` : "";
407
+ const status = draft.status.padEnd(10);
408
+ stdout.write(`${draft.id} ${status} ${draft.updated_at} ${title}\n`);
409
+ if (draft.status === "failed" && draft.error_message) {
410
+ stdout.write(` Error: ${draft.error_message}\n`);
411
+ }
412
+ stdout.write(` ${baseUrl}/publish/edit/${draft.id}\n`);
413
+ }
414
+
298
415
  async function resolveLoginToken(explicitToken?: string): Promise<string> {
299
416
  if (explicitToken?.trim()) {
300
417
  return explicitToken.trim();
@@ -321,15 +438,15 @@ async function resolveLoginToken(explicitToken?: string): Promise<string> {
321
438
 
322
439
  function readDateFilterOption(
323
440
  parsed: ReturnType<typeof parseArgv>,
324
- ): "today" | "weekend" | undefined {
441
+ ): "today" | "weekend" | "upcoming" | undefined {
325
442
  const value = readStringOption(parsed, "date");
326
443
  if (!value) {
327
444
  return undefined;
328
445
  }
329
- if (value === "today" || value === "weekend") {
446
+ if (value === "today" || value === "weekend" || value === "upcoming") {
330
447
  return value;
331
448
  }
332
- throw new Error("`--date` must be `today` or `weekend`.");
449
+ throw new Error("`--date` must be `today`, `weekend`, or `upcoming`.\n localpulse search \"amsterdam\" --date today --tz Europe/Amsterdam");
333
450
  }
334
451
 
335
452
  function rootHelp(): string {
@@ -337,16 +454,23 @@ function rootHelp(): string {
337
454
 
338
455
  Commands:
339
456
  ingest Ingest an event poster into Local Pulse
340
- search Search existing events
457
+ search Search upcoming events
458
+ drafts List your submission drafts
341
459
  auth Login and logout
342
460
 
343
461
  Options:
344
462
  --help Show help
345
463
  --version Show version
464
+ --json Output structured JSON (supported by all commands)
346
465
 
347
466
  Environment:
348
467
  LP_TOKEN Override stored auth token
349
468
  LP_API_URL Override API base URL (default: https://localpulse.nl)
469
+
470
+ Quick start:
471
+ localpulse auth login --token <token>
472
+ localpulse search "amsterdam"
473
+ localpulse ingest poster.jpg --research data.json
350
474
  `;
351
475
  }
352
476
 
@@ -356,8 +480,13 @@ function authHelp(): string {
356
480
  Commands:
357
481
  login Authenticate with a Local Pulse CLI token
358
482
  logout Remove stored credentials
483
+ status Check authentication status and verify token
359
484
 
360
- Run \`localpulse auth login --help\` for login options.
485
+ Examples:
486
+ localpulse auth login --token lp_cli_...
487
+ localpulse auth status
488
+ localpulse auth status --json
489
+ localpulse auth logout
361
490
  `;
362
491
  }
363
492
 
@@ -367,9 +496,15 @@ function authLoginHelp(): string {
367
496
  Options:
368
497
  --token <token> Local Pulse CLI token
369
498
  --api-url <url> Override API base URL
499
+ --json Output structured JSON
370
500
  --help Show this help
371
501
 
372
502
  Without --token, prompts interactively (visit https://localpulse.nl/dev to create a token).
503
+
504
+ Examples:
505
+ localpulse auth login --token lp_cli_...
506
+ localpulse auth login --token lp_cli_... --json
507
+ LP_TOKEN=lp_cli_... localpulse auth login
373
508
  `;
374
509
  }
375
510
 
@@ -388,7 +523,16 @@ Research payload:
388
523
  --generate-skeleton Print an example research template and exit
389
524
 
390
525
  The payload is JSON with five top-level sections. All are optional,
391
- but the more you provide, the better the enrichment.
526
+ but richer research produces much better event listings.
527
+
528
+ Quality checklist (aim to fill as many as possible):
529
+ ✓ Performer socials: Instagram, Spotify, Bandcamp, SoundCloud, RA, website
530
+ ✓ Organizer socials: Instagram, website, RA promoter page
531
+ ✓ Venue google_place_id (search Google Maps → share → extract place ID)
532
+ ✓ Multiple event.urls: venue page, RA, Facebook event, artist page
533
+ ✓ Embed URLs in context: Spotify album/track links, YouTube videos
534
+ ✓ Performer context: recent releases, labels, residencies, tour info
535
+ ✓ All support acts as separate performer entries
392
536
 
393
537
  performers[] Who is performing or presenting
394
538
  .name Full name (required per performer)
@@ -441,29 +585,60 @@ Extra:
441
585
  --extra-media <file>... Up to 2 additional poster or flyer images
442
586
 
443
587
  Options:
588
+ --json Output structured JSON
444
589
  --help Show this help
445
590
 
446
- Example:
591
+ Examples:
447
592
  localpulse ingest --generate-skeleton > research.json
448
- # research and fill in the payload
449
- localpulse ingest ./poster.jpg --research research.json
593
+ localpulse ingest poster.jpg --research research.json
594
+ localpulse ingest poster.jpg --research research.json --force
595
+ localpulse ingest poster.jpg --research research.json --json
596
+ cat research.json | localpulse ingest poster.jpg --research -
450
597
  `;
451
598
  }
452
599
 
453
600
  function searchHelp(): string {
454
601
  return `Usage: localpulse search <query> [options]
455
602
 
456
- Search existing events on Local Pulse.
603
+ Search events on Local Pulse. Returns upcoming events by default.
457
604
 
458
605
  Arguments:
459
606
  query Free-text search query
460
607
 
461
608
  Options:
462
609
  --city <text> Filter by city
463
- --date <today|weekend> Time intent filter
610
+ --date <today|weekend|upcoming> Time intent filter (default: upcoming)
464
611
  --tz <timezone> IANA timezone (requires --date)
612
+ --all Include past events
613
+ --json Output structured JSON
465
614
  --limit <n> Results per page (1-25, default 10)
466
615
  --cursor <n> Pagination offset
467
616
  --help Show this help
617
+
618
+ Examples:
619
+ localpulse search "amsterdam"
620
+ localpulse search "techno" --city Amsterdam
621
+ localpulse search "festival" --date weekend --tz Europe/Amsterdam
622
+ localpulse search "amsterdam" --all
623
+ localpulse search "berlin" --json
624
+ localpulse search "amsterdam" --json | jq '.results[].frontend_url'
625
+ `;
626
+ }
627
+
628
+ function draftsHelp(): string {
629
+ return `Usage: localpulse drafts [options]
630
+
631
+ List your event submission drafts and their status.
632
+
633
+ Options:
634
+ --status <uploading|processing|ready|failed> Filter by status
635
+ --json Output structured JSON
636
+ --help Show this help
637
+
638
+ Examples:
639
+ localpulse drafts
640
+ localpulse drafts --status failed
641
+ localpulse drafts --json
642
+ localpulse drafts --json | jq '.counts'
468
643
  `;
469
644
  }
@@ -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
+ };
@@ -60,29 +60,54 @@ export function generateResearchSkeleton(): ResearchPayload {
60
60
  name: "DJ Nobu",
61
61
  type: "DJ",
62
62
  genre: "techno",
63
- socials: ["https://instagram.com/djnobu", "https://ra.co/dj/djnobu"],
64
- context: "Berlin-based Japanese DJ, known for long hypnotic sets",
63
+ socials: [
64
+ "https://instagram.com/djnobu",
65
+ "https://ra.co/dj/djnobu",
66
+ "https://soundcloud.com/djnobu",
67
+ "https://djnobu.bandcamp.com",
68
+ "https://open.spotify.com/artist/0abc123",
69
+ ],
70
+ context:
71
+ "Berlin-based Japanese DJ, known for long hypnotic sets. Resident at Future Terror (Tokyo). Recent release: 'Prism' on Bitta (2025).",
72
+ },
73
+ {
74
+ name: "Nene H",
75
+ type: "DJ",
76
+ genre: "experimental electronic",
77
+ socials: [
78
+ "https://instagram.com/nenehmusic",
79
+ "https://ra.co/dj/neneh",
80
+ ],
81
+ context: "Support. Tehran-born, Berlin-based. Known for deconstructed club music.",
65
82
  },
66
83
  ],
67
84
  organizer: {
68
85
  name: "Dekmantel",
69
- socials: ["https://instagram.com/daboratorium"],
70
- context: "Amsterdam-based collective, running events since 2007",
86
+ socials: [
87
+ "https://instagram.com/daboratorium",
88
+ "https://www.dekmantel.com",
89
+ "https://ra.co/promoters/16478",
90
+ ],
91
+ context: "Amsterdam-based collective, running events since 2007. Annual festival + club nights.",
71
92
  },
72
93
  venue: {
73
94
  name: "Shelter Amsterdam",
74
95
  city: "Amsterdam",
75
96
  google_place_id: "",
76
- context: "Underground club beneath A'DAM Tower, 350 capacity, one room",
97
+ context: "Underground club beneath A'DAM Tower, 350 capacity, one room, Funktion-One sound.",
77
98
  },
78
99
  event: {
79
100
  title: "Shelter Presents: DJ Nobu",
80
101
  date: "2026-03-14T22:00:00+01:00",
81
102
  type: "club night",
82
103
  price: "€15-25",
83
- urls: ["https://ra.co/events/1234567"],
104
+ urls: [
105
+ "https://ra.co/events/1234567",
106
+ "https://www.dekmantel.com/events/shelter-nobu",
107
+ ],
84
108
  ticket_url: "https://tickets.example.com/shelter-nobu",
85
- context: "Part of ADE week. Doors at 22:00. Cash only at door.",
109
+ context:
110
+ "Part of ADE week. Doors at 22:00. Cash only at door. DJ Nobu plays an extended 4-hour set.",
86
111
  },
87
112
  context: "",
88
113
  };
@@ -23,8 +23,7 @@ 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;
26
+ draftId?: string;
28
27
  };
29
28
 
30
29
  export type UploadDryRunResult = {
@@ -90,6 +89,7 @@ export async function uploadPoster(
90
89
  if (options.city) {
91
90
  setOptionalField(form, "venue_city", options.city);
92
91
  }
92
+ setOptionalField(form, "draft_id", options.draftId);
93
93
 
94
94
  if (options.extraMedia?.length) {
95
95
  const mediaTypes: string[] = [];