@localpulse/cli 0.0.1
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 +65 -0
- package/package.json +37 -0
- package/src/index.test.ts +137 -0
- package/src/index.ts +469 -0
- package/src/lib/api-response.ts +63 -0
- package/src/lib/api-url.ts +15 -0
- package/src/lib/argv.ts +126 -0
- package/src/lib/auth.ts +9 -0
- package/src/lib/cli-read-client.test.ts +94 -0
- package/src/lib/cli-read-client.ts +97 -0
- package/src/lib/cli-read-types.ts +27 -0
- package/src/lib/credentials.test.ts +115 -0
- package/src/lib/credentials.ts +113 -0
- package/src/lib/login.test.ts +73 -0
- package/src/lib/login.ts +42 -0
- package/src/lib/output.ts +16 -0
- package/src/lib/research-reader.ts +29 -0
- package/src/lib/research-schema.test.ts +180 -0
- package/src/lib/research-schema.ts +160 -0
- package/src/lib/token.test.ts +17 -0
- package/src/lib/token.ts +8 -0
- package/src/lib/upload-client.test.ts +82 -0
- package/src/lib/upload-client.ts +353 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin, stdout } from "node:process";
|
|
4
|
+
|
|
5
|
+
import { hasOption, parseArgv, readNumberOption, readStringArrayOption, readStringOption } from "./lib/argv";
|
|
6
|
+
import { requireToken } from "./lib/auth";
|
|
7
|
+
import { searchCliEvents } from "./lib/cli-read-client";
|
|
8
|
+
import type { SearchEventCard } from "./lib/cli-read-types";
|
|
9
|
+
import {
|
|
10
|
+
deleteCredentials,
|
|
11
|
+
getCredentialsPath,
|
|
12
|
+
getDefaultApiUrl,
|
|
13
|
+
resolveApiUrl,
|
|
14
|
+
} from "./lib/credentials";
|
|
15
|
+
import { loginWithToken } from "./lib/login";
|
|
16
|
+
import { exitCodeForError, printError } from "./lib/output";
|
|
17
|
+
import { readResearchPayload } from "./lib/research-reader";
|
|
18
|
+
import {
|
|
19
|
+
generateResearchSkeleton,
|
|
20
|
+
mapResearchToUploadFields,
|
|
21
|
+
stitchResearchContext,
|
|
22
|
+
} from "./lib/research-schema";
|
|
23
|
+
import { isLikelyCliToken } from "./lib/token";
|
|
24
|
+
import {
|
|
25
|
+
type UploadPosterResult,
|
|
26
|
+
type DraftResult,
|
|
27
|
+
uploadPoster,
|
|
28
|
+
createDraft,
|
|
29
|
+
uploadDraftMedia,
|
|
30
|
+
detectMediaType,
|
|
31
|
+
} from "./lib/upload-client";
|
|
32
|
+
import packageJson from "../package.json";
|
|
33
|
+
|
|
34
|
+
const VERSION = packageJson.version;
|
|
35
|
+
|
|
36
|
+
void main(process.argv.slice(2));
|
|
37
|
+
|
|
38
|
+
async function main(argv: string[]): Promise<void> {
|
|
39
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
40
|
+
stdout.write(rootHelp());
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (argv[0] === "--version") {
|
|
45
|
+
stdout.write(`${VERSION}\n`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [command, ...rest] = argv;
|
|
50
|
+
const parsed = parseArgv(rest);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
switch (command) {
|
|
54
|
+
case "auth":
|
|
55
|
+
await runAuth(parsed);
|
|
56
|
+
break;
|
|
57
|
+
case "ingest":
|
|
58
|
+
await runIngest(parsed);
|
|
59
|
+
break;
|
|
60
|
+
case "search":
|
|
61
|
+
await runSearch(parsed);
|
|
62
|
+
break;
|
|
63
|
+
default:
|
|
64
|
+
throw new Error(`Unknown command: ${command}. Run \`localpulse --help\` for usage.`);
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
printError(error);
|
|
68
|
+
process.exitCode = exitCodeForError(error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runAuth(parsed: ReturnType<typeof parseArgv>): Promise<void> {
|
|
73
|
+
if (hasOption(parsed, "help") && parsed.positionals.length === 0) {
|
|
74
|
+
stdout.write(authHelp());
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
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.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (subcommand === "login") {
|
|
84
|
+
await runAuthLogin(parsed);
|
|
85
|
+
} else {
|
|
86
|
+
await runAuthLogout();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function runAuthLogin(parsed: ReturnType<typeof parseArgv>): Promise<void> {
|
|
91
|
+
if (hasOption(parsed, "help")) {
|
|
92
|
+
stdout.write(authLoginHelp());
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const apiUrl = readStringOption(parsed, "api-url")?.trim() || getDefaultApiUrl();
|
|
97
|
+
const token = await resolveLoginToken(readStringOption(parsed, "token"));
|
|
98
|
+
if (!isLikelyCliToken(token)) {
|
|
99
|
+
throw new Error("Invalid token format. Expected lp_cli_<uuid>_<secret> or lp_mcp_<uuid>_<secret>.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = await loginWithToken(apiUrl, token);
|
|
103
|
+
stdout.write(`Authenticated. Credentials saved to ${result.credentials_path}\n`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function runAuthLogout(): Promise<void> {
|
|
107
|
+
const deleted = await deleteCredentials();
|
|
108
|
+
if (deleted) {
|
|
109
|
+
stdout.write("Logged out. Credentials removed.\n");
|
|
110
|
+
} else {
|
|
111
|
+
stdout.write("No credentials found.\n");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
|
|
116
|
+
if (hasOption(parsed, "help")) {
|
|
117
|
+
stdout.write(ingestHelp());
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (hasOption(parsed, "generate-skeleton")) {
|
|
122
|
+
stdout.write(`${JSON.stringify(generateResearchSkeleton(), null, 2)}\n`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (parsed.positionals.length !== 1) {
|
|
127
|
+
throw new Error("Usage: localpulse ingest <file> --research <payload.json>. Run `localpulse ingest --help` for details.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const file = parsed.positionals[0];
|
|
131
|
+
const researchPath = readStringOption(parsed, "research");
|
|
132
|
+
|
|
133
|
+
if (!researchPath) {
|
|
134
|
+
throw new Error("--research <file|-> is required. Run `localpulse ingest --generate-skeleton` for the expected format.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const payload = await readResearchPayload(researchPath);
|
|
138
|
+
const mapped = mapResearchToUploadFields(payload);
|
|
139
|
+
const stitched = stitchResearchContext(payload);
|
|
140
|
+
const explicitContext = readStringOption(parsed, "context");
|
|
141
|
+
const context = [stitched, explicitContext].filter(Boolean).join("\n\n") || undefined;
|
|
142
|
+
|
|
143
|
+
const apiUrl = await resolveApiUrl();
|
|
144
|
+
const dryRun = hasOption(parsed, "dry-run");
|
|
145
|
+
const force = hasOption(parsed, "force");
|
|
146
|
+
const token = dryRun ? "" : await requireToken();
|
|
147
|
+
|
|
148
|
+
const uploadOptions = {
|
|
149
|
+
file,
|
|
150
|
+
urls: readStringArrayOption(parsed, "urls") ?? mapped.urls,
|
|
151
|
+
city: readStringOption(parsed, "city") ?? mapped.city,
|
|
152
|
+
venuePlaceId: readStringOption(parsed, "google-place-id") ?? mapped.venuePlaceId,
|
|
153
|
+
context,
|
|
154
|
+
ticketUrl: readStringOption(parsed, "ticket-url") ?? mapped.ticketUrl,
|
|
155
|
+
title: readStringOption(parsed, "title") ?? mapped.title,
|
|
156
|
+
datetime: readStringOption(parsed, "date") ?? mapped.datetime,
|
|
157
|
+
venue: readStringOption(parsed, "venue") ?? mapped.venue,
|
|
158
|
+
extraMedia: readStringArrayOption(parsed, "extra-media"),
|
|
159
|
+
dryRun,
|
|
160
|
+
force,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (dryRun) {
|
|
164
|
+
const result = await uploadPoster(apiUrl, token, uploadOptions);
|
|
165
|
+
if ("dry_run" in result) {
|
|
166
|
+
stdout.write(`Dry run passed: ${result.file} (${result.extra_media_count} extra media)\n`);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (force) {
|
|
172
|
+
const result = await uploadPoster(apiUrl, token, uploadOptions);
|
|
173
|
+
if (!("dry_run" in result)) {
|
|
174
|
+
printIngestResult(result);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Default: draft flow
|
|
180
|
+
const draft = await runDraftIngest(apiUrl, token, file, uploadOptions);
|
|
181
|
+
printDraftResult(draft, apiUrl);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
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);
|
|
190
|
+
}
|
|
191
|
+
if (missing.length) {
|
|
192
|
+
throw new Error(`File(s) not found: ${missing.join(", ")}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function runDraftIngest(
|
|
197
|
+
apiUrl: string,
|
|
198
|
+
token: string,
|
|
199
|
+
primaryFile: string,
|
|
200
|
+
options: Pick<import("./lib/upload-client").IngestUploadOptions, "extraMedia" | "urls" | "city" | "venuePlaceId" | "context" | "ticketUrl" | "title" | "datetime" | "venue">,
|
|
201
|
+
): Promise<DraftResult> {
|
|
202
|
+
await validateFilePaths(primaryFile, options.extraMedia);
|
|
203
|
+
|
|
204
|
+
stdout.write("Creating draft...\n");
|
|
205
|
+
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
|
+
}
|
|
225
|
+
|
|
226
|
+
return draft;
|
|
227
|
+
}
|
|
228
|
+
|
|
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
|
+
function printIngestResult(result: UploadPosterResult): void {
|
|
237
|
+
stdout.write(`${result.message}\n`);
|
|
238
|
+
if (result.run_id) {
|
|
239
|
+
stdout.write(`Run ID: ${result.run_id}\n`);
|
|
240
|
+
}
|
|
241
|
+
if (result.event_url) {
|
|
242
|
+
stdout.write(`Event: ${result.event_url}\n`);
|
|
243
|
+
}
|
|
244
|
+
if (result.is_duplicate && result.existing_event_url) {
|
|
245
|
+
stdout.write(`Duplicate of: ${result.existing_event_url}\n`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function runSearch(parsed: ReturnType<typeof parseArgv>): Promise<void> {
|
|
250
|
+
if (hasOption(parsed, "help")) {
|
|
251
|
+
stdout.write(searchHelp());
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const query = parsed.positionals.join(" ").trim();
|
|
256
|
+
if (!query) {
|
|
257
|
+
throw new Error("Search query is required. Run `localpulse search --help` for usage.");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const apiUrl = await resolveApiUrl();
|
|
261
|
+
const result = await searchCliEvents(apiUrl, {
|
|
262
|
+
query,
|
|
263
|
+
city: readStringOption(parsed, "city"),
|
|
264
|
+
time_intent: readDateFilterOption(parsed),
|
|
265
|
+
timezone: readStringOption(parsed, "tz"),
|
|
266
|
+
limit: readNumberOption(parsed, "limit") ?? 10,
|
|
267
|
+
cursor: readNumberOption(parsed, "cursor") ?? 0,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (result.results.length === 0) {
|
|
271
|
+
stdout.write("No events found.\n");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const card of result.results) {
|
|
276
|
+
printSearchCard(card);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (result.next_cursor !== null) {
|
|
280
|
+
stdout.write(`\n(more results: localpulse search "${query}" --cursor ${result.next_cursor})\n`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function printSearchCard(card: SearchEventCard): void {
|
|
285
|
+
const parts = [card.title ?? "Untitled"];
|
|
286
|
+
if (card.event_datetime) {
|
|
287
|
+
parts.push(card.event_datetime);
|
|
288
|
+
}
|
|
289
|
+
if (card.venue_name && card.venue_city) {
|
|
290
|
+
parts.push(`${card.venue_name}, ${card.venue_city}`);
|
|
291
|
+
} else if (card.location) {
|
|
292
|
+
parts.push(card.location);
|
|
293
|
+
}
|
|
294
|
+
stdout.write(`${parts.join(" — ")}\n`);
|
|
295
|
+
stdout.write(` ${card.frontend_url}\n`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function resolveLoginToken(explicitToken?: string): Promise<string> {
|
|
299
|
+
if (explicitToken?.trim()) {
|
|
300
|
+
return explicitToken.trim();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`No token provided. Visit https://localpulse.nl/dev and rerun with --token, or set LP_TOKEN. Credentials path: ${getCredentialsPath()}`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
stdout.write("Create or copy a token from https://localpulse.nl/dev\n");
|
|
310
|
+
const readline = createInterface({ input: stdin, output: stdout });
|
|
311
|
+
try {
|
|
312
|
+
const token = await readline.question("Paste token: ");
|
|
313
|
+
if (!token.trim()) {
|
|
314
|
+
throw new Error("Token is required.");
|
|
315
|
+
}
|
|
316
|
+
return token.trim();
|
|
317
|
+
} finally {
|
|
318
|
+
readline.close();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function readDateFilterOption(
|
|
323
|
+
parsed: ReturnType<typeof parseArgv>,
|
|
324
|
+
): "today" | "weekend" | undefined {
|
|
325
|
+
const value = readStringOption(parsed, "date");
|
|
326
|
+
if (!value) {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
if (value === "today" || value === "weekend") {
|
|
330
|
+
return value;
|
|
331
|
+
}
|
|
332
|
+
throw new Error("`--date` must be `today` or `weekend`.");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function rootHelp(): string {
|
|
336
|
+
return `Usage: localpulse <command> [options]
|
|
337
|
+
|
|
338
|
+
Commands:
|
|
339
|
+
ingest Ingest an event poster into Local Pulse
|
|
340
|
+
search Search existing events
|
|
341
|
+
auth Login and logout
|
|
342
|
+
|
|
343
|
+
Options:
|
|
344
|
+
--help Show help
|
|
345
|
+
--version Show version
|
|
346
|
+
|
|
347
|
+
Environment:
|
|
348
|
+
LP_TOKEN Override stored auth token
|
|
349
|
+
LP_API_URL Override API base URL (default: https://localpulse.nl)
|
|
350
|
+
`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function authHelp(): string {
|
|
354
|
+
return `Usage: localpulse auth <command>
|
|
355
|
+
|
|
356
|
+
Commands:
|
|
357
|
+
login Authenticate with a Local Pulse CLI token
|
|
358
|
+
logout Remove stored credentials
|
|
359
|
+
|
|
360
|
+
Run \`localpulse auth login --help\` for login options.
|
|
361
|
+
`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function authLoginHelp(): string {
|
|
365
|
+
return `Usage: localpulse auth login [--token <token>]
|
|
366
|
+
|
|
367
|
+
Options:
|
|
368
|
+
--token <token> Local Pulse CLI token
|
|
369
|
+
--api-url <url> Override API base URL
|
|
370
|
+
--help Show this help
|
|
371
|
+
|
|
372
|
+
Without --token, prompts interactively (visit https://localpulse.nl/dev to create a token).
|
|
373
|
+
`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function ingestHelp(): string {
|
|
377
|
+
return `Usage: localpulse ingest <file> --research <payload.json> [options]
|
|
378
|
+
|
|
379
|
+
Ingest an event poster into Local Pulse. By default, creates a draft that
|
|
380
|
+
can be reviewed on the publish dashboard before processing. Use --force
|
|
381
|
+
to bypass drafts and submit directly to the ingestion pipeline.
|
|
382
|
+
|
|
383
|
+
Arguments:
|
|
384
|
+
file Path to primary poster image (JPEG, PNG, WebP)
|
|
385
|
+
|
|
386
|
+
Research payload:
|
|
387
|
+
--research <file|-> JSON research payload (required)
|
|
388
|
+
--generate-skeleton Print an example research template and exit
|
|
389
|
+
|
|
390
|
+
The payload is JSON with five top-level sections. All are optional,
|
|
391
|
+
but the more you provide, the better the enrichment.
|
|
392
|
+
|
|
393
|
+
performers[] Who is performing or presenting
|
|
394
|
+
.name Full name (required per performer)
|
|
395
|
+
.type What they are: "DJ", "band", "speaker", "visual artist"
|
|
396
|
+
.genre Style or genre: "techno", "jazz", "stand-up comedy"
|
|
397
|
+
.socials[] Profile URLs: Instagram, RA, Bandcamp, personal site
|
|
398
|
+
.context Anything else: bio, residencies, notable releases
|
|
399
|
+
|
|
400
|
+
organizer Who is putting on the event
|
|
401
|
+
.name Collective, promoter, or brand name (required)
|
|
402
|
+
.socials[] Profile URLs
|
|
403
|
+
.context History, reputation, previous events, affiliation
|
|
404
|
+
|
|
405
|
+
venue Where the event happens
|
|
406
|
+
.name Venue name
|
|
407
|
+
.city City name
|
|
408
|
+
.google_place_id Google Places ID (skips venue matching if provided)
|
|
409
|
+
.context Capacity, room layout, vibe, accessibility, location tips
|
|
410
|
+
|
|
411
|
+
event What the event is
|
|
412
|
+
.title Event name
|
|
413
|
+
.date ISO 8601 datetime (e.g. "2026-03-14T22:00:00+01:00")
|
|
414
|
+
.type Kind of event: "club night", "festival", "exhibition",
|
|
415
|
+
"workshop", "concert", "open air", "listening session"
|
|
416
|
+
.price Ticket price info: "€15-25", "Free", "Sold out"
|
|
417
|
+
.urls[] Source pages: RA, venue site, Facebook event
|
|
418
|
+
.ticket_url Direct ticketing / purchase URL
|
|
419
|
+
.context Schedule, door policy, age restrictions, special notes
|
|
420
|
+
|
|
421
|
+
context Anything that doesn't fit above: background on the
|
|
422
|
+
scene, cross-references to other events, corrections
|
|
423
|
+
|
|
424
|
+
Run \`localpulse ingest --generate-skeleton\` for a complete example.
|
|
425
|
+
|
|
426
|
+
Override fields (take precedence over research values):
|
|
427
|
+
--title <text> Override event title
|
|
428
|
+
--date <iso8601> Override event date/time
|
|
429
|
+
--city <text> Override city
|
|
430
|
+
--venue <text> Override venue name
|
|
431
|
+
--google-place-id <id> Override Google Places ID
|
|
432
|
+
--urls <url>... Override source URLs
|
|
433
|
+
--ticket-url <url> Override ticketing URL
|
|
434
|
+
--context <text> Append extra context to research payload
|
|
435
|
+
|
|
436
|
+
Modes:
|
|
437
|
+
--force Bypass draft and submit directly to ingestion
|
|
438
|
+
--dry-run Check that all inputs are valid without uploading
|
|
439
|
+
|
|
440
|
+
Extra:
|
|
441
|
+
--extra-media <file>... Up to 2 additional poster or flyer images
|
|
442
|
+
|
|
443
|
+
Options:
|
|
444
|
+
--help Show this help
|
|
445
|
+
|
|
446
|
+
Example:
|
|
447
|
+
localpulse ingest --generate-skeleton > research.json
|
|
448
|
+
# research and fill in the payload
|
|
449
|
+
localpulse ingest ./poster.jpg --research research.json
|
|
450
|
+
`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function searchHelp(): string {
|
|
454
|
+
return `Usage: localpulse search <query> [options]
|
|
455
|
+
|
|
456
|
+
Search existing events on Local Pulse.
|
|
457
|
+
|
|
458
|
+
Arguments:
|
|
459
|
+
query Free-text search query
|
|
460
|
+
|
|
461
|
+
Options:
|
|
462
|
+
--city <text> Filter by city
|
|
463
|
+
--date <today|weekend> Time intent filter
|
|
464
|
+
--tz <timezone> IANA timezone (requires --date)
|
|
465
|
+
--limit <n> Results per page (1-25, default 10)
|
|
466
|
+
--cursor <n> Pagination offset
|
|
467
|
+
--help Show this help
|
|
468
|
+
`;
|
|
469
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type ApiResponseBody = Record<string, unknown> & {
|
|
2
|
+
detail?: unknown;
|
|
3
|
+
error?: unknown;
|
|
4
|
+
bodySnippet?: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export class CliApiError extends Error {
|
|
8
|
+
readonly httpStatus: number;
|
|
9
|
+
readonly body?: ApiResponseBody;
|
|
10
|
+
|
|
11
|
+
constructor(message: string, options: { httpStatus: number; body?: ApiResponseBody }) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "CliApiError";
|
|
14
|
+
this.httpStatus = options.httpStatus;
|
|
15
|
+
this.body = options.body;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function extractApiErrorMessage(payload: ApiResponseBody, fallback: string): string {
|
|
20
|
+
if (typeof payload.detail === "string") {
|
|
21
|
+
return payload.detail;
|
|
22
|
+
}
|
|
23
|
+
if (typeof payload.error === "string") {
|
|
24
|
+
return payload.error;
|
|
25
|
+
}
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function parseApiJsonBody(response: Response): Promise<ApiResponseBody> {
|
|
30
|
+
const text = await response.text();
|
|
31
|
+
if (!text.trim()) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let parsed: unknown;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(text) as unknown;
|
|
38
|
+
} catch {
|
|
39
|
+
const bodySnippet = text.slice(0, 200);
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new CliApiError("API returned a non-JSON error response", {
|
|
42
|
+
httpStatus: response.status,
|
|
43
|
+
body: bodySnippet ? { bodySnippet } : undefined,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
throw new Error("API returned a non-JSON response");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!isApiResponseBody(parsed)) {
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new CliApiError("API returned a non-object error response", {
|
|
52
|
+
httpStatus: response.status,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
throw new Error("API returned a non-object JSON response");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isApiResponseBody(value: unknown): value is ApiResponseBody {
|
|
62
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const DEFAULT_API_URL = "https://localpulse.nl";
|
|
2
|
+
|
|
3
|
+
export function normalizeApiUrl(value?: string | null): string {
|
|
4
|
+
const trimmed = value?.trim();
|
|
5
|
+
if (!trimmed) {
|
|
6
|
+
return DEFAULT_API_URL;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return trimmed.replace(/\/+$/, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildApiUrl(apiUrl: string, path: string): string {
|
|
13
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
14
|
+
return `${normalizeApiUrl(apiUrl)}${normalizedPath}`;
|
|
15
|
+
}
|
package/src/lib/argv.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export type ParsedArgv = {
|
|
2
|
+
options: Map<string, Array<string | true>>;
|
|
3
|
+
positionals: string[];
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function parseArgv(argv: string[]): ParsedArgv {
|
|
7
|
+
const options = new Map<string, Array<string | true>>();
|
|
8
|
+
const positionals: string[] = [];
|
|
9
|
+
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
const token = argv[index];
|
|
12
|
+
if (!token) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (token === "--") {
|
|
17
|
+
positionals.push(...argv.slice(index + 1));
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (token === "-h") {
|
|
22
|
+
appendOption(options, "help", true);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!token.startsWith("--")) {
|
|
27
|
+
positionals.push(token);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const trimmed = token.slice(2);
|
|
32
|
+
const equalsIndex = trimmed.indexOf("=");
|
|
33
|
+
if (equalsIndex >= 0) {
|
|
34
|
+
const name = trimmed.slice(0, equalsIndex);
|
|
35
|
+
const value = trimmed.slice(equalsIndex + 1);
|
|
36
|
+
appendOption(options, name, value);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const next = argv[index + 1];
|
|
41
|
+
if (next && !next.startsWith("-")) {
|
|
42
|
+
appendOption(options, trimmed, next);
|
|
43
|
+
index += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
appendOption(options, trimmed, true);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { options, positionals };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function hasOption(parsed: ParsedArgv, name: string): boolean {
|
|
54
|
+
return parsed.options.has(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readStringOption(parsed: ParsedArgv, name: string): string | undefined {
|
|
58
|
+
const values = parsed.options.get(name);
|
|
59
|
+
if (!values || values.length === 0) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const value = values.at(-1);
|
|
63
|
+
if (value === true) {
|
|
64
|
+
throw new Error(`\`--${name}\` requires a value.`);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readStringArrayOption(parsed: ParsedArgv, name: string): string[] | undefined {
|
|
70
|
+
const values = parsed.options.get(name);
|
|
71
|
+
if (!values || values.length === 0) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return values.map((value) => {
|
|
75
|
+
if (value === true) {
|
|
76
|
+
throw new Error(`\`--${name}\` requires a value.`);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readBooleanOption(parsed: ParsedArgv, name: string): boolean | undefined {
|
|
83
|
+
const values = parsed.options.get(name);
|
|
84
|
+
if (!values || values.length === 0) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const value = values.at(-1);
|
|
89
|
+
if (value === true) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (value === undefined) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const normalized = value.trim().toLowerCase();
|
|
97
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`\`--${name}\` must be true or false.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readNumberOption(parsed: ParsedArgv, name: string): number | undefined {
|
|
107
|
+
const value = readStringOption(parsed, name);
|
|
108
|
+
if (value === undefined) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const parsedNumber = Number(value);
|
|
112
|
+
if (!Number.isFinite(parsedNumber)) {
|
|
113
|
+
throw new Error(`\`--${name}\` must be a number.`);
|
|
114
|
+
}
|
|
115
|
+
return parsedNumber;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function appendOption(
|
|
119
|
+
options: Map<string, Array<string | true>>,
|
|
120
|
+
name: string,
|
|
121
|
+
value: string | true,
|
|
122
|
+
): void {
|
|
123
|
+
const existing = options.get(name) ?? [];
|
|
124
|
+
existing.push(value);
|
|
125
|
+
options.set(name, existing);
|
|
126
|
+
}
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { resolveToken } from "./credentials";
|
|
2
|
+
|
|
3
|
+
export async function requireToken(): Promise<string> {
|
|
4
|
+
const token = await resolveToken();
|
|
5
|
+
if (!token) {
|
|
6
|
+
throw new Error("Authentication token is missing. Run `localpulse auth login` or set LP_TOKEN.");
|
|
7
|
+
}
|
|
8
|
+
return token;
|
|
9
|
+
}
|