@iann29/synapse 1.6.17 → 1.7.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 CHANGED
@@ -15,6 +15,26 @@ npm install -g @iann29/synapse
15
15
  synapse --help
16
16
  ```
17
17
 
18
+ ### Windows: ensure the npm global bin directory is in PATH
19
+
20
+ On a fresh Node.js install, Windows does **not** always add
21
+ `%APPDATA%\npm` to the user PATH. After `npm install -g`, the
22
+ `synapse` binary exists but `synapse --help` errors with
23
+ *"not recognised as the name of a cmdlet"*. Fix once in PowerShell:
24
+
25
+ ```powershell
26
+ [Environment]::SetEnvironmentVariable(
27
+ 'PATH',
28
+ "$([Environment]::GetEnvironmentVariable('PATH','User'));$env:APPDATA\npm",
29
+ 'User'
30
+ )
31
+ ```
32
+
33
+ Close every terminal (and your IDE — VS Code caches the env at launch)
34
+ and reopen. `synapse --help` should now print the usage. This is a
35
+ one-time, Node-installer-version-dependent issue; it is not specific
36
+ to this package.
37
+
18
38
  For one app/project:
19
39
 
20
40
  ```bash
package/bin/synapse.js CHANGED
@@ -9,17 +9,29 @@ const {
9
9
  readProjectConfig,
10
10
  writeProjectConfig,
11
11
  } = require("../lib/project");
12
- const { askCredentials, choose } = require("../lib/prompts");
12
+ const { BACK, askCredentials, choose, confirm } = require("../lib/prompts");
13
+ const colors = require("../lib/colors");
13
14
  const { runConvex } = require("../lib/convex");
14
15
 
16
+ function debugLog(msg) {
17
+ if (process.env.DEBUG_SYNAPSE) {
18
+ process.stderr.write(`[DEBUG] ${msg}\n`);
19
+ }
20
+ }
21
+
15
22
  function usage() {
16
23
  return `Usage:
17
24
  synapse login <url>
18
25
  synapse logout
19
26
  synapse whoami
20
27
  synapse select
28
+ synapse dev [...args] Run \`convex dev\` against the linked dev deployment.
29
+ synapse deploy [--yes] [...args] Run \`convex deploy\` against the linked prod deployment (asks for confirmation).
21
30
  synapse credentials <deployment> [--format env|shell|json]
22
- synapse convex [--target dev|prod] [...args]
31
+ synapse convex [--target dev|prod] [...args] Escape hatch for any other \`convex\` subcommand.
32
+
33
+ Tip: \`synapse select\` writes the deployment credentials to .env.local, so you can also
34
+ run \`npx convex <args>\` directly without going through this wrapper.
23
35
  `;
24
36
  }
25
37
 
@@ -73,12 +85,13 @@ function teamRef(team) {
73
85
  }
74
86
 
75
87
  function deploymentLabel(deployment) {
76
- const bits = [deployment.name];
77
- if (deployment.deploymentType || deployment.type) {
78
- bits.push(deployment.deploymentType || deployment.type);
88
+ const bits = [colors.bold(deployment.name)];
89
+ const type = deployment.deploymentType || deployment.type;
90
+ if (type) {
91
+ bits.push(colors.dim(type));
79
92
  }
80
93
  if (deployment.status) {
81
- bits.push(deployment.status);
94
+ bits.push(colors.statusBadge(deployment.status));
82
95
  }
83
96
  return bits.filter(Boolean).join(" - ");
84
97
  }
@@ -96,16 +109,21 @@ function sortDeploymentsForChoice(deployments) {
96
109
  });
97
110
  }
98
111
 
99
- async function chooseDeploymentForType(type, deployments) {
112
+ async function chooseDeploymentForType(type, deployments, chooseOpts = {}) {
100
113
  const matches = sortDeploymentsForChoice(
101
114
  deployments.filter((d) => deploymentType(d) === type && d.status !== "deleted"),
102
115
  );
116
+ debugLog(
117
+ `chooseDeploymentForType(${type}): matched ${matches.length} of ${deployments.length} ` +
118
+ `(types: ${deployments.map((d) => deploymentType(d) || "?").join(",")})`,
119
+ );
103
120
  if (matches.length === 0) {
104
121
  return null;
105
122
  }
106
123
  return await choose(
107
124
  `${type} deployments`,
108
125
  matches.map((d) => ({ label: deploymentLabel(d), value: d })),
126
+ { singularLabel: `${type} deployment`, ...chooseOpts },
109
127
  );
110
128
  }
111
129
 
@@ -259,18 +277,94 @@ async function whoami() {
259
277
  process.stdout.write(`${name ? `${name} ` : ""}<${email}> on ${cfg.baseUrl}\n`);
260
278
  }
261
279
 
280
+ // selectDeployment walks the operator through team → project → dev → prod
281
+ // pickers, then writes .synapse/project.json + .env.local. Implemented as a
282
+ // small state machine so the user can type `b` / `back` at any step to
283
+ // re-choose the previous selection without restarting the whole CLI.
284
+ //
285
+ // Network results are cached per (team, project) so back-navigation stays
286
+ // snappy and doesn't burn pagination roundtrips. `DEBUG_SYNAPSE=1` dumps
287
+ // the raw lists at each step — useful when an expected deployment is
288
+ // missing from the menu.
262
289
  async function selectDeployment() {
263
290
  const { cfg, api } = clientFromConfig();
264
- const teams = await api.teams();
265
- const team = await choose("teams", teams.map((t) => ({ label: labelName(t), value: t })));
266
- const projects = await api.projects(teamRef(team));
267
- const project = await choose("projects", projects.map((p) => ({ label: labelName(p), value: p })));
268
- const deployments = await api.deployments(project.id);
269
- const dev = await chooseDeploymentForType("dev", deployments);
270
- if (!dev) {
271
- throw new Error("No dev deployments available in this project. Create one first.");
291
+
292
+ const cache = {
293
+ teamsList: null,
294
+ projectsByTeamKey: new Map(),
295
+ deploymentsByProjectId: new Map(),
296
+ };
297
+ async function fetchTeams() {
298
+ if (!cache.teamsList) {
299
+ cache.teamsList = await api.teams();
300
+ debugLog(`teams loaded: ${cache.teamsList.length}`);
301
+ }
302
+ return cache.teamsList;
303
+ }
304
+ async function fetchProjects(team) {
305
+ const key = team.id || team.slug || team.name;
306
+ if (!cache.projectsByTeamKey.has(key)) {
307
+ const projects = await api.projects(teamRef(team));
308
+ cache.projectsByTeamKey.set(key, projects);
309
+ debugLog(`projects for team ${key}: ${projects.length}`);
310
+ }
311
+ return cache.projectsByTeamKey.get(key);
272
312
  }
273
- const prod = await chooseDeploymentForType("prod", deployments);
313
+ async function fetchDeployments(project) {
314
+ if (!cache.deploymentsByProjectId.has(project.id)) {
315
+ const deployments = await api.deployments(project.id);
316
+ cache.deploymentsByProjectId.set(project.id, deployments);
317
+ debugLog(`deployments for project ${project.id}: ${deployments.length}`);
318
+ }
319
+ return cache.deploymentsByProjectId.get(project.id);
320
+ }
321
+
322
+ let team = null;
323
+ let project = null;
324
+ let dev = null;
325
+ let prod = null;
326
+ let step = "team";
327
+ while (step !== "done") {
328
+ if (step === "team") {
329
+ const teams = await fetchTeams();
330
+ // Back from team would be "exit" — not useful at the top of the flow.
331
+ const picked = await choose(
332
+ "teams",
333
+ teams.map((t) => ({ label: labelName(t), value: t })),
334
+ { singularLabel: "team", allowBack: false },
335
+ );
336
+ team = picked;
337
+ step = "project";
338
+ } else if (step === "project") {
339
+ const projects = await fetchProjects(team);
340
+ const picked = await choose(
341
+ "projects",
342
+ projects.map((p) => ({ label: labelName(p), value: p })),
343
+ { singularLabel: "project", allowBack: true },
344
+ );
345
+ if (picked === BACK) { step = "team"; continue; }
346
+ project = picked;
347
+ step = "dev";
348
+ } else if (step === "dev") {
349
+ const deployments = await fetchDeployments(project);
350
+ const picked = await chooseDeploymentForType("dev", deployments, { allowBack: true });
351
+ if (picked === BACK) { step = "project"; continue; }
352
+ if (picked === null) {
353
+ throw new Error(
354
+ "No dev deployments available in this project. Create one first in the dashboard.",
355
+ );
356
+ }
357
+ dev = picked;
358
+ step = "prod";
359
+ } else if (step === "prod") {
360
+ const deployments = await fetchDeployments(project);
361
+ const picked = await chooseDeploymentForType("prod", deployments, { allowBack: true });
362
+ if (picked === BACK) { step = "dev"; continue; }
363
+ prod = picked; // null is a valid outcome here (project has no prod yet)
364
+ step = "done";
365
+ }
366
+ }
367
+
274
368
  const projectPath = writeProjectConfig(
275
369
  process.cwd(),
276
370
  buildProjectConfig({
@@ -282,16 +376,34 @@ async function selectDeployment() {
282
376
  );
283
377
  const creds = await api.cliCredentials(dev.name);
284
378
  const envPath = writeProjectEnv(process.cwd(), creds);
285
- process.stderr.write(`Linked ${labelName(project)} to ${projectPath}.\n`);
286
- process.stderr.write(`Selected dev deployment ${dev.name}. Updated ${envPath}.\n`);
379
+
380
+ process.stderr.write(`\nLinked ${labelName(project)} to ${projectPath}.\n`);
381
+ process.stderr.write(`Selected dev deployment ${colors.bold(dev.name)}. Updated ${envPath}.\n`);
287
382
  if (prod) {
288
- process.stderr.write(`Selected prod deployment ${prod.name}.\n`);
383
+ process.stderr.write(`Selected prod deployment ${colors.bold(prod.name)}.\n`);
289
384
  } else {
290
- process.stderr.write("Warning: no prod deployment found. `synapse convex deploy` will require a prod deployment saved by `synapse select`.\n");
385
+ process.stderr.write(
386
+ `\n${colors.yellow("Warning:")} no prod deployment found. ` +
387
+ "`synapse deploy` (and `synapse convex deploy`) will fail with a clear " +
388
+ "error until you create a prod deployment and run `synapse select` again.\n",
389
+ );
291
390
  }
292
391
  if (process.env.CONVEX_DEPLOYMENT) {
293
- process.stderr.write("Warning: shell CONVEX_DEPLOYMENT is set. Use `synapse convex ...` or unset it before running `npx convex` directly.\n");
392
+ process.stderr.write(
393
+ `\n${colors.yellow("Warning:")} shell CONVEX_DEPLOYMENT is set. ` +
394
+ "Use `synapse dev` / `synapse deploy` / `synapse convex ...` " +
395
+ "or unset CONVEX_DEPLOYMENT before running `npx convex` directly.\n",
396
+ );
294
397
  }
398
+ // Discoverability hint (P3-012). The upstream Convex CLI's `dev` command
399
+ // is what pushes the project's schema/functions and starts a dev server;
400
+ // many operators land here from frameworks (Next/Vite) without knowing
401
+ // that, then hit "page hangs forever" the first time their client tries
402
+ // to query a backend that has no code deployed yet. Spell it out.
403
+ process.stderr.write(
404
+ `\nNext step: run ${colors.bold("synapse dev")} (or ${colors.bold("npx convex dev")}) once in this directory ` +
405
+ "to push your schema and watch for changes.\n",
406
+ );
295
407
  }
296
408
 
297
409
  async function credentials(args) {
@@ -327,6 +439,71 @@ async function convex(args) {
327
439
  process.exitCode = code;
328
440
  }
329
441
 
442
+ // extractYesFlag pulls --yes / -y out of an arg vector so the rest can be
443
+ // passed verbatim to the underlying `convex` invocation. We strip only
444
+ // these synapse-level flags; everything else is forwarded.
445
+ function extractYesFlag(args) {
446
+ let yes = false;
447
+ const rest = [];
448
+ for (const arg of args) {
449
+ if (arg === "--yes" || arg === "-y") {
450
+ yes = true;
451
+ } else {
452
+ rest.push(arg);
453
+ }
454
+ }
455
+ return { yes, rest };
456
+ }
457
+
458
+ // dev is a convenience for `synapse convex --target dev dev`. We delegate
459
+ // to the existing convex pipeline so target resolution, credential fetching,
460
+ // and env-var sanitization stay in one place.
461
+ //
462
+ // The `convexImpl` seam exists so unit tests can short-circuit before
463
+ // runConvex actually spawns `npx`. Production wiring uses the local
464
+ // `convex` function above unchanged.
465
+ async function dev(args, { convexImpl = convex } = {}) {
466
+ return await convexImpl(["--target", "dev", "dev", ...args]);
467
+ }
468
+
469
+ // deploy is the same delegation pattern as `dev`, but with a confirmation
470
+ // gate because publishing to prod is destructive (overwrites functions and
471
+ // schema). The gate is skippable via --yes / -y for CI use. Non-interactive
472
+ // callers without --yes get a clear refusal rather than a hang on
473
+ // readline.question() that never fires.
474
+ //
475
+ // We resolve the prod deployment name from the local project metadata so the
476
+ // prompt names the exact target. When there's no metadata (no `synapse
477
+ // select` yet), we let `convex()` produce its own "run select first" error
478
+ // without prompting — the operator obviously isn't ready to deploy.
479
+ async function deploy(args, {
480
+ input = process.stdin,
481
+ output = process.stderr,
482
+ confirmImpl = confirm,
483
+ convexImpl = convex,
484
+ } = {}) {
485
+ const { yes, rest } = extractYesFlag(args);
486
+ const projectConfig = readProjectConfig(process.cwd());
487
+ const deploymentName = deploymentNameForTarget(projectConfig, "prod");
488
+ if (deploymentName && !yes) {
489
+ if (!input.isTTY) {
490
+ throw new Error(
491
+ "synapse deploy needs confirmation. Pass --yes to skip in non-interactive contexts (CI, scripts), " +
492
+ "or run `synapse deploy` again inside a regular terminal.",
493
+ );
494
+ }
495
+ const ok = await confirmImpl(
496
+ `About to run \`convex deploy\` against PROD deployment ${deploymentName}. Continue? [y/N] `,
497
+ { input, output, defaultAnswer: false },
498
+ );
499
+ if (!ok) {
500
+ output.write("Deploy cancelled.\n");
501
+ return;
502
+ }
503
+ }
504
+ return await convexImpl(["--target", "prod", "deploy", ...rest]);
505
+ }
506
+
330
507
  async function main(argv) {
331
508
  const [command, ...args] = argv;
332
509
  switch (command) {
@@ -340,6 +517,10 @@ async function main(argv) {
340
517
  return await selectDeployment();
341
518
  case "credentials":
342
519
  return await credentials(args);
520
+ case "dev":
521
+ return await dev(args);
522
+ case "deploy":
523
+ return await deploy(args);
343
524
  case "convex":
344
525
  return await convex(args);
345
526
  case "-h":
@@ -356,6 +537,16 @@ async function main(argv) {
356
537
  if (require.main === module) {
357
538
  main(process.argv.slice(2)).catch((err) => {
358
539
  process.stderr.write(`${err.message}\n`);
540
+ // Surface a concrete next step for the most common failure mode —
541
+ // the user typed a Synapse URL that doesn't resolve or whose server
542
+ // refused the connection. Without this hint, "fetch failed" reads
543
+ // like a Node bug instead of a config / connectivity problem.
544
+ if (err && err.code === "network_error") {
545
+ process.stderr.write(
546
+ "Hint: double-check the URL is reachable from this machine (try `curl <url>/v1/install_status`) " +
547
+ "and that the Synapse server is running.\n",
548
+ );
549
+ }
359
550
  process.exitCode = 1;
360
551
  });
361
552
  }
@@ -363,6 +554,9 @@ if (require.main === module) {
363
554
  module.exports = {
364
555
  chooseDeploymentForType,
365
556
  clientFromConfig,
557
+ deploy,
558
+ dev,
559
+ extractYesFlag,
366
560
  formatCredentials,
367
561
  inferConvexTarget,
368
562
  main,
package/lib/api.js CHANGED
@@ -75,10 +75,23 @@ class SynapseAPI {
75
75
  pageURL.searchParams.set("cursor", cursor);
76
76
  }
77
77
  const page = await this.request("GET", `${pageURL.pathname}${pageURL.search}`, undefined, { includeHeaders: true });
78
- if (!Array.isArray(page.data)) {
79
- throw new SynapseAPIError(0, "bad_response", `Expected ${path} to return a JSON array`);
78
+ // Backend currently returns a bare JSON array for every paginated
79
+ // endpoint, but the dashboard already tolerates both `[...]` and
80
+ // `{ <noun>: [...] }` (see `collectPaginated` in dashboard/lib/api.ts).
81
+ // Mirror that resilience here so a future server-side reshape doesn't
82
+ // brick `synapse select` for every CLI user simultaneously.
83
+ const arr = extractListPayload(page.data);
84
+ if (arr === null) {
85
+ const shape = page.data && typeof page.data === "object"
86
+ ? `object with keys [${Object.keys(page.data).join(", ")}]`
87
+ : typeof page.data;
88
+ throw new SynapseAPIError(
89
+ 0,
90
+ "bad_response",
91
+ `Expected ${path} to return a JSON array (got ${shape})`,
92
+ );
80
93
  }
81
- items.push(...page.data);
94
+ items.push(...arr);
82
95
  cursor = page.headers.get("x-next-cursor") || "";
83
96
  } while (cursor);
84
97
  return items;
@@ -113,7 +126,36 @@ class SynapseAPI {
113
126
  }
114
127
  }
115
128
 
129
+ // Known envelope keys, in priority order. We try these explicitly before
130
+ // falling back to a generic "first array-valued property" lookup so a
131
+ // future endpoint that wraps results in `{ items: [...] }` (or similar)
132
+ // keeps working. The fallback handles the unlikely case of a renamed
133
+ // envelope without crashing the CLI.
134
+ const KNOWN_LIST_KEYS = [
135
+ "teams",
136
+ "projects",
137
+ "deployments",
138
+ "members",
139
+ "items",
140
+ "data",
141
+ "results",
142
+ ];
143
+
144
+ function extractListPayload(data) {
145
+ if (Array.isArray(data)) return data;
146
+ if (data && typeof data === "object") {
147
+ for (const key of KNOWN_LIST_KEYS) {
148
+ if (Array.isArray(data[key])) return data[key];
149
+ }
150
+ for (const value of Object.values(data)) {
151
+ if (Array.isArray(value)) return value;
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+
116
157
  module.exports = {
117
158
  SynapseAPI,
118
159
  SynapseAPIError,
160
+ extractListPayload,
119
161
  };
package/lib/colors.js ADDED
@@ -0,0 +1,51 @@
1
+ // Zero-dependency ANSI helpers. Each helper returns its argument as-is when
2
+ // the terminal does not support colour (stdout is not a TTY) or the user
3
+ // opted out via NO_COLOR (https://no-color.org). Checked at call time, not
4
+ // at require time, so tests that toggle `process.env.NO_COLOR` or that run
5
+ // under `node --test` (no TTY) get plain output without further wiring.
6
+ //
7
+ // We deliberately do not depend on `chalk` / `kleur` / etc — the @iann29/
8
+ // synapse package ships zero runtime deps, and a handful of escape codes
9
+ // don't justify breaking that.
10
+
11
+ function enabled(stream = process.stdout) {
12
+ if (process.env.NO_COLOR) return false;
13
+ if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") return true;
14
+ return Boolean(stream && stream.isTTY);
15
+ }
16
+
17
+ function wrap(code) {
18
+ return (value, stream) =>
19
+ enabled(stream) ? `\x1b[${code}m${value}\x1b[0m` : String(value);
20
+ }
21
+
22
+ // Renders the status portion of a deployment row. `running` reads as success,
23
+ // `failed` as error, `provisioning` as in-flight, anything else as dim noise.
24
+ // Falls through to the plain string when colour is off so logs grep cleanly.
25
+ function statusBadge(status, stream) {
26
+ switch (status) {
27
+ case "running":
28
+ return module.exports.green(status, stream);
29
+ case "failed":
30
+ case "errored":
31
+ return module.exports.red(status, stream);
32
+ case "provisioning":
33
+ return module.exports.yellow(status, stream);
34
+ case "stopped":
35
+ case "deleted":
36
+ return module.exports.dim(status, stream);
37
+ default:
38
+ return String(status || "");
39
+ }
40
+ }
41
+
42
+ module.exports = {
43
+ enabled,
44
+ bold: wrap("1"),
45
+ dim: wrap("2"),
46
+ red: wrap("31"),
47
+ green: wrap("32"),
48
+ yellow: wrap("33"),
49
+ cyan: wrap("36"),
50
+ statusBadge,
51
+ };
package/lib/convex.js CHANGED
@@ -29,12 +29,29 @@ function buildConvexEnv(source = process.env, projectEnv = {}, overrides = {}) {
29
29
  return env;
30
30
  }
31
31
 
32
- function runConvex(args, { env = process.env, stdio = "inherit", credentials = null, spawnImpl = spawn } = {}) {
33
- const executable = process.platform === "win32" ? "npx.cmd" : "npx";
32
+ // Node 18.20.0+, 20.12.0+, 22.0.0+ refuse to spawn `.cmd`/`.bat` shims on
33
+ // Windows without `shell: true` (CVE-2024-27980 mitigation). `npx` on Windows
34
+ // is `npx.cmd`, so without this flag every `synapse convex …` invocation
35
+ // dies with `spawn EINVAL`. Safe to enable because the argv is controlled
36
+ // by us — `"convex"` is literal and the remaining args come from the user's
37
+ // CLI line, which `child_process` quotes when handing them to `cmd.exe`.
38
+ function shouldUseShell(platform = process.platform) {
39
+ return platform === "win32";
40
+ }
41
+
42
+ function runConvex(args, {
43
+ env = process.env,
44
+ stdio = "inherit",
45
+ credentials = null,
46
+ spawnImpl = spawn,
47
+ platform = process.platform,
48
+ } = {}) {
49
+ const executable = platform === "win32" ? "npx.cmd" : "npx";
34
50
  const projectEnv = readProjectEnv(process.cwd());
35
51
  const child = spawnImpl(executable, ["convex", ...args], {
36
52
  env: buildConvexEnv(env, projectEnv, envFromCredentials(credentials)),
37
53
  stdio,
54
+ shell: shouldUseShell(platform),
38
55
  });
39
56
 
40
57
  return new Promise((resolve, reject) => {
@@ -53,4 +70,5 @@ module.exports = {
53
70
  buildConvexEnv,
54
71
  envFromCredentials,
55
72
  runConvex,
73
+ shouldUseShell,
56
74
  };
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
- async function choose(label, choices, { input = process.stdin, output = process.stderr } = {}) {
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(`Using ${label}: ${choices[0].label}\n`);
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} [1-${choices.length}]: `, { input, output });
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
- output.write(`Enter a number from 1 to ${choices.length}.\n`);
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iann29/synapse",
3
- "version": "1.6.17",
3
+ "version": "1.7.0",
4
4
  "description": "Thin CLI wrapper for using the official Convex CLI with Synapse-managed deployments.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {