@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/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
+ }
@@ -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
+ }
@@ -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
+ }