@series-a/cli 1.3.0 → 1.3.2

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/bin/setu.mjs DELETED
@@ -1,797 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * setu — internal CLI for building claimable practice and dentist profiles.
4
- *
5
- * setu login
6
- * setu practice create --url https://smiledental.co.uk
7
- * setu practice create --name "Smile Dental" --town Portsmouth
8
- * setu practice import practices.csv
9
- * setu practice review <draft-id>
10
- * setu practice publish <draft-id>
11
- * setu practice claim-link <practice-id>
12
- * setu dentist create --practice <id> --name "Dr Jane Roe"
13
- * setu status [<practice-id>]
14
- *
15
- * Global flags: --json (machine-readable output), --operator <name>
16
- */
17
- import { readFileSync, writeFileSync } from "node:fs";
18
- import { fileURLToPath } from "node:url";
19
- import { call } from "../lib/api.mjs";
20
- import { loadConfig, saveConfig, clearConfig, configPath, DEFAULT_API } from "../lib/config.mjs";
21
-
22
- const VERSION = JSON.parse(
23
- readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"),
24
- ).version;
25
- import { ask, confirm, log, ok, fail, warn, heading, table, spinner, c } from "../lib/ui.mjs";
26
-
27
- function parseArgs(argv) {
28
- const positional = [];
29
- const flags = {};
30
- for (let i = 0; i < argv.length; i++) {
31
- const a = argv[i];
32
- if (a.startsWith("--")) {
33
- const key = a.slice(2);
34
- const next = argv[i + 1];
35
- let value = true;
36
- if (next && !next.startsWith("--")) { value = next; i++; }
37
- // Repeatable flags (--set, --add, --remove) collect into arrays.
38
- if (key in flags) {
39
- flags[key] = Array.isArray(flags[key]) ? [...flags[key], value] : [flags[key], value];
40
- } else flags[key] = value;
41
- } else positional.push(a);
42
- }
43
- return { positional, flags };
44
- }
45
-
46
- const { positional, flags } = parseArgs(process.argv.slice(2));
47
- const JSON_OUT = !!flags.json;
48
- const out = (obj) => { if (JSON_OUT) log(JSON.stringify(obj, null, 2)); };
49
-
50
- function usage() {
51
- log(`
52
- ${c.bold("setu")} — build claimable Setu profiles
53
-
54
- ${c.bold("Commands")}
55
- login Store your internal CLI key
56
- logout Remove the stored key from this machine
57
- whoami Show the signed-in operator
58
- practice create Build a profile draft with AI
59
- --url <website> from a website, or
60
- --name <name> --town <town> from a name + town (found on Google)
61
- [--pricing <url>] [--booking <url>] [--email <contact email>]
62
- practice review <draft-id> Show everything the AI extracted
63
- practice publish <draft-id> Publish as an unclaimed listing
64
- [--no-dentists] don't seed detected clinicians
65
- practice import <file.csv> Bulk build + optionally publish
66
- [--publish] [--concurrency 2] [--dry-run]
67
- [--out results.json] [--resume results.json] [--limit N]
68
- practice claim-link <practice-id> Outreach link for the owner
69
- [--rotate]
70
- practice delete <practice-id> Remove an unclaimed seeded profile
71
- [--unpublish] [--yes]
72
- practice update <practice-id> Edit fields on a seeded profile
73
- --set field=value repeatable, e.g. --set tagline="Gentle care"
74
- --set-enrichment path=value e.g. --set-enrichment amenities="Wi-Fi|Parking"
75
- [--booking <url>] [--website <url>] shortcuts for the booking / website links
76
- [--fields] list the fields you can set
77
- practice images <practice-id> Manage photos on a seeded profile
78
- [--add <url>] [--remove <url|index>] [--order "url1,url2"]
79
- [--logo <url>] [--replace "url1,url2"] [--clear] [--list]
80
- [--upload <file|url>] [--upload-logo <file|url>]
81
- practice pricing <practice-id> Manage treatment prices
82
- [--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]
83
- practice list List seeded practices
84
- [--limit 50] [--search <name>]
85
- dentist create --name "Dr Jane Roe" Seed one clinician
86
- [--practice <id>] [--profile-url <url>] [--town <town>]
87
- [--photo <url>] [--gallery "url1,url2"] [--phone <n>]
88
- [--website <url>] [--booking <url>] [--bio "..."]
89
- dentist list List seeded clinicians
90
- [--limit 50] [--search <name>] [--practice <id>]
91
- dentist update <dentist-id> Edit fields on a seeded clinician
92
- dentist images <dentist-id> Manage a clinician's photos
93
- dentist pricing <dentist-id> Manage a clinician's treatment prices
94
- dentist delete <dentist-id> Remove an unclaimed seeded clinician
95
- [--unpublish] [--yes]
96
- status [<profile-id>] List or inspect seeded profiles
97
- [--dentist] [--full] [--limit 50] [--search <name>]
98
- claims Review dashboard: pending claims + ready profiles
99
- [--status pending|approved|rejected|all] [--dentist] [--ready] [--limit 50]
100
- claims show <claim-id> Full detail for one claim
101
- claims approve <claim-id> Hand the profile to the claimant
102
- claims decline <claim-id> Decline a claim [--reason "..."]
103
-
104
- ${c.bold("Global")} --json --yes --verbose --operator <name> --version
105
- `);
106
- }
107
-
108
- async function cmdLogin() {
109
- const cfg = loadConfig();
110
- const api = (await ask(`API base ${c.dim(`[${cfg.api || DEFAULT_API}]`)}: `)) || cfg.api || DEFAULT_API;
111
- const key = await ask("Internal CLI key: ", { silent: true });
112
- const operator = (await ask(`Your name (for attribution) ${c.dim(`[${cfg.operator || ""}]`)}: `)) || cfg.operator || "cli";
113
- const res = await call("cli-auth", {}, { api, key, operator });
114
- saveConfig({ api, key, operator });
115
- ok(`Signed in as ${c.bold(operator)} — ${res.site}`);
116
- }
117
-
118
- function cmdLogout() {
119
- clearConfig();
120
- ok(`Signed out — removed ${configPath()}`);
121
- }
122
-
123
- async function cmdPracticeDelete(id, type = "practice") {
124
- if (!id) { fail("Usage: setu practice delete <practice-id> [--unpublish]"); process.exit(1); }
125
- const unpublishOnly = !!flags.unpublish;
126
- if (!JSON_OUT && !flags.yes) {
127
- const proceed = await confirm(
128
- unpublishOnly
129
- ? "Hide this seeded profile from patients?"
130
- : "Permanently delete this unclaimed seeded profile (and its seeded clinicians)?",
131
- );
132
- if (!proceed) { warn("Cancelled."); return; }
133
- }
134
- const res = await call("cli-profile-delete", {
135
- id,
136
- profile_type: type === "dentist" || flags.dentist ? "dentist" : "practice",
137
- unpublish_only: unpublishOnly,
138
- });
139
- if (JSON_OUT) return out(res);
140
- ok(`${res.name} — ${res.action}`);
141
- }
142
-
143
- const asList = (v) => (v === undefined || v === true ? [] : Array.isArray(v) ? v : [v]);
144
- const splitList = (v) =>
145
- asList(v).flatMap((item) => String(item).split(",").map((s) => s.trim()).filter(Boolean));
146
-
147
- async function cmdProfileUpdate(type, id) {
148
- if (flags.fields) {
149
- // The server answers with the whitelist when nothing valid is sent.
150
- try {
151
- const r = await call("cli-profile-update", { id: id || "00000000-0000-0000-0000-000000000000", profile_type: type, read: true });
152
- if (JSON_OUT) return out({ fields: r.allowed_fields, enrichment_paths: r.allowed_enrichment_paths ?? [] });
153
- heading(`Fields you can set on a ${type}`);
154
- log(" " + r.allowed_fields.join(", "));
155
- if (r.allowed_enrichment_paths?.length) {
156
- heading("Extra fields (use --set-enrichment path=value)");
157
- log(" " + r.allowed_enrichment_paths.join(", "));
158
- }
159
- return;
160
- } catch (e) {
161
- const fields = e.data?.allowed_fields;
162
- if (fields) {
163
- if (JSON_OUT) return out({ fields });
164
- heading(`Fields you can set on a ${type}`);
165
- log(" " + fields.join(", "));
166
- return;
167
- }
168
- throw e;
169
- }
170
- return;
171
- }
172
- if (!id) { fail(`Usage: setu ${type} update <id> [--set field=value] [--booking <url>] [--website <url>]`); process.exit(1); }
173
-
174
- const set = {};
175
- const pairs = asList(flags.set);
176
- for (const pair of pairs) {
177
- const raw = String(pair);
178
- const eq = raw.indexOf("=");
179
- if (eq < 1) { fail(`Bad --set value: "${raw}". Use field=value.`); process.exit(1); }
180
- set[raw.slice(0, eq).trim()] = raw.slice(eq + 1);
181
- }
182
- const enrichmentSet = {};
183
- for (const pair of asList(flags["set-enrichment"])) {
184
- const raw = String(pair);
185
- const eq = raw.indexOf("=");
186
- if (eq < 1) { fail(`Bad --set-enrichment value: "${raw}". Use path=value.`); process.exit(1); }
187
- enrichmentSet[raw.slice(0, eq).trim()] = raw.slice(eq + 1);
188
- }
189
- if (flags.booking !== undefined) set.booking_url = flags.booking === true ? "" : String(flags.booking);
190
- if (flags.website !== undefined) set[type === "dentist" ? "website_url" : "website"] = flags.website === true ? "" : String(flags.website);
191
- if (!Object.keys(set).length && !Object.keys(enrichmentSet).length) { fail("Pass at least one --set field=value, --set-enrichment path=value, --booking <url> or --website <url> (or --fields to see what you can set)."); process.exit(1); }
192
-
193
- const res = await call("cli-profile-update", {
194
- id,
195
- profile_type: type,
196
- set,
197
- ...(Object.keys(enrichmentSet).length ? { enrichment_set: enrichmentSet } : {}),
198
- });
199
- if (JSON_OUT) return out(res);
200
- ok(`${res.name} — updated ${res.updated.join(", ")}`);
201
- if (res.rejected?.length) warn(`Ignored unknown field(s): ${res.rejected.join(", ")}`);
202
- }
203
-
204
- const MIME_BY_EXT = {
205
- jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
206
- webp: "image/webp", svg: "image/svg+xml", gif: "image/gif",
207
- };
208
-
209
- /** A local path becomes base64; a URL is fetched server-side. */
210
- function uploadPayload(src) {
211
- const value = String(src);
212
- if (/^https?:\/\//i.test(value)) return { source_url: value };
213
- const ext = value.split(".").pop().toLowerCase();
214
- return {
215
- data: readFileSync(value).toString("base64"),
216
- content_type: MIME_BY_EXT[ext] || "image/jpeg",
217
- filename: value.split(/[\\/]/).pop(),
218
- };
219
- }
220
-
221
- async function uploadImages(type, id, sources, attach) {
222
- const urls = [];
223
- for (const src of sources) {
224
- const s = spinner(`Uploading ${src}`);
225
- try {
226
- const res = await call("cli-profile-upload", { id, profile_type: type, attach, ...uploadPayload(src) });
227
- s.stop();
228
- urls.push(res.url);
229
- if (!JSON_OUT) ok(`${attach === "logo" ? "Logo" : "Photo"} uploaded — ${c.dim(res.url)}`);
230
- } catch (e) { s.stop(); throw e; }
231
- }
232
- return urls;
233
- }
234
-
235
- async function cmdProfileImages(type, id) {
236
- if (!id) { fail(`Usage: setu ${type} images <id> [--add <url>] [--upload <file>] [--remove <url|index>] [--order "a,b"] [--logo <url>] [--upload-logo <file>]`); process.exit(1); }
237
-
238
- const uploads = splitList(flags.upload);
239
- const logoUploads = splitList(flags["upload-logo"]);
240
- const uploaded = [];
241
- if (uploads.length) uploaded.push(...await uploadImages(type, id, uploads, "gallery"));
242
- if (logoUploads.length) await uploadImages(type, id, logoUploads.slice(0, 1), "logo");
243
-
244
- const images = {};
245
- if (splitList(flags.add).length) images.add = splitList(flags.add);
246
- if (splitList(flags.remove).length) images.remove = splitList(flags.remove);
247
- if (splitList(flags.order).length) images.order = splitList(flags.order);
248
- if (splitList(flags.replace).length) images.replace = splitList(flags.replace);
249
- if (flags.clear === true) images.clear = true;
250
- if (flags.logo !== undefined) images.logo = flags.logo === true ? null : String(flags.logo);
251
-
252
- if (!Object.keys(images).length || flags.list) {
253
- // Listing is a no-op update: ask the server for the current state.
254
- const res = await call("cli-profile-update", { id, profile_type: type, read: true });
255
- if (JSON_OUT) return out(res);
256
- heading(`${res.name} — photos (${res.gallery.length})`);
257
- res.gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${url}`));
258
- log(` ${c.dim(type === "dentist" ? "main photo" : "logo")}: ${res.logo ?? c.dim("none")}`);
259
- return;
260
- }
261
-
262
- if (images.clear && !JSON_OUT && !flags.yes) {
263
- const proceed = await confirm("Remove every photo from this profile?");
264
- if (!proceed) { warn("Cancelled."); return; }
265
- }
266
-
267
- const res = await call("cli-profile-update", { id, profile_type: type, images });
268
- if (JSON_OUT) return out(res);
269
- ok(`${res.name} — ${res.gallery.length} photo(s)${res.logo ? ", logo set" : ""}`);
270
- res.gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${c.dim(url)}`));
271
- }
272
-
273
- async function cmdProfilePricing(type, id) {
274
- if (!id) { fail(`Usage: setu ${type} pricing <id> [--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]`); process.exit(1); }
275
-
276
- const setPairs = asList(flags.set);
277
- const removals = splitList(flags.remove);
278
- const wantsWrite = setPairs.length || removals.length || flags.clear === true;
279
-
280
- if (!wantsWrite || flags.list) {
281
- const res = await call("cli-profile-update", { id, profile_type: type, read: true });
282
- if (JSON_OUT) return out({ id, pricing: res.pricing ?? [] });
283
- const rows = res.pricing ?? [];
284
- heading(`${res.name} — prices (${rows.length})`);
285
- if (!rows.length) log(c.dim(" none yet — add with --set \"Implant=1200\""));
286
- rows.forEach((r) => {
287
- const range = r.amount_to ? `${r.amount}–${r.amount_to}` : r.amount;
288
- log(` ${String(r.label).padEnd(34)} £${range ?? "?"}`);
289
- });
290
- return;
291
- }
292
-
293
- const pricing = {};
294
- if (setPairs.length) {
295
- pricing.set = {};
296
- for (const pair of setPairs) {
297
- const raw = String(pair);
298
- const eq = raw.indexOf("=");
299
- if (eq < 1) { fail(`Bad --set value: "${raw}". Use "Treatment=amount" (or "Treatment=800-1200").`); process.exit(1); }
300
- pricing.set[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
301
- }
302
- }
303
- if (removals.length) pricing.remove = removals;
304
- if (flags.clear === true) {
305
- if (!JSON_OUT && !flags.yes) {
306
- const proceed = await confirm("Remove every price from this profile?");
307
- if (!proceed) { warn("Cancelled."); return; }
308
- }
309
- pricing.clear = true;
310
- }
311
-
312
- const res = await call("cli-profile-update", { id, profile_type: type, pricing });
313
- if (JSON_OUT) return out(res);
314
- const rows = res.pricing ?? [];
315
- ok(`${res.name} — ${rows.length} price(s) on file`);
316
- rows.forEach((r) => log(` ${String(r.label).padEnd(34)} ${c.dim(`£${r.amount_to ? `${r.amount}–${r.amount_to}` : r.amount}`)}`));
317
- }
318
-
319
- async function cmdWhoami() {
320
- const res = await call("cli-auth", {});
321
- const cfg = loadConfig();
322
- if (JSON_OUT) return out({ ...res, api: cfg.api });
323
- table([["Operator", res.operator], ["Site", res.site], ["API", cfg.api]]);
324
- }
325
-
326
- function summarise(res) {
327
- table([
328
- ["Draft", res.draft_id],
329
- ["Name", res.name],
330
- ["Status", res.status],
331
- ["Readiness", `${res.readiness_score}/100`],
332
- ["Services", res.services],
333
- ["Priced items", res.pricing_rows],
334
- ["Clinicians found", res.dentists_found],
335
- ["Google reviews", res.google_reviews],
336
- ]);
337
- if (Array.isArray(res.missing) && res.missing.length) {
338
- warn(`Missing: ${res.missing.slice(0, 6).map((m) => (typeof m === "string" ? m : m.field ?? "")).join(", ")}`);
339
- }
340
- }
341
-
342
- async function cmdPracticeCreate() {
343
- const payload = {
344
- website: typeof flags.url === "string" ? flags.url : undefined,
345
- name: typeof flags.name === "string" ? flags.name : undefined,
346
- town: typeof flags.town === "string" ? flags.town : undefined,
347
- postcode: typeof flags.postcode === "string" ? flags.postcode : undefined,
348
- contact_email: typeof flags.email === "string" ? flags.email : undefined,
349
- booking_url: typeof flags.booking === "string" ? flags.booking : undefined,
350
- pricing_urls: typeof flags.pricing === "string" ? [flags.pricing] : undefined,
351
- };
352
- if (!payload.website && !payload.name) {
353
- fail("Pass --url, or --name with --town.");
354
- process.exit(1);
355
- }
356
- const s = spinner("Finding and reading the practice online");
357
- try {
358
- const res = await call("cli-profile-create", payload);
359
- s.stop();
360
- if (JSON_OUT) return out(res);
361
- heading("Draft built");
362
- summarise(res);
363
- log(`\n${c.dim("Next:")} setu practice publish ${res.draft_id}`);
364
- } catch (e) {
365
- s.stop();
366
- throw e;
367
- }
368
- }
369
-
370
- async function cmdPracticeReview(draftId) {
371
- if (!draftId) { fail("Usage: setu practice review <draft-id>"); process.exit(1); }
372
- const res = await call("cli-profile-review", { draft_id: draftId });
373
- if (JSON_OUT) return out(res);
374
- heading(res.identity.name || "Draft");
375
- table([
376
- ["Status", res.status],
377
- ["Readiness", `${res.readiness_score}/100`],
378
- ["Tagline", res.identity.tagline],
379
- ["Phone", res.identity.contact?.phone],
380
- ["Email", res.identity.contact?.email],
381
- ["Published as", res.published_practice_id],
382
- ]);
383
- heading(`Services (${res.services.length})`);
384
- log(" " + (res.services.join(", ") || c.dim("none found")));
385
- heading(`Pricing (${res.service_pricing.length})`);
386
- for (const p of res.service_pricing.slice(0, 12)) {
387
- log(` ${String(p.label || p.service).padEnd(34)} £${p.amount_from}${p.amount_to ? `–£${p.amount_to}` : ""}`);
388
- }
389
- heading(`Clinicians (${res.dentist_candidates.length})`);
390
- for (const d of res.dentist_candidates.slice(0, 12)) log(` ${d.name}${d.role ? c.dim(` — ${d.role}`) : ""}`);
391
- if (res.errors) warn(`Notes: ${JSON.stringify(res.errors).slice(0, 300)}`);
392
- }
393
-
394
- async function cmdPracticePublish(draftId) {
395
- if (!draftId) { fail("Usage: setu practice publish <draft-id>"); process.exit(1); }
396
- if (!JSON_OUT && !flags.yes) {
397
- const proceed = await confirm("Publish this profile as an unclaimed public listing?");
398
- if (!proceed) { warn("Cancelled."); return; }
399
- }
400
- const res = await call("cli-profile-publish", {
401
- draft_id: draftId,
402
- create_dentists: !flags["no-dentists"],
403
- });
404
- if (JSON_OUT) return out(res);
405
- ok("Published as an unclaimed listing");
406
- table([
407
- ["Practice", res.practice_id],
408
- ["Profile", res.profile_url],
409
- ["Claim link", res.claim_url],
410
- ["Clinicians", res.dentists_created],
411
- ]);
412
- }
413
-
414
- function parseCsv(text) {
415
- const lines = text.split(/\r?\n/).filter((l) => l.trim());
416
- if (!lines.length) return [];
417
- const split = (line) => {
418
- const cells = [];
419
- let cur = "", inQ = false;
420
- for (const ch of line) {
421
- if (ch === '"') inQ = !inQ;
422
- else if (ch === "," && !inQ) { cells.push(cur); cur = ""; }
423
- else cur += ch;
424
- }
425
- cells.push(cur);
426
- return cells.map((s) => s.trim());
427
- };
428
- const header = split(lines[0]).map((h) => h.toLowerCase());
429
- return lines.slice(1).map((line) => {
430
- const cells = split(line);
431
- const row = {};
432
- header.forEach((h, i) => { if (cells[i]) row[h] = cells[i]; });
433
- return row;
434
- });
435
- }
436
-
437
- async function cmdPracticeImport(file) {
438
- if (!file) { fail("Usage: setu practice import <file.csv>"); process.exit(1); }
439
- let rows;
440
- try {
441
- rows = parseCsv(readFileSync(file, "utf8"));
442
- } catch (e) {
443
- fail(`Could not read ${file}: ${e.message}`);
444
- process.exit(1);
445
- }
446
- if (!rows.length) { fail("No rows found in that CSV."); process.exit(1); }
447
-
448
- // Validate before spending any API calls.
449
- const invalid = [];
450
- rows = rows.map((row, i) => ({ ...row, __line: i + 2 }));
451
- const valid = rows.filter((row) => {
452
- const hasSite = !!(row.website || row.url);
453
- const hasNameTown = !!(row.name && (row.town || row.city));
454
- if (!hasSite && !hasNameTown) {
455
- invalid.push(`line ${row.__line}: needs a website/url, or a name plus town`);
456
- return false;
457
- }
458
- return true;
459
- });
460
- if (invalid.length) {
461
- warn(`Skipping ${invalid.length} unusable row(s):`);
462
- for (const m of invalid.slice(0, 10)) log(` ${c.dim(m)}`);
463
- }
464
-
465
- // Resume: skip anything already completed in a previous results file.
466
- let done = new Map();
467
- if (typeof flags.resume === "string") {
468
- try {
469
- const prev = JSON.parse(readFileSync(flags.resume, "utf8"));
470
- for (const r of prev.results ?? []) if (r.ok) done.set(r.label, r);
471
- } catch (e) { warn(`Could not read resume file: ${e.message}`); }
472
- }
473
-
474
- const labelOf = (row, i) => row.name || row.website || row.url || `row ${i + 1}`;
475
- let queue = valid.filter((row, i) => !done.has(labelOf(row, i)));
476
- if (flags.limit) queue = queue.slice(0, Number(flags.limit));
477
-
478
- if (flags["dry-run"]) {
479
- heading(`Dry run — ${queue.length} practice(s) would be built${flags.publish ? " and published" : ""}`);
480
- for (const row of queue.slice(0, 50)) log(` ${labelOf(row, 0)} ${c.dim(row.website || row.url || row.town || "")}`);
481
- if (JSON_OUT) out({ dry_run: true, total: queue.length, skipped: invalid.length, already_done: done.size });
482
- return;
483
- }
484
-
485
- const concurrency = Math.max(1, Math.min(5, Number(flags.concurrency) || 2));
486
- log(`Importing ${c.bold(queue.length)} practices (${concurrency} at a time)…\n`);
487
-
488
- const results = [...done.values()];
489
- let index = 0;
490
- let completed = 0;
491
-
492
- async function worker() {
493
- while (index < queue.length) {
494
- const i = index++;
495
- const row = queue[i];
496
- const label = labelOf(row, i);
497
- try {
498
- const created = await call("cli-profile-create", {
499
- website: row.website || row.url,
500
- name: row.name,
501
- town: row.town || row.city,
502
- postcode: row.postcode,
503
- contact_email: row.email,
504
- contact_phone: row.phone,
505
- });
506
- let published = null;
507
- if (flags.publish && created.status !== "failed") {
508
- published = await call("cli-profile-publish", { draft_id: created.draft_id });
509
- }
510
- completed++;
511
- log(`${c.green("✔")} [${completed}/${queue.length}] ${label} ${c.dim(`(${created.readiness_score}/100)`)}${published ? ` → ${published.claim_url}` : ""}`);
512
- results.push({
513
- label, ok: true, draft_id: created.draft_id,
514
- practice_id: published?.practice_id ?? null,
515
- claim_url: published?.claim_url ?? null,
516
- });
517
- } catch (e) {
518
- completed++;
519
- log(`${c.red("✖")} [${completed}/${queue.length}] ${label} — ${e.message}`);
520
- results.push({ label, ok: false, error: e.message, line: row.__line });
521
- }
522
- }
523
- }
524
-
525
- await Promise.all(Array.from({ length: concurrency }, worker));
526
-
527
- const failed = results.filter((r) => !r.ok);
528
- const payload = {
529
- total: results.length,
530
- built: results.length - failed.length,
531
- failed: failed.length,
532
- skipped_invalid: invalid.length,
533
- results,
534
- };
535
-
536
- if (typeof flags.out === "string") {
537
- try {
538
- writeFileSync(flags.out, JSON.stringify(payload, null, 2));
539
- ok(`Results written to ${flags.out}${failed.length ? ` — rerun with --resume ${flags.out}` : ""}`);
540
- } catch (e) { warn(`Could not write results file: ${e.message}`); }
541
- }
542
-
543
- if (JSON_OUT) { out(payload); }
544
- else {
545
- heading(`Done — ${payload.built} built, ${failed.length} failed`);
546
- for (const f of failed) log(` ${c.red("✖")} ${f.label}: ${f.error}`);
547
- }
548
- if (failed.length) process.exitCode = 1;
549
- }
550
-
551
- async function cmdClaimLink(id) {
552
- if (!id) { fail("Usage: setu practice claim-link <practice-id>"); process.exit(1); }
553
- const res = await call("cli-claim-link", {
554
- id,
555
- profile_type: type === "dentist" || flags.dentist ? "dentist" : "practice",
556
- rotate: !!flags.rotate,
557
- });
558
- if (JSON_OUT) return out(res);
559
- ok(res.name);
560
- log(` ${res.claim_url}`);
561
- }
562
-
563
- async function cmdDentistCreate() {
564
- if (typeof flags.name !== "string") { fail("Pass --name \"Dr Jane Roe\"."); process.exit(1); }
565
- const s = spinner("Building the clinician profile");
566
- try {
567
- const res = await call("cli-dentist-create", {
568
- name: flags.name,
569
- practice_id: typeof flags.practice === "string" ? flags.practice : undefined,
570
- profile_url: typeof flags["profile-url"] === "string" ? flags["profile-url"] : undefined,
571
- town: typeof flags.town === "string" ? flags.town : undefined,
572
- postcode: typeof flags.postcode === "string" ? flags.postcode : undefined,
573
- email: typeof flags.email === "string" ? flags.email : undefined,
574
- phone: typeof flags.phone === "string" ? flags.phone : undefined,
575
- website_url: typeof flags.website === "string" ? flags.website : undefined,
576
- booking_url: typeof flags.booking === "string" ? flags.booking : undefined,
577
- bio: typeof flags.bio === "string" ? flags.bio : undefined,
578
- photo_url: typeof flags.photo === "string" ? flags.photo : undefined,
579
- gallery: splitList(flags.gallery),
580
- });
581
- s.stop();
582
- if (JSON_OUT) return out(res);
583
- ok("Clinician seeded as unclaimed");
584
- table([
585
- ["Dentist", res.dentist_id],
586
- ["AI enriched", res.enriched ? "yes" : "no"],
587
- ["Photo", res.photo_url ?? "—"],
588
- ["Claim link", res.claim_url],
589
- ]);
590
- } catch (e) { s.stop(); throw e; }
591
- }
592
-
593
- function printProfileList(res) {
594
- heading(`Seeded ${res.profile_type === "dentist" ? "clinicians" : "practices"} (${res.items.length})`);
595
- for (const p of res.items) {
596
- const state = p.claim_status === "claimed" ? c.green("claimed") : p.is_live ? c.green("live") : c.yellow(p.claim_status);
597
- log(` ${state.padEnd(20)} ${String(p.name).slice(0, 38).padEnd(40)} ${c.dim(p.id)}`);
598
- }
599
- }
600
-
601
- async function cmdProfileList(type) {
602
- const res = await call("cli-profile-status", {
603
- list: true,
604
- profile_type: type,
605
- limit: flags.limit,
606
- search: typeof flags.search === "string" ? flags.search : undefined,
607
- practice_id: type === "dentist" && typeof flags.practice === "string" ? flags.practice : undefined,
608
- });
609
- if (JSON_OUT) return out(res);
610
- printProfileList(res);
611
- }
612
-
613
- async function cmdStatus(id, type = flags.dentist ? "dentist" : "practice") {
614
- const res = await call(
615
- "cli-profile-status",
616
- id
617
- ? { id, profile_type: type, full: !!flags.full }
618
- : { list: true, profile_type: type, limit: flags.limit, search: typeof flags.search === "string" ? flags.search : undefined },
619
- );
620
- if (JSON_OUT) return out(res);
621
- if (res.items) return printProfileList(res);
622
-
623
- const p = res.profile ?? res.practice;
624
- heading(p.name);
625
- table([
626
- ["Type", res.profile_type],
627
- ["Status", p.claim_status],
628
- ["Live", p.is_live ? "yes" : "no"],
629
- ["Source", `${p.source} (${p.created_by_operator ?? "—"})`],
630
- ["Claim link", p.claim_url],
631
- ]);
632
-
633
- if (flags.full) {
634
- const gallery = res.gallery ?? [];
635
- heading(`Photos (${gallery.length})`);
636
- gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${c.dim(url)}`));
637
- log(` ${c.dim(res.profile_type === "dentist" ? "main photo" : "logo")}: ${res.logo ?? c.dim("none")}`);
638
-
639
- const pricing = res.pricing ?? [];
640
- heading(`Prices (${pricing.length})`);
641
- for (const row of pricing) {
642
- const amount = row.amount_to ? `${row.amount}–${row.amount_to}` : row.amount ?? "—";
643
- log(` ${String(row.label).slice(0, 38).padEnd(40)} ${amount}`);
644
- }
645
-
646
- if (res.dentists) {
647
- heading(`Clinicians (${res.dentists.length})`);
648
- for (const d of res.dentists) log(` ${String(d.name).slice(0, 38).padEnd(40)} ${c.dim(d.id)}`);
649
- }
650
- }
651
-
652
- heading(`Claims (${res.claims.length})`);
653
- for (const cl of res.claims) log(` ${cl.status.padEnd(10)} ${cl.claimant_email} ${c.dim(cl.method)}`);
654
- }
655
-
656
- function printClaimRow(cl) {
657
- const who = String(cl.claimant_email ?? "—").slice(0, 30).padEnd(32);
658
- const name = String(cl.profile?.name ?? cl.profile_type ?? "—").slice(0, 26).padEnd(28);
659
- const flag = cl.awaiting_signin ? c.yellow(" (not signed in)") : "";
660
- log(` ${String(cl.status).padEnd(9)} ${who} ${name} ${c.dim(cl.method ?? "")}${flag}`);
661
- log(` ${c.dim(cl.id)}`);
662
- }
663
-
664
- async function cmdClaims(sub, arg) {
665
- if (sub === "show") {
666
- if (!arg) throw new Error("Usage: setu claims show <claim-id>");
667
- const res = await call("cli-claims", { action: "detail", claim_id: arg });
668
- if (JSON_OUT) return out(res);
669
- heading(`Claim ${res.claim.id}`);
670
- table([
671
- ["Status", res.claim.status],
672
- ["Claimant", res.claim.claimant_email],
673
- ["Method", res.claim.method],
674
- ["Attempts", res.claim.attempts],
675
- ["Signed in", res.claim.claimant_user_id ? "yes" : "no"],
676
- ["Profile", res.profile ? `${res.profile.name} (${res.claim.profile_type})` : "—"],
677
- ["Readiness", res.profile ? `${res.profile.score}%` : "—"],
678
- ["Missing", res.profile?.missing?.join(", ")],
679
- ["Claim link", res.profile?.claim_url],
680
- ]);
681
- return;
682
- }
683
-
684
- if (sub === "approve" || sub === "decline" || sub === "reject") {
685
- if (!arg) throw new Error(`Usage: setu claims ${sub} <claim-id>`);
686
- if (!flags.yes && !JSON_OUT) {
687
- const go = await confirm(`${sub === "approve" ? "Approve" : "Decline"} claim ${arg}?`);
688
- if (!go) return warn("Cancelled.");
689
- }
690
- const res = await call("cli-claims", {
691
- action: sub === "approve" ? "approve" : "decline",
692
- claim_id: arg,
693
- reason: typeof flags.reason === "string" ? flags.reason : undefined,
694
- });
695
- if (JSON_OUT) return out(res);
696
- return ok(`Claim ${res.status} — ${res.claim_id}`);
697
- }
698
-
699
- if (sub && sub !== "list") throw new Error(`Unknown command: claims ${sub}`);
700
-
701
- const status = typeof flags.status === "string" ? flags.status : "pending";
702
- const profileType = flags.dentist ? "dentist" : undefined;
703
- if (sub === "list" || (typeof flags.status === "string" && flags.status !== "pending")) {
704
- const res = await call("cli-claims", { action: "list", status, profile_type: profileType, limit: flags.limit });
705
- if (JSON_OUT) return out(res);
706
- heading(`Claims — ${status} (${res.claims.length})`);
707
- res.claims.forEach(printClaimRow);
708
- if (!res.claims.length) log(c.dim(" none"));
709
- return;
710
- }
711
-
712
- const res = await call("cli-claims", {
713
- action: "dashboard",
714
- profile_type: profileType,
715
- ready_only: !!flags.ready,
716
- limit: flags.limit,
717
- });
718
- if (JSON_OUT) return out(res);
719
-
720
- heading("Claims");
721
- table([
722
- ["Pending", res.counts.pending],
723
- ["Approved", res.counts.approved],
724
- ["Rejected", res.counts.rejected],
725
- ]);
726
-
727
- heading(`Pending review (${res.pending_claims.length})`);
728
- res.pending_claims.forEach(printClaimRow);
729
- if (!res.pending_claims.length) log(c.dim(" nothing waiting"));
730
-
731
- heading(`Unclaimed profiles — ${res.unclaimed.ready}/${res.unclaimed.total} ready, ${res.unclaimed.not_live} ready but offline`);
732
- for (const u of res.unclaimed.items) {
733
- const badge = u.ready ? c.green("ready") : c.yellow("gaps ");
734
- log(` ${badge} ${String(u.readiness_score + "%").padStart(4)} ${String(u.name).slice(0, 30).padEnd(32)} ${c.dim(u.profile_type)}${u.is_live ? "" : c.dim(" offline")}`);
735
- if (u.missing.length) log(` ${c.dim("missing: " + u.missing.join(", "))}`);
736
- }
737
- if (!res.unclaimed.items.length) log(c.dim(" none"));
738
- }
739
-
740
- async function main() {
741
- if (typeof flags.operator === "string") {
742
- const cfg = loadConfig();
743
- saveConfig({ ...cfg, operator: flags.operator });
744
- }
745
- if (flags.version) { log(`setu ${VERSION}`); return; }
746
- const [group, sub, arg] = positional;
747
- if (!group || flags.help || group === "help") { usage(); return; }
748
- switch (group) {
749
- case "login": return cmdLogin();
750
- case "logout": return cmdLogout();
751
- case "whoami": return cmdWhoami();
752
- case "status": return cmdStatus(sub);
753
- case "claims": return cmdClaims(sub, arg);
754
- case "practice":
755
- switch (sub) {
756
- case "create": return cmdPracticeCreate();
757
- case "review": return cmdPracticeReview(arg);
758
- case "publish": return cmdPracticePublish(arg);
759
- case "import": return cmdPracticeImport(arg);
760
- case "claim-link": return cmdClaimLink(arg);
761
- case "delete": return cmdPracticeDelete(arg);
762
- case "update": return cmdProfileUpdate("practice", arg);
763
- case "images": return cmdProfileImages("practice", arg);
764
- case "pricing": return cmdProfilePricing("practice", arg);
765
- case "list": return cmdProfileList("practice");
766
- default:
767
- fail(`Unknown command: practice ${sub ?? ""}`.trim());
768
- usage();
769
- process.exitCode = 1;
770
- return;
771
- }
772
- case "dentist":
773
- if (sub === "create") return cmdDentistCreate();
774
- if (sub === "list") return cmdProfileList("dentist");
775
- if (sub === "status") return cmdStatus(arg, "dentist");
776
- if (sub === "update") return cmdProfileUpdate("dentist", arg);
777
- if (sub === "images") return cmdProfileImages("dentist", arg);
778
- if (sub === "pricing") return cmdProfilePricing("dentist", arg);
779
- if (sub === "delete") return cmdPracticeDelete(arg, "dentist");
780
- fail(`Unknown command: dentist ${sub ?? ""}`.trim());
781
- usage();
782
- process.exitCode = 1;
783
- return;
784
- default:
785
- fail(`Unknown command: ${group}`);
786
- usage();
787
- process.exitCode = 1;
788
- return;
789
- }
790
- }
791
-
792
- main().catch((e) => {
793
- fail(e.message);
794
- if (flags.verbose && e.stack) log(c.dim(e.stack));
795
- if (e.data && !JSON_OUT && e.data.practice_id) log(c.dim(` existing: ${e.data.practice_id}`));
796
- process.exit(1);
797
- });