@iann29/synapse 1.8.0 → 1.8.2
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/lib/api.js +7 -0
- package/lib/commands/doctor.js +5 -1
- package/lib/commands/open.js +50 -3
- package/lib/commands/select.js +5 -1
- package/lib/doctor/checks.js +184 -10
- package/lib/env-file.js +168 -26
- package/lib/project.js +8 -0
- package/package.json +1 -1
package/lib/api.js
CHANGED
|
@@ -117,6 +117,13 @@ class SynapseAPI {
|
|
|
117
117
|
return this.listAll(`/v1/teams/${encodeURIComponent(teamRef)}/list_projects`);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// Single-project lookup by ID. Cheaper than listing a team's projects
|
|
121
|
+
// (no pagination) and FK cascade team→projects means this also detects
|
|
122
|
+
// a deleted team — backend returns 404 in both cases.
|
|
123
|
+
getProject(projectId) {
|
|
124
|
+
return this.request("GET", `/v1/projects/${encodeURIComponent(projectId)}/`);
|
|
125
|
+
}
|
|
126
|
+
|
|
120
127
|
deployments(projectId) {
|
|
121
128
|
return this.listAll(`/v1/projects/${encodeURIComponent(projectId)}/list_deployments`);
|
|
122
129
|
}
|
package/lib/commands/doctor.js
CHANGED
|
@@ -39,7 +39,11 @@ module.exports = {
|
|
|
39
39
|
Flags:
|
|
40
40
|
--fix apply safe fixes automatically (chmod, gitignore appends, etc).
|
|
41
41
|
Checks marked 'prompt' require --yes too.
|
|
42
|
-
--yes upgrade 'prompt' fixes to auto.
|
|
42
|
+
--yes upgrade 'prompt' fixes to auto. Combined with --fix, also
|
|
43
|
+
cleans up a stale .synapse/project.json — re-links if the
|
|
44
|
+
project was transferred to another of your teams,
|
|
45
|
+
otherwise marks the entry stale so \`synapse select\` can
|
|
46
|
+
start fresh.
|
|
43
47
|
--verbose show detailed data per check.
|
|
44
48
|
--json stable schema (current: v${SCHEMA_VERSION}); CI-friendly.
|
|
45
49
|
|
package/lib/commands/open.js
CHANGED
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
// deployment <n> /embed/<name> — the dashboard with Convex Dashboard iframe
|
|
10
10
|
// url just the synapse base URL (for "open the dashboard root")
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// For `dashboard` (the default), we do a single cheap GET probe against
|
|
13
|
+
// the linked project before spawning the browser, so an operator with a
|
|
14
|
+
// stale .synapse/project.json gets a warning on stderr instead of
|
|
15
|
+
// landing on a page that cascades "Failed to load X" errors. We never
|
|
16
|
+
// BLOCK the launch — operator may want to see the broken state.
|
|
17
|
+
// `docs` / `deployment <name>` / `url` skip the probe.
|
|
14
18
|
|
|
15
19
|
const { spawn } = require("node:child_process");
|
|
20
|
+
const { SynapseAPIError } = require("../api");
|
|
16
21
|
|
|
17
22
|
function buildUrl(target, restArgs, { cfg, projectConfig }) {
|
|
18
23
|
const base = cfg?.baseUrl ?? "";
|
|
@@ -50,6 +55,29 @@ function launcher(platform = process.platform) {
|
|
|
50
55
|
return { cmd: "xdg-open", shell: false };
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
// Cheap pre-flight probe: confirm the linked project still exists before
|
|
59
|
+
// launching the browser. Returns one of:
|
|
60
|
+
// "ok" — project resolved on the backend
|
|
61
|
+
// "not_found" — backend returned 404 (project deleted or transferred)
|
|
62
|
+
// "unverified" — couldn't reach backend / non-404 error / no session /
|
|
63
|
+
// no linked project
|
|
64
|
+
// Only `dashboard` target calls this — `docs` is external, `deployment
|
|
65
|
+
// <name>` and `url` are operator-supplied + assumed intentional.
|
|
66
|
+
async function checkProjectStatus(ctx, projectConfig) {
|
|
67
|
+
if (!projectConfig?.project?.id) return "unverified";
|
|
68
|
+
if (!ctx?.cfgOrNull?.accessToken) return "unverified";
|
|
69
|
+
if (!ctx.api) return "unverified";
|
|
70
|
+
try {
|
|
71
|
+
await ctx.api.getProject(projectConfig.project.id);
|
|
72
|
+
return "ok";
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err instanceof SynapseAPIError && err.status === 404) {
|
|
75
|
+
return "not_found";
|
|
76
|
+
}
|
|
77
|
+
return "unverified";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
module.exports = {
|
|
54
82
|
name: "open",
|
|
55
83
|
summary: "Open a Synapse-related URL in your default browser.",
|
|
@@ -65,6 +93,7 @@ Targets:
|
|
|
65
93
|
// Exports for tests.
|
|
66
94
|
buildUrl,
|
|
67
95
|
launcher,
|
|
96
|
+
checkProjectStatus,
|
|
68
97
|
|
|
69
98
|
async run(args, ctx) {
|
|
70
99
|
const target = args[0];
|
|
@@ -73,11 +102,29 @@ Targets:
|
|
|
73
102
|
projectConfig: ctx.projectConfig,
|
|
74
103
|
});
|
|
75
104
|
|
|
105
|
+
// Pre-flight only for dashboard (default + explicit). Other targets
|
|
106
|
+
// either point externally or are intentionally URL-direct.
|
|
107
|
+
const shouldProbe = target === undefined || target === "dashboard";
|
|
108
|
+
let projectStatus = "unverified";
|
|
109
|
+
if (shouldProbe && ctx.projectConfig?.project?.id) {
|
|
110
|
+
projectStatus = await checkProjectStatus(ctx, ctx.projectConfig);
|
|
111
|
+
}
|
|
112
|
+
|
|
76
113
|
if (ctx.out.json) {
|
|
77
|
-
ctx.out.result({ url, target: target ?? "dashboard" }, () => {});
|
|
114
|
+
ctx.out.result({ url, target: target ?? "dashboard", projectStatus }, () => {});
|
|
78
115
|
return;
|
|
79
116
|
}
|
|
80
117
|
|
|
118
|
+
if (projectStatus === "not_found") {
|
|
119
|
+
const projName = ctx.projectConfig?.project?.name || ctx.projectConfig?.project?.id;
|
|
120
|
+
const base = ctx.cfgOrNull?.baseUrl ?? "";
|
|
121
|
+
ctx.out.warn(
|
|
122
|
+
`Linked project ${projName} was not found on ${base}. It may have been deleted. Run \`synapse select\` to relink.`,
|
|
123
|
+
);
|
|
124
|
+
} else if (shouldProbe && projectStatus === "unverified" && ctx.projectConfig?.project?.id && ctx.cfgOrNull?.accessToken) {
|
|
125
|
+
ctx.out.info("Could not verify project state (offline?), opening anyway.");
|
|
126
|
+
}
|
|
127
|
+
|
|
81
128
|
const { cmd, shell } = launcher();
|
|
82
129
|
ctx.out.info(`Opening ${url}`);
|
|
83
130
|
try {
|
package/lib/commands/select.js
CHANGED
|
@@ -195,7 +195,11 @@ show up in the menu).`,
|
|
|
195
195
|
}),
|
|
196
196
|
);
|
|
197
197
|
const creds = await api.cliCredentials(dev.name);
|
|
198
|
-
const envPath = writeProjectEnv(ctx.cwd, creds
|
|
198
|
+
const envPath = writeProjectEnv(ctx.cwd, creds, {
|
|
199
|
+
team: { slug: team.slug, name: team.name },
|
|
200
|
+
project: { slug: project.slug, name: project.name },
|
|
201
|
+
target: "dev",
|
|
202
|
+
});
|
|
199
203
|
|
|
200
204
|
ctx.out.result(
|
|
201
205
|
{
|
package/lib/doctor/checks.js
CHANGED
|
@@ -11,6 +11,7 @@ const path = require("node:path");
|
|
|
11
11
|
const os = require("node:os");
|
|
12
12
|
const { SynapseAPI, SynapseAPIError } = require("../api");
|
|
13
13
|
const { readProjectEnv } = require("../env-file");
|
|
14
|
+
const { writeProjectConfig } = require("../project");
|
|
14
15
|
|
|
15
16
|
const REQUIRED_NODE = "18.17.0";
|
|
16
17
|
|
|
@@ -110,16 +111,40 @@ const checkInProjectDir = {
|
|
|
110
111
|
dependsOn: [],
|
|
111
112
|
run: safeRun(async (ctx) => {
|
|
112
113
|
const exists = ctx.projectConfig !== null && ctx.projectConfig !== undefined;
|
|
114
|
+
if (!exists) {
|
|
115
|
+
return {
|
|
116
|
+
status: "warn",
|
|
117
|
+
summary: "no project metadata in this directory",
|
|
118
|
+
remediation: "Run `synapse select` to link this directory.",
|
|
119
|
+
data: { cwd: ctx.cwd, linked: false, project: null },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// v1.8.1: `doctor --fix --yes` may have written a stale marker into
|
|
123
|
+
// project.json. Detect it explicitly so the next doctor run says
|
|
124
|
+
// "linked-but-stale" instead of confidently claiming a healthy link.
|
|
125
|
+
if (ctx.projectConfig.staleReason === "project-not-found") {
|
|
126
|
+
const date = ctx.projectConfig.staleAt
|
|
127
|
+
? ctx.projectConfig.staleAt.slice(0, 10)
|
|
128
|
+
: "previously";
|
|
129
|
+
return {
|
|
130
|
+
status: "warn",
|
|
131
|
+
summary: `directory was unlinked by doctor — staleReason: project-not-found (${date})`,
|
|
132
|
+
remediation: "Run `synapse select` to re-link.",
|
|
133
|
+
data: {
|
|
134
|
+
cwd: ctx.cwd,
|
|
135
|
+
linked: false,
|
|
136
|
+
stale: true,
|
|
137
|
+
previous: ctx.projectConfig.previous ?? null,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
113
141
|
return {
|
|
114
|
-
status:
|
|
115
|
-
summary:
|
|
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.",
|
|
142
|
+
status: "ok",
|
|
143
|
+
summary: `linked to ${ctx.projectConfig.project?.name || "?"}`,
|
|
119
144
|
data: {
|
|
120
145
|
cwd: ctx.cwd,
|
|
121
|
-
linked:
|
|
122
|
-
project:
|
|
146
|
+
linked: true,
|
|
147
|
+
project: ctx.projectConfig.project?.id,
|
|
123
148
|
},
|
|
124
149
|
};
|
|
125
150
|
}),
|
|
@@ -175,6 +200,44 @@ const checkEnvLocalHasVars = {
|
|
|
175
200
|
}),
|
|
176
201
|
};
|
|
177
202
|
|
|
203
|
+
// v1.8.2: separate check for the Cloud-compatible NEXT_PUBLIC_* vars.
|
|
204
|
+
// Self-hosted auth (above) is REQUIRED. These public vars are
|
|
205
|
+
// convenience for code that imports CONVEX_URL the way Cloud
|
|
206
|
+
// tutorials do — missing them only warns, never blocks (the CLI
|
|
207
|
+
// still works without them).
|
|
208
|
+
const checkEnvLocalHasPublicVars = {
|
|
209
|
+
id: "env-local-has-public-convex-vars",
|
|
210
|
+
category: "project",
|
|
211
|
+
title: "NEXT_PUBLIC_CONVEX_URL + NEXT_PUBLIC_CONVEX_SITE_URL in .env.local",
|
|
212
|
+
autoFix: "never",
|
|
213
|
+
dependsOn: ["env-local-present"],
|
|
214
|
+
run: safeRun(async (ctx) => {
|
|
215
|
+
if (!ctx.projectConfig) {
|
|
216
|
+
return { status: "skipped", summary: "no linked project", data: {} };
|
|
217
|
+
}
|
|
218
|
+
const env = readProjectEnv(ctx.cwd);
|
|
219
|
+
const hasUrl = !!env.NEXT_PUBLIC_CONVEX_URL;
|
|
220
|
+
const hasSite = !!env.NEXT_PUBLIC_CONVEX_SITE_URL;
|
|
221
|
+
if (hasUrl && hasSite) {
|
|
222
|
+
return {
|
|
223
|
+
status: "ok",
|
|
224
|
+
summary: "both public vars present",
|
|
225
|
+
data: { hasUrl, hasSite },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const missing = [];
|
|
229
|
+
if (!hasUrl) missing.push("NEXT_PUBLIC_CONVEX_URL");
|
|
230
|
+
if (!hasSite) missing.push("NEXT_PUBLIC_CONVEX_SITE_URL");
|
|
231
|
+
return {
|
|
232
|
+
status: "warn",
|
|
233
|
+
summary: `missing: ${missing.join(", ")} (Cloud-style vars; CLI still works)`,
|
|
234
|
+
remediation:
|
|
235
|
+
"Run `synapse select` to regenerate .env.local with Cloud-compatible vars (CLI v1.8.2+).",
|
|
236
|
+
data: { hasUrl, hasSite, missing },
|
|
237
|
+
};
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
|
|
178
241
|
const checkGitignoreProtectsEnv = {
|
|
179
242
|
id: "gitignore-protects-env",
|
|
180
243
|
category: "project",
|
|
@@ -323,12 +386,21 @@ const checkProjectStillExists = {
|
|
|
323
386
|
id: "project-still-exists",
|
|
324
387
|
category: "backend",
|
|
325
388
|
title: "linked project exists on backend",
|
|
326
|
-
|
|
389
|
+
// v1.8.1: was "never" — promoted to "prompt" so `doctor --fix --yes`
|
|
390
|
+
// can auto-remediate stale .synapse/project.json. See Bug 3 in
|
|
391
|
+
// docs/V1_8_1_STALE_LINK_FIXES.md for the design (B-then-C hybrid).
|
|
392
|
+
autoFix: "prompt",
|
|
327
393
|
dependsOn: ["auth-token-valid", "in-project-dir"],
|
|
328
394
|
run: safeRun(async (ctx) => {
|
|
329
395
|
if (!ctx.projectConfig || !ctx.api) {
|
|
330
396
|
return { status: "skipped", summary: "no linked project or no session", data: {} };
|
|
331
397
|
}
|
|
398
|
+
// The marker case (stale link written by a prior --fix) has no
|
|
399
|
+
// project.id to look up — checkInProjectDir already warned about
|
|
400
|
+
// it. Skip the network call.
|
|
401
|
+
if (!ctx.projectConfig.project?.id) {
|
|
402
|
+
return { status: "skipped", summary: "no project id (stale marker?)", data: {} };
|
|
403
|
+
}
|
|
332
404
|
const teamRef = ctx.projectConfig.team?.slug || ctx.projectConfig.team?.id;
|
|
333
405
|
if (!teamRef) {
|
|
334
406
|
return { status: "warn", summary: "linked project has no team ref", data: {} };
|
|
@@ -346,8 +418,13 @@ const checkProjectStillExists = {
|
|
|
346
418
|
return {
|
|
347
419
|
status: "issue",
|
|
348
420
|
summary: "project not found in team — deleted or transferred?",
|
|
349
|
-
remediation: "Run `synapse select` to re-link
|
|
350
|
-
data: {
|
|
421
|
+
remediation: "Run `synapse select` to re-link, or `synapse doctor --fix --yes`.",
|
|
422
|
+
data: {
|
|
423
|
+
teamRef,
|
|
424
|
+
projectId: ctx.projectConfig.project?.id,
|
|
425
|
+
teamSlug: ctx.projectConfig.team?.slug,
|
|
426
|
+
projectSlug: ctx.projectConfig.project?.slug,
|
|
427
|
+
},
|
|
351
428
|
};
|
|
352
429
|
} catch (err) {
|
|
353
430
|
return {
|
|
@@ -358,6 +435,102 @@ const checkProjectStillExists = {
|
|
|
358
435
|
};
|
|
359
436
|
}
|
|
360
437
|
}),
|
|
438
|
+
// Two-phase fix (Bug 3 design):
|
|
439
|
+
// B) If exactly one other team owns a project with the same slug,
|
|
440
|
+
// auto-relink (project was transferred). Deployments are reset
|
|
441
|
+
// because the old refs are stale — operator runs `synapse
|
|
442
|
+
// select` once if they want specific dev/prod refs.
|
|
443
|
+
// C) Otherwise (no match, ambiguous match, or any API error):
|
|
444
|
+
// mark project.json as stale and keep the operator's previous
|
|
445
|
+
// block for forensics. Append an idempotent comment marker to
|
|
446
|
+
// .env.local so the bogus admin key isn't silently trusted.
|
|
447
|
+
// Both paths are reachable only under `--fix --yes` (autoFix=prompt
|
|
448
|
+
// + allowPrompt=true at runner.js applyAutoFixes).
|
|
449
|
+
fix: async (ctx) => {
|
|
450
|
+
if (!ctx.projectConfig || !ctx.api) {
|
|
451
|
+
return { kind: "failed", message: "no project config or no API session" };
|
|
452
|
+
}
|
|
453
|
+
const savedProjectId = ctx.projectConfig.project?.id;
|
|
454
|
+
const savedProjectSlug = ctx.projectConfig.project?.slug;
|
|
455
|
+
const previous = {
|
|
456
|
+
team: ctx.projectConfig.team,
|
|
457
|
+
project: ctx.projectConfig.project,
|
|
458
|
+
};
|
|
459
|
+
// Fresh listing — never trust the upstream check's stale data.
|
|
460
|
+
let teams;
|
|
461
|
+
try {
|
|
462
|
+
teams = await ctx.api.teams();
|
|
463
|
+
} catch (err) {
|
|
464
|
+
return { kind: "failed", message: `could not list teams: ${err.message}` };
|
|
465
|
+
}
|
|
466
|
+
const candidates = [];
|
|
467
|
+
if (savedProjectSlug) {
|
|
468
|
+
for (const team of teams) {
|
|
469
|
+
let projects;
|
|
470
|
+
try {
|
|
471
|
+
projects = await ctx.api.projects(team.slug || team.id);
|
|
472
|
+
} catch {
|
|
473
|
+
continue; // one team's lookup failed; try the rest
|
|
474
|
+
}
|
|
475
|
+
for (const p of projects) {
|
|
476
|
+
if (p.slug === savedProjectSlug && p.id !== savedProjectId) {
|
|
477
|
+
candidates.push({ team, project: p });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (candidates.length === 1) {
|
|
483
|
+
// Heuristic B: unambiguous re-link (most likely a transfer).
|
|
484
|
+
const { team, project } = candidates[0];
|
|
485
|
+
const newConfig = {
|
|
486
|
+
synapseUrl: ctx.projectConfig.synapseUrl,
|
|
487
|
+
team,
|
|
488
|
+
project,
|
|
489
|
+
deployments: {},
|
|
490
|
+
};
|
|
491
|
+
writeProjectConfig(ctx.cwd, newConfig);
|
|
492
|
+
// Sync in-memory ctx so the runner's recheck sees the new state.
|
|
493
|
+
// Without this, `Object.assign(r, fresh, {fixedBy})` overwrites
|
|
494
|
+
// with another "issue" because run() still reads the old project.id.
|
|
495
|
+
ctx.projectConfig = newConfig;
|
|
496
|
+
return {
|
|
497
|
+
kind: "applied",
|
|
498
|
+
message: `re-linked to ${team.slug || team.name}/${project.slug || project.name} (project was transferred)`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
// Fallback C: write a stale marker. Keep synapseUrl + the previous
|
|
502
|
+
// block so the operator can audit what was there.
|
|
503
|
+
const staleAt = new Date().toISOString();
|
|
504
|
+
const staleConfig = {
|
|
505
|
+
synapseUrl: ctx.projectConfig.synapseUrl,
|
|
506
|
+
staleReason: "project-not-found",
|
|
507
|
+
staleAt,
|
|
508
|
+
previous,
|
|
509
|
+
};
|
|
510
|
+
writeProjectConfig(ctx.cwd, staleConfig);
|
|
511
|
+
ctx.projectConfig = staleConfig;
|
|
512
|
+
// Idempotent comment marker on .env.local. The admin key inside is
|
|
513
|
+
// still bogus, but deletion would lose info the operator may want
|
|
514
|
+
// to grep, so just annotate. Marker string is stable so re-running
|
|
515
|
+
// fix doesn't keep appending.
|
|
516
|
+
try {
|
|
517
|
+
const envPath = path.join(ctx.cwd, ".env.local");
|
|
518
|
+
if (fs.existsSync(envPath)) {
|
|
519
|
+
const content = fs.readFileSync(envPath, "utf8");
|
|
520
|
+
const marker = "# stale — admin key invalid";
|
|
521
|
+
if (!content.includes(marker)) {
|
|
522
|
+
const banner = `${marker} since ${staleAt.slice(0, 10)}, run \`synapse select\`\n`;
|
|
523
|
+
fs.writeFileSync(envPath, banner + content);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
} catch {
|
|
527
|
+
// Best-effort; project.json marker is the source of truth.
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
kind: "applied",
|
|
531
|
+
message: "marked stale — run `synapse select` to re-link",
|
|
532
|
+
};
|
|
533
|
+
},
|
|
361
534
|
};
|
|
362
535
|
|
|
363
536
|
// -------- deployments ----------------------------------------------
|
|
@@ -474,6 +647,7 @@ const ALL_CHECKS = [
|
|
|
474
647
|
checkAuthTokenValid,
|
|
475
648
|
checkEnvLocalPresent,
|
|
476
649
|
checkEnvLocalHasVars,
|
|
650
|
+
checkEnvLocalHasPublicVars,
|
|
477
651
|
checkProjectStillExists,
|
|
478
652
|
|
|
479
653
|
// Tier C (per deployment)
|
package/lib/env-file.js
CHANGED
|
@@ -1,9 +1,48 @@
|
|
|
1
|
+
// Writes / reads `.env.local` for Synapse-linked projects.
|
|
2
|
+
//
|
|
3
|
+
// As of v1.8.2 the file is drop-in compatible with Convex Cloud
|
|
4
|
+
// tutorials:
|
|
5
|
+
//
|
|
6
|
+
// # Convex (Synapse self-hosted — drop-in compatible with Cloud tutorials)
|
|
7
|
+
// NEXT_PUBLIC_CONVEX_URL="https://<name>.app.synapsepanel.com"
|
|
8
|
+
// NEXT_PUBLIC_CONVEX_SITE_URL="https://<name>.app.synapsepanel.com"
|
|
9
|
+
// CONVEX_DEPLOYMENT=dev:<name> # team: <team>, project: <project>
|
|
10
|
+
//
|
|
11
|
+
// # Self-hosted auth (Synapse cannot use Cloud account session)
|
|
12
|
+
// CONVEX_SELF_HOSTED_URL="https://<name>.app.synapsepanel.com"
|
|
13
|
+
// CONVEX_SELF_HOSTED_ADMIN_KEY="<name>|..."
|
|
14
|
+
//
|
|
15
|
+
// In Cloud, NEXT_PUBLIC_CONVEX_URL points at `.convex.cloud` and
|
|
16
|
+
// NEXT_PUBLIC_CONVEX_SITE_URL at `.convex.site` — two different
|
|
17
|
+
// origins. In self-hosted both point at the same URL; the backend
|
|
18
|
+
// container routes API calls and HTTP actions on the same host.
|
|
19
|
+
//
|
|
20
|
+
// CONVEX_DEPLOYMENT is kept uncommented for cosmetic familiarity —
|
|
21
|
+
// the synapse wrapper around `npx convex` (`runConvex` in
|
|
22
|
+
// lib/convex.js) deletes it from the child env when self-hosted
|
|
23
|
+
// vars are present, so it never accidentally triggers Cloud auth.
|
|
24
|
+
|
|
1
25
|
const fs = require("node:fs");
|
|
2
26
|
const path = require("node:path");
|
|
3
27
|
|
|
4
28
|
const SELF_HOSTED_URL = "CONVEX_SELF_HOSTED_URL";
|
|
5
29
|
const SELF_HOSTED_ADMIN_KEY = "CONVEX_SELF_HOSTED_ADMIN_KEY";
|
|
6
30
|
const CONVEX_DEPLOYMENT = "CONVEX_DEPLOYMENT";
|
|
31
|
+
const NEXT_PUBLIC_CONVEX_URL = "NEXT_PUBLIC_CONVEX_URL";
|
|
32
|
+
const NEXT_PUBLIC_CONVEX_SITE_URL = "NEXT_PUBLIC_CONVEX_SITE_URL";
|
|
33
|
+
|
|
34
|
+
const PUBLIC_HEADER = "# Convex (Synapse self-hosted — drop-in compatible with Cloud tutorials)";
|
|
35
|
+
const AUTH_HEADER = "# Self-hosted auth (Synapse cannot use Cloud account session)";
|
|
36
|
+
|
|
37
|
+
// Keys whose VALUE we own. The CONVEX_DEPLOYMENT line is special-cased
|
|
38
|
+
// (it's a bare assignment with an inline comment, not a quoted value),
|
|
39
|
+
// so it's not in this map.
|
|
40
|
+
const MANAGED_VALUE_KEYS = [
|
|
41
|
+
NEXT_PUBLIC_CONVEX_URL,
|
|
42
|
+
NEXT_PUBLIC_CONVEX_SITE_URL,
|
|
43
|
+
SELF_HOSTED_URL,
|
|
44
|
+
SELF_HOSTED_ADMIN_KEY,
|
|
45
|
+
];
|
|
7
46
|
|
|
8
47
|
function quoteEnvValue(value) {
|
|
9
48
|
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
|
@@ -18,11 +57,24 @@ function keyFromLine(line) {
|
|
|
18
57
|
return match ? match[1] : null;
|
|
19
58
|
}
|
|
20
59
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
60
|
+
// Detects a CONVEX_DEPLOYMENT line whether it's:
|
|
61
|
+
// - Bare: `CONVEX_DEPLOYMENT=dev:x`
|
|
62
|
+
// - Exported: `export CONVEX_DEPLOYMENT=dev:x`
|
|
63
|
+
// - Commented: `# CONVEX_DEPLOYMENT=... # disabled by synapse CLI...` (v1.8.1-)
|
|
64
|
+
// Returns true for any of these so `synapse select` can authoritatively
|
|
65
|
+
// replace a previously-commented form with the new uncommented line.
|
|
66
|
+
function isDeploymentLine(line) {
|
|
67
|
+
if (keyFromLine(line) === CONVEX_DEPLOYMENT) return true;
|
|
68
|
+
return /^\s*#\s*(?:export\s+)?CONVEX_DEPLOYMENT\s*=/.test(line);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Trim any character that would break a single-line trailing comment.
|
|
72
|
+
// We're not parsing it back as a value (it lives in a `# ...` tail),
|
|
73
|
+
// but a literal newline in a team/project name would split the line and
|
|
74
|
+
// poison subsequent parsing. Defensive normalization only.
|
|
75
|
+
function sanitizeForComment(value) {
|
|
76
|
+
if (value === undefined || value === null) return "";
|
|
77
|
+
return String(value).replace(/[\r\n]+/g, " ").replace(/#/g, "·").trim();
|
|
26
78
|
}
|
|
27
79
|
|
|
28
80
|
function unquoteEnvValue(raw) {
|
|
@@ -63,53 +115,135 @@ function readProjectEnv(projectDir) {
|
|
|
63
115
|
return parseEnvContent(fs.readFileSync(file, "utf8"));
|
|
64
116
|
}
|
|
65
117
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
118
|
+
// Build the authoritative CONVEX_DEPLOYMENT line. Returns null when we
|
|
119
|
+
// don't have enough info to write one (e.g. legacy caller with no
|
|
120
|
+
// deploymentName) — caller should skip writing instead of writing a
|
|
121
|
+
// half-formed line.
|
|
122
|
+
function buildDeploymentLine({ deploymentName, target, teamName, projectName, teamSlug, projectSlug }) {
|
|
123
|
+
if (!deploymentName) return null;
|
|
124
|
+
const safeTarget = target === "prod" ? "prod" : "dev";
|
|
125
|
+
const teamLabel = sanitizeForComment(teamName || teamSlug);
|
|
126
|
+
const projectLabel = sanitizeForComment(projectName || projectSlug);
|
|
127
|
+
const parts = [];
|
|
128
|
+
if (teamLabel) parts.push(`team: ${teamLabel}`);
|
|
129
|
+
if (projectLabel) parts.push(`project: ${projectLabel}`);
|
|
130
|
+
const comment = parts.length > 0 ? ` # ${parts.join(", ")}` : "";
|
|
131
|
+
return `${CONVEX_DEPLOYMENT}=${safeTarget}:${deploymentName}${comment}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function updateEnvContent(content, opts) {
|
|
135
|
+
const { convexUrl, adminKey } = opts || {};
|
|
136
|
+
if (!convexUrl || !adminKey) {
|
|
137
|
+
throw new Error("updateEnvContent requires convexUrl + adminKey");
|
|
70
138
|
}
|
|
139
|
+
const deploymentLine = buildDeploymentLine(opts || {});
|
|
140
|
+
|
|
141
|
+
const lines = content ? content.split(/\r?\n/) : [];
|
|
142
|
+
// Drop trailing empty lines — we'll re-add a single newline via join.
|
|
143
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
144
|
+
|
|
145
|
+
// Canonical assignments by key.
|
|
146
|
+
const assignments = {
|
|
147
|
+
[NEXT_PUBLIC_CONVEX_URL]: envAssignment(NEXT_PUBLIC_CONVEX_URL, convexUrl),
|
|
148
|
+
[NEXT_PUBLIC_CONVEX_SITE_URL]: envAssignment(NEXT_PUBLIC_CONVEX_SITE_URL, convexUrl),
|
|
149
|
+
[SELF_HOSTED_URL]: envAssignment(SELF_HOSTED_URL, convexUrl),
|
|
150
|
+
[SELF_HOSTED_ADMIN_KEY]: envAssignment(SELF_HOSTED_ADMIN_KEY, adminKey),
|
|
151
|
+
};
|
|
71
152
|
|
|
72
|
-
const replacements = new Map([
|
|
73
|
-
[SELF_HOSTED_URL, envAssignment(SELF_HOSTED_URL, convexUrl)],
|
|
74
|
-
[SELF_HOSTED_ADMIN_KEY, envAssignment(SELF_HOSTED_ADMIN_KEY, adminKey)],
|
|
75
|
-
]);
|
|
76
153
|
const seen = new Set();
|
|
77
154
|
const out = [];
|
|
155
|
+
let seenDeployment = false;
|
|
78
156
|
|
|
79
157
|
for (const line of lines) {
|
|
80
158
|
const key = keyFromLine(line);
|
|
81
|
-
|
|
82
|
-
|
|
159
|
+
|
|
160
|
+
// CONVEX_DEPLOYMENT handling (incl. legacy `# CONVEX_DEPLOYMENT=...
|
|
161
|
+
// # disabled by synapse CLI` form):
|
|
162
|
+
// - new API (caller passed deploymentName) → first match becomes
|
|
163
|
+
// the authoritative line; subsequent matches are dropped.
|
|
164
|
+
// - legacy API (no deploymentName) → leave existing lines
|
|
165
|
+
// untouched so callers still using the old shape don't surprise
|
|
166
|
+
// their users.
|
|
167
|
+
if (isDeploymentLine(line)) {
|
|
168
|
+
if (deploymentLine) {
|
|
169
|
+
if (!seenDeployment) {
|
|
170
|
+
out.push(deploymentLine);
|
|
171
|
+
seenDeployment = true;
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
out.push(line);
|
|
83
176
|
continue;
|
|
84
177
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
178
|
+
|
|
179
|
+
if (assignments[key] && !seen.has(key)) {
|
|
180
|
+
out.push(assignments[key]);
|
|
181
|
+
seen.add(key);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (assignments[key] && seen.has(key)) {
|
|
185
|
+
// Duplicate of a key we already wrote — drop to keep file canonical.
|
|
90
186
|
continue;
|
|
91
187
|
}
|
|
92
188
|
out.push(line);
|
|
93
189
|
}
|
|
94
190
|
|
|
95
|
-
|
|
96
|
-
|
|
191
|
+
// Append any unseen managed keys, grouped under section headers so a
|
|
192
|
+
// brand-new file gets a tidy layout. On idempotent rewrites the loop
|
|
193
|
+
// above will have replaced everything in place; only the first-time
|
|
194
|
+
// write hits this block.
|
|
195
|
+
const newPublicLines = [];
|
|
196
|
+
if (!seen.has(NEXT_PUBLIC_CONVEX_URL)) {
|
|
197
|
+
newPublicLines.push(assignments[NEXT_PUBLIC_CONVEX_URL]);
|
|
198
|
+
seen.add(NEXT_PUBLIC_CONVEX_URL);
|
|
97
199
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
200
|
+
if (!seen.has(NEXT_PUBLIC_CONVEX_SITE_URL)) {
|
|
201
|
+
newPublicLines.push(assignments[NEXT_PUBLIC_CONVEX_SITE_URL]);
|
|
202
|
+
seen.add(NEXT_PUBLIC_CONVEX_SITE_URL);
|
|
203
|
+
}
|
|
204
|
+
if (deploymentLine && !seenDeployment) {
|
|
205
|
+
newPublicLines.push(deploymentLine);
|
|
206
|
+
seenDeployment = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const newAuthLines = [];
|
|
210
|
+
if (!seen.has(SELF_HOSTED_URL)) {
|
|
211
|
+
newAuthLines.push(assignments[SELF_HOSTED_URL]);
|
|
212
|
+
seen.add(SELF_HOSTED_URL);
|
|
213
|
+
}
|
|
214
|
+
if (!seen.has(SELF_HOSTED_ADMIN_KEY)) {
|
|
215
|
+
newAuthLines.push(assignments[SELF_HOSTED_ADMIN_KEY]);
|
|
216
|
+
seen.add(SELF_HOSTED_ADMIN_KEY);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (newPublicLines.length > 0 || newAuthLines.length > 0) {
|
|
220
|
+
if (out.length > 0) out.push("");
|
|
221
|
+
if (newPublicLines.length > 0) {
|
|
222
|
+
out.push(PUBLIC_HEADER);
|
|
223
|
+
out.push(...newPublicLines);
|
|
224
|
+
}
|
|
225
|
+
if (newAuthLines.length > 0) {
|
|
226
|
+
if (newPublicLines.length > 0) out.push("");
|
|
227
|
+
out.push(AUTH_HEADER);
|
|
228
|
+
out.push(...newAuthLines);
|
|
101
229
|
}
|
|
102
230
|
}
|
|
103
231
|
|
|
104
232
|
return out.join("\n") + "\n";
|
|
105
233
|
}
|
|
106
234
|
|
|
107
|
-
function writeProjectEnv(projectDir, credentials) {
|
|
235
|
+
function writeProjectEnv(projectDir, credentials, opts = {}) {
|
|
108
236
|
const file = path.join(projectDir, ".env.local");
|
|
109
237
|
const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
110
238
|
const next = updateEnvContent(existing, {
|
|
111
239
|
convexUrl: credentials.convexUrl,
|
|
112
240
|
adminKey: credentials.adminKey,
|
|
241
|
+
deploymentName: credentials.deploymentName || opts.deploymentName,
|
|
242
|
+
target: opts.target,
|
|
243
|
+
teamName: opts.team?.name,
|
|
244
|
+
teamSlug: opts.team?.slug,
|
|
245
|
+
projectName: opts.project?.name,
|
|
246
|
+
projectSlug: opts.project?.slug,
|
|
113
247
|
});
|
|
114
248
|
fs.writeFileSync(file, next, { mode: 0o600 });
|
|
115
249
|
try {
|
|
@@ -122,12 +256,20 @@ function writeProjectEnv(projectDir, credentials) {
|
|
|
122
256
|
|
|
123
257
|
module.exports = {
|
|
124
258
|
CONVEX_DEPLOYMENT,
|
|
259
|
+
NEXT_PUBLIC_CONVEX_URL,
|
|
260
|
+
NEXT_PUBLIC_CONVEX_SITE_URL,
|
|
125
261
|
SELF_HOSTED_ADMIN_KEY,
|
|
126
262
|
SELF_HOSTED_URL,
|
|
263
|
+
MANAGED_VALUE_KEYS,
|
|
264
|
+
PUBLIC_HEADER,
|
|
265
|
+
AUTH_HEADER,
|
|
266
|
+
buildDeploymentLine,
|
|
267
|
+
isDeploymentLine,
|
|
127
268
|
keyFromLine,
|
|
128
269
|
parseEnvContent,
|
|
129
270
|
quoteEnvValue,
|
|
130
271
|
readProjectEnv,
|
|
272
|
+
sanitizeForComment,
|
|
131
273
|
updateEnvContent,
|
|
132
274
|
writeProjectEnv,
|
|
133
275
|
};
|
package/lib/project.js
CHANGED
|
@@ -50,12 +50,20 @@ function sanitizeProjectConfig(input) {
|
|
|
50
50
|
if (input.deployments?.prod) {
|
|
51
51
|
deployments.prod = deploymentRef(input.deployments.prod);
|
|
52
52
|
}
|
|
53
|
+
// v1.8.1: `doctor --fix --yes` can mark a project.json stale when the
|
|
54
|
+
// linked project was deleted/transferred (see Bug 3). The marker
|
|
55
|
+
// fields (staleReason / staleAt / previous) need to round-trip
|
|
56
|
+
// through writeProjectConfig — without these allowlist entries
|
|
57
|
+
// sanitizeProjectConfig would strip them on every write.
|
|
53
58
|
return compactObject({
|
|
54
59
|
version: 1,
|
|
55
60
|
synapseUrl: input.synapseUrl,
|
|
56
61
|
team: entityRef(input.team),
|
|
57
62
|
project: entityRef(input.project),
|
|
58
63
|
deployments,
|
|
64
|
+
staleReason: input.staleReason,
|
|
65
|
+
staleAt: input.staleAt,
|
|
66
|
+
previous: input.previous,
|
|
59
67
|
});
|
|
60
68
|
}
|
|
61
69
|
|