@series-a/cli 0.23.1 → 1.0.0

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 ADDED
@@ -0,0 +1,442 @@
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
+ if (!next || next.startsWith("--")) flags[key] = true;
36
+ else { flags[key] = next; i++; }
37
+ } else positional.push(a);
38
+ }
39
+ return { positional, flags };
40
+ }
41
+
42
+ const { positional, flags } = parseArgs(process.argv.slice(2));
43
+ const JSON_OUT = !!flags.json;
44
+ const out = (obj) => { if (JSON_OUT) log(JSON.stringify(obj, null, 2)); };
45
+
46
+ function usage() {
47
+ log(`
48
+ ${c.bold("setu")} — build claimable Setu profiles
49
+
50
+ ${c.bold("Commands")}
51
+ login Store your internal CLI key
52
+ logout Remove the stored key from this machine
53
+ whoami Show the signed-in operator
54
+ practice create Build a profile draft with AI
55
+ --url <website> from a website, or
56
+ --name <name> --town <town> from a name + town (found on Google)
57
+ [--pricing <url>] [--booking <url>] [--email <contact email>]
58
+ practice review <draft-id> Show everything the AI extracted
59
+ practice publish <draft-id> Publish as an unclaimed listing
60
+ [--no-dentists] don't seed detected clinicians
61
+ practice import <file.csv> Bulk build + optionally publish
62
+ [--publish] [--concurrency 2] [--dry-run]
63
+ [--out results.json] [--resume results.json] [--limit N]
64
+ practice claim-link <practice-id> Outreach link for the owner
65
+ [--rotate]
66
+ practice delete <practice-id> Remove an unclaimed seeded profile
67
+ [--unpublish] [--yes]
68
+ dentist create --name "Dr Jane Roe" Seed one clinician
69
+ [--practice <id>] [--profile-url <url>] [--town <town>]
70
+ status [<practice-id>] List or inspect seeded profiles
71
+
72
+ ${c.bold("Global")} --json --yes --verbose --operator <name> --version
73
+ `);
74
+ }
75
+
76
+ async function cmdLogin() {
77
+ const cfg = loadConfig();
78
+ const api = (await ask(`API base ${c.dim(`[${cfg.api || DEFAULT_API}]`)}: `)) || cfg.api || DEFAULT_API;
79
+ const key = await ask("Internal CLI key: ", { silent: true });
80
+ const operator = (await ask(`Your name (for attribution) ${c.dim(`[${cfg.operator || ""}]`)}: `)) || cfg.operator || "cli";
81
+ const res = await call("cli-auth", {}, { api, key, operator });
82
+ saveConfig({ api, key, operator });
83
+ ok(`Signed in as ${c.bold(operator)} — ${res.site}`);
84
+ }
85
+
86
+ function cmdLogout() {
87
+ clearConfig();
88
+ ok(`Signed out — removed ${configPath()}`);
89
+ }
90
+
91
+ async function cmdPracticeDelete(id) {
92
+ if (!id) { fail("Usage: setu practice delete <practice-id> [--unpublish]"); process.exit(1); }
93
+ const unpublishOnly = !!flags.unpublish;
94
+ if (!JSON_OUT && !flags.yes) {
95
+ const proceed = await confirm(
96
+ unpublishOnly
97
+ ? "Hide this seeded profile from patients?"
98
+ : "Permanently delete this unclaimed seeded profile (and its seeded clinicians)?",
99
+ );
100
+ if (!proceed) { warn("Cancelled."); return; }
101
+ }
102
+ const res = await call("cli-profile-delete", {
103
+ id,
104
+ profile_type: flags.dentist ? "dentist" : "practice",
105
+ unpublish_only: unpublishOnly,
106
+ });
107
+ if (JSON_OUT) return out(res);
108
+ ok(`${res.name} — ${res.action}`);
109
+ }
110
+
111
+ async function cmdWhoami() {
112
+ const res = await call("cli-auth", {});
113
+ const cfg = loadConfig();
114
+ if (JSON_OUT) return out({ ...res, api: cfg.api });
115
+ table([["Operator", res.operator], ["Site", res.site], ["API", cfg.api]]);
116
+ }
117
+
118
+ function summarise(res) {
119
+ table([
120
+ ["Draft", res.draft_id],
121
+ ["Name", res.name],
122
+ ["Status", res.status],
123
+ ["Readiness", `${res.readiness_score}/100`],
124
+ ["Services", res.services],
125
+ ["Priced items", res.pricing_rows],
126
+ ["Clinicians found", res.dentists_found],
127
+ ["Google reviews", res.google_reviews],
128
+ ]);
129
+ if (Array.isArray(res.missing) && res.missing.length) {
130
+ warn(`Missing: ${res.missing.slice(0, 6).map((m) => (typeof m === "string" ? m : m.field ?? "")).join(", ")}`);
131
+ }
132
+ }
133
+
134
+ async function cmdPracticeCreate() {
135
+ const payload = {
136
+ website: typeof flags.url === "string" ? flags.url : undefined,
137
+ name: typeof flags.name === "string" ? flags.name : undefined,
138
+ town: typeof flags.town === "string" ? flags.town : undefined,
139
+ postcode: typeof flags.postcode === "string" ? flags.postcode : undefined,
140
+ contact_email: typeof flags.email === "string" ? flags.email : undefined,
141
+ booking_url: typeof flags.booking === "string" ? flags.booking : undefined,
142
+ pricing_urls: typeof flags.pricing === "string" ? [flags.pricing] : undefined,
143
+ };
144
+ if (!payload.website && !payload.name) {
145
+ fail("Pass --url, or --name with --town.");
146
+ process.exit(1);
147
+ }
148
+ const s = spinner("Finding and reading the practice online");
149
+ try {
150
+ const res = await call("cli-profile-create", payload);
151
+ s.stop();
152
+ if (JSON_OUT) return out(res);
153
+ heading("Draft built");
154
+ summarise(res);
155
+ log(`\n${c.dim("Next:")} setu practice publish ${res.draft_id}`);
156
+ } catch (e) {
157
+ s.stop();
158
+ throw e;
159
+ }
160
+ }
161
+
162
+ async function cmdPracticeReview(draftId) {
163
+ if (!draftId) { fail("Usage: setu practice review <draft-id>"); process.exit(1); }
164
+ const res = await call("cli-profile-review", { draft_id: draftId });
165
+ if (JSON_OUT) return out(res);
166
+ heading(res.identity.name || "Draft");
167
+ table([
168
+ ["Status", res.status],
169
+ ["Readiness", `${res.readiness_score}/100`],
170
+ ["Tagline", res.identity.tagline],
171
+ ["Phone", res.identity.contact?.phone],
172
+ ["Email", res.identity.contact?.email],
173
+ ["Published as", res.published_practice_id],
174
+ ]);
175
+ heading(`Services (${res.services.length})`);
176
+ log(" " + (res.services.join(", ") || c.dim("none found")));
177
+ heading(`Pricing (${res.service_pricing.length})`);
178
+ for (const p of res.service_pricing.slice(0, 12)) {
179
+ log(` ${String(p.label || p.service).padEnd(34)} £${p.amount_from}${p.amount_to ? `–£${p.amount_to}` : ""}`);
180
+ }
181
+ heading(`Clinicians (${res.dentist_candidates.length})`);
182
+ for (const d of res.dentist_candidates.slice(0, 12)) log(` ${d.name}${d.role ? c.dim(` — ${d.role}`) : ""}`);
183
+ if (res.errors) warn(`Notes: ${JSON.stringify(res.errors).slice(0, 300)}`);
184
+ }
185
+
186
+ async function cmdPracticePublish(draftId) {
187
+ if (!draftId) { fail("Usage: setu practice publish <draft-id>"); process.exit(1); }
188
+ if (!JSON_OUT && !flags.yes) {
189
+ const proceed = await confirm("Publish this profile as an unclaimed public listing?");
190
+ if (!proceed) { warn("Cancelled."); return; }
191
+ }
192
+ const res = await call("cli-profile-publish", {
193
+ draft_id: draftId,
194
+ create_dentists: !flags["no-dentists"],
195
+ });
196
+ if (JSON_OUT) return out(res);
197
+ ok("Published as an unclaimed listing");
198
+ table([
199
+ ["Practice", res.practice_id],
200
+ ["Profile", res.profile_url],
201
+ ["Claim link", res.claim_url],
202
+ ["Clinicians", res.dentists_created],
203
+ ]);
204
+ }
205
+
206
+ function parseCsv(text) {
207
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
208
+ if (!lines.length) return [];
209
+ const split = (line) => {
210
+ const cells = [];
211
+ let cur = "", inQ = false;
212
+ for (const ch of line) {
213
+ if (ch === '"') inQ = !inQ;
214
+ else if (ch === "," && !inQ) { cells.push(cur); cur = ""; }
215
+ else cur += ch;
216
+ }
217
+ cells.push(cur);
218
+ return cells.map((s) => s.trim());
219
+ };
220
+ const header = split(lines[0]).map((h) => h.toLowerCase());
221
+ return lines.slice(1).map((line) => {
222
+ const cells = split(line);
223
+ const row = {};
224
+ header.forEach((h, i) => { if (cells[i]) row[h] = cells[i]; });
225
+ return row;
226
+ });
227
+ }
228
+
229
+ async function cmdPracticeImport(file) {
230
+ if (!file) { fail("Usage: setu practice import <file.csv>"); process.exit(1); }
231
+ let rows;
232
+ try {
233
+ rows = parseCsv(readFileSync(file, "utf8"));
234
+ } catch (e) {
235
+ fail(`Could not read ${file}: ${e.message}`);
236
+ process.exit(1);
237
+ }
238
+ if (!rows.length) { fail("No rows found in that CSV."); process.exit(1); }
239
+
240
+ // Validate before spending any API calls.
241
+ const invalid = [];
242
+ rows = rows.map((row, i) => ({ ...row, __line: i + 2 }));
243
+ const valid = rows.filter((row) => {
244
+ const hasSite = !!(row.website || row.url);
245
+ const hasNameTown = !!(row.name && (row.town || row.city));
246
+ if (!hasSite && !hasNameTown) {
247
+ invalid.push(`line ${row.__line}: needs a website/url, or a name plus town`);
248
+ return false;
249
+ }
250
+ return true;
251
+ });
252
+ if (invalid.length) {
253
+ warn(`Skipping ${invalid.length} unusable row(s):`);
254
+ for (const m of invalid.slice(0, 10)) log(` ${c.dim(m)}`);
255
+ }
256
+
257
+ // Resume: skip anything already completed in a previous results file.
258
+ let done = new Map();
259
+ if (typeof flags.resume === "string") {
260
+ try {
261
+ const prev = JSON.parse(readFileSync(flags.resume, "utf8"));
262
+ for (const r of prev.results ?? []) if (r.ok) done.set(r.label, r);
263
+ } catch (e) { warn(`Could not read resume file: ${e.message}`); }
264
+ }
265
+
266
+ const labelOf = (row, i) => row.name || row.website || row.url || `row ${i + 1}`;
267
+ let queue = valid.filter((row, i) => !done.has(labelOf(row, i)));
268
+ if (flags.limit) queue = queue.slice(0, Number(flags.limit));
269
+
270
+ if (flags["dry-run"]) {
271
+ heading(`Dry run — ${queue.length} practice(s) would be built${flags.publish ? " and published" : ""}`);
272
+ for (const row of queue.slice(0, 50)) log(` ${labelOf(row, 0)} ${c.dim(row.website || row.url || row.town || "")}`);
273
+ if (JSON_OUT) out({ dry_run: true, total: queue.length, skipped: invalid.length, already_done: done.size });
274
+ return;
275
+ }
276
+
277
+ const concurrency = Math.max(1, Math.min(5, Number(flags.concurrency) || 2));
278
+ log(`Importing ${c.bold(queue.length)} practices (${concurrency} at a time)…\n`);
279
+
280
+ const results = [...done.values()];
281
+ let index = 0;
282
+ let completed = 0;
283
+
284
+ async function worker() {
285
+ while (index < queue.length) {
286
+ const i = index++;
287
+ const row = queue[i];
288
+ const label = labelOf(row, i);
289
+ try {
290
+ const created = await call("cli-profile-create", {
291
+ website: row.website || row.url,
292
+ name: row.name,
293
+ town: row.town || row.city,
294
+ postcode: row.postcode,
295
+ contact_email: row.email,
296
+ contact_phone: row.phone,
297
+ });
298
+ let published = null;
299
+ if (flags.publish && created.status !== "failed") {
300
+ published = await call("cli-profile-publish", { draft_id: created.draft_id });
301
+ }
302
+ completed++;
303
+ log(`${c.green("✔")} [${completed}/${queue.length}] ${label} ${c.dim(`(${created.readiness_score}/100)`)}${published ? ` → ${published.claim_url}` : ""}`);
304
+ results.push({
305
+ label, ok: true, draft_id: created.draft_id,
306
+ practice_id: published?.practice_id ?? null,
307
+ claim_url: published?.claim_url ?? null,
308
+ });
309
+ } catch (e) {
310
+ completed++;
311
+ log(`${c.red("✖")} [${completed}/${queue.length}] ${label} — ${e.message}`);
312
+ results.push({ label, ok: false, error: e.message, line: row.__line });
313
+ }
314
+ }
315
+ }
316
+
317
+ await Promise.all(Array.from({ length: concurrency }, worker));
318
+
319
+ const failed = results.filter((r) => !r.ok);
320
+ const payload = {
321
+ total: results.length,
322
+ built: results.length - failed.length,
323
+ failed: failed.length,
324
+ skipped_invalid: invalid.length,
325
+ results,
326
+ };
327
+
328
+ if (typeof flags.out === "string") {
329
+ try {
330
+ writeFileSync(flags.out, JSON.stringify(payload, null, 2));
331
+ ok(`Results written to ${flags.out}${failed.length ? ` — rerun with --resume ${flags.out}` : ""}`);
332
+ } catch (e) { warn(`Could not write results file: ${e.message}`); }
333
+ }
334
+
335
+ if (JSON_OUT) { out(payload); }
336
+ else {
337
+ heading(`Done — ${payload.built} built, ${failed.length} failed`);
338
+ for (const f of failed) log(` ${c.red("✖")} ${f.label}: ${f.error}`);
339
+ }
340
+ if (failed.length) process.exitCode = 1;
341
+ }
342
+
343
+ async function cmdClaimLink(id) {
344
+ if (!id) { fail("Usage: setu practice claim-link <practice-id>"); process.exit(1); }
345
+ const res = await call("cli-claim-link", {
346
+ id,
347
+ profile_type: flags.dentist ? "dentist" : "practice",
348
+ rotate: !!flags.rotate,
349
+ });
350
+ if (JSON_OUT) return out(res);
351
+ ok(res.name);
352
+ log(` ${res.claim_url}`);
353
+ }
354
+
355
+ async function cmdDentistCreate() {
356
+ if (typeof flags.name !== "string") { fail("Pass --name \"Dr Jane Roe\"."); process.exit(1); }
357
+ const s = spinner("Building the clinician profile");
358
+ try {
359
+ const res = await call("cli-dentist-create", {
360
+ name: flags.name,
361
+ practice_id: typeof flags.practice === "string" ? flags.practice : undefined,
362
+ profile_url: typeof flags["profile-url"] === "string" ? flags["profile-url"] : undefined,
363
+ town: typeof flags.town === "string" ? flags.town : undefined,
364
+ postcode: typeof flags.postcode === "string" ? flags.postcode : undefined,
365
+ email: typeof flags.email === "string" ? flags.email : undefined,
366
+ });
367
+ s.stop();
368
+ if (JSON_OUT) return out(res);
369
+ ok("Clinician seeded as unclaimed");
370
+ table([["Dentist", res.dentist_id], ["AI enriched", res.enriched ? "yes" : "no"], ["Claim link", res.claim_url]]);
371
+ } catch (e) { s.stop(); throw e; }
372
+ }
373
+
374
+ async function cmdStatus(id) {
375
+ const res = await call("cli-profile-status", id ? { id } : { list: true, limit: flags.limit });
376
+ if (JSON_OUT) return out(res);
377
+ if (res.items) {
378
+ heading(`Seeded profiles (${res.items.length})`);
379
+ for (const p of res.items) {
380
+ const state = p.claim_status === "claimed" ? c.green("claimed") : p.is_live ? c.green("live") : c.yellow(p.claim_status);
381
+ log(` ${state.padEnd(20)} ${String(p.name).slice(0, 38).padEnd(40)} ${c.dim(p.id)}`);
382
+ }
383
+ return;
384
+ }
385
+ heading(res.practice.name);
386
+ table([
387
+ ["Status", res.practice.claim_status],
388
+ ["Live", res.practice.is_live ? "yes" : "no"],
389
+ ["Source", `${res.practice.source} (${res.practice.created_by_operator ?? "—"})`],
390
+ ["Claim link", res.practice.claim_url],
391
+ ]);
392
+ heading(`Claims (${res.claims.length})`);
393
+ for (const cl of res.claims) log(` ${cl.status.padEnd(10)} ${cl.claimant_email} ${c.dim(cl.method)}`);
394
+ }
395
+
396
+ async function main() {
397
+ if (typeof flags.operator === "string") {
398
+ const cfg = loadConfig();
399
+ saveConfig({ ...cfg, operator: flags.operator });
400
+ }
401
+ if (flags.version) { log(`setu ${VERSION}`); return; }
402
+ const [group, sub, arg] = positional;
403
+ if (!group || flags.help || group === "help") { usage(); return; }
404
+ switch (group) {
405
+ case "login": return cmdLogin();
406
+ case "logout": return cmdLogout();
407
+ case "whoami": return cmdWhoami();
408
+ case "status": return cmdStatus(sub);
409
+ case "practice":
410
+ switch (sub) {
411
+ case "create": return cmdPracticeCreate();
412
+ case "review": return cmdPracticeReview(arg);
413
+ case "publish": return cmdPracticePublish(arg);
414
+ case "import": return cmdPracticeImport(arg);
415
+ case "claim-link": return cmdClaimLink(arg);
416
+ case "delete": return cmdPracticeDelete(arg);
417
+ default:
418
+ fail(`Unknown command: practice ${sub ?? ""}`.trim());
419
+ usage();
420
+ process.exitCode = 1;
421
+ return;
422
+ }
423
+ case "dentist":
424
+ if (sub === "create") return cmdDentistCreate();
425
+ fail(`Unknown command: dentist ${sub ?? ""}`.trim());
426
+ usage();
427
+ process.exitCode = 1;
428
+ return;
429
+ default:
430
+ fail(`Unknown command: ${group}`);
431
+ usage();
432
+ process.exitCode = 1;
433
+ return;
434
+ }
435
+ }
436
+
437
+ main().catch((e) => {
438
+ fail(e.message);
439
+ if (flags.verbose && e.stack) log(c.dim(e.stack));
440
+ if (e.data && !JSON_OUT && e.data.practice_id) log(c.dim(` existing: ${e.data.practice_id}`));
441
+ process.exit(1);
442
+ });
package/lib/api.mjs ADDED
@@ -0,0 +1,84 @@
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
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,42 @@
1
+ import { homedir } from "node:os";
2
+ import { join, dirname } from "node:path";
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync, rmSync } from "node:fs";
4
+
5
+ const DEFAULT_API =
6
+ process.env.SETU_API_URL || "https://vaanfxluxietcicoikcg.supabase.co/functions/v1";
7
+
8
+ function configPath() {
9
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
10
+ return join(base, "setu", "config.json");
11
+ }
12
+
13
+ export function loadConfig() {
14
+ const path = configPath();
15
+ if (!existsSync(path)) return { api: DEFAULT_API, key: process.env.SETU_CLI_API_KEY || "", operator: "" };
16
+ try {
17
+ const raw = JSON.parse(readFileSync(path, "utf8"));
18
+ return {
19
+ api: process.env.SETU_API_URL || raw.api || DEFAULT_API,
20
+ key: process.env.SETU_CLI_API_KEY || raw.key || "",
21
+ operator: raw.operator || "",
22
+ };
23
+ } catch {
24
+ return { api: DEFAULT_API, key: process.env.SETU_CLI_API_KEY || "", operator: "" };
25
+ }
26
+ }
27
+
28
+ export function saveConfig(cfg) {
29
+ const path = configPath();
30
+ mkdirSync(dirname(path), { recursive: true });
31
+ writeFileSync(path, JSON.stringify(cfg, null, 2));
32
+ try { chmodSync(path, 0o600); } catch { /* windows */ }
33
+ return path;
34
+ }
35
+
36
+ export function clearConfig() {
37
+ const path = configPath();
38
+ try { rmSync(path, { force: true }); } catch { /* ignore */ }
39
+ return path;
40
+ }
41
+
42
+ export { DEFAULT_API, configPath };
package/lib/ui.mjs ADDED
@@ -0,0 +1,68 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+
4
+ const useColor = stdout.isTTY && !process.env.NO_COLOR;
5
+ const wrap = (code) => (s) => (useColor ? `\u001b[${code}m${s}\u001b[0m` : String(s));
6
+
7
+ export const c = {
8
+ bold: wrap("1"),
9
+ dim: wrap("2"),
10
+ blue: wrap("36"),
11
+ green: wrap("32"),
12
+ yellow: wrap("33"),
13
+ red: wrap("31"),
14
+ };
15
+
16
+ export function log(...args) { console.log(...args); }
17
+ export function ok(msg) { log(`${c.green("✔")} ${msg}`); }
18
+ export function warn(msg) { log(`${c.yellow("!")} ${msg}`); }
19
+ export function fail(msg) { console.error(`${c.red("✖")} ${msg}`); }
20
+
21
+ export function heading(msg) { log(`\n${c.bold(msg)}`); }
22
+
23
+ export function table(rows) {
24
+ for (const [k, v] of rows) {
25
+ log(` ${c.dim(String(k).padEnd(18))} ${v ?? c.dim("—")}`);
26
+ }
27
+ }
28
+
29
+ export async function ask(question, { silent = false } = {}) {
30
+ const rl = createInterface({ input: stdin, output: stdout, terminal: true });
31
+ if (!silent) {
32
+ const answer = await rl.question(question);
33
+ rl.close();
34
+ return answer.trim();
35
+ }
36
+ // Hidden input for secrets.
37
+ const onData = (char) => {
38
+ if (["\n", "\r", "\u0004"].includes(char.toString())) return;
39
+ stdout.write("\u001b[2K\u001b[200D" + question + "*".repeat(rl.line.length));
40
+ };
41
+ stdin.on("data", onData);
42
+ const answer = await rl.question(question);
43
+ stdin.off("data", onData);
44
+ rl.close();
45
+ stdout.write("\n");
46
+ return answer.trim();
47
+ }
48
+
49
+ export async function confirm(question) {
50
+ const a = await ask(`${question} ${c.dim("(y/N)")} `);
51
+ return /^y(es)?$/i.test(a);
52
+ }
53
+
54
+ export function spinner(label) {
55
+ if (!stdout.isTTY) { log(`${label}…`); return { stop: () => {} }; }
56
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
57
+ let i = 0;
58
+ const timer = setInterval(() => {
59
+ stdout.write(`\r${c.blue(frames[i++ % frames.length])} ${label} `);
60
+ }, 80);
61
+ return {
62
+ stop: (final) => {
63
+ clearInterval(timer);
64
+ stdout.write("\r\u001b[2K");
65
+ if (final) log(final);
66
+ },
67
+ };
68
+ }