@iann29/synapse 1.6.17 → 1.8.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/README.md +20 -0
- package/bin/synapse.js +75 -349
- package/lib/api.js +45 -3
- package/lib/colors.js +51 -0
- package/lib/commands/_context.js +133 -0
- package/lib/commands/_dispatcher.js +75 -0
- package/lib/commands/_help.js +105 -0
- package/lib/commands/convex.js +151 -0
- package/lib/commands/credentials.js +85 -0
- package/lib/commands/deploy.js +72 -0
- package/lib/commands/dev.js +24 -0
- package/lib/commands/doctor.js +82 -0
- package/lib/commands/login.js +43 -0
- package/lib/commands/logout.js +20 -0
- package/lib/commands/open.js +96 -0
- package/lib/commands/select.js +239 -0
- package/lib/commands/status.js +173 -0
- package/lib/commands/version.js +77 -0
- package/lib/commands/whoami.js +22 -0
- package/lib/convex.js +20 -2
- package/lib/doctor/checks.js +484 -0
- package/lib/doctor/renderer.js +93 -0
- package/lib/doctor/runner.js +148 -0
- package/lib/output.js +140 -0
- package/lib/prompts.js +80 -4
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Human-mode renderer for the DoctorReport. JSON mode lives in the
|
|
2
|
+
// command file; this is purely the terminal pretty-printer.
|
|
3
|
+
|
|
4
|
+
const colors = require("../colors");
|
|
5
|
+
|
|
6
|
+
const SYMBOL = {
|
|
7
|
+
ok: "✓",
|
|
8
|
+
warn: "!",
|
|
9
|
+
issue: "✗",
|
|
10
|
+
skipped: "·",
|
|
11
|
+
};
|
|
12
|
+
const TONE = {
|
|
13
|
+
ok: colors.green,
|
|
14
|
+
warn: colors.yellow,
|
|
15
|
+
issue: colors.red,
|
|
16
|
+
skipped: colors.dim,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const CATEGORY_TITLE = {
|
|
20
|
+
"local-env": "Local environment",
|
|
21
|
+
project: "Project",
|
|
22
|
+
backend: "Backend",
|
|
23
|
+
deployments: "Deployments",
|
|
24
|
+
upstream: "Upstream",
|
|
25
|
+
workspace: "Workspace",
|
|
26
|
+
};
|
|
27
|
+
const CATEGORY_ORDER = ["local-env", "project", "backend", "deployments", "upstream", "workspace"];
|
|
28
|
+
|
|
29
|
+
function renderHeader(report, write) {
|
|
30
|
+
const parts = ["Synapse doctor"];
|
|
31
|
+
if (report.env.synapseUrl) parts.push(colors.dim(`· ${report.env.synapseUrl}`));
|
|
32
|
+
if (report.env.user) parts.push(colors.dim(`· ${report.env.user}`));
|
|
33
|
+
write(parts.join(" ") + "\n\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderCheck(r, { verbose }, write) {
|
|
37
|
+
const sym = TONE[r.status](SYMBOL[r.status]);
|
|
38
|
+
write(` ${sym} ${r.title}`);
|
|
39
|
+
if (r.summary) write(colors.dim(` — ${r.summary}`));
|
|
40
|
+
if (r.fixedBy) write(` ${colors.cyan ? colors.cyan("↻ " + r.fixedBy) : "↻ " + r.fixedBy}`);
|
|
41
|
+
write("\n");
|
|
42
|
+
if (r.remediation && (r.status === "issue" || r.status === "warn")) {
|
|
43
|
+
write(colors.dim(` → ${r.remediation}\n`));
|
|
44
|
+
}
|
|
45
|
+
if (verbose && r.detail) {
|
|
46
|
+
write(colors.dim(` ${r.detail.replace(/\n/g, "\n ")}\n`));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderReport(report, { stdout, verbose = false } = {}) {
|
|
51
|
+
const write = (s) => stdout.write(s);
|
|
52
|
+
renderHeader(report, write);
|
|
53
|
+
|
|
54
|
+
const byCategory = new Map();
|
|
55
|
+
for (const r of report.results) {
|
|
56
|
+
if (!byCategory.has(r.category)) byCategory.set(r.category, []);
|
|
57
|
+
byCategory.get(r.category).push(r);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const cat of CATEGORY_ORDER) {
|
|
61
|
+
const rows = byCategory.get(cat);
|
|
62
|
+
if (!rows || rows.length === 0) continue;
|
|
63
|
+
write(` ${colors.bold(CATEGORY_TITLE[cat] ?? cat)}\n`);
|
|
64
|
+
for (const r of rows) renderCheck(r, { verbose }, write);
|
|
65
|
+
write("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Categories outside the canonical order (defensive — keeps the
|
|
69
|
+
// report honest if a check is added without updating CATEGORY_ORDER).
|
|
70
|
+
for (const [cat, rows] of byCategory) {
|
|
71
|
+
if (CATEGORY_ORDER.includes(cat)) continue;
|
|
72
|
+
write(` ${colors.bold(cat)}\n`);
|
|
73
|
+
for (const r of rows) renderCheck(r, { verbose }, write);
|
|
74
|
+
write("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const t = report.totals;
|
|
78
|
+
const summary = [
|
|
79
|
+
t.ok > 0 ? colors.green(`${t.ok} ok`) : null,
|
|
80
|
+
t.warn > 0 ? colors.yellow(`${t.warn} warn`) : null,
|
|
81
|
+
t.issue > 0 ? colors.red(`${t.issue} issue${t.issue > 1 ? "s" : ""}`) : null,
|
|
82
|
+
t.skipped > 0 ? colors.dim(`${t.skipped} skipped`) : null,
|
|
83
|
+
t.fixed > 0 ? colors.cyan ? colors.cyan(`${t.fixed} fixed`) : `${t.fixed} fixed` : null,
|
|
84
|
+
]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join(" · ");
|
|
87
|
+
write(` ${summary} · ${report.durationMs}ms\n`);
|
|
88
|
+
if (report.exitCode !== 0) {
|
|
89
|
+
write(` ${colors.dim("exit " + report.exitCode)}\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { renderReport };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Runs the doctor checks with the DAG defined in checks.js (each
|
|
2
|
+
// check declares its `dependsOn` ids). Within a tier, checks run in
|
|
3
|
+
// parallel via Promise.all; dependent checks wait on their prereqs.
|
|
4
|
+
//
|
|
5
|
+
// The orchestrator produces a stable DoctorReport — same tree feeds
|
|
6
|
+
// both the human renderer and `--json`.
|
|
7
|
+
|
|
8
|
+
const { ALL_CHECKS } = require("./checks");
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = "1.0";
|
|
11
|
+
|
|
12
|
+
async function runChecks(checks, ctx) {
|
|
13
|
+
// Topological execution: for each check, wait for its dependencies
|
|
14
|
+
// (already-resolved CheckResults) before invoking run(). Failed or
|
|
15
|
+
// skipped prereqs cascade to skipped state (don't run the dependent).
|
|
16
|
+
const resultsById = new Map();
|
|
17
|
+
const order = []; // preserve insertion order for the final report
|
|
18
|
+
// Resolve sequentially through the catalog — checks declared earlier
|
|
19
|
+
// become available as deps for later ones. Inside a "tier" we still
|
|
20
|
+
// get the parallel benefit because awaiting an already-resolved
|
|
21
|
+
// result is a microtask, not a roundtrip.
|
|
22
|
+
for (const check of checks) {
|
|
23
|
+
order.push(check.id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// First pass: kick off every check whose dependencies are all "ok"
|
|
27
|
+
// or "warn"; cascade-skip the rest.
|
|
28
|
+
const pending = new Map();
|
|
29
|
+
for (const check of checks) {
|
|
30
|
+
pending.set(check.id, check);
|
|
31
|
+
}
|
|
32
|
+
while (pending.size > 0) {
|
|
33
|
+
let advanced = false;
|
|
34
|
+
for (const [id, check] of pending) {
|
|
35
|
+
const depsResolved = (check.dependsOn || []).every((d) => resultsById.has(d));
|
|
36
|
+
if (!depsResolved) continue;
|
|
37
|
+
const failedDeps = (check.dependsOn || []).filter((d) => {
|
|
38
|
+
const r = resultsById.get(d);
|
|
39
|
+
return r.status === "issue" || r.status === "skipped";
|
|
40
|
+
});
|
|
41
|
+
if (failedDeps.length > 0) {
|
|
42
|
+
resultsById.set(id, {
|
|
43
|
+
id,
|
|
44
|
+
category: check.category,
|
|
45
|
+
title: check.title,
|
|
46
|
+
status: "skipped",
|
|
47
|
+
summary: `skipped (prereq failed: ${failedDeps.join(", ")})`,
|
|
48
|
+
data: { skippedBecause: failedDeps },
|
|
49
|
+
durationMs: 0,
|
|
50
|
+
});
|
|
51
|
+
pending.delete(id);
|
|
52
|
+
advanced = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const result = await check.run(ctx);
|
|
56
|
+
resultsById.set(id, {
|
|
57
|
+
id,
|
|
58
|
+
category: check.category,
|
|
59
|
+
title: check.title,
|
|
60
|
+
...result,
|
|
61
|
+
});
|
|
62
|
+
pending.delete(id);
|
|
63
|
+
advanced = true;
|
|
64
|
+
}
|
|
65
|
+
if (!advanced) {
|
|
66
|
+
// Cycle in deps — should never happen; bail to avoid an infinite
|
|
67
|
+
// loop. Mark anything still pending as a hard error.
|
|
68
|
+
for (const [id, check] of pending) {
|
|
69
|
+
resultsById.set(id, {
|
|
70
|
+
id,
|
|
71
|
+
category: check.category,
|
|
72
|
+
title: check.title,
|
|
73
|
+
status: "issue",
|
|
74
|
+
summary: `cycle in dependsOn: ${(check.dependsOn || []).join(", ")}`,
|
|
75
|
+
durationMs: 0,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return order.map((id) => resultsById.get(id));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function totalize(results) {
|
|
86
|
+
const t = { ok: 0, warn: 0, issue: 0, skipped: 0, fixed: 0 };
|
|
87
|
+
for (const r of results) {
|
|
88
|
+
if (r.fixedBy) t.fixed += 1;
|
|
89
|
+
if (r.status === "ok") t.ok += 1;
|
|
90
|
+
else if (r.status === "warn") t.warn += 1;
|
|
91
|
+
else if (r.status === "issue") t.issue += 1;
|
|
92
|
+
else if (r.status === "skipped") t.skipped += 1;
|
|
93
|
+
}
|
|
94
|
+
return t;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function exitCodeFor(totals) {
|
|
98
|
+
if (totals.issue > 0) return 2;
|
|
99
|
+
if (totals.warn > 0) return 1;
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function applyAutoFixes(results, checks, ctx, { allowPrompt = false } = {}) {
|
|
104
|
+
// Walk results, find non-ok ones whose check has autoFix=auto (or
|
|
105
|
+
// prompt + allowPrompt=true), invoke the fix, re-run THAT check.
|
|
106
|
+
// Stays simple: one fix pass, no recursive heal-then-recheck loops.
|
|
107
|
+
const byId = new Map(checks.map((c) => [c.id, c]));
|
|
108
|
+
for (const r of results) {
|
|
109
|
+
if (r.status === "ok" || r.status === "skipped") continue;
|
|
110
|
+
const check = byId.get(r.id);
|
|
111
|
+
if (!check || typeof check.fix !== "function") continue;
|
|
112
|
+
if (check.autoFix === "never") continue;
|
|
113
|
+
if (check.autoFix === "prompt" && !allowPrompt) continue;
|
|
114
|
+
const fixOutcome = await check.fix(ctx, r);
|
|
115
|
+
if (fixOutcome.kind === "applied") {
|
|
116
|
+
// Re-run the check; if it's green now, mark fixedBy.
|
|
117
|
+
const fresh = await check.run(ctx);
|
|
118
|
+
Object.assign(r, fresh, { fixedBy: fixOutcome.message });
|
|
119
|
+
} else if (fixOutcome.kind === "failed") {
|
|
120
|
+
r.detail = (r.detail ? r.detail + "\n" : "") + `fix attempt failed: ${fixOutcome.message}`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function generateReport(ctx, { fix = false, allowPrompt = false } = {}) {
|
|
126
|
+
const t0 = Date.now();
|
|
127
|
+
const results = await runChecks(ALL_CHECKS, ctx);
|
|
128
|
+
if (fix) {
|
|
129
|
+
await applyAutoFixes(results, ALL_CHECKS, ctx, { allowPrompt });
|
|
130
|
+
}
|
|
131
|
+
const totals = totalize(results);
|
|
132
|
+
const exitCode = exitCodeFor(totals);
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: SCHEMA_VERSION,
|
|
135
|
+
cliVersion: require("../../package.json").version,
|
|
136
|
+
env: {
|
|
137
|
+
cwd: ctx.cwd,
|
|
138
|
+
synapseUrl: ctx.cfg?.baseUrl ?? null,
|
|
139
|
+
user: ctx.cfg?.user?.email ?? null,
|
|
140
|
+
},
|
|
141
|
+
results,
|
|
142
|
+
totals,
|
|
143
|
+
durationMs: Date.now() - t0,
|
|
144
|
+
exitCode,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { runChecks, totalize, exitCodeFor, applyAutoFixes, generateReport, SCHEMA_VERSION };
|
package/lib/output.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Shared output layer for every synapse CLI command.
|
|
2
|
+
//
|
|
3
|
+
// Every handler writes through `createOutput()` instead of poking
|
|
4
|
+
// process.stdout directly. This is the only thing that makes `--json`
|
|
5
|
+
// honest: in JSON mode the renderer never emits ANSI codes, partial
|
|
6
|
+
// human strings, or stray progress lines that would corrupt a piped
|
|
7
|
+
// script's parse.
|
|
8
|
+
//
|
|
9
|
+
// Contract:
|
|
10
|
+
// - `result(data, renderHuman)` is THE command result. In JSON mode
|
|
11
|
+
// emits `JSON.stringify(data)` to stdout. In human mode invokes
|
|
12
|
+
// renderHuman(data, ctx).
|
|
13
|
+
// - `info`/`warn` are side-channel status (progress, hints). They go
|
|
14
|
+
// to stderr and are SUPPRESSED entirely in JSON mode so a piped
|
|
15
|
+
// `--json` stream stays parse-clean.
|
|
16
|
+
// - `error(msg, { hint })` writes to stderr. In JSON mode emits
|
|
17
|
+
// `{error, hint}` so scripts can branch on it.
|
|
18
|
+
// - `table(rows, columns)` is a small helper that lays out aligned
|
|
19
|
+
// columns in human mode; in JSON mode the caller passes `rows`
|
|
20
|
+
// directly to `result()` instead.
|
|
21
|
+
|
|
22
|
+
const colors = require("./colors");
|
|
23
|
+
|
|
24
|
+
function createOutput({
|
|
25
|
+
json = false,
|
|
26
|
+
stdout = process.stdout,
|
|
27
|
+
stderr = process.stderr,
|
|
28
|
+
} = {}) {
|
|
29
|
+
function writeOut(s) {
|
|
30
|
+
stdout.write(s);
|
|
31
|
+
}
|
|
32
|
+
function writeErr(s) {
|
|
33
|
+
stderr.write(s);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
json,
|
|
38
|
+
stdout,
|
|
39
|
+
stderr,
|
|
40
|
+
|
|
41
|
+
// The command's structured result. JSON mode emits a single line.
|
|
42
|
+
// Human mode delegates rendering — the renderer is free to write
|
|
43
|
+
// tables, multi-line copy, colors, anything. Callers MUST supply
|
|
44
|
+
// a renderer; commands with no useful human output should pass a
|
|
45
|
+
// no-op (`() => {}`) explicitly so the JSON-vs-human split stays
|
|
46
|
+
// obvious in code review.
|
|
47
|
+
result(data, renderHuman) {
|
|
48
|
+
if (this.json) {
|
|
49
|
+
writeOut(JSON.stringify(data) + "\n");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
renderHuman(data, { stdout, stderr });
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Progress / status. Suppressed under --json so a piped consumer
|
|
56
|
+
// never sees mixed JSON and prose on stdout.
|
|
57
|
+
info(msg) {
|
|
58
|
+
if (this.json) return;
|
|
59
|
+
writeErr(msg + "\n");
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
warn(msg) {
|
|
63
|
+
if (this.json) return;
|
|
64
|
+
writeErr(colors.yellow("Warning: ") + msg + "\n");
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// Fatal-ish error message. Returns nothing — callers throw or
|
|
68
|
+
// set process.exitCode on their own to control control flow.
|
|
69
|
+
// The dispatcher's top-level catch is the canonical exit path.
|
|
70
|
+
error(msg, { hint } = {}) {
|
|
71
|
+
if (this.json) {
|
|
72
|
+
writeErr(JSON.stringify({ error: msg, ...(hint ? { hint } : {}) }) + "\n");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
writeErr(colors.red("Error: ") + msg + "\n");
|
|
76
|
+
if (hint) writeErr(colors.dim("Hint: ") + hint + "\n");
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// Lightweight column-aligned table renderer for human mode.
|
|
80
|
+
// `columns` is [{ key, header, align?, color? }]. `align` is
|
|
81
|
+
// "left" (default) or "right". `color` is a colors.* function
|
|
82
|
+
// applied per-cell after the value is stringified.
|
|
83
|
+
//
|
|
84
|
+
// In JSON mode the function is a no-op — callers should hand the
|
|
85
|
+
// raw rows array to `result()` instead.
|
|
86
|
+
table(rows, columns) {
|
|
87
|
+
if (this.json) return;
|
|
88
|
+
if (!rows || rows.length === 0) {
|
|
89
|
+
writeOut(colors.dim("(no rows)\n"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const widths = columns.map((c) => c.header.length);
|
|
93
|
+
const cells = rows.map((row) =>
|
|
94
|
+
columns.map((c, i) => {
|
|
95
|
+
const raw = row[c.key];
|
|
96
|
+
const text = raw == null ? "" : String(raw);
|
|
97
|
+
if (text.length > widths[i]) widths[i] = text.length;
|
|
98
|
+
return text;
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
const fmt = (text, w, align) =>
|
|
102
|
+
align === "right" ? text.padStart(w) : text.padEnd(w);
|
|
103
|
+
const header = columns
|
|
104
|
+
.map((c, i) => colors.dim(fmt(c.header, widths[i], c.align)))
|
|
105
|
+
.join(" ");
|
|
106
|
+
writeOut(header + "\n");
|
|
107
|
+
writeOut(
|
|
108
|
+
colors.dim(widths.map((w) => "─".repeat(w)).join(" ")) + "\n",
|
|
109
|
+
);
|
|
110
|
+
for (const row of cells) {
|
|
111
|
+
const line = row
|
|
112
|
+
.map((cell, i) => {
|
|
113
|
+
const padded = fmt(cell, widths[i], columns[i].align);
|
|
114
|
+
return columns[i].color ? columns[i].color(padded) : padded;
|
|
115
|
+
})
|
|
116
|
+
.join(" ");
|
|
117
|
+
writeOut(line + "\n");
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Detects `--json` from an args vector and returns
|
|
124
|
+
// `{ json: boolean, rest: string[] }` so handlers can strip the flag
|
|
125
|
+
// before parsing their own positionals. Bash/CI scripts commonly
|
|
126
|
+
// pass `--json` in any position; we accept it anywhere.
|
|
127
|
+
function extractJsonFlag(argv) {
|
|
128
|
+
const rest = [];
|
|
129
|
+
let json = false;
|
|
130
|
+
for (const a of argv) {
|
|
131
|
+
if (a === "--json") {
|
|
132
|
+
json = true;
|
|
133
|
+
} else {
|
|
134
|
+
rest.push(a);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { json, rest };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { createOutput, extractJsonFlag };
|
package/lib/prompts.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
const readline = require("node:readline");
|
|
2
2
|
|
|
3
|
+
// Sentinel returned by `choose` when the user asks to go back to the
|
|
4
|
+
// previous step. Use a Symbol so it can never collide with a legitimate
|
|
5
|
+
// choice payload.
|
|
6
|
+
const BACK = Symbol("synapse-choose-back");
|
|
7
|
+
|
|
3
8
|
function ask(question, { input = process.stdin, output = process.stderr } = {}) {
|
|
4
9
|
const rl = readline.createInterface({ input, output });
|
|
5
10
|
return new Promise((resolve) => {
|
|
@@ -89,12 +94,69 @@ async function askCredentials({ input = process.stdin, output = process.stderr }
|
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
|
|
97
|
+
// confirm prompts the user with a yes/no question and resolves to a boolean.
|
|
98
|
+
//
|
|
99
|
+
// - Empty answer applies `defaultAnswer` (so `[y/N]` matches "no by default"
|
|
100
|
+
// and `[Y/n]` matches "yes by default"). The hint in the question is the
|
|
101
|
+
// caller's responsibility — we render the prompt literally.
|
|
102
|
+
// - "y" / "yes" / "n" / "no" (case-insensitive) are accepted.
|
|
103
|
+
// - Anything else re-prompts with a tip, capped at `maxAttempts` to keep
|
|
104
|
+
// pasted-shell-history scenarios from looping forever.
|
|
105
|
+
// - Non-interactive callers (no TTY) cannot disambiguate — they must use a
|
|
106
|
+
// skip-confirmation flag at the call site instead of relying on `confirm`.
|
|
107
|
+
//
|
|
108
|
+
// We open a single readline interface for the whole prompt session.
|
|
109
|
+
// Calling `ask()` multiple times would re-create the interface and re-bind
|
|
110
|
+
// `data` listeners on the input stream — which is fine for real stdin but
|
|
111
|
+
// produces flaky behaviour on a buffered PassThrough where the second
|
|
112
|
+
// listener can't see data the first consumed. One rl, multiple questions.
|
|
113
|
+
async function confirm(question, {
|
|
114
|
+
input = process.stdin,
|
|
115
|
+
output = process.stderr,
|
|
116
|
+
defaultAnswer = false,
|
|
117
|
+
maxAttempts = 3,
|
|
118
|
+
} = {}) {
|
|
119
|
+
const rl = readline.createInterface({ input, output });
|
|
120
|
+
try {
|
|
121
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
122
|
+
const raw = await new Promise((resolve) => rl.question(question, resolve));
|
|
123
|
+
const answer = raw.trim().toLowerCase();
|
|
124
|
+
if (answer === "") return defaultAnswer;
|
|
125
|
+
if (answer === "y" || answer === "yes") return true;
|
|
126
|
+
if (answer === "n" || answer === "no") return false;
|
|
127
|
+
output.write("Please answer y or n.\n");
|
|
128
|
+
}
|
|
129
|
+
throw new Error("Confirmation prompt cancelled after too many invalid answers");
|
|
130
|
+
} finally {
|
|
131
|
+
rl.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// choose prompts the user to pick one of `choices`.
|
|
136
|
+
//
|
|
137
|
+
// - `label` is the plural noun used for the menu header ("dev deployments").
|
|
138
|
+
// - `singularLabel` defaults to `label` stripped of a trailing "s" — it's
|
|
139
|
+
// the noun used when only one option exists and we auto-select silently.
|
|
140
|
+
// Pass a custom value when the strip heuristic produces something
|
|
141
|
+
// awkward ("members" → "member" is fine; "people" → "peopl" is not).
|
|
142
|
+
// - `allowBack` lets the user type "b" / "back" / "0" to return the BACK
|
|
143
|
+
// sentinel, which the caller maps to a previous step.
|
|
144
|
+
// - `maxInvalid` caps how many garbage answers in a row we tolerate
|
|
145
|
+
// before throwing. Protects against pasted shell history / broken
|
|
146
|
+
// stdin where the loop would otherwise spin forever.
|
|
147
|
+
async function choose(label, choices, {
|
|
148
|
+
input = process.stdin,
|
|
149
|
+
output = process.stderr,
|
|
150
|
+
singularLabel,
|
|
151
|
+
allowBack = false,
|
|
152
|
+
maxInvalid = 3,
|
|
153
|
+
} = {}) {
|
|
93
154
|
if (!Array.isArray(choices) || choices.length === 0) {
|
|
94
155
|
throw new Error(`No ${label} available.`);
|
|
95
156
|
}
|
|
157
|
+
const singular = singularLabel || label.replace(/s$/, "") || label;
|
|
96
158
|
if (choices.length === 1) {
|
|
97
|
-
output.write(`
|
|
159
|
+
output.write(`Auto-selected ${singular}: ${choices[0].label} (only one available)\n`);
|
|
98
160
|
return choices[0].value;
|
|
99
161
|
}
|
|
100
162
|
|
|
@@ -103,20 +165,34 @@ async function choose(label, choices, { input = process.stdin, output = process.
|
|
|
103
165
|
output.write(` ${index + 1}. ${choice.label}\n`);
|
|
104
166
|
});
|
|
105
167
|
|
|
168
|
+
const hint = allowBack
|
|
169
|
+
? `[1-${choices.length}, b=back]`
|
|
170
|
+
: `[1-${choices.length}]`;
|
|
171
|
+
|
|
172
|
+
let invalid = 0;
|
|
106
173
|
while (true) {
|
|
107
|
-
const answer = await ask(`Choose ${label}
|
|
174
|
+
const answer = (await ask(`Choose ${label} ${hint}: `, { input, output })).trim();
|
|
175
|
+
if (allowBack && (answer === "b" || answer === "B" || answer === "back" || answer === "0")) {
|
|
176
|
+
return BACK;
|
|
177
|
+
}
|
|
108
178
|
const n = Number.parseInt(answer, 10);
|
|
109
179
|
if (Number.isInteger(n) && n >= 1 && n <= choices.length) {
|
|
110
180
|
return choices[n - 1].value;
|
|
111
181
|
}
|
|
112
|
-
|
|
182
|
+
invalid += 1;
|
|
183
|
+
if (invalid >= maxInvalid) {
|
|
184
|
+
throw new Error(`Cancelled ${label} prompt after ${invalid} invalid answers`);
|
|
185
|
+
}
|
|
186
|
+
output.write(`Enter a number from 1 to ${choices.length}${allowBack ? " (or 'b' to go back)" : ""}.\n`);
|
|
113
187
|
}
|
|
114
188
|
}
|
|
115
189
|
|
|
116
190
|
module.exports = {
|
|
191
|
+
BACK,
|
|
117
192
|
ask,
|
|
118
193
|
askCredentials,
|
|
119
194
|
askHidden,
|
|
120
195
|
choose,
|
|
196
|
+
confirm,
|
|
121
197
|
parseCredentialsInput,
|
|
122
198
|
};
|