@iann29/synapse 1.8.0 → 1.8.1

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 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
  }
@@ -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
 
@@ -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
- // No backend call; everything is built client-side from the saved cfg
13
- // + projectConfig.
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 {
@@ -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: 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.",
142
+ status: "ok",
143
+ summary: `linked to ${ctx.projectConfig.project?.name || "?"}`,
119
144
  data: {
120
145
  cwd: ctx.cwd,
121
- linked: exists,
122
- project: exists ? ctx.projectConfig.project?.id : null,
146
+ linked: true,
147
+ project: ctx.projectConfig.project?.id,
123
148
  },
124
149
  };
125
150
  }),
@@ -323,12 +348,21 @@ const checkProjectStillExists = {
323
348
  id: "project-still-exists",
324
349
  category: "backend",
325
350
  title: "linked project exists on backend",
326
- autoFix: "never",
351
+ // v1.8.1: was "never" — promoted to "prompt" so `doctor --fix --yes`
352
+ // can auto-remediate stale .synapse/project.json. See Bug 3 in
353
+ // docs/V1_8_1_STALE_LINK_FIXES.md for the design (B-then-C hybrid).
354
+ autoFix: "prompt",
327
355
  dependsOn: ["auth-token-valid", "in-project-dir"],
328
356
  run: safeRun(async (ctx) => {
329
357
  if (!ctx.projectConfig || !ctx.api) {
330
358
  return { status: "skipped", summary: "no linked project or no session", data: {} };
331
359
  }
360
+ // The marker case (stale link written by a prior --fix) has no
361
+ // project.id to look up — checkInProjectDir already warned about
362
+ // it. Skip the network call.
363
+ if (!ctx.projectConfig.project?.id) {
364
+ return { status: "skipped", summary: "no project id (stale marker?)", data: {} };
365
+ }
332
366
  const teamRef = ctx.projectConfig.team?.slug || ctx.projectConfig.team?.id;
333
367
  if (!teamRef) {
334
368
  return { status: "warn", summary: "linked project has no team ref", data: {} };
@@ -346,8 +380,13 @@ const checkProjectStillExists = {
346
380
  return {
347
381
  status: "issue",
348
382
  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 },
383
+ remediation: "Run `synapse select` to re-link, or `synapse doctor --fix --yes`.",
384
+ data: {
385
+ teamRef,
386
+ projectId: ctx.projectConfig.project?.id,
387
+ teamSlug: ctx.projectConfig.team?.slug,
388
+ projectSlug: ctx.projectConfig.project?.slug,
389
+ },
351
390
  };
352
391
  } catch (err) {
353
392
  return {
@@ -358,6 +397,102 @@ const checkProjectStillExists = {
358
397
  };
359
398
  }
360
399
  }),
400
+ // Two-phase fix (Bug 3 design):
401
+ // B) If exactly one other team owns a project with the same slug,
402
+ // auto-relink (project was transferred). Deployments are reset
403
+ // because the old refs are stale — operator runs `synapse
404
+ // select` once if they want specific dev/prod refs.
405
+ // C) Otherwise (no match, ambiguous match, or any API error):
406
+ // mark project.json as stale and keep the operator's previous
407
+ // block for forensics. Append an idempotent comment marker to
408
+ // .env.local so the bogus admin key isn't silently trusted.
409
+ // Both paths are reachable only under `--fix --yes` (autoFix=prompt
410
+ // + allowPrompt=true at runner.js applyAutoFixes).
411
+ fix: async (ctx) => {
412
+ if (!ctx.projectConfig || !ctx.api) {
413
+ return { kind: "failed", message: "no project config or no API session" };
414
+ }
415
+ const savedProjectId = ctx.projectConfig.project?.id;
416
+ const savedProjectSlug = ctx.projectConfig.project?.slug;
417
+ const previous = {
418
+ team: ctx.projectConfig.team,
419
+ project: ctx.projectConfig.project,
420
+ };
421
+ // Fresh listing — never trust the upstream check's stale data.
422
+ let teams;
423
+ try {
424
+ teams = await ctx.api.teams();
425
+ } catch (err) {
426
+ return { kind: "failed", message: `could not list teams: ${err.message}` };
427
+ }
428
+ const candidates = [];
429
+ if (savedProjectSlug) {
430
+ for (const team of teams) {
431
+ let projects;
432
+ try {
433
+ projects = await ctx.api.projects(team.slug || team.id);
434
+ } catch {
435
+ continue; // one team's lookup failed; try the rest
436
+ }
437
+ for (const p of projects) {
438
+ if (p.slug === savedProjectSlug && p.id !== savedProjectId) {
439
+ candidates.push({ team, project: p });
440
+ }
441
+ }
442
+ }
443
+ }
444
+ if (candidates.length === 1) {
445
+ // Heuristic B: unambiguous re-link (most likely a transfer).
446
+ const { team, project } = candidates[0];
447
+ const newConfig = {
448
+ synapseUrl: ctx.projectConfig.synapseUrl,
449
+ team,
450
+ project,
451
+ deployments: {},
452
+ };
453
+ writeProjectConfig(ctx.cwd, newConfig);
454
+ // Sync in-memory ctx so the runner's recheck sees the new state.
455
+ // Without this, `Object.assign(r, fresh, {fixedBy})` overwrites
456
+ // with another "issue" because run() still reads the old project.id.
457
+ ctx.projectConfig = newConfig;
458
+ return {
459
+ kind: "applied",
460
+ message: `re-linked to ${team.slug || team.name}/${project.slug || project.name} (project was transferred)`,
461
+ };
462
+ }
463
+ // Fallback C: write a stale marker. Keep synapseUrl + the previous
464
+ // block so the operator can audit what was there.
465
+ const staleAt = new Date().toISOString();
466
+ const staleConfig = {
467
+ synapseUrl: ctx.projectConfig.synapseUrl,
468
+ staleReason: "project-not-found",
469
+ staleAt,
470
+ previous,
471
+ };
472
+ writeProjectConfig(ctx.cwd, staleConfig);
473
+ ctx.projectConfig = staleConfig;
474
+ // Idempotent comment marker on .env.local. The admin key inside is
475
+ // still bogus, but deletion would lose info the operator may want
476
+ // to grep, so just annotate. Marker string is stable so re-running
477
+ // fix doesn't keep appending.
478
+ try {
479
+ const envPath = path.join(ctx.cwd, ".env.local");
480
+ if (fs.existsSync(envPath)) {
481
+ const content = fs.readFileSync(envPath, "utf8");
482
+ const marker = "# stale — admin key invalid";
483
+ if (!content.includes(marker)) {
484
+ const banner = `${marker} since ${staleAt.slice(0, 10)}, run \`synapse select\`\n`;
485
+ fs.writeFileSync(envPath, banner + content);
486
+ }
487
+ }
488
+ } catch {
489
+ // Best-effort; project.json marker is the source of truth.
490
+ }
491
+ return {
492
+ kind: "applied",
493
+ message: "marked stale — run `synapse select` to re-link",
494
+ };
495
+ },
361
496
  };
362
497
 
363
498
  // -------- deployments ----------------------------------------------
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iann29/synapse",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
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": {