@series-a/cli 1.2.0 → 1.3.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/CHANGELOG.md +175 -39
- package/LICENSE +7 -0
- package/README.md +257 -91
- package/SKILLS.md +344 -0
- package/bin/advisoros.mjs +2 -0
- package/package.json +50 -15
- package/src/api.mjs +102 -0
- package/src/commands/auth.mjs +193 -0
- package/src/commands/brand.mjs +439 -0
- package/src/commands/flow.mjs +175 -0
- package/src/commands/insights.mjs +1813 -0
- package/src/commands/knowledge-sync.mjs +519 -0
- package/src/commands/resources.mjs +2196 -0
- package/src/commands/strategies.mjs +242 -0
- package/src/commands/voice.mjs +212 -0
- package/src/config.mjs +82 -0
- package/src/index.mjs +77 -0
- package/src/ui.mjs +104 -0
- package/bin/setu.mjs +0 -607
- package/lib/api.mjs +0 -84
- package/lib/config.mjs +0 -42
- package/lib/ui.mjs +0 -68
package/bin/setu.mjs
DELETED
|
@@ -1,607 +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
|
-
practice pricing <practice-id> Manage treatment prices
|
|
81
|
-
[--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]
|
|
82
|
-
dentist create --name "Dr Jane Roe" Seed one clinician
|
|
83
|
-
[--practice <id>] [--profile-url <url>] [--town <town>]
|
|
84
|
-
dentist update <dentist-id> Edit fields on a seeded clinician
|
|
85
|
-
dentist images <dentist-id> Manage a clinician's photos
|
|
86
|
-
dentist pricing <dentist-id> Manage a clinician's treatment prices
|
|
87
|
-
dentist delete <dentist-id> Remove an unclaimed seeded clinician
|
|
88
|
-
[--unpublish] [--yes]
|
|
89
|
-
status [<practice-id>] List or inspect seeded profiles
|
|
90
|
-
|
|
91
|
-
${c.bold("Global")} --json --yes --verbose --operator <name> --version
|
|
92
|
-
`);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function cmdLogin() {
|
|
96
|
-
const cfg = loadConfig();
|
|
97
|
-
const api = (await ask(`API base ${c.dim(`[${cfg.api || DEFAULT_API}]`)}: `)) || cfg.api || DEFAULT_API;
|
|
98
|
-
const key = await ask("Internal CLI key: ", { silent: true });
|
|
99
|
-
const operator = (await ask(`Your name (for attribution) ${c.dim(`[${cfg.operator || ""}]`)}: `)) || cfg.operator || "cli";
|
|
100
|
-
const res = await call("cli-auth", {}, { api, key, operator });
|
|
101
|
-
saveConfig({ api, key, operator });
|
|
102
|
-
ok(`Signed in as ${c.bold(operator)} — ${res.site}`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function cmdLogout() {
|
|
106
|
-
clearConfig();
|
|
107
|
-
ok(`Signed out — removed ${configPath()}`);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function cmdPracticeDelete(id, type = "practice") {
|
|
111
|
-
if (!id) { fail("Usage: setu practice delete <practice-id> [--unpublish]"); process.exit(1); }
|
|
112
|
-
const unpublishOnly = !!flags.unpublish;
|
|
113
|
-
if (!JSON_OUT && !flags.yes) {
|
|
114
|
-
const proceed = await confirm(
|
|
115
|
-
unpublishOnly
|
|
116
|
-
? "Hide this seeded profile from patients?"
|
|
117
|
-
: "Permanently delete this unclaimed seeded profile (and its seeded clinicians)?",
|
|
118
|
-
);
|
|
119
|
-
if (!proceed) { warn("Cancelled."); return; }
|
|
120
|
-
}
|
|
121
|
-
const res = await call("cli-profile-delete", {
|
|
122
|
-
id,
|
|
123
|
-
profile_type: type === "dentist" || flags.dentist ? "dentist" : "practice",
|
|
124
|
-
unpublish_only: unpublishOnly,
|
|
125
|
-
});
|
|
126
|
-
if (JSON_OUT) return out(res);
|
|
127
|
-
ok(`${res.name} — ${res.action}`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const asList = (v) => (v === undefined || v === true ? [] : Array.isArray(v) ? v : [v]);
|
|
131
|
-
const splitList = (v) =>
|
|
132
|
-
asList(v).flatMap((item) => String(item).split(",").map((s) => s.trim()).filter(Boolean));
|
|
133
|
-
|
|
134
|
-
async function cmdProfileUpdate(type, id) {
|
|
135
|
-
if (flags.fields) {
|
|
136
|
-
// The server answers with the whitelist when nothing valid is sent.
|
|
137
|
-
try {
|
|
138
|
-
const r = await call("cli-profile-update", { id: id || "00000000-0000-0000-0000-000000000000", profile_type: type, read: true });
|
|
139
|
-
if (JSON_OUT) return out({ fields: r.allowed_fields, enrichment_paths: r.allowed_enrichment_paths ?? [] });
|
|
140
|
-
heading(`Fields you can set on a ${type}`);
|
|
141
|
-
log(" " + r.allowed_fields.join(", "));
|
|
142
|
-
if (r.allowed_enrichment_paths?.length) {
|
|
143
|
-
heading("Extra fields (use --set-enrichment path=value)");
|
|
144
|
-
log(" " + r.allowed_enrichment_paths.join(", "));
|
|
145
|
-
}
|
|
146
|
-
return;
|
|
147
|
-
} catch (e) {
|
|
148
|
-
const fields = e.data?.allowed_fields;
|
|
149
|
-
if (fields) {
|
|
150
|
-
if (JSON_OUT) return out({ fields });
|
|
151
|
-
heading(`Fields you can set on a ${type}`);
|
|
152
|
-
log(" " + fields.join(", "));
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
throw e;
|
|
156
|
-
}
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (!id) { fail(`Usage: setu ${type} update <id> [--set field=value] [--booking <url>] [--website <url>]`); process.exit(1); }
|
|
160
|
-
|
|
161
|
-
const set = {};
|
|
162
|
-
const pairs = asList(flags.set);
|
|
163
|
-
for (const pair of pairs) {
|
|
164
|
-
const raw = String(pair);
|
|
165
|
-
const eq = raw.indexOf("=");
|
|
166
|
-
if (eq < 1) { fail(`Bad --set value: "${raw}". Use field=value.`); process.exit(1); }
|
|
167
|
-
set[raw.slice(0, eq).trim()] = raw.slice(eq + 1);
|
|
168
|
-
}
|
|
169
|
-
const enrichmentSet = {};
|
|
170
|
-
for (const pair of asList(flags["set-enrichment"])) {
|
|
171
|
-
const raw = String(pair);
|
|
172
|
-
const eq = raw.indexOf("=");
|
|
173
|
-
if (eq < 1) { fail(`Bad --set-enrichment value: "${raw}". Use path=value.`); process.exit(1); }
|
|
174
|
-
enrichmentSet[raw.slice(0, eq).trim()] = raw.slice(eq + 1);
|
|
175
|
-
}
|
|
176
|
-
if (flags.booking !== undefined) set.booking_url = flags.booking === true ? "" : String(flags.booking);
|
|
177
|
-
if (flags.website !== undefined) set[type === "dentist" ? "website_url" : "website"] = flags.website === true ? "" : String(flags.website);
|
|
178
|
-
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); }
|
|
179
|
-
|
|
180
|
-
const res = await call("cli-profile-update", {
|
|
181
|
-
id,
|
|
182
|
-
profile_type: type,
|
|
183
|
-
set,
|
|
184
|
-
...(Object.keys(enrichmentSet).length ? { enrichment_set: enrichmentSet } : {}),
|
|
185
|
-
});
|
|
186
|
-
if (JSON_OUT) return out(res);
|
|
187
|
-
ok(`${res.name} — updated ${res.updated.join(", ")}`);
|
|
188
|
-
if (res.rejected?.length) warn(`Ignored unknown field(s): ${res.rejected.join(", ")}`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
async function cmdProfileImages(type, id) {
|
|
192
|
-
if (!id) { fail(`Usage: setu ${type} images <id> [--add <url>] [--remove <url|index>] [--order "a,b"] [--logo <url>]`); process.exit(1); }
|
|
193
|
-
|
|
194
|
-
const images = {};
|
|
195
|
-
if (splitList(flags.add).length) images.add = splitList(flags.add);
|
|
196
|
-
if (splitList(flags.remove).length) images.remove = splitList(flags.remove);
|
|
197
|
-
if (splitList(flags.order).length) images.order = splitList(flags.order);
|
|
198
|
-
if (splitList(flags.replace).length) images.replace = splitList(flags.replace);
|
|
199
|
-
if (flags.clear === true) images.clear = true;
|
|
200
|
-
if (flags.logo !== undefined) images.logo = flags.logo === true ? null : String(flags.logo);
|
|
201
|
-
|
|
202
|
-
if (!Object.keys(images).length || flags.list) {
|
|
203
|
-
// Listing is a no-op update: ask the server for the current state.
|
|
204
|
-
const res = await call("cli-profile-update", { id, profile_type: type, read: true });
|
|
205
|
-
if (JSON_OUT) return out(res);
|
|
206
|
-
heading(`${res.name} — photos (${res.gallery.length})`);
|
|
207
|
-
res.gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${url}`));
|
|
208
|
-
log(` ${c.dim(type === "dentist" ? "main photo" : "logo")}: ${res.logo ?? c.dim("none")}`);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (images.clear && !JSON_OUT && !flags.yes) {
|
|
213
|
-
const proceed = await confirm("Remove every photo from this profile?");
|
|
214
|
-
if (!proceed) { warn("Cancelled."); return; }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const res = await call("cli-profile-update", { id, profile_type: type, images });
|
|
218
|
-
if (JSON_OUT) return out(res);
|
|
219
|
-
ok(`${res.name} — ${res.gallery.length} photo(s)${res.logo ? ", logo set" : ""}`);
|
|
220
|
-
res.gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${c.dim(url)}`));
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async function cmdProfilePricing(type, id) {
|
|
224
|
-
if (!id) { fail(`Usage: setu ${type} pricing <id> [--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]`); process.exit(1); }
|
|
225
|
-
|
|
226
|
-
const setPairs = asList(flags.set);
|
|
227
|
-
const removals = splitList(flags.remove);
|
|
228
|
-
const wantsWrite = setPairs.length || removals.length || flags.clear === true;
|
|
229
|
-
|
|
230
|
-
if (!wantsWrite || flags.list) {
|
|
231
|
-
const res = await call("cli-profile-update", { id, profile_type: type, read: true });
|
|
232
|
-
if (JSON_OUT) return out({ id, pricing: res.pricing ?? [] });
|
|
233
|
-
const rows = res.pricing ?? [];
|
|
234
|
-
heading(`${res.name} — prices (${rows.length})`);
|
|
235
|
-
if (!rows.length) log(c.dim(" none yet — add with --set \"Implant=1200\""));
|
|
236
|
-
rows.forEach((r) => {
|
|
237
|
-
const range = r.amount_to ? `${r.amount}–${r.amount_to}` : r.amount;
|
|
238
|
-
log(` ${String(r.label).padEnd(34)} £${range ?? "?"}`);
|
|
239
|
-
});
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const pricing = {};
|
|
244
|
-
if (setPairs.length) {
|
|
245
|
-
pricing.set = {};
|
|
246
|
-
for (const pair of setPairs) {
|
|
247
|
-
const raw = String(pair);
|
|
248
|
-
const eq = raw.indexOf("=");
|
|
249
|
-
if (eq < 1) { fail(`Bad --set value: "${raw}". Use "Treatment=amount" (or "Treatment=800-1200").`); process.exit(1); }
|
|
250
|
-
pricing.set[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
if (removals.length) pricing.remove = removals;
|
|
254
|
-
if (flags.clear === true) {
|
|
255
|
-
if (!JSON_OUT && !flags.yes) {
|
|
256
|
-
const proceed = await confirm("Remove every price from this profile?");
|
|
257
|
-
if (!proceed) { warn("Cancelled."); return; }
|
|
258
|
-
}
|
|
259
|
-
pricing.clear = true;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const res = await call("cli-profile-update", { id, profile_type: type, pricing });
|
|
263
|
-
if (JSON_OUT) return out(res);
|
|
264
|
-
const rows = res.pricing ?? [];
|
|
265
|
-
ok(`${res.name} — ${rows.length} price(s) on file`);
|
|
266
|
-
rows.forEach((r) => log(` ${String(r.label).padEnd(34)} ${c.dim(`£${r.amount_to ? `${r.amount}–${r.amount_to}` : r.amount}`)}`));
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function cmdWhoami() {
|
|
270
|
-
const res = await call("cli-auth", {});
|
|
271
|
-
const cfg = loadConfig();
|
|
272
|
-
if (JSON_OUT) return out({ ...res, api: cfg.api });
|
|
273
|
-
table([["Operator", res.operator], ["Site", res.site], ["API", cfg.api]]);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function summarise(res) {
|
|
277
|
-
table([
|
|
278
|
-
["Draft", res.draft_id],
|
|
279
|
-
["Name", res.name],
|
|
280
|
-
["Status", res.status],
|
|
281
|
-
["Readiness", `${res.readiness_score}/100`],
|
|
282
|
-
["Services", res.services],
|
|
283
|
-
["Priced items", res.pricing_rows],
|
|
284
|
-
["Clinicians found", res.dentists_found],
|
|
285
|
-
["Google reviews", res.google_reviews],
|
|
286
|
-
]);
|
|
287
|
-
if (Array.isArray(res.missing) && res.missing.length) {
|
|
288
|
-
warn(`Missing: ${res.missing.slice(0, 6).map((m) => (typeof m === "string" ? m : m.field ?? "")).join(", ")}`);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
async function cmdPracticeCreate() {
|
|
293
|
-
const payload = {
|
|
294
|
-
website: typeof flags.url === "string" ? flags.url : undefined,
|
|
295
|
-
name: typeof flags.name === "string" ? flags.name : undefined,
|
|
296
|
-
town: typeof flags.town === "string" ? flags.town : undefined,
|
|
297
|
-
postcode: typeof flags.postcode === "string" ? flags.postcode : undefined,
|
|
298
|
-
contact_email: typeof flags.email === "string" ? flags.email : undefined,
|
|
299
|
-
booking_url: typeof flags.booking === "string" ? flags.booking : undefined,
|
|
300
|
-
pricing_urls: typeof flags.pricing === "string" ? [flags.pricing] : undefined,
|
|
301
|
-
};
|
|
302
|
-
if (!payload.website && !payload.name) {
|
|
303
|
-
fail("Pass --url, or --name with --town.");
|
|
304
|
-
process.exit(1);
|
|
305
|
-
}
|
|
306
|
-
const s = spinner("Finding and reading the practice online");
|
|
307
|
-
try {
|
|
308
|
-
const res = await call("cli-profile-create", payload);
|
|
309
|
-
s.stop();
|
|
310
|
-
if (JSON_OUT) return out(res);
|
|
311
|
-
heading("Draft built");
|
|
312
|
-
summarise(res);
|
|
313
|
-
log(`\n${c.dim("Next:")} setu practice publish ${res.draft_id}`);
|
|
314
|
-
} catch (e) {
|
|
315
|
-
s.stop();
|
|
316
|
-
throw e;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
async function cmdPracticeReview(draftId) {
|
|
321
|
-
if (!draftId) { fail("Usage: setu practice review <draft-id>"); process.exit(1); }
|
|
322
|
-
const res = await call("cli-profile-review", { draft_id: draftId });
|
|
323
|
-
if (JSON_OUT) return out(res);
|
|
324
|
-
heading(res.identity.name || "Draft");
|
|
325
|
-
table([
|
|
326
|
-
["Status", res.status],
|
|
327
|
-
["Readiness", `${res.readiness_score}/100`],
|
|
328
|
-
["Tagline", res.identity.tagline],
|
|
329
|
-
["Phone", res.identity.contact?.phone],
|
|
330
|
-
["Email", res.identity.contact?.email],
|
|
331
|
-
["Published as", res.published_practice_id],
|
|
332
|
-
]);
|
|
333
|
-
heading(`Services (${res.services.length})`);
|
|
334
|
-
log(" " + (res.services.join(", ") || c.dim("none found")));
|
|
335
|
-
heading(`Pricing (${res.service_pricing.length})`);
|
|
336
|
-
for (const p of res.service_pricing.slice(0, 12)) {
|
|
337
|
-
log(` ${String(p.label || p.service).padEnd(34)} £${p.amount_from}${p.amount_to ? `–£${p.amount_to}` : ""}`);
|
|
338
|
-
}
|
|
339
|
-
heading(`Clinicians (${res.dentist_candidates.length})`);
|
|
340
|
-
for (const d of res.dentist_candidates.slice(0, 12)) log(` ${d.name}${d.role ? c.dim(` — ${d.role}`) : ""}`);
|
|
341
|
-
if (res.errors) warn(`Notes: ${JSON.stringify(res.errors).slice(0, 300)}`);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
async function cmdPracticePublish(draftId) {
|
|
345
|
-
if (!draftId) { fail("Usage: setu practice publish <draft-id>"); process.exit(1); }
|
|
346
|
-
if (!JSON_OUT && !flags.yes) {
|
|
347
|
-
const proceed = await confirm("Publish this profile as an unclaimed public listing?");
|
|
348
|
-
if (!proceed) { warn("Cancelled."); return; }
|
|
349
|
-
}
|
|
350
|
-
const res = await call("cli-profile-publish", {
|
|
351
|
-
draft_id: draftId,
|
|
352
|
-
create_dentists: !flags["no-dentists"],
|
|
353
|
-
});
|
|
354
|
-
if (JSON_OUT) return out(res);
|
|
355
|
-
ok("Published as an unclaimed listing");
|
|
356
|
-
table([
|
|
357
|
-
["Practice", res.practice_id],
|
|
358
|
-
["Profile", res.profile_url],
|
|
359
|
-
["Claim link", res.claim_url],
|
|
360
|
-
["Clinicians", res.dentists_created],
|
|
361
|
-
]);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function parseCsv(text) {
|
|
365
|
-
const lines = text.split(/\r?\n/).filter((l) => l.trim());
|
|
366
|
-
if (!lines.length) return [];
|
|
367
|
-
const split = (line) => {
|
|
368
|
-
const cells = [];
|
|
369
|
-
let cur = "", inQ = false;
|
|
370
|
-
for (const ch of line) {
|
|
371
|
-
if (ch === '"') inQ = !inQ;
|
|
372
|
-
else if (ch === "," && !inQ) { cells.push(cur); cur = ""; }
|
|
373
|
-
else cur += ch;
|
|
374
|
-
}
|
|
375
|
-
cells.push(cur);
|
|
376
|
-
return cells.map((s) => s.trim());
|
|
377
|
-
};
|
|
378
|
-
const header = split(lines[0]).map((h) => h.toLowerCase());
|
|
379
|
-
return lines.slice(1).map((line) => {
|
|
380
|
-
const cells = split(line);
|
|
381
|
-
const row = {};
|
|
382
|
-
header.forEach((h, i) => { if (cells[i]) row[h] = cells[i]; });
|
|
383
|
-
return row;
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
async function cmdPracticeImport(file) {
|
|
388
|
-
if (!file) { fail("Usage: setu practice import <file.csv>"); process.exit(1); }
|
|
389
|
-
let rows;
|
|
390
|
-
try {
|
|
391
|
-
rows = parseCsv(readFileSync(file, "utf8"));
|
|
392
|
-
} catch (e) {
|
|
393
|
-
fail(`Could not read ${file}: ${e.message}`);
|
|
394
|
-
process.exit(1);
|
|
395
|
-
}
|
|
396
|
-
if (!rows.length) { fail("No rows found in that CSV."); process.exit(1); }
|
|
397
|
-
|
|
398
|
-
// Validate before spending any API calls.
|
|
399
|
-
const invalid = [];
|
|
400
|
-
rows = rows.map((row, i) => ({ ...row, __line: i + 2 }));
|
|
401
|
-
const valid = rows.filter((row) => {
|
|
402
|
-
const hasSite = !!(row.website || row.url);
|
|
403
|
-
const hasNameTown = !!(row.name && (row.town || row.city));
|
|
404
|
-
if (!hasSite && !hasNameTown) {
|
|
405
|
-
invalid.push(`line ${row.__line}: needs a website/url, or a name plus town`);
|
|
406
|
-
return false;
|
|
407
|
-
}
|
|
408
|
-
return true;
|
|
409
|
-
});
|
|
410
|
-
if (invalid.length) {
|
|
411
|
-
warn(`Skipping ${invalid.length} unusable row(s):`);
|
|
412
|
-
for (const m of invalid.slice(0, 10)) log(` ${c.dim(m)}`);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Resume: skip anything already completed in a previous results file.
|
|
416
|
-
let done = new Map();
|
|
417
|
-
if (typeof flags.resume === "string") {
|
|
418
|
-
try {
|
|
419
|
-
const prev = JSON.parse(readFileSync(flags.resume, "utf8"));
|
|
420
|
-
for (const r of prev.results ?? []) if (r.ok) done.set(r.label, r);
|
|
421
|
-
} catch (e) { warn(`Could not read resume file: ${e.message}`); }
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const labelOf = (row, i) => row.name || row.website || row.url || `row ${i + 1}`;
|
|
425
|
-
let queue = valid.filter((row, i) => !done.has(labelOf(row, i)));
|
|
426
|
-
if (flags.limit) queue = queue.slice(0, Number(flags.limit));
|
|
427
|
-
|
|
428
|
-
if (flags["dry-run"]) {
|
|
429
|
-
heading(`Dry run — ${queue.length} practice(s) would be built${flags.publish ? " and published" : ""}`);
|
|
430
|
-
for (const row of queue.slice(0, 50)) log(` ${labelOf(row, 0)} ${c.dim(row.website || row.url || row.town || "")}`);
|
|
431
|
-
if (JSON_OUT) out({ dry_run: true, total: queue.length, skipped: invalid.length, already_done: done.size });
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const concurrency = Math.max(1, Math.min(5, Number(flags.concurrency) || 2));
|
|
436
|
-
log(`Importing ${c.bold(queue.length)} practices (${concurrency} at a time)…\n`);
|
|
437
|
-
|
|
438
|
-
const results = [...done.values()];
|
|
439
|
-
let index = 0;
|
|
440
|
-
let completed = 0;
|
|
441
|
-
|
|
442
|
-
async function worker() {
|
|
443
|
-
while (index < queue.length) {
|
|
444
|
-
const i = index++;
|
|
445
|
-
const row = queue[i];
|
|
446
|
-
const label = labelOf(row, i);
|
|
447
|
-
try {
|
|
448
|
-
const created = await call("cli-profile-create", {
|
|
449
|
-
website: row.website || row.url,
|
|
450
|
-
name: row.name,
|
|
451
|
-
town: row.town || row.city,
|
|
452
|
-
postcode: row.postcode,
|
|
453
|
-
contact_email: row.email,
|
|
454
|
-
contact_phone: row.phone,
|
|
455
|
-
});
|
|
456
|
-
let published = null;
|
|
457
|
-
if (flags.publish && created.status !== "failed") {
|
|
458
|
-
published = await call("cli-profile-publish", { draft_id: created.draft_id });
|
|
459
|
-
}
|
|
460
|
-
completed++;
|
|
461
|
-
log(`${c.green("✔")} [${completed}/${queue.length}] ${label} ${c.dim(`(${created.readiness_score}/100)`)}${published ? ` → ${published.claim_url}` : ""}`);
|
|
462
|
-
results.push({
|
|
463
|
-
label, ok: true, draft_id: created.draft_id,
|
|
464
|
-
practice_id: published?.practice_id ?? null,
|
|
465
|
-
claim_url: published?.claim_url ?? null,
|
|
466
|
-
});
|
|
467
|
-
} catch (e) {
|
|
468
|
-
completed++;
|
|
469
|
-
log(`${c.red("✖")} [${completed}/${queue.length}] ${label} — ${e.message}`);
|
|
470
|
-
results.push({ label, ok: false, error: e.message, line: row.__line });
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
476
|
-
|
|
477
|
-
const failed = results.filter((r) => !r.ok);
|
|
478
|
-
const payload = {
|
|
479
|
-
total: results.length,
|
|
480
|
-
built: results.length - failed.length,
|
|
481
|
-
failed: failed.length,
|
|
482
|
-
skipped_invalid: invalid.length,
|
|
483
|
-
results,
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
if (typeof flags.out === "string") {
|
|
487
|
-
try {
|
|
488
|
-
writeFileSync(flags.out, JSON.stringify(payload, null, 2));
|
|
489
|
-
ok(`Results written to ${flags.out}${failed.length ? ` — rerun with --resume ${flags.out}` : ""}`);
|
|
490
|
-
} catch (e) { warn(`Could not write results file: ${e.message}`); }
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (JSON_OUT) { out(payload); }
|
|
494
|
-
else {
|
|
495
|
-
heading(`Done — ${payload.built} built, ${failed.length} failed`);
|
|
496
|
-
for (const f of failed) log(` ${c.red("✖")} ${f.label}: ${f.error}`);
|
|
497
|
-
}
|
|
498
|
-
if (failed.length) process.exitCode = 1;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
async function cmdClaimLink(id) {
|
|
502
|
-
if (!id) { fail("Usage: setu practice claim-link <practice-id>"); process.exit(1); }
|
|
503
|
-
const res = await call("cli-claim-link", {
|
|
504
|
-
id,
|
|
505
|
-
profile_type: type === "dentist" || flags.dentist ? "dentist" : "practice",
|
|
506
|
-
rotate: !!flags.rotate,
|
|
507
|
-
});
|
|
508
|
-
if (JSON_OUT) return out(res);
|
|
509
|
-
ok(res.name);
|
|
510
|
-
log(` ${res.claim_url}`);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
async function cmdDentistCreate() {
|
|
514
|
-
if (typeof flags.name !== "string") { fail("Pass --name \"Dr Jane Roe\"."); process.exit(1); }
|
|
515
|
-
const s = spinner("Building the clinician profile");
|
|
516
|
-
try {
|
|
517
|
-
const res = await call("cli-dentist-create", {
|
|
518
|
-
name: flags.name,
|
|
519
|
-
practice_id: typeof flags.practice === "string" ? flags.practice : undefined,
|
|
520
|
-
profile_url: typeof flags["profile-url"] === "string" ? flags["profile-url"] : undefined,
|
|
521
|
-
town: typeof flags.town === "string" ? flags.town : undefined,
|
|
522
|
-
postcode: typeof flags.postcode === "string" ? flags.postcode : undefined,
|
|
523
|
-
email: typeof flags.email === "string" ? flags.email : undefined,
|
|
524
|
-
});
|
|
525
|
-
s.stop();
|
|
526
|
-
if (JSON_OUT) return out(res);
|
|
527
|
-
ok("Clinician seeded as unclaimed");
|
|
528
|
-
table([["Dentist", res.dentist_id], ["AI enriched", res.enriched ? "yes" : "no"], ["Claim link", res.claim_url]]);
|
|
529
|
-
} catch (e) { s.stop(); throw e; }
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
async function cmdStatus(id) {
|
|
533
|
-
const res = await call("cli-profile-status", id ? { id } : { list: true, limit: flags.limit });
|
|
534
|
-
if (JSON_OUT) return out(res);
|
|
535
|
-
if (res.items) {
|
|
536
|
-
heading(`Seeded profiles (${res.items.length})`);
|
|
537
|
-
for (const p of res.items) {
|
|
538
|
-
const state = p.claim_status === "claimed" ? c.green("claimed") : p.is_live ? c.green("live") : c.yellow(p.claim_status);
|
|
539
|
-
log(` ${state.padEnd(20)} ${String(p.name).slice(0, 38).padEnd(40)} ${c.dim(p.id)}`);
|
|
540
|
-
}
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
heading(res.practice.name);
|
|
544
|
-
table([
|
|
545
|
-
["Status", res.practice.claim_status],
|
|
546
|
-
["Live", res.practice.is_live ? "yes" : "no"],
|
|
547
|
-
["Source", `${res.practice.source} (${res.practice.created_by_operator ?? "—"})`],
|
|
548
|
-
["Claim link", res.practice.claim_url],
|
|
549
|
-
]);
|
|
550
|
-
heading(`Claims (${res.claims.length})`);
|
|
551
|
-
for (const cl of res.claims) log(` ${cl.status.padEnd(10)} ${cl.claimant_email} ${c.dim(cl.method)}`);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
async function main() {
|
|
555
|
-
if (typeof flags.operator === "string") {
|
|
556
|
-
const cfg = loadConfig();
|
|
557
|
-
saveConfig({ ...cfg, operator: flags.operator });
|
|
558
|
-
}
|
|
559
|
-
if (flags.version) { log(`setu ${VERSION}`); return; }
|
|
560
|
-
const [group, sub, arg] = positional;
|
|
561
|
-
if (!group || flags.help || group === "help") { usage(); return; }
|
|
562
|
-
switch (group) {
|
|
563
|
-
case "login": return cmdLogin();
|
|
564
|
-
case "logout": return cmdLogout();
|
|
565
|
-
case "whoami": return cmdWhoami();
|
|
566
|
-
case "status": return cmdStatus(sub);
|
|
567
|
-
case "practice":
|
|
568
|
-
switch (sub) {
|
|
569
|
-
case "create": return cmdPracticeCreate();
|
|
570
|
-
case "review": return cmdPracticeReview(arg);
|
|
571
|
-
case "publish": return cmdPracticePublish(arg);
|
|
572
|
-
case "import": return cmdPracticeImport(arg);
|
|
573
|
-
case "claim-link": return cmdClaimLink(arg);
|
|
574
|
-
case "delete": return cmdPracticeDelete(arg);
|
|
575
|
-
case "update": return cmdProfileUpdate("practice", arg);
|
|
576
|
-
case "images": return cmdProfileImages("practice", arg);
|
|
577
|
-
case "pricing": return cmdProfilePricing("practice", arg);
|
|
578
|
-
default:
|
|
579
|
-
fail(`Unknown command: practice ${sub ?? ""}`.trim());
|
|
580
|
-
usage();
|
|
581
|
-
process.exitCode = 1;
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
case "dentist":
|
|
585
|
-
if (sub === "create") return cmdDentistCreate();
|
|
586
|
-
if (sub === "update") return cmdProfileUpdate("dentist", arg);
|
|
587
|
-
if (sub === "images") return cmdProfileImages("dentist", arg);
|
|
588
|
-
if (sub === "pricing") return cmdProfilePricing("dentist", arg);
|
|
589
|
-
if (sub === "delete") return cmdPracticeDelete(arg, "dentist");
|
|
590
|
-
fail(`Unknown command: dentist ${sub ?? ""}`.trim());
|
|
591
|
-
usage();
|
|
592
|
-
process.exitCode = 1;
|
|
593
|
-
return;
|
|
594
|
-
default:
|
|
595
|
-
fail(`Unknown command: ${group}`);
|
|
596
|
-
usage();
|
|
597
|
-
process.exitCode = 1;
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
main().catch((e) => {
|
|
603
|
-
fail(e.message);
|
|
604
|
-
if (flags.verbose && e.stack) log(c.dim(e.stack));
|
|
605
|
-
if (e.data && !JSON_OUT && e.data.practice_id) log(c.dim(` existing: ${e.data.practice_id}`));
|
|
606
|
-
process.exit(1);
|
|
607
|
-
});
|
package/lib/api.mjs
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { loadConfig } from "./config.mjs";
|
|
2
|
-
|
|
3
|
-
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
4
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
-
|
|
6
|
-
const VERBOSE = process.argv.includes("--verbose");
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Call an internal Setu edge function.
|
|
10
|
-
*
|
|
11
|
-
* Hardened for unattended/bulk use: every request has a timeout, transient
|
|
12
|
-
* failures (network drops, 429s, 5xx) are retried with exponential backoff,
|
|
13
|
-
* and a `Retry-After` header is honoured when the server sends one.
|
|
14
|
-
*/
|
|
15
|
-
export async function call(fn, body = {}, overrides = {}) {
|
|
16
|
-
const cfg = { ...loadConfig(), ...overrides };
|
|
17
|
-
if (!cfg.key) {
|
|
18
|
-
throw new Error("Not signed in. Run `setu login` first (or set SETU_CLI_API_KEY).");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const timeoutMs = Number(overrides.timeoutMs || process.env.SETU_TIMEOUT_MS || 240_000);
|
|
22
|
-
const maxAttempts = Number(overrides.retries ?? process.env.SETU_RETRIES ?? 3);
|
|
23
|
-
let lastError;
|
|
24
|
-
|
|
25
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
26
|
-
const controller = new AbortController();
|
|
27
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
28
|
-
try {
|
|
29
|
-
const res = await fetch(`${cfg.api}/${fn}`, {
|
|
30
|
-
method: "POST",
|
|
31
|
-
headers: {
|
|
32
|
-
"Content-Type": "application/json",
|
|
33
|
-
"x-setu-cli-key": cfg.key,
|
|
34
|
-
"x-setu-operator": cfg.operator || "cli",
|
|
35
|
-
"x-setu-request-id": crypto.randomUUID(),
|
|
36
|
-
},
|
|
37
|
-
body: JSON.stringify(body),
|
|
38
|
-
signal: controller.signal,
|
|
39
|
-
});
|
|
40
|
-
clearTimeout(timer);
|
|
41
|
-
|
|
42
|
-
const text = await res.text();
|
|
43
|
-
let data;
|
|
44
|
-
try { data = text ? JSON.parse(text) : {}; } catch { data = { error: text }; }
|
|
45
|
-
|
|
46
|
-
if (res.ok) return data;
|
|
47
|
-
|
|
48
|
-
const err = new Error(data.message || data.error || `${fn} failed (${res.status})`);
|
|
49
|
-
err.status = res.status;
|
|
50
|
-
err.data = data;
|
|
51
|
-
|
|
52
|
-
if (RETRYABLE_STATUS.has(res.status) && attempt < maxAttempts) {
|
|
53
|
-
const retryAfter = Number(res.headers.get("retry-after")) * 1000;
|
|
54
|
-
const wait = Number.isFinite(retryAfter) && retryAfter > 0
|
|
55
|
-
? retryAfter
|
|
56
|
-
: Math.min(30_000, 1000 * 2 ** (attempt - 1)) + Math.random() * 500;
|
|
57
|
-
if (VERBOSE) console.error(` retry ${attempt}/${maxAttempts - 1} on ${fn} (${res.status}) in ${Math.round(wait)}ms`);
|
|
58
|
-
await sleep(wait);
|
|
59
|
-
lastError = err;
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
throw err;
|
|
63
|
-
} catch (e) {
|
|
64
|
-
clearTimeout(timer);
|
|
65
|
-
if (e.status && !RETRYABLE_STATUS.has(e.status)) throw e;
|
|
66
|
-
|
|
67
|
-
const aborted = e.name === "AbortError";
|
|
68
|
-
const message = aborted
|
|
69
|
-
? `${fn} timed out after ${Math.round(timeoutMs / 1000)}s`
|
|
70
|
-
: e.message;
|
|
71
|
-
|
|
72
|
-
if (attempt < maxAttempts) {
|
|
73
|
-
const wait = Math.min(30_000, 1000 * 2 ** (attempt - 1)) + Math.random() * 500;
|
|
74
|
-
if (VERBOSE) console.error(` retry ${attempt}/${maxAttempts - 1} on ${fn} (${message}) in ${Math.round(wait)}ms`);
|
|
75
|
-
await sleep(wait);
|
|
76
|
-
lastError = Object.assign(new Error(message), { status: e.status, data: e.data });
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
throw Object.assign(new Error(message), { status: e.status, data: e.data });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
throw lastError ?? new Error(`${fn} failed`);
|
|
84
|
-
}
|