@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.
@@ -0,0 +1,77 @@
1
+ // `synapse version` — print the CLI's own version, plus the version of
2
+ // the Synapse backend it's logged into (best-effort), plus Node + OS.
3
+ //
4
+ // Designed to be the first thing operators run when reporting a bug —
5
+ // the data here is enough to triage without further questions.
6
+
7
+ const os = require("node:os");
8
+ const pkg = require("../../package.json");
9
+
10
+ module.exports = {
11
+ name: "version",
12
+ summary: "Show CLI, backend, Node and OS versions.",
13
+ usage: "synapse version [--json]",
14
+ description: `Reports:
15
+ cli this package's npm version (from package.json)
16
+ backend the Synapse instance's version (via /v1/install_status)
17
+ node runtime version
18
+ platform os + arch
19
+
20
+ If you're not logged in, backend is reported as null with a reason.
21
+ Output is stable across releases — safe to grep in CI logs.`,
22
+
23
+ async run(_args, ctx) {
24
+ const cliVersion = pkg.version;
25
+ const node = process.version;
26
+ const platform = `${os.platform()} ${os.release()} (${process.arch})`;
27
+
28
+ let backend = null;
29
+ let backendError = null;
30
+ const cfg = ctx.cfgOrNull;
31
+ if (!cfg || !cfg.baseUrl) {
32
+ backendError = "not logged in";
33
+ } else {
34
+ try {
35
+ // install_status is public — no auth needed, no refresh races.
36
+ // Use the unauthenticated SynapseAPI so a 401 on /me/ doesn't
37
+ // muddy this purely informational call.
38
+ const { SynapseAPI } = require("../api");
39
+ const probe = new SynapseAPI({ baseUrl: cfg.baseUrl });
40
+ const status = await probe.request(
41
+ "GET",
42
+ "/v1/install_status",
43
+ undefined,
44
+ { auth: false },
45
+ );
46
+ backend = { url: cfg.baseUrl, version: status.version, firstRun: status.firstRun };
47
+ } catch (err) {
48
+ backendError = err && err.message ? err.message : String(err);
49
+ backend = { url: cfg.baseUrl, version: null };
50
+ }
51
+ }
52
+
53
+ const payload = {
54
+ cli: cliVersion,
55
+ backend,
56
+ backendError,
57
+ node,
58
+ platform,
59
+ };
60
+
61
+ ctx.out.result(payload, (d, { stdout }) => {
62
+ const widest = "platform".length;
63
+ const pad = (k) => k.padEnd(widest);
64
+ stdout.write(`${pad("cli")} ${d.cli}\n`);
65
+ if (d.backend) {
66
+ const v = d.backend.version ? `${d.backend.version}` : "(unknown)";
67
+ const reason = d.backendError ? ` — ${d.backendError}` : "";
68
+ stdout.write(`${pad("backend")} ${v}${reason}\n`);
69
+ stdout.write(`${pad("")} at ${d.backend.url}\n`);
70
+ } else {
71
+ stdout.write(`${pad("backend")} (not logged in)\n`);
72
+ }
73
+ stdout.write(`${pad("node")} ${d.node}\n`);
74
+ stdout.write(`${pad("platform")} ${d.platform}\n`);
75
+ });
76
+ },
77
+ };
@@ -0,0 +1,22 @@
1
+ // `synapse whoami` — confirm the saved session is alive against the
2
+ // backend. Hits /v1/me; if the access token is expired but the
3
+ // refresh token still works, the api proxy silently rotates the
4
+ // bundle transparently before reporting OK.
5
+
6
+ module.exports = {
7
+ name: "whoami",
8
+ summary: "Show the email + URL the saved session is authenticated for.",
9
+ usage: "synapse whoami",
10
+ description: `Calls /v1/me/ on the backend. Returns the user's display name + email + the base URL of the Synapse instance. Triggers silent-refresh under the hood if the access token has expired but the refresh token is still good.`,
11
+
12
+ async run(_args, ctx) {
13
+ const me = await ctx.api.me();
14
+ const email = me.email || me.user?.email || "(unknown email)";
15
+ const name = me.name || me.user?.name || "";
16
+ ctx.out.result(
17
+ { email, name, baseUrl: ctx.cfg.baseUrl },
18
+ ({ email, name, baseUrl }, { stdout }) =>
19
+ stdout.write(`${name ? `${name} ` : ""}<${email}> on ${baseUrl}\n`),
20
+ );
21
+ },
22
+ };
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
  };
@@ -0,0 +1,484 @@
1
+ // Doctor's check catalog. Each check is a pure-ish function that returns
2
+ // a CheckResult (or throws — the runner catches and converts to issue).
3
+ //
4
+ // The runner imports `ALL_CHECKS` (in execution order, respecting tier
5
+ // dependencies) and iterates. New checks should be added here with a
6
+ // stable id; --json consumers depend on the id surface staying stable
7
+ // across schemaVersion bumps.
8
+
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+ const os = require("node:os");
12
+ const { SynapseAPI, SynapseAPIError } = require("../api");
13
+ const { readProjectEnv } = require("../env-file");
14
+
15
+ const REQUIRED_NODE = "18.17.0";
16
+
17
+ // Compare semver-ish strings. Returns -1 / 0 / 1.
18
+ function cmpVer(a, b) {
19
+ const ap = a.replace(/^v/, "").split(".").map(Number);
20
+ const bp = b.replace(/^v/, "").split(".").map(Number);
21
+ for (let i = 0; i < 3; i += 1) {
22
+ const av = ap[i] || 0;
23
+ const bv = bp[i] || 0;
24
+ if (av !== bv) return av < bv ? -1 : 1;
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ // Wrap a check function so a thrown error doesn't crash the runner.
30
+ function safeRun(fn) {
31
+ return async (ctx) => {
32
+ const t0 = Date.now();
33
+ try {
34
+ const r = await fn(ctx);
35
+ return { ...r, durationMs: Date.now() - t0 };
36
+ } catch (err) {
37
+ return {
38
+ status: "issue",
39
+ summary: `unexpected error: ${err.message || String(err)}`,
40
+ data: { error: String(err.message || err) },
41
+ durationMs: Date.now() - t0,
42
+ };
43
+ }
44
+ };
45
+ }
46
+
47
+ // -------- local-env -------------------------------------------------
48
+
49
+ const checkNodeVersion = {
50
+ id: "node-version",
51
+ category: "local-env",
52
+ title: `Node.js >= ${REQUIRED_NODE}`,
53
+ autoFix: "never",
54
+ dependsOn: [],
55
+ run: safeRun(async () => {
56
+ const observed = process.version;
57
+ const ok = cmpVer(observed, REQUIRED_NODE) >= 0;
58
+ return {
59
+ status: ok ? "ok" : "issue",
60
+ summary: ok ? observed : `${observed} (need >= v${REQUIRED_NODE})`,
61
+ remediation: ok ? null : "Upgrade Node via nvm/volta/asdf.",
62
+ data: { observed, required: REQUIRED_NODE },
63
+ };
64
+ }),
65
+ };
66
+
67
+ const checkConfigFileMode = {
68
+ id: "home-config-readable",
69
+ category: "local-env",
70
+ title: "~/.synapse/config.json mode 0600",
71
+ autoFix: "auto",
72
+ dependsOn: [],
73
+ run: safeRun(async (ctx) => {
74
+ const cfg = ctx.cfgPath;
75
+ if (!fs.existsSync(cfg)) {
76
+ return {
77
+ status: "warn",
78
+ summary: "no saved session",
79
+ remediation: "Run `synapse login <url>` to authenticate.",
80
+ data: { path: cfg, exists: false },
81
+ };
82
+ }
83
+ const mode = fs.statSync(cfg).mode & 0o777;
84
+ const ok = mode === 0o600 || process.platform === "win32";
85
+ return {
86
+ status: ok ? "ok" : "warn",
87
+ summary: ok ? `${cfg} (mode 0600)` : `mode 0${mode.toString(8)} (expected 0600)`,
88
+ remediation: ok ? null : `chmod 600 ${cfg}`,
89
+ data: { path: cfg, mode: mode.toString(8) },
90
+ };
91
+ }),
92
+ // Auto-fix: chmod 600.
93
+ fix: async (ctx) => {
94
+ try {
95
+ fs.chmodSync(ctx.cfgPath, 0o600);
96
+ return { kind: "applied", message: "chmod 600 applied" };
97
+ } catch (err) {
98
+ return { kind: "failed", message: err.message };
99
+ }
100
+ },
101
+ };
102
+
103
+ // -------- project --------------------------------------------------
104
+
105
+ const checkInProjectDir = {
106
+ id: "in-project-dir",
107
+ category: "project",
108
+ title: ".synapse/project.json present",
109
+ autoFix: "never",
110
+ dependsOn: [],
111
+ run: safeRun(async (ctx) => {
112
+ const exists = ctx.projectConfig !== null && ctx.projectConfig !== undefined;
113
+ return {
114
+ status: exists ? "ok" : "warn",
115
+ summary: exists
116
+ ? `linked to ${ctx.projectConfig.project?.name || "?"}`
117
+ : "no project metadata in this directory",
118
+ remediation: exists ? null : "Run `synapse select` to link this directory.",
119
+ data: {
120
+ cwd: ctx.cwd,
121
+ linked: exists,
122
+ project: exists ? ctx.projectConfig.project?.id : null,
123
+ },
124
+ };
125
+ }),
126
+ };
127
+
128
+ const checkEnvLocalPresent = {
129
+ id: "env-local-present",
130
+ category: "project",
131
+ title: ".env.local exists",
132
+ autoFix: "never",
133
+ dependsOn: ["in-project-dir"],
134
+ run: safeRun(async (ctx) => {
135
+ if (!ctx.projectConfig) {
136
+ return { status: "skipped", summary: "no linked project", data: {} };
137
+ }
138
+ const p = path.join(ctx.cwd, ".env.local");
139
+ const exists = fs.existsSync(p);
140
+ return {
141
+ status: exists ? "ok" : "issue",
142
+ summary: exists ? p : "missing .env.local",
143
+ remediation: exists ? null : "Run `synapse select` — it writes .env.local.",
144
+ data: { path: p, exists },
145
+ };
146
+ }),
147
+ };
148
+
149
+ const checkEnvLocalHasVars = {
150
+ id: "env-local-has-self-hosted-vars",
151
+ category: "project",
152
+ title: "CONVEX_SELF_HOSTED_URL + ADMIN_KEY in .env.local",
153
+ autoFix: "never",
154
+ dependsOn: ["env-local-present"],
155
+ run: safeRun(async (ctx) => {
156
+ if (!ctx.projectConfig) {
157
+ return { status: "skipped", summary: "no linked project", data: {} };
158
+ }
159
+ const env = readProjectEnv(ctx.cwd);
160
+ const hasUrl = !!env.CONVEX_SELF_HOSTED_URL;
161
+ const hasKey = !!env.CONVEX_SELF_HOSTED_ADMIN_KEY;
162
+ if (hasUrl && hasKey) {
163
+ return {
164
+ status: "ok",
165
+ summary: "both vars present",
166
+ data: { hasUrl, hasKey },
167
+ };
168
+ }
169
+ return {
170
+ status: "issue",
171
+ summary: !hasUrl && !hasKey ? "both vars missing" : hasUrl ? "admin key missing" : "URL missing",
172
+ remediation: "Run `synapse select` to rewrite .env.local.",
173
+ data: { hasUrl, hasKey },
174
+ };
175
+ }),
176
+ };
177
+
178
+ const checkGitignoreProtectsEnv = {
179
+ id: "gitignore-protects-env",
180
+ category: "project",
181
+ title: ".gitignore protects .env.local and .synapse/",
182
+ autoFix: "auto",
183
+ dependsOn: [],
184
+ run: safeRun(async (ctx) => {
185
+ const p = path.join(ctx.cwd, ".gitignore");
186
+ if (!fs.existsSync(p)) {
187
+ return {
188
+ status: "warn",
189
+ summary: "no .gitignore in this directory",
190
+ remediation: "Create .gitignore with `.env.local` and `.synapse/` entries.",
191
+ data: { exists: false },
192
+ };
193
+ }
194
+ const content = fs.readFileSync(p, "utf8");
195
+ const hasEnv = /^\.env\.local\s*$/m.test(content) || /^\.env\*\.local\s*$/m.test(content);
196
+ const hasSyn = /^\.synapse\//m.test(content) || /^\.synapse\s*$/m.test(content);
197
+ if (hasEnv && hasSyn) {
198
+ return { status: "ok", summary: "both ignored", data: { hasEnv, hasSyn } };
199
+ }
200
+ const missing = [];
201
+ if (!hasEnv) missing.push(".env.local");
202
+ if (!hasSyn) missing.push(".synapse/");
203
+ return {
204
+ status: "warn",
205
+ summary: `missing entries: ${missing.join(", ")}`,
206
+ remediation: `Append to .gitignore: ${missing.join(" ")}`,
207
+ data: { hasEnv, hasSyn, missing },
208
+ };
209
+ }),
210
+ fix: async (ctx) => {
211
+ const p = path.join(ctx.cwd, ".gitignore");
212
+ let content = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
213
+ if (content && !content.endsWith("\n")) content += "\n";
214
+ const toAppend = [];
215
+ if (!/^\.env\.local\s*$/m.test(content) && !/^\.env\*\.local\s*$/m.test(content)) {
216
+ toAppend.push(".env.local");
217
+ }
218
+ if (!/^\.synapse\//m.test(content) && !/^\.synapse\s*$/m.test(content)) {
219
+ toAppend.push(".synapse/");
220
+ }
221
+ if (toAppend.length === 0) {
222
+ return { kind: "skipped", message: "already protected" };
223
+ }
224
+ content += "# added by `synapse doctor --fix`\n" + toAppend.join("\n") + "\n";
225
+ fs.writeFileSync(p, content);
226
+ return { kind: "applied", message: `appended ${toAppend.join(" + ")}` };
227
+ },
228
+ };
229
+
230
+ const checkNoShellConvexDeployment = {
231
+ id: "no-shell-convex-deployment",
232
+ category: "project",
233
+ title: "shell does NOT export CONVEX_DEPLOYMENT",
234
+ autoFix: "never",
235
+ dependsOn: [],
236
+ run: safeRun(async (ctx) => {
237
+ const v = ctx.env.CONVEX_DEPLOYMENT;
238
+ if (!v) return { status: "ok", summary: "unset", data: {} };
239
+ return {
240
+ status: "warn",
241
+ summary: `set to "${v}" — overrides .env.local`,
242
+ remediation: "Unset CONVEX_DEPLOYMENT in your shell rc, or use `synapse dev` which strips it.",
243
+ data: { value: v },
244
+ };
245
+ }),
246
+ };
247
+
248
+ // -------- backend --------------------------------------------------
249
+
250
+ const checkBackendReachable = {
251
+ id: "backend-reachable",
252
+ category: "backend",
253
+ title: "Synapse backend reachable",
254
+ autoFix: "never",
255
+ dependsOn: [],
256
+ run: safeRun(async (ctx) => {
257
+ if (!ctx.cfg) {
258
+ return {
259
+ status: "skipped",
260
+ summary: "not logged in",
261
+ remediation: "Run `synapse login <url>`.",
262
+ data: {},
263
+ };
264
+ }
265
+ const t0 = Date.now();
266
+ const probe = new SynapseAPI({ baseUrl: ctx.cfg.baseUrl });
267
+ try {
268
+ const status = await probe.request("GET", "/v1/install_status", undefined, {
269
+ auth: false,
270
+ });
271
+ const latency = Date.now() - t0;
272
+ return {
273
+ status: "ok",
274
+ summary: `${ctx.cfg.baseUrl} (v${status.version}, ${latency}ms)`,
275
+ data: {
276
+ baseUrl: ctx.cfg.baseUrl,
277
+ version: status.version,
278
+ firstRun: status.firstRun,
279
+ latencyMs: latency,
280
+ },
281
+ };
282
+ } catch (err) {
283
+ return {
284
+ status: "issue",
285
+ summary: `unreachable — ${err.message || String(err)}`,
286
+ remediation: `Check VPN/firewall. Try \`curl ${ctx.cfg.baseUrl}/v1/install_status\` from this machine.`,
287
+ data: { baseUrl: ctx.cfg.baseUrl, error: String(err.message || err) },
288
+ };
289
+ }
290
+ }),
291
+ };
292
+
293
+ const checkAuthTokenValid = {
294
+ id: "auth-token-valid",
295
+ category: "backend",
296
+ title: "session valid",
297
+ autoFix: "never",
298
+ dependsOn: ["backend-reachable"],
299
+ run: safeRun(async (ctx) => {
300
+ if (!ctx.cfg || !ctx.api) {
301
+ return { status: "skipped", summary: "not logged in", data: {} };
302
+ }
303
+ try {
304
+ const me = await ctx.api.me();
305
+ return {
306
+ status: "ok",
307
+ summary: me.email || me.user?.email || "(unknown email)",
308
+ data: { email: me.email || me.user?.email },
309
+ };
310
+ } catch (err) {
311
+ const code = err instanceof SynapseAPIError ? err.code : "unknown";
312
+ return {
313
+ status: "issue",
314
+ summary: `auth failed (${code})`,
315
+ remediation: "Run `synapse login <url>` again.",
316
+ data: { code, message: err.message },
317
+ };
318
+ }
319
+ }),
320
+ };
321
+
322
+ const checkProjectStillExists = {
323
+ id: "project-still-exists",
324
+ category: "backend",
325
+ title: "linked project exists on backend",
326
+ autoFix: "never",
327
+ dependsOn: ["auth-token-valid", "in-project-dir"],
328
+ run: safeRun(async (ctx) => {
329
+ if (!ctx.projectConfig || !ctx.api) {
330
+ return { status: "skipped", summary: "no linked project or no session", data: {} };
331
+ }
332
+ const teamRef = ctx.projectConfig.team?.slug || ctx.projectConfig.team?.id;
333
+ if (!teamRef) {
334
+ return { status: "warn", summary: "linked project has no team ref", data: {} };
335
+ }
336
+ try {
337
+ const projects = await ctx.api.projects(teamRef);
338
+ const found = projects.find((p) => p.id === ctx.projectConfig.project?.id);
339
+ if (found) {
340
+ return {
341
+ status: "ok",
342
+ summary: `${found.name} (${found.slug})`,
343
+ data: { id: found.id },
344
+ };
345
+ }
346
+ return {
347
+ status: "issue",
348
+ summary: "project not found in team — deleted or transferred?",
349
+ remediation: "Run `synapse select` to re-link.",
350
+ data: { teamRef, projectId: ctx.projectConfig.project?.id },
351
+ };
352
+ } catch (err) {
353
+ return {
354
+ status: "issue",
355
+ summary: `lookup failed: ${err.message}`,
356
+ remediation: "Check membership; project may have been deleted.",
357
+ data: { error: err.message },
358
+ };
359
+ }
360
+ }),
361
+ };
362
+
363
+ // -------- deployments ----------------------------------------------
364
+
365
+ function isBrowserReachable(url) {
366
+ if (!url) return false;
367
+ let u;
368
+ try {
369
+ u = new URL(url);
370
+ } catch {
371
+ return false;
372
+ }
373
+ const STANDARD = new Set(["", "443", "80", "6791"]);
374
+ if (STANDARD.has(u.port)) return true;
375
+ return u.hostname === "localhost" || u.hostname === "127.0.0.1";
376
+ }
377
+
378
+ function makeDeploymentCheck(target) {
379
+ return {
380
+ id: `deployment-${target}-health`,
381
+ category: "deployments",
382
+ title: `${target} deployment health`,
383
+ autoFix: "never",
384
+ dependsOn: ["project-still-exists"],
385
+ run: safeRun(async (ctx) => {
386
+ if (!ctx.projectConfig || !ctx.api) {
387
+ return { status: "skipped", summary: "no project or session", data: {} };
388
+ }
389
+ const ref = ctx.projectConfig.deployments?.[target];
390
+ if (!ref || !ref.name) {
391
+ return {
392
+ status: target === "dev" ? "issue" : "warn",
393
+ summary: "no deployment saved",
394
+ remediation: `Run \`synapse select\` and pick a ${target} deployment.`,
395
+ data: { target, saved: false },
396
+ };
397
+ }
398
+ let auth;
399
+ try {
400
+ auth = await ctx.api.cliCredentials(ref.name);
401
+ } catch (err) {
402
+ return {
403
+ status: "issue",
404
+ summary: `${ref.name}: credentials fetch failed (${err.message})`,
405
+ remediation: "Backend may be down, or the deployment was deleted.",
406
+ data: { name: ref.name, error: err.message },
407
+ };
408
+ }
409
+ const reachable = isBrowserReachable(auth.convexUrl);
410
+ // Probe /version on the deployment (it's a public endpoint on the
411
+ // Convex backend container). Cheap signal that the URL responds.
412
+ let probeOk = false;
413
+ let probeError = null;
414
+ let probeLatencyMs = null;
415
+ if (reachable) {
416
+ const t0 = Date.now();
417
+ try {
418
+ const ac = new AbortController();
419
+ const timeout = setTimeout(() => ac.abort(), 3500);
420
+ const res = await fetch(auth.convexUrl + "/version", {
421
+ signal: ac.signal,
422
+ });
423
+ clearTimeout(timeout);
424
+ probeOk = res.ok;
425
+ probeLatencyMs = Date.now() - t0;
426
+ } catch (err) {
427
+ probeError = err.name === "AbortError" ? "timeout (>3.5s)" : err.message;
428
+ }
429
+ }
430
+ const data = {
431
+ name: ref.name,
432
+ url: auth.convexUrl,
433
+ browserReachable: reachable,
434
+ probeOk,
435
+ probeError,
436
+ probeLatencyMs,
437
+ };
438
+ if (!reachable) {
439
+ return {
440
+ status: "issue",
441
+ summary: `${ref.name}: URL not browser-reachable (${auth.convexUrl})`,
442
+ remediation:
443
+ "Set SYNAPSE_BASE_DOMAIN on the server (wildcard subdomain) OR add a custom domain to this deployment.",
444
+ data,
445
+ };
446
+ }
447
+ if (!probeOk) {
448
+ return {
449
+ status: "warn",
450
+ summary: `${ref.name}: URL reachable but /version probe failed (${probeError ?? "no body"})`,
451
+ remediation: "TLS may still be provisioning. Retry in 30s.",
452
+ data,
453
+ };
454
+ }
455
+ return {
456
+ status: "ok",
457
+ summary: `${ref.name} — ${auth.convexUrl} (${probeLatencyMs}ms)`,
458
+ data,
459
+ };
460
+ }),
461
+ };
462
+ }
463
+
464
+ const ALL_CHECKS = [
465
+ // Tier A
466
+ checkNodeVersion,
467
+ checkConfigFileMode,
468
+ checkInProjectDir,
469
+ checkNoShellConvexDeployment,
470
+ checkGitignoreProtectsEnv,
471
+ checkBackendReachable,
472
+
473
+ // Tier B
474
+ checkAuthTokenValid,
475
+ checkEnvLocalPresent,
476
+ checkEnvLocalHasVars,
477
+ checkProjectStillExists,
478
+
479
+ // Tier C (per deployment)
480
+ makeDeploymentCheck("dev"),
481
+ makeDeploymentCheck("prod"),
482
+ ];
483
+
484
+ module.exports = { ALL_CHECKS, isBrowserReachable, cmpVer };