@localpulse/cli 0.0.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@localpulse/cli",
3
- "version": "0.0.2",
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.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  getCredentialsPath,
14
14
  getDefaultApiUrl,
15
15
  resolveApiUrl,
16
+ resolveToken,
16
17
  } from "./lib/credentials";
17
18
  import { loginWithToken } from "./lib/login";
18
19
  import { exitCodeForError, printError } from "./lib/output";
@@ -28,8 +29,7 @@ import {
28
29
  type DraftResult,
29
30
  uploadPoster,
30
31
  createDraft,
31
- uploadDraftMedia,
32
- detectMediaType,
32
+ verifyCliToken,
33
33
  } from "./lib/upload-client";
34
34
  import packageJson from "../package.json";
35
35
 
@@ -81,12 +81,14 @@ async function runAuth(parsed: ReturnType<typeof parseArgv>): Promise<void> {
81
81
  }
82
82
 
83
83
  const subcommand = parsed.positionals[0];
84
- if (!subcommand || (subcommand !== "login" && subcommand !== "logout")) {
85
- 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.");
86
86
  }
87
87
 
88
88
  if (subcommand === "login") {
89
89
  await runAuthLogin(parsed);
90
+ } else if (subcommand === "status") {
91
+ await runAuthStatus(parsed);
90
92
  } else {
91
93
  await runAuthLogout(parsed);
92
94
  }
@@ -125,6 +127,45 @@ async function runAuthLogout(parsed: ReturnType<typeof parseArgv>): Promise<void
125
127
  }
126
128
  }
127
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
+
128
169
  async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
129
170
  if (hasOption(parsed, "help")) {
130
171
  stdout.write(ingestHelp());
@@ -197,13 +238,16 @@ async function runIngest(parsed: ReturnType<typeof parseArgv>): Promise<void> {
197
238
  return;
198
239
  }
199
240
 
200
- // Default: draft flow
241
+ // Default: draft flow — create draft then submit to ingestion pipeline
201
242
  const draft = await runDraftIngest(apiUrl, token, file, uploadOptions);
243
+ process.stderr.write("Submitting to ingestion pipeline...\n");
244
+ const result = await uploadPoster(apiUrl, token, { ...uploadOptions, draftId: draft.id }) as UploadPosterResult;
202
245
  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`);
246
+ stdout.write(`${JSON.stringify({ draft_id: draft.id, ...result })}\n`);
205
247
  } else {
206
- printDraftResult(draft, apiUrl);
248
+ printIngestResult(result);
249
+ const baseUrl = toFrontendBaseUrl(apiUrl);
250
+ stdout.write(`Draft: ${baseUrl}/publish/edit/${draft.id}\n`);
207
251
  }
208
252
  }
209
253
 
@@ -234,36 +278,11 @@ async function runDraftIngest(
234
278
 
235
279
  process.stderr.write("Creating draft...\n");
236
280
  const draft = await createDraft(apiUrl, token, options);
237
-
238
281
  process.stderr.write(`Draft created: ${draft.id}\n`);
239
- process.stderr.write("Uploading primary media...\n");
240
- await uploadDraftMedia(apiUrl, token, draft.id, primaryFile, {
241
- mediaType: detectMediaType(primaryFile),
242
- sortOrder: 0,
243
- isPrimary: true,
244
- });
245
-
246
- if (options.extraMedia?.length) {
247
- for (const [index, path] of options.extraMedia.entries()) {
248
- process.stderr.write(`Uploading extra media ${index + 1}...\n`);
249
- await uploadDraftMedia(apiUrl, token, draft.id, path, {
250
- mediaType: detectMediaType(path),
251
- sortOrder: index + 1,
252
- isPrimary: false,
253
- });
254
- }
255
- }
256
282
 
257
283
  return draft;
258
284
  }
259
285
 
260
- function printDraftResult(draft: DraftResult, apiUrl: string): void {
261
- const baseUrl = toFrontendBaseUrl(apiUrl);
262
- stdout.write(`\nDraft saved: ${draft.id}\n`);
263
- stdout.write("Open the publish dashboard to review and submit:\n");
264
- stdout.write(` ${baseUrl}/publish/edit/${draft.id}\n`);
265
- }
266
-
267
286
  function printIngestResult(result: UploadPosterResult): void {
268
287
  stdout.write(`${result.message}\n`);
269
288
  if (result.run_id) {
@@ -461,10 +480,12 @@ function authHelp(): string {
461
480
  Commands:
462
481
  login Authenticate with a Local Pulse CLI token
463
482
  logout Remove stored credentials
483
+ status Check authentication status and verify token
464
484
 
465
485
  Examples:
466
486
  localpulse auth login --token lp_cli_...
467
- LP_TOKEN=lp_cli_... localpulse auth login
487
+ localpulse auth status
488
+ localpulse auth status --json
468
489
  localpulse auth logout
469
490
  `;
470
491
  }
@@ -502,7 +523,16 @@ Research payload:
502
523
  --generate-skeleton Print an example research template and exit
503
524
 
504
525
  The payload is JSON with five top-level sections. All are optional,
505
- 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
506
536
 
507
537
  performers[] Who is performing or presenting
508
538
  .name Full name (required per performer)
@@ -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,6 +23,7 @@ export type IngestUploadOptions = {
23
23
  venue?: string;
24
24
  extraMedia?: string[];
25
25
  dryRun?: boolean;
26
+ draftId?: string;
26
27
  };
27
28
 
28
29
  export type UploadDryRunResult = {
@@ -88,6 +89,7 @@ export async function uploadPoster(
88
89
  if (options.city) {
89
90
  setOptionalField(form, "venue_city", options.city);
90
91
  }
92
+ setOptionalField(form, "draft_id", options.draftId);
91
93
 
92
94
  if (options.extraMedia?.length) {
93
95
  const mediaTypes: string[] = [];