@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
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CliApiError,
|
|
6
|
+
type ApiResponseBody,
|
|
7
|
+
extractApiErrorMessage,
|
|
8
|
+
parseApiJsonBody,
|
|
9
|
+
} from "./api-response";
|
|
10
|
+
import { buildApiUrl, normalizeApiUrl } from "./api-url";
|
|
11
|
+
|
|
12
|
+
export { CliApiError } from "./api-response";
|
|
13
|
+
|
|
14
|
+
export type IngestUploadOptions = {
|
|
15
|
+
file: string;
|
|
16
|
+
urls?: string[];
|
|
17
|
+
city?: string;
|
|
18
|
+
venuePlaceId?: string;
|
|
19
|
+
context?: string;
|
|
20
|
+
ticketUrl?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
datetime?: string;
|
|
23
|
+
venue?: string;
|
|
24
|
+
extraMedia?: string[];
|
|
25
|
+
dryRun?: boolean;
|
|
26
|
+
/** When true, bypass drafts and upload directly to the ingestion pipeline. */
|
|
27
|
+
force?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type UploadDryRunResult = {
|
|
31
|
+
dry_run: true;
|
|
32
|
+
valid: true;
|
|
33
|
+
file: string;
|
|
34
|
+
extra_media_count: number;
|
|
35
|
+
api_url: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type UploadPosterResult = {
|
|
39
|
+
success: boolean;
|
|
40
|
+
accepted: boolean;
|
|
41
|
+
run_id: string | null;
|
|
42
|
+
poster_id: string | null;
|
|
43
|
+
poster_url: string | null;
|
|
44
|
+
event_url: string | null;
|
|
45
|
+
existing_event_url: string | null;
|
|
46
|
+
event_title: string | null;
|
|
47
|
+
venue_name: string | null;
|
|
48
|
+
is_duplicate: boolean;
|
|
49
|
+
duplicate_similarity: number | null;
|
|
50
|
+
processing_time: number;
|
|
51
|
+
vision_completed: boolean;
|
|
52
|
+
message: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type UploadResult = UploadDryRunResult | UploadPosterResult;
|
|
56
|
+
|
|
57
|
+
export type VerifyCliTokenResult = {
|
|
58
|
+
email: string;
|
|
59
|
+
authenticated?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export async function uploadPoster(
|
|
63
|
+
apiUrl: string,
|
|
64
|
+
token: string,
|
|
65
|
+
options: IngestUploadOptions,
|
|
66
|
+
): Promise<UploadResult> {
|
|
67
|
+
await validateIngestOptions(options);
|
|
68
|
+
if (options.dryRun) {
|
|
69
|
+
return {
|
|
70
|
+
dry_run: true,
|
|
71
|
+
valid: true,
|
|
72
|
+
file: options.file,
|
|
73
|
+
extra_media_count: options.extraMedia?.length ?? 0,
|
|
74
|
+
api_url: normalizeApiUrl(apiUrl),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const form = new FormData();
|
|
79
|
+
form.set("file", await fileToUpload(options.file));
|
|
80
|
+
if (options.urls?.length) {
|
|
81
|
+
form.set("user_provided_urls", JSON.stringify(options.urls));
|
|
82
|
+
}
|
|
83
|
+
setOptionalField(form, "location_hint", options.city);
|
|
84
|
+
setOptionalField(form, "venue_place_id", options.venuePlaceId);
|
|
85
|
+
setOptionalField(form, "extra_context", options.context);
|
|
86
|
+
setOptionalField(form, "ticketing_url", options.ticketUrl);
|
|
87
|
+
setOptionalField(form, "event_title", options.title);
|
|
88
|
+
setOptionalField(form, "event_datetime", options.datetime);
|
|
89
|
+
setOptionalField(form, "venue_name", options.venue);
|
|
90
|
+
if (options.city) {
|
|
91
|
+
setOptionalField(form, "venue_city", options.city);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (options.extraMedia?.length) {
|
|
95
|
+
const mediaTypes: string[] = [];
|
|
96
|
+
const mediaOrder: number[] = [];
|
|
97
|
+
for (const [index, path] of options.extraMedia.entries()) {
|
|
98
|
+
form.append("media_files", await fileToUpload(path));
|
|
99
|
+
mediaTypes.push(detectMediaType(path));
|
|
100
|
+
mediaOrder.push(index + 1);
|
|
101
|
+
}
|
|
102
|
+
form.set("media_types", JSON.stringify(mediaTypes));
|
|
103
|
+
form.set("media_order", JSON.stringify(mediaOrder));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const response = await fetch(buildApiUrl(apiUrl, "/api/v1/upload"), {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {
|
|
109
|
+
authorization: `Bearer ${token}`,
|
|
110
|
+
accept: "application/json",
|
|
111
|
+
},
|
|
112
|
+
body: form,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const payload = await parseApiJsonBody(response);
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new CliApiError(extractApiErrorMessage(payload, `Upload failed (${response.status})`), {
|
|
118
|
+
httpStatus: response.status,
|
|
119
|
+
body: payload,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return parseUploadPosterResult(payload);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function verifyCliToken(
|
|
127
|
+
apiUrl: string,
|
|
128
|
+
token: string,
|
|
129
|
+
): Promise<VerifyCliTokenResult> {
|
|
130
|
+
const response = await fetch(buildApiUrl(apiUrl, "/api/v1/token/verify"), {
|
|
131
|
+
method: "GET",
|
|
132
|
+
headers: {
|
|
133
|
+
authorization: `Bearer ${token}`,
|
|
134
|
+
accept: "application/json",
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const payload = await parseApiJsonBody(response);
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new CliApiError(extractApiErrorMessage(payload, `Token verification failed (${response.status})`), {
|
|
141
|
+
httpStatus: response.status,
|
|
142
|
+
body: payload,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return parseVerifyCliTokenResult(payload);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function validateIngestOptions(options: IngestUploadOptions): Promise<void> {
|
|
150
|
+
await assertReadableFile(options.file, "file");
|
|
151
|
+
|
|
152
|
+
if (options.extraMedia && options.extraMedia.length > 2) {
|
|
153
|
+
throw new Error("`--extra-media` supports at most 2 files.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const [index, file] of (options.extraMedia ?? []).entries()) {
|
|
157
|
+
await assertReadableFile(file, `extra-media[${index}]`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function assertReadableFile(path: string, label: string): Promise<void> {
|
|
162
|
+
if (!path.trim()) {
|
|
163
|
+
throw new Error(`\`${label}\` is required.`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await access(path);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new Error(`File not found: ${path}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function fileToUpload(path: string): Promise<File> {
|
|
174
|
+
const bytes = await readFile(path);
|
|
175
|
+
return new File([bytes], basename(path), { type: mimeTypeForPath(path) });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setOptionalField(form: FormData, key: string, value: string | undefined): void {
|
|
179
|
+
if (value?.trim()) {
|
|
180
|
+
form.set(key, value.trim());
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function detectMediaType(path: string): "image" | "video" {
|
|
185
|
+
const extension = extname(path).toLowerCase();
|
|
186
|
+
return VIDEO_EXTENSIONS.has(extension) ? "video" : "image";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function mimeTypeForPath(path: string): string {
|
|
190
|
+
const extension = extname(path).toLowerCase();
|
|
191
|
+
return MIME_BY_EXTENSION[extension] ?? (
|
|
192
|
+
detectMediaType(path) === "video" ? "video/mp4" : "image/jpeg"
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseUploadPosterResult(payload: ApiResponseBody): UploadPosterResult {
|
|
197
|
+
const result = payload as Partial<UploadPosterResult>;
|
|
198
|
+
if (
|
|
199
|
+
!isBoolean(result.success) ||
|
|
200
|
+
!isBoolean(result.accepted) ||
|
|
201
|
+
!isNullableString(result.run_id) ||
|
|
202
|
+
!isNullableString(result.poster_id) ||
|
|
203
|
+
!isNullableString(result.poster_url) ||
|
|
204
|
+
!isNullableString(result.event_url) ||
|
|
205
|
+
!isNullableString(result.existing_event_url) ||
|
|
206
|
+
!isNullableString(result.event_title) ||
|
|
207
|
+
!isNullableString(result.venue_name) ||
|
|
208
|
+
!isBoolean(result.is_duplicate) ||
|
|
209
|
+
!isNullableNumber(result.duplicate_similarity) ||
|
|
210
|
+
!isNumber(result.processing_time) ||
|
|
211
|
+
!isBoolean(result.vision_completed) ||
|
|
212
|
+
!isString(result.message)
|
|
213
|
+
) {
|
|
214
|
+
throw new Error("Invalid API response: expected upload result payload.");
|
|
215
|
+
}
|
|
216
|
+
return result as UploadPosterResult;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseVerifyCliTokenResult(payload: ApiResponseBody): VerifyCliTokenResult {
|
|
220
|
+
const result = payload as Partial<VerifyCliTokenResult>;
|
|
221
|
+
if (!isString(result.email) || !isOptionalBoolean(result.authenticated)) {
|
|
222
|
+
throw new Error("Invalid API response: expected token verification payload.");
|
|
223
|
+
}
|
|
224
|
+
return result as VerifyCliTokenResult;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isString(value: unknown): value is string {
|
|
228
|
+
return typeof value === "string";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isNullableString(value: unknown): value is string | null {
|
|
232
|
+
return typeof value === "string" || value === null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isBoolean(value: unknown): value is boolean {
|
|
236
|
+
return typeof value === "boolean";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isOptionalBoolean(value: unknown): value is boolean | undefined {
|
|
240
|
+
return typeof value === "boolean" || typeof value === "undefined";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isNumber(value: unknown): value is number {
|
|
244
|
+
return typeof value === "number";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isNullableNumber(value: unknown): value is number | null {
|
|
248
|
+
return typeof value === "number" || value === null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Draft flow ---
|
|
252
|
+
|
|
253
|
+
export type DraftResult = {
|
|
254
|
+
id: string;
|
|
255
|
+
status: string;
|
|
256
|
+
created_at: string;
|
|
257
|
+
updated_at: string;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export type DraftMediaResult = {
|
|
261
|
+
id: string;
|
|
262
|
+
draft_id: string;
|
|
263
|
+
media_type: string;
|
|
264
|
+
storage_path: string;
|
|
265
|
+
sort_order: number;
|
|
266
|
+
is_primary: boolean;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export type DraftCreateOptions = Pick<IngestUploadOptions, "urls" | "city" | "venuePlaceId" | "context" | "ticketUrl" | "title" | "datetime" | "venue">;
|
|
270
|
+
|
|
271
|
+
export async function createDraft(
|
|
272
|
+
apiUrl: string,
|
|
273
|
+
token: string,
|
|
274
|
+
options: DraftCreateOptions,
|
|
275
|
+
): Promise<DraftResult> {
|
|
276
|
+
const body: Record<string, unknown> = {};
|
|
277
|
+
if (options.urls?.length) body.urls = options.urls;
|
|
278
|
+
if (options.ticketUrl) body.ticket_url = options.ticketUrl;
|
|
279
|
+
if (options.city) body.location_hint = options.city;
|
|
280
|
+
if (options.context) body.extra_context = options.context;
|
|
281
|
+
if (options.venuePlaceId) body.venue_place_id = options.venuePlaceId;
|
|
282
|
+
if (options.title || options.datetime || options.venue) {
|
|
283
|
+
body.metadata = {
|
|
284
|
+
...(options.title ? { event_title: options.title } : {}),
|
|
285
|
+
...(options.datetime ? { event_datetime: options.datetime } : {}),
|
|
286
|
+
...(options.venue ? { venue_name: options.venue } : {}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const response = await fetch(buildApiUrl(apiUrl, "/api/drafts"), {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: {
|
|
293
|
+
authorization: `Bearer ${token}`,
|
|
294
|
+
"content-type": "application/json",
|
|
295
|
+
accept: "application/json",
|
|
296
|
+
},
|
|
297
|
+
body: JSON.stringify(body),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const payload = await parseApiJsonBody(response);
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
throw new CliApiError(extractApiErrorMessage(payload, `Draft creation failed (${response.status})`), {
|
|
303
|
+
httpStatus: response.status,
|
|
304
|
+
body: payload,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return payload as DraftResult;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function uploadDraftMedia(
|
|
311
|
+
apiUrl: string,
|
|
312
|
+
token: string,
|
|
313
|
+
draftId: string,
|
|
314
|
+
filePath: string,
|
|
315
|
+
options: { mediaType: "image" | "video"; sortOrder: number; isPrimary: boolean },
|
|
316
|
+
): Promise<DraftMediaResult> {
|
|
317
|
+
const form = new FormData();
|
|
318
|
+
form.set("file", await fileToUpload(filePath));
|
|
319
|
+
form.set("media_type", options.mediaType);
|
|
320
|
+
form.set("sort_order", String(options.sortOrder));
|
|
321
|
+
form.set("is_primary", options.isPrimary ? "true" : "false");
|
|
322
|
+
|
|
323
|
+
const response = await fetch(buildApiUrl(apiUrl, `/api/drafts/${draftId}/media`), {
|
|
324
|
+
method: "POST",
|
|
325
|
+
headers: {
|
|
326
|
+
authorization: `Bearer ${token}`,
|
|
327
|
+
accept: "application/json",
|
|
328
|
+
},
|
|
329
|
+
body: form,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const payload = await parseApiJsonBody(response);
|
|
333
|
+
if (!response.ok) {
|
|
334
|
+
throw new CliApiError(extractApiErrorMessage(payload, `Draft media upload failed (${response.status})`), {
|
|
335
|
+
httpStatus: response.status,
|
|
336
|
+
body: payload,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return payload as DraftMediaResult;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".m4v", ".webm"]);
|
|
343
|
+
|
|
344
|
+
const MIME_BY_EXTENSION: Record<string, string> = {
|
|
345
|
+
".jpg": "image/jpeg",
|
|
346
|
+
".jpeg": "image/jpeg",
|
|
347
|
+
".png": "image/png",
|
|
348
|
+
".webp": "image/webp",
|
|
349
|
+
".mp4": "video/mp4",
|
|
350
|
+
".mov": "video/quicktime",
|
|
351
|
+
".m4v": "video/x-m4v",
|
|
352
|
+
".webm": "video/webm",
|
|
353
|
+
};
|