@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 +15 -4
- package/package.json +1 -1
- package/src/index.test.ts +24 -5
- package/src/index.ts +237 -62
- 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/research-schema.ts +32 -7
- package/src/lib/upload-client.ts +2 -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,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 {
|
|
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,
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
591
|
+
Examples:
|
|
447
592
|
localpulse ingest --generate-skeleton > research.json
|
|
448
|
-
|
|
449
|
-
localpulse ingest
|
|
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
|
|
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>
|
|
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
|
}
|
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
|
+
};
|
|
@@ -60,29 +60,54 @@ export function generateResearchSkeleton(): ResearchPayload {
|
|
|
60
60
|
name: "DJ Nobu",
|
|
61
61
|
type: "DJ",
|
|
62
62
|
genre: "techno",
|
|
63
|
-
socials: [
|
|
64
|
-
|
|
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: [
|
|
70
|
-
|
|
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: [
|
|
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:
|
|
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
|
};
|
package/src/lib/upload-client.ts
CHANGED
|
@@ -23,8 +23,7 @@ export type IngestUploadOptions = {
|
|
|
23
23
|
venue?: string;
|
|
24
24
|
extraMedia?: string[];
|
|
25
25
|
dryRun?: boolean;
|
|
26
|
-
|
|
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[] = [];
|