@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 +15 -4
- package/package.json +1 -1
- package/src/index.test.ts +24 -5
- package/src/index.ts +179 -34
- package/src/lib/api-url.ts +4 -0
- package/src/lib/cli-read-client.test.ts +48 -0
- package/src/lib/cli-read-client.ts +39 -1
- package/src/lib/cli-read-types.ts +12 -0
- package/src/lib/upload-client.ts +0 -2
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"
|
|
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
|
|
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
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
|
|
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
|
|
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
|
|
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 {
|
|
8
|
-
import
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
235
|
+
process.stderr.write("Creating draft...\n");
|
|
205
236
|
const draft = await createDraft(apiUrl, token, options);
|
|
206
237
|
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
561
|
+
Examples:
|
|
447
562
|
localpulse ingest --generate-skeleton > research.json
|
|
448
|
-
|
|
449
|
-
localpulse ingest
|
|
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
|
|
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>
|
|
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
|
}
|
package/src/lib/api-url.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/lib/upload-client.ts
CHANGED