@iann29/synapse 1.6.17

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 ADDED
@@ -0,0 +1,78 @@
1
+ # @iann29/synapse
2
+
3
+ Thin CLI wrapper for using the official Convex CLI with Synapse-managed
4
+ self-hosted deployments.
5
+
6
+ The package name is scoped because `synapse` is already taken on npm, but the
7
+ installed binary is still named `synapse`.
8
+
9
+ ## Install
10
+
11
+ For one machine:
12
+
13
+ ```bash
14
+ npm install -g @iann29/synapse
15
+ synapse --help
16
+ ```
17
+
18
+ For one app/project:
19
+
20
+ ```bash
21
+ npm install -D @iann29/synapse
22
+ npx synapse --help
23
+ ```
24
+
25
+ Without installing into the project first:
26
+
27
+ ```bash
28
+ npm exec --package @iann29/synapse -- synapse --help
29
+ ```
30
+
31
+ Until the package is published to npm, install from a release tarball:
32
+
33
+ ```bash
34
+ npm install -D https://github.com/Iann29/convex-synapse/releases/download/v1.6.2/iann29-synapse-1.6.2.tgz
35
+ npx synapse --help
36
+ ```
37
+
38
+ For local development from this repository:
39
+
40
+ ```bash
41
+ cd /path/to/convex-synapse/cli
42
+ npm link
43
+
44
+ cd /path/to/your-convex-app
45
+ synapse --help
46
+ ```
47
+
48
+ Or install this checkout into a single app without a global link:
49
+
50
+ ```bash
51
+ cd /path/to/your-convex-app
52
+ npm install -D /path/to/convex-synapse/cli
53
+ npx synapse --help
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ synapse login https://synapse.example.com
60
+ synapse select
61
+ synapse convex dev --once
62
+ synapse convex deploy
63
+ ```
64
+
65
+ `synapse select` stores non-secret project metadata in
66
+ `.synapse/project.json`. It also writes `.env.local` with the selected dev
67
+ deployment credentials for compatibility with direct Convex CLI use.
68
+
69
+ `synapse convex ...` is the safer project-aware path:
70
+
71
+ - `synapse convex dev` uses the linked dev deployment.
72
+ - `synapse convex deploy` uses the linked prod deployment.
73
+ - `synapse convex --target dev deploy` forces the dev target.
74
+ - Other Convex commands default to dev unless `--target prod` is passed.
75
+
76
+ At runtime the wrapper fetches fresh deployment credentials from Synapse,
77
+ sets `CONVEX_SELF_HOSTED_URL` and `CONVEX_SELF_HOSTED_ADMIN_KEY`, removes
78
+ `CONVEX_DEPLOYMENT`, and delegates to the official `npx convex ...` command.
package/bin/synapse.js ADDED
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { SynapseAPI, SynapseAPIError } = require("../lib/api");
4
+ const { clearConfig, normalizeBaseUrl, requireConfig, writeConfig } = require("../lib/config");
5
+ const { quoteEnvValue, writeProjectEnv } = require("../lib/env-file");
6
+ const {
7
+ buildProjectConfig,
8
+ deploymentNameForTarget,
9
+ readProjectConfig,
10
+ writeProjectConfig,
11
+ } = require("../lib/project");
12
+ const { askCredentials, choose } = require("../lib/prompts");
13
+ const { runConvex } = require("../lib/convex");
14
+
15
+ function usage() {
16
+ return `Usage:
17
+ synapse login <url>
18
+ synapse logout
19
+ synapse whoami
20
+ synapse select
21
+ synapse credentials <deployment> [--format env|shell|json]
22
+ synapse convex [--target dev|prod] [...args]
23
+ `;
24
+ }
25
+
26
+ function clientFromConfig() {
27
+ const cfg = requireConfig();
28
+ const api = new SynapseAPI({ baseUrl: cfg.baseUrl, accessToken: cfg.accessToken });
29
+ const refreshable = new Proxy(api, {
30
+ get(target, prop) {
31
+ const value = target[prop];
32
+ if (typeof value !== "function") {
33
+ return value;
34
+ }
35
+ return async (...args) => {
36
+ try {
37
+ return await value.apply(target, args);
38
+ } catch (err) {
39
+ if (!(err instanceof SynapseAPIError) || err.status !== 401 || !cfg.refreshToken) {
40
+ throw err;
41
+ }
42
+ const session = await new SynapseAPI({ baseUrl: cfg.baseUrl }).refresh(cfg.refreshToken);
43
+ if (!session.accessToken) {
44
+ throw err;
45
+ }
46
+ cfg.accessToken = session.accessToken;
47
+ cfg.refreshToken = session.refreshToken || cfg.refreshToken;
48
+ cfg.tokenType = session.tokenType || cfg.tokenType || "Bearer";
49
+ if (session.user) {
50
+ cfg.user = session.user;
51
+ }
52
+ writeConfig(cfg);
53
+ target.accessToken = cfg.accessToken;
54
+ return await value.apply(target, args);
55
+ }
56
+ };
57
+ },
58
+ });
59
+ return {
60
+ cfg,
61
+ api: refreshable,
62
+ };
63
+ }
64
+
65
+ function labelName(item) {
66
+ const name = item.name || item.slug || item.id;
67
+ const slug = item.slug && item.slug !== name ? ` (${item.slug})` : "";
68
+ return `${name}${slug}`;
69
+ }
70
+
71
+ function teamRef(team) {
72
+ return team.slug || team.id;
73
+ }
74
+
75
+ function deploymentLabel(deployment) {
76
+ const bits = [deployment.name];
77
+ if (deployment.deploymentType || deployment.type) {
78
+ bits.push(deployment.deploymentType || deployment.type);
79
+ }
80
+ if (deployment.status) {
81
+ bits.push(deployment.status);
82
+ }
83
+ return bits.filter(Boolean).join(" - ");
84
+ }
85
+
86
+ function deploymentType(deployment) {
87
+ return deployment.deploymentType || deployment.type || "";
88
+ }
89
+
90
+ function sortDeploymentsForChoice(deployments) {
91
+ return [...deployments].sort((a, b) => {
92
+ if (!!a.isDefault !== !!b.isDefault) {
93
+ return a.isDefault ? -1 : 1;
94
+ }
95
+ return String(b.createTime || b.createdAt || "").localeCompare(String(a.createTime || a.createdAt || ""));
96
+ });
97
+ }
98
+
99
+ async function chooseDeploymentForType(type, deployments) {
100
+ const matches = sortDeploymentsForChoice(
101
+ deployments.filter((d) => deploymentType(d) === type && d.status !== "deleted"),
102
+ );
103
+ if (matches.length === 0) {
104
+ return null;
105
+ }
106
+ return await choose(
107
+ `${type} deployments`,
108
+ matches.map((d) => ({ label: deploymentLabel(d), value: d })),
109
+ );
110
+ }
111
+
112
+ function parseConvexTarget(args) {
113
+ let target = null;
114
+ let index = 0;
115
+ while (index < args.length) {
116
+ const arg = args[index];
117
+ if (arg === "--target") {
118
+ target = args[index + 1];
119
+ if (!target) {
120
+ throw new Error("--target requires dev or prod");
121
+ }
122
+ index += 2;
123
+ continue;
124
+ }
125
+ if (arg && arg.startsWith("--target=")) {
126
+ target = arg.slice("--target=".length);
127
+ index += 1;
128
+ continue;
129
+ }
130
+ break;
131
+ }
132
+ if (target && target !== "dev" && target !== "prod") {
133
+ throw new Error("--target must be dev or prod");
134
+ }
135
+ return {
136
+ explicitTarget: Boolean(target),
137
+ target,
138
+ args: args.slice(index),
139
+ };
140
+ }
141
+
142
+ function inferConvexTarget(args) {
143
+ const command = args.find((arg) => arg && !arg.startsWith("-")) || "";
144
+ return command === "deploy" ? "prod" : "dev";
145
+ }
146
+
147
+ function parseConvexInvocation(args) {
148
+ const parsed = parseConvexTarget(args);
149
+ return {
150
+ ...parsed,
151
+ target: parsed.target || inferConvexTarget(parsed.args),
152
+ };
153
+ }
154
+
155
+ async function resolveConvexInvocation(args, { cfg = null, api = null, projectDir = process.cwd() } = {}) {
156
+ const parsed = parseConvexInvocation(args);
157
+ const projectConfig = readProjectConfig(projectDir);
158
+ if (!projectConfig) {
159
+ if (parsed.explicitTarget) {
160
+ throw new Error("No Synapse project metadata found. Run `synapse select` first.");
161
+ }
162
+ return {
163
+ ...parsed,
164
+ credentials: null,
165
+ deploymentName: "",
166
+ projectConfig: null,
167
+ target: null,
168
+ };
169
+ }
170
+
171
+ if (!cfg || !api) {
172
+ throw new Error("Not logged in. Run `synapse login <url>` first.");
173
+ }
174
+ if (
175
+ projectConfig.synapseUrl &&
176
+ cfg.baseUrl &&
177
+ normalizeBaseUrl(projectConfig.synapseUrl) !== normalizeBaseUrl(cfg.baseUrl)
178
+ ) {
179
+ throw new Error(
180
+ `This project is linked to ${projectConfig.synapseUrl}, but the saved Synapse session is for ${cfg.baseUrl}. Run \`synapse login ${projectConfig.synapseUrl}\` or \`synapse select\` again.`,
181
+ );
182
+ }
183
+
184
+ const deploymentName = deploymentNameForTarget(projectConfig, parsed.target);
185
+ if (!deploymentName) {
186
+ throw new Error(`No ${parsed.target} deployment saved for this project. Run \`synapse select\` again.`);
187
+ }
188
+ const credentials = await api.cliCredentials(deploymentName);
189
+ return {
190
+ ...parsed,
191
+ credentials,
192
+ deploymentName,
193
+ projectConfig,
194
+ };
195
+ }
196
+
197
+ function formatCredentials(creds, format) {
198
+ switch (format) {
199
+ case "json":
200
+ return JSON.stringify(creds, null, 2);
201
+ case "shell":
202
+ return creds.exportSnippet;
203
+ case "env":
204
+ return creds.envSnippet || `CONVEX_SELF_HOSTED_URL=${quoteEnvValue(creds.convexUrl)}\nCONVEX_SELF_HOSTED_ADMIN_KEY=${quoteEnvValue(creds.adminKey)}`;
205
+ default:
206
+ throw new Error("format must be one of: env, shell, json");
207
+ }
208
+ }
209
+
210
+ function parseFormat(args) {
211
+ let format = "env";
212
+ const rest = [];
213
+ for (let i = 0; i < args.length; i += 1) {
214
+ const arg = args[i];
215
+ if (arg === "--format") {
216
+ format = args[i + 1];
217
+ i += 1;
218
+ } else if (arg.startsWith("--format=")) {
219
+ format = arg.slice("--format=".length);
220
+ } else {
221
+ rest.push(arg);
222
+ }
223
+ }
224
+ return { format, rest };
225
+ }
226
+
227
+ async function login(args) {
228
+ const url = args[0];
229
+ if (!url) {
230
+ throw new Error("Usage: synapse login <url>");
231
+ }
232
+ const baseUrl = normalizeBaseUrl(url);
233
+ const { email, password } = await askCredentials();
234
+ const api = new SynapseAPI({ baseUrl });
235
+ const session = await api.login(email, password);
236
+ if (!session.accessToken) {
237
+ throw new Error("Synapse login response did not include accessToken");
238
+ }
239
+ const file = writeConfig({
240
+ baseUrl,
241
+ accessToken: session.accessToken,
242
+ refreshToken: session.refreshToken || null,
243
+ tokenType: session.tokenType || "Bearer",
244
+ user: session.user || null,
245
+ });
246
+ process.stderr.write(`Saved Synapse session to ${file}\n`);
247
+ }
248
+
249
+ async function logout() {
250
+ const removed = clearConfig();
251
+ process.stderr.write(removed ? "Logged out of Synapse.\n" : "No Synapse session was saved.\n");
252
+ }
253
+
254
+ async function whoami() {
255
+ const { cfg, api } = clientFromConfig();
256
+ const me = await api.me();
257
+ const email = me.email || me.user?.email || "(unknown email)";
258
+ const name = me.name || me.user?.name || "";
259
+ process.stdout.write(`${name ? `${name} ` : ""}<${email}> on ${cfg.baseUrl}\n`);
260
+ }
261
+
262
+ async function selectDeployment() {
263
+ const { cfg, api } = clientFromConfig();
264
+ const teams = await api.teams();
265
+ const team = await choose("teams", teams.map((t) => ({ label: labelName(t), value: t })));
266
+ const projects = await api.projects(teamRef(team));
267
+ const project = await choose("projects", projects.map((p) => ({ label: labelName(p), value: p })));
268
+ const deployments = await api.deployments(project.id);
269
+ const dev = await chooseDeploymentForType("dev", deployments);
270
+ if (!dev) {
271
+ throw new Error("No dev deployments available in this project. Create one first.");
272
+ }
273
+ const prod = await chooseDeploymentForType("prod", deployments);
274
+ const projectPath = writeProjectConfig(
275
+ process.cwd(),
276
+ buildProjectConfig({
277
+ synapseUrl: cfg.baseUrl,
278
+ team,
279
+ project,
280
+ deployments: { dev, prod },
281
+ }),
282
+ );
283
+ const creds = await api.cliCredentials(dev.name);
284
+ const envPath = writeProjectEnv(process.cwd(), creds);
285
+ process.stderr.write(`Linked ${labelName(project)} to ${projectPath}.\n`);
286
+ process.stderr.write(`Selected dev deployment ${dev.name}. Updated ${envPath}.\n`);
287
+ if (prod) {
288
+ process.stderr.write(`Selected prod deployment ${prod.name}.\n`);
289
+ } else {
290
+ process.stderr.write("Warning: no prod deployment found. `synapse convex deploy` will require a prod deployment saved by `synapse select`.\n");
291
+ }
292
+ if (process.env.CONVEX_DEPLOYMENT) {
293
+ process.stderr.write("Warning: shell CONVEX_DEPLOYMENT is set. Use `synapse convex ...` or unset it before running `npx convex` directly.\n");
294
+ }
295
+ }
296
+
297
+ async function credentials(args) {
298
+ const { format, rest } = parseFormat(args);
299
+ const deployment = rest[0];
300
+ if (!deployment) {
301
+ throw new Error("Usage: synapse credentials <deployment> [--format env|shell|json]");
302
+ }
303
+ if (!["env", "shell", "json"].includes(format)) {
304
+ throw new Error("format must be one of: env, shell, json");
305
+ }
306
+ const { api } = clientFromConfig();
307
+ const creds = await api.cliCredentials(deployment);
308
+ process.stdout.write(formatCredentials(creds, format) + "\n");
309
+ }
310
+
311
+ async function convex(args) {
312
+ const projectConfig = readProjectConfig(process.cwd());
313
+ let resolved = {
314
+ args,
315
+ credentials: null,
316
+ deploymentName: "",
317
+ target: null,
318
+ };
319
+ if (projectConfig) {
320
+ const { cfg, api } = clientFromConfig();
321
+ resolved = await resolveConvexInvocation(args, { cfg, api });
322
+ process.stderr.write(`Using Synapse ${resolved.target} deployment ${resolved.deploymentName}.\n`);
323
+ } else {
324
+ resolved = await resolveConvexInvocation(args);
325
+ }
326
+ const code = await runConvex(resolved.args, { credentials: resolved.credentials });
327
+ process.exitCode = code;
328
+ }
329
+
330
+ async function main(argv) {
331
+ const [command, ...args] = argv;
332
+ switch (command) {
333
+ case "login":
334
+ return await login(args);
335
+ case "logout":
336
+ return await logout();
337
+ case "whoami":
338
+ return await whoami();
339
+ case "select":
340
+ return await selectDeployment();
341
+ case "credentials":
342
+ return await credentials(args);
343
+ case "convex":
344
+ return await convex(args);
345
+ case "-h":
346
+ case "--help":
347
+ case "help":
348
+ case undefined:
349
+ process.stdout.write(usage());
350
+ return;
351
+ default:
352
+ throw new Error(`Unknown command: ${command}\n\n${usage()}`);
353
+ }
354
+ }
355
+
356
+ if (require.main === module) {
357
+ main(process.argv.slice(2)).catch((err) => {
358
+ process.stderr.write(`${err.message}\n`);
359
+ process.exitCode = 1;
360
+ });
361
+ }
362
+
363
+ module.exports = {
364
+ chooseDeploymentForType,
365
+ clientFromConfig,
366
+ formatCredentials,
367
+ inferConvexTarget,
368
+ main,
369
+ parseConvexInvocation,
370
+ parseFormat,
371
+ resolveConvexInvocation,
372
+ };
package/lib/api.js ADDED
@@ -0,0 +1,119 @@
1
+ class SynapseAPIError extends Error {
2
+ constructor(status, code, message) {
3
+ super(message || code || `Synapse API returned ${status}`);
4
+ this.name = "SynapseAPIError";
5
+ this.status = status;
6
+ this.code = code || "request_failed";
7
+ }
8
+ }
9
+
10
+ class SynapseAPI {
11
+ constructor({ baseUrl, accessToken, fetchImpl = globalThis.fetch }) {
12
+ if (!fetchImpl) {
13
+ throw new Error("This Node version does not provide fetch()");
14
+ }
15
+ this.baseUrl = String(baseUrl || "").replace(/\/+$/, "");
16
+ this.accessToken = accessToken || "";
17
+ this.fetch = fetchImpl;
18
+ }
19
+
20
+ async request(method, path, body, { auth = true, includeHeaders = false } = {}) {
21
+ const url = new URL(path, `${this.baseUrl}/`);
22
+ const headers = {
23
+ "Accept": "application/json",
24
+ };
25
+ if (body !== undefined) {
26
+ headers["Content-Type"] = "application/json";
27
+ }
28
+ if (auth && this.accessToken) {
29
+ headers.Authorization = `Bearer ${this.accessToken}`;
30
+ }
31
+ let res;
32
+ try {
33
+ res = await this.fetch(url, {
34
+ method,
35
+ headers,
36
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
37
+ });
38
+ } catch (err) {
39
+ const detail = err && err.message ? err.message : String(err);
40
+ throw new SynapseAPIError(0, "network_error", `Could not reach Synapse at ${this.baseUrl}: ${detail}`);
41
+ }
42
+ if (!res.ok) {
43
+ let code = "request_failed";
44
+ let message = `${method} ${url.pathname} failed with ${res.status}`;
45
+ try {
46
+ const data = await res.json();
47
+ code = data.code || data.error || code;
48
+ message = data.message || data.error || message;
49
+ } catch {
50
+ // Keep the generic message if the server didn't return JSON.
51
+ }
52
+ throw new SynapseAPIError(res.status, code, message);
53
+ }
54
+ let data = null;
55
+ if (res.status !== 204) {
56
+ try {
57
+ data = await res.json();
58
+ } catch {
59
+ throw new SynapseAPIError(res.status, "bad_response", `${method} ${url.pathname} did not return JSON`);
60
+ }
61
+ }
62
+ if (includeHeaders) {
63
+ return { data, headers: res.headers };
64
+ }
65
+ return data;
66
+ }
67
+
68
+ async listAll(path) {
69
+ const items = [];
70
+ let cursor = "";
71
+ do {
72
+ const pageURL = new URL(path, "http://synapse.local");
73
+ pageURL.searchParams.set("limit", "500");
74
+ if (cursor) {
75
+ pageURL.searchParams.set("cursor", cursor);
76
+ }
77
+ const page = await this.request("GET", `${pageURL.pathname}${pageURL.search}`, undefined, { includeHeaders: true });
78
+ if (!Array.isArray(page.data)) {
79
+ throw new SynapseAPIError(0, "bad_response", `Expected ${path} to return a JSON array`);
80
+ }
81
+ items.push(...page.data);
82
+ cursor = page.headers.get("x-next-cursor") || "";
83
+ } while (cursor);
84
+ return items;
85
+ }
86
+
87
+ login(email, password) {
88
+ return this.request("POST", "/v1/auth/login", { email, password }, { auth: false });
89
+ }
90
+
91
+ refresh(refreshToken) {
92
+ return this.request("POST", "/v1/auth/refresh", { refreshToken }, { auth: false });
93
+ }
94
+
95
+ me() {
96
+ return this.request("GET", "/v1/me/");
97
+ }
98
+
99
+ teams() {
100
+ return this.listAll("/v1/teams/");
101
+ }
102
+
103
+ projects(teamRef) {
104
+ return this.listAll(`/v1/teams/${encodeURIComponent(teamRef)}/list_projects`);
105
+ }
106
+
107
+ deployments(projectId) {
108
+ return this.listAll(`/v1/projects/${encodeURIComponent(projectId)}/list_deployments`);
109
+ }
110
+
111
+ cliCredentials(deploymentName) {
112
+ return this.request("GET", `/v1/deployments/${encodeURIComponent(deploymentName)}/cli_credentials`);
113
+ }
114
+ }
115
+
116
+ module.exports = {
117
+ SynapseAPI,
118
+ SynapseAPIError,
119
+ };
package/lib/config.js ADDED
@@ -0,0 +1,87 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+
5
+ function configPath() {
6
+ if (process.env.SYNAPSE_CLI_CONFIG) {
7
+ return process.env.SYNAPSE_CLI_CONFIG;
8
+ }
9
+ return path.join(os.homedir(), ".synapse", "config.json");
10
+ }
11
+
12
+ function normalizeBaseUrl(raw) {
13
+ const value = String(raw || "").trim();
14
+ if (!value) {
15
+ throw new Error("Synapse URL is required");
16
+ }
17
+ let parsed;
18
+ try {
19
+ parsed = new URL(value);
20
+ } catch {
21
+ throw new Error(`Invalid Synapse URL: ${value}`);
22
+ }
23
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
24
+ throw new Error("Synapse URL must start with http:// or https://");
25
+ }
26
+ parsed.hash = "";
27
+ parsed.search = "";
28
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
29
+ return parsed.toString().replace(/\/+$/, "");
30
+ }
31
+
32
+ function readConfig() {
33
+ const file = configPath();
34
+ if (!fs.existsSync(file)) {
35
+ return null;
36
+ }
37
+ try {
38
+ return JSON.parse(fs.readFileSync(file, "utf8"));
39
+ } catch (err) {
40
+ throw new Error(`Could not read ${file}: ${err.message}`);
41
+ }
42
+ }
43
+
44
+ function requireConfig() {
45
+ const cfg = readConfig();
46
+ if (!cfg || !cfg.baseUrl || !cfg.accessToken) {
47
+ throw new Error("Not logged in. Run `synapse login <url>` first.");
48
+ }
49
+ return cfg;
50
+ }
51
+
52
+ function writeConfig(config) {
53
+ const file = configPath();
54
+ const dir = path.dirname(file);
55
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
56
+ try {
57
+ fs.chmodSync(dir, 0o700);
58
+ } catch {
59
+ // Best-effort on filesystems that do not support POSIX modes.
60
+ }
61
+ const payload = JSON.stringify(config, null, 2) + "\n";
62
+ fs.writeFileSync(file, payload, { mode: 0o600 });
63
+ try {
64
+ fs.chmodSync(file, 0o600);
65
+ } catch {
66
+ // Best-effort on filesystems that do not support POSIX modes.
67
+ }
68
+ return file;
69
+ }
70
+
71
+ function clearConfig() {
72
+ const file = configPath();
73
+ if (fs.existsSync(file)) {
74
+ fs.rmSync(file);
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ module.exports = {
81
+ clearConfig,
82
+ configPath,
83
+ normalizeBaseUrl,
84
+ readConfig,
85
+ requireConfig,
86
+ writeConfig,
87
+ };
package/lib/convex.js ADDED
@@ -0,0 +1,56 @@
1
+ const { spawn } = require("node:child_process");
2
+ const { readProjectEnv } = require("./env-file");
3
+
4
+ function envFromCredentials(credentials) {
5
+ if (!credentials) {
6
+ return {};
7
+ }
8
+ return {
9
+ CONVEX_SELF_HOSTED_URL: credentials.convexUrl,
10
+ CONVEX_SELF_HOSTED_ADMIN_KEY: credentials.adminKey,
11
+ };
12
+ }
13
+
14
+ function buildConvexEnv(source = process.env, projectEnv = {}, overrides = {}) {
15
+ const env = { ...source };
16
+ for (const key of ["CONVEX_SELF_HOSTED_URL", "CONVEX_SELF_HOSTED_ADMIN_KEY"]) {
17
+ if (!env[key] && projectEnv[key]) {
18
+ env[key] = projectEnv[key];
19
+ }
20
+ }
21
+ for (const [key, value] of Object.entries(overrides)) {
22
+ if (value) {
23
+ env[key] = value;
24
+ }
25
+ }
26
+ if (env.CONVEX_SELF_HOSTED_URL && env.CONVEX_SELF_HOSTED_ADMIN_KEY) {
27
+ delete env.CONVEX_DEPLOYMENT;
28
+ }
29
+ return env;
30
+ }
31
+
32
+ function runConvex(args, { env = process.env, stdio = "inherit", credentials = null, spawnImpl = spawn } = {}) {
33
+ const executable = process.platform === "win32" ? "npx.cmd" : "npx";
34
+ const projectEnv = readProjectEnv(process.cwd());
35
+ const child = spawnImpl(executable, ["convex", ...args], {
36
+ env: buildConvexEnv(env, projectEnv, envFromCredentials(credentials)),
37
+ stdio,
38
+ });
39
+
40
+ return new Promise((resolve, reject) => {
41
+ child.once("error", reject);
42
+ child.once("close", (code, signal) => {
43
+ if (signal) {
44
+ resolve(1);
45
+ return;
46
+ }
47
+ resolve(code ?? 1);
48
+ });
49
+ });
50
+ }
51
+
52
+ module.exports = {
53
+ buildConvexEnv,
54
+ envFromCredentials,
55
+ runConvex,
56
+ };
@@ -0,0 +1,133 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ const SELF_HOSTED_URL = "CONVEX_SELF_HOSTED_URL";
5
+ const SELF_HOSTED_ADMIN_KEY = "CONVEX_SELF_HOSTED_ADMIN_KEY";
6
+ const CONVEX_DEPLOYMENT = "CONVEX_DEPLOYMENT";
7
+
8
+ function quoteEnvValue(value) {
9
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
10
+ }
11
+
12
+ function envAssignment(name, value) {
13
+ return `${name}=${quoteEnvValue(value)}`;
14
+ }
15
+
16
+ function keyFromLine(line) {
17
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
18
+ return match ? match[1] : null;
19
+ }
20
+
21
+ function commentDeploymentLine(line) {
22
+ if (/^\s*#/.test(line)) {
23
+ return line;
24
+ }
25
+ return `# ${line} # disabled by synapse CLI for self-hosted Convex`;
26
+ }
27
+
28
+ function unquoteEnvValue(raw) {
29
+ const value = String(raw || "").trim();
30
+ if (
31
+ (value.startsWith('"') && value.endsWith('"')) ||
32
+ (value.startsWith("'") && value.endsWith("'"))
33
+ ) {
34
+ return value.slice(1, -1);
35
+ }
36
+ return value;
37
+ }
38
+
39
+ function parseEnvContent(content) {
40
+ const out = {};
41
+ for (const line of String(content || "").split(/\r?\n/)) {
42
+ if (/^\s*(?:#|$)/.test(line)) {
43
+ continue;
44
+ }
45
+ const key = keyFromLine(line);
46
+ if (!key) {
47
+ continue;
48
+ }
49
+ const valueStart = line.indexOf("=");
50
+ if (valueStart < 0) {
51
+ continue;
52
+ }
53
+ out[key] = unquoteEnvValue(line.slice(valueStart + 1));
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function readProjectEnv(projectDir) {
59
+ const file = path.join(projectDir, ".env.local");
60
+ if (!fs.existsSync(file)) {
61
+ return {};
62
+ }
63
+ return parseEnvContent(fs.readFileSync(file, "utf8"));
64
+ }
65
+
66
+ function updateEnvContent(content, { convexUrl, adminKey }) {
67
+ const lines = content ? content.split(/\r?\n/) : [];
68
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
69
+ lines.pop();
70
+ }
71
+
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
+ const seen = new Set();
77
+ const out = [];
78
+
79
+ for (const line of lines) {
80
+ const key = keyFromLine(line);
81
+ if (key === CONVEX_DEPLOYMENT) {
82
+ out.push(commentDeploymentLine(line));
83
+ continue;
84
+ }
85
+ if (replacements.has(key)) {
86
+ if (!seen.has(key)) {
87
+ out.push(replacements.get(key));
88
+ seen.add(key);
89
+ }
90
+ continue;
91
+ }
92
+ out.push(line);
93
+ }
94
+
95
+ if (out.length > 0 && out[out.length - 1] !== "") {
96
+ out.push("");
97
+ }
98
+ for (const [key, line] of replacements.entries()) {
99
+ if (!seen.has(key)) {
100
+ out.push(line);
101
+ }
102
+ }
103
+
104
+ return out.join("\n") + "\n";
105
+ }
106
+
107
+ function writeProjectEnv(projectDir, credentials) {
108
+ const file = path.join(projectDir, ".env.local");
109
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
110
+ const next = updateEnvContent(existing, {
111
+ convexUrl: credentials.convexUrl,
112
+ adminKey: credentials.adminKey,
113
+ });
114
+ fs.writeFileSync(file, next, { mode: 0o600 });
115
+ try {
116
+ fs.chmodSync(file, 0o600);
117
+ } catch {
118
+ // Best-effort on filesystems that do not support POSIX modes.
119
+ }
120
+ return file;
121
+ }
122
+
123
+ module.exports = {
124
+ CONVEX_DEPLOYMENT,
125
+ SELF_HOSTED_ADMIN_KEY,
126
+ SELF_HOSTED_URL,
127
+ keyFromLine,
128
+ parseEnvContent,
129
+ quoteEnvValue,
130
+ readProjectEnv,
131
+ updateEnvContent,
132
+ writeProjectEnv,
133
+ };
package/lib/project.js ADDED
@@ -0,0 +1,113 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ const PROJECT_DIR = ".synapse";
5
+ const PROJECT_FILE = "project.json";
6
+
7
+ function projectConfigPath(projectDir = process.cwd()) {
8
+ return path.join(projectDir, PROJECT_DIR, PROJECT_FILE);
9
+ }
10
+
11
+ function compactObject(value) {
12
+ const out = {};
13
+ for (const [key, item] of Object.entries(value)) {
14
+ if (item !== undefined && item !== null && item !== "") {
15
+ out[key] = item;
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+
21
+ function entityRef(entity) {
22
+ if (!entity) {
23
+ return undefined;
24
+ }
25
+ return compactObject({
26
+ id: entity.id,
27
+ slug: entity.slug,
28
+ name: entity.name,
29
+ });
30
+ }
31
+
32
+ function deploymentRef(deployment) {
33
+ if (!deployment) {
34
+ return undefined;
35
+ }
36
+ return compactObject({
37
+ id: deployment.id,
38
+ name: deployment.name,
39
+ deploymentType: deployment.deploymentType || deployment.type,
40
+ isDefault: deployment.isDefault,
41
+ status: deployment.status,
42
+ });
43
+ }
44
+
45
+ function sanitizeProjectConfig(input) {
46
+ const deployments = {};
47
+ if (input.deployments?.dev) {
48
+ deployments.dev = deploymentRef(input.deployments.dev);
49
+ }
50
+ if (input.deployments?.prod) {
51
+ deployments.prod = deploymentRef(input.deployments.prod);
52
+ }
53
+ return compactObject({
54
+ version: 1,
55
+ synapseUrl: input.synapseUrl,
56
+ team: entityRef(input.team),
57
+ project: entityRef(input.project),
58
+ deployments,
59
+ });
60
+ }
61
+
62
+ function buildProjectConfig({ synapseUrl, team, project, deployments }) {
63
+ return sanitizeProjectConfig({
64
+ synapseUrl,
65
+ team,
66
+ project,
67
+ deployments,
68
+ });
69
+ }
70
+
71
+ function writeProjectConfig(projectDir, config) {
72
+ const file = projectConfigPath(projectDir);
73
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
74
+ const safe = sanitizeProjectConfig(config);
75
+ fs.writeFileSync(file, JSON.stringify(safe, null, 2) + "\n", { mode: 0o600 });
76
+ try {
77
+ fs.chmodSync(file, 0o600);
78
+ } catch {
79
+ // Best-effort on filesystems that do not support POSIX modes.
80
+ }
81
+ return file;
82
+ }
83
+
84
+ function readProjectConfig(projectDir = process.cwd()) {
85
+ const file = projectConfigPath(projectDir);
86
+ if (!fs.existsSync(file)) {
87
+ return null;
88
+ }
89
+ try {
90
+ return JSON.parse(fs.readFileSync(file, "utf8"));
91
+ } catch (err) {
92
+ throw new Error(`Could not read ${file}: ${err.message}`);
93
+ }
94
+ }
95
+
96
+ function deploymentNameForTarget(config, target) {
97
+ const deployment = config?.deployments?.[target];
98
+ if (!deployment) {
99
+ return "";
100
+ }
101
+ return typeof deployment === "string" ? deployment : deployment.name || "";
102
+ }
103
+
104
+ module.exports = {
105
+ PROJECT_DIR,
106
+ PROJECT_FILE,
107
+ buildProjectConfig,
108
+ deploymentNameForTarget,
109
+ projectConfigPath,
110
+ readProjectConfig,
111
+ sanitizeProjectConfig,
112
+ writeProjectConfig,
113
+ };
package/lib/prompts.js ADDED
@@ -0,0 +1,122 @@
1
+ const readline = require("node:readline");
2
+
3
+ function ask(question, { input = process.stdin, output = process.stderr } = {}) {
4
+ const rl = readline.createInterface({ input, output });
5
+ return new Promise((resolve) => {
6
+ rl.question(question, (answer) => {
7
+ rl.close();
8
+ resolve(answer.trim());
9
+ });
10
+ });
11
+ }
12
+
13
+ function askHidden(question, { input = process.stdin, output = process.stderr } = {}) {
14
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") {
15
+ return ask(question, { input, output });
16
+ }
17
+
18
+ return new Promise((resolve, reject) => {
19
+ let value = "";
20
+ const wasRaw = input.isRaw;
21
+
22
+ function cleanup() {
23
+ input.off("data", onData);
24
+ input.setRawMode(wasRaw);
25
+ output.write("\n");
26
+ }
27
+
28
+ function onData(buffer) {
29
+ const text = buffer.toString("utf8");
30
+ for (const ch of text) {
31
+ if (ch === "\u0003") {
32
+ cleanup();
33
+ reject(new Error("Cancelled"));
34
+ return;
35
+ }
36
+ if (ch === "\r" || ch === "\n") {
37
+ cleanup();
38
+ resolve(value);
39
+ return;
40
+ }
41
+ if (ch === "\u007f" || ch === "\b") {
42
+ value = value.slice(0, -1);
43
+ continue;
44
+ }
45
+ if (ch >= " ") {
46
+ value += ch;
47
+ }
48
+ }
49
+ }
50
+
51
+ output.write(question);
52
+ input.setRawMode(true);
53
+ input.resume();
54
+ input.on("data", onData);
55
+ });
56
+ }
57
+
58
+ function parseCredentialsInput(text) {
59
+ const lines = String(text || "").split(/\r?\n/);
60
+ return {
61
+ email: (lines[0] || "").trim(),
62
+ password: lines[1] || "",
63
+ };
64
+ }
65
+
66
+ function readAll(input) {
67
+ return new Promise((resolve, reject) => {
68
+ let text = "";
69
+ input.setEncoding("utf8");
70
+ input.on("data", (chunk) => {
71
+ text += chunk;
72
+ });
73
+ input.on("error", reject);
74
+ input.on("end", () => resolve(text));
75
+ });
76
+ }
77
+
78
+ async function askCredentials({ input = process.stdin, output = process.stderr } = {}) {
79
+ if (!input.isTTY) {
80
+ const parsed = parseCredentialsInput(await readAll(input));
81
+ if (!parsed.email || !parsed.password) {
82
+ throw new Error("Non-interactive login expects email and password on stdin, one per line.");
83
+ }
84
+ return parsed;
85
+ }
86
+ return {
87
+ email: await ask("Email: ", { input, output }),
88
+ password: await askHidden("Password: ", { input, output }),
89
+ };
90
+ }
91
+
92
+ async function choose(label, choices, { input = process.stdin, output = process.stderr } = {}) {
93
+ if (!Array.isArray(choices) || choices.length === 0) {
94
+ throw new Error(`No ${label} available.`);
95
+ }
96
+ if (choices.length === 1) {
97
+ output.write(`Using ${label}: ${choices[0].label}\n`);
98
+ return choices[0].value;
99
+ }
100
+
101
+ output.write(`${label}:\n`);
102
+ choices.forEach((choice, index) => {
103
+ output.write(` ${index + 1}. ${choice.label}\n`);
104
+ });
105
+
106
+ while (true) {
107
+ const answer = await ask(`Choose ${label} [1-${choices.length}]: `, { input, output });
108
+ const n = Number.parseInt(answer, 10);
109
+ if (Number.isInteger(n) && n >= 1 && n <= choices.length) {
110
+ return choices[n - 1].value;
111
+ }
112
+ output.write(`Enter a number from 1 to ${choices.length}.\n`);
113
+ }
114
+ }
115
+
116
+ module.exports = {
117
+ ask,
118
+ askCredentials,
119
+ askHidden,
120
+ choose,
121
+ parseCredentialsInput,
122
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@iann29/synapse",
3
+ "version": "1.6.17",
4
+ "description": "Thin CLI wrapper for using the official Convex CLI with Synapse-managed deployments.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Iann29/convex-synapse.git",
9
+ "directory": "cli"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Iann29/convex-synapse/issues"
13
+ },
14
+ "homepage": "https://github.com/Iann29/convex-synapse#readme",
15
+ "keywords": [
16
+ "convex",
17
+ "synapse",
18
+ "convex-synapse",
19
+ "self-hosted",
20
+ "cli"
21
+ ],
22
+ "bin": {
23
+ "synapse": "bin/synapse.js"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "lib",
28
+ "README.md"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "test": "node --test"
35
+ },
36
+ "engines": {
37
+ "node": ">=18.17"
38
+ },
39
+ "dependencies": {},
40
+ "devDependencies": {}
41
+ }