@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 +20 -0
- package/bin/synapse.js +215 -21
- package/lib/api.js +45 -3
- package/lib/colors.js +51 -0
- package/lib/convex.js +20 -2
- package/lib/prompts.js +80 -4
- package/package.json +1 -1
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
process.stderr.write(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
79
|
-
|
|
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(...
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
};
|