@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.
package/README.md CHANGED
@@ -15,6 +15,26 @@ npm install -g @iann29/synapse
15
15
  synapse --help
16
16
  ```
17
17
 
18
+ ### Windows: ensure the npm global bin directory is in PATH
19
+
20
+ On a fresh Node.js install, Windows does **not** always add
21
+ `%APPDATA%\npm` to the user PATH. After `npm install -g`, the
22
+ `synapse` binary exists but `synapse --help` errors with
23
+ *"not recognised as the name of a cmdlet"*. Fix once in PowerShell:
24
+
25
+ ```powershell
26
+ [Environment]::SetEnvironmentVariable(
27
+ 'PATH',
28
+ "$([Environment]::GetEnvironmentVariable('PATH','User'));$env:APPDATA\npm",
29
+ 'User'
30
+ )
31
+ ```
32
+
33
+ Close every terminal (and your IDE — VS Code caches the env at launch)
34
+ and reopen. `synapse --help` should now print the usage. This is a
35
+ one-time, Node-installer-version-dependent issue; it is not specific
36
+ to this package.
37
+
18
38
  For one app/project:
19
39
 
20
40
  ```bash
package/bin/synapse.js CHANGED
@@ -1,372 +1,98 @@
1
1
  #!/usr/bin/env node
2
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");
3
+ // Thin dispatcher. Every command's logic lives in lib/commands/*.js;
4
+ // this file's only jobs are:
5
+ // 1. Parse argv into (cmd, rest) via the two-then-one registry.
6
+ // 2. Short-circuit --help / help.
7
+ // 3. Construct the runtime ctx (output layer, lazy session+API).
8
+ // 4. Catch top-level errors and emit a consistent stderr message.
9
+ //
10
+ // Legacy named exports at the bottom are kept ONLY for backwards-
11
+ // compatibility with test/bin.test.js — production code paths never
12
+ // need to require this file as a library.
13
+
14
+ const { buildRegistry, resolve, wantsHelp } = require("../lib/commands/_dispatcher");
15
+ const { renderRootHelp, renderCommandHelp } = require("../lib/commands/_help");
16
+ const { createContext } = require("../lib/commands/_context");
17
+ const { createOutput, extractJsonFlag } = require("../lib/output");
18
+ const { SynapseAPIError } = require("../lib/api");
19
+
20
+ const REGISTRY = buildRegistry();
14
21
 
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
- }
22
+ async function main(argv) {
23
+ // Strip --json from any position so commands see clean positionals.
24
+ const { json, rest: cleanArgv } = extractJsonFlag(argv);
111
25
 
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");
26
+ // help / no-args → root help.
27
+ if (cleanArgv.length === 0 || cleanArgv[0] === "help" || cleanArgv[0] === "-h" || cleanArgv[0] === "--help") {
28
+ return renderRootHelp(REGISTRY);
134
29
  }
135
- return {
136
- explicitTarget: Boolean(target),
137
- target,
138
- args: args.slice(index),
139
- };
140
- }
141
30
 
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
- };
31
+ const { cmd, rest } = resolve(REGISTRY, cleanArgv);
32
+ if (!cmd) {
33
+ process.stderr.write(`Unknown command: ${cleanArgv.join(" ")}\n\nRun \`synapse help\` for the full list.\n`);
34
+ process.exitCode = 1;
35
+ return;
169
36
  }
170
37
 
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
- );
38
+ if (wantsHelp(rest)) {
39
+ return renderCommandHelp(cmd);
182
40
  }
183
41
 
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
- };
42
+ const out = createOutput({ json });
43
+ const ctx = createContext({ out });
44
+ return await cmd.run(rest, ctx);
195
45
  }
196
46
 
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);
47
+ if (require.main === module) {
48
+ main(process.argv.slice(2)).catch((err) => {
49
+ process.stderr.write(`${err.message}\n`);
50
+ if (err && err.code === "network_error") {
51
+ process.stderr.write(
52
+ "Hint: double-check the URL is reachable from this machine (try `curl <url>/v1/install_status`) and that the Synapse server is running.\n",
53
+ );
222
54
  }
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,
55
+ process.exitCode = 1;
245
56
  });
246
- process.stderr.write(`Saved Synapse session to ${file}\n`);
247
57
  }
248
58
 
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
- }
59
+ // ---- Legacy exports (test/bin.test.js consumes these) --------------
60
+ //
61
+ // Re-export the helpers the existing tests already import. Adding new
62
+ // commands does NOT add to this list — new code lives in lib/commands/
63
+ // and is tested directly there.
253
64
 
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
- }
65
+ const _convexCmd = require("../lib/commands/convex");
66
+ const _deployCmd = require("../lib/commands/deploy");
67
+ const _devCmd = require("../lib/commands/dev");
68
+ const _credentialsCmd = require("../lib/commands/credentials");
69
+ const _selectCmd = require("../lib/commands/select");
70
+ const _ctxModule = require("../lib/commands/_context");
261
71
 
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
- });
72
+ // `clientFromConfig` was the pre-refactor entry point that returned
73
+ // { cfg, api } for any command that needed auth. Kept here as a thin
74
+ // shim around the same underlying helper so test/bin.test.js's
75
+ // "clientFromConfig refreshes an expired access token" still passes.
76
+ function clientFromConfig() {
77
+ const { requireConfig } = require("../lib/config");
78
+ const cfg = requireConfig();
79
+ const api = _ctxModule.makeRefreshableApi(cfg);
80
+ return { cfg, api };
361
81
  }
362
82
 
363
83
  module.exports = {
364
- chooseDeploymentForType,
365
- clientFromConfig,
366
- formatCredentials,
367
- inferConvexTarget,
368
84
  main,
369
- parseConvexInvocation,
370
- parseFormat,
371
- resolveConvexInvocation,
85
+ clientFromConfig,
86
+ // dev / deploy keep their pre-refactor signatures for test injectors.
87
+ deploy: _deployCmd.deploy,
88
+ dev: _devCmd.dev,
89
+ extractYesFlag: _deployCmd.extractYesFlag,
90
+ formatCredentials: _credentialsCmd.formatCredentials,
91
+ parseFormat: _credentialsCmd.parseFormat,
92
+ // convex command exposes the pure parsers used by tests.
93
+ inferConvexTarget: _convexCmd.inferConvexTarget,
94
+ parseConvexInvocation: _convexCmd.parseConvexInvocation,
95
+ resolveConvexInvocation: _convexCmd.resolveConvexInvocation,
96
+ // select command's helpers.
97
+ chooseDeploymentForType: _selectCmd.chooseDeploymentForType,
372
98
  };
package/lib/api.js CHANGED
@@ -75,10 +75,23 @@ class SynapseAPI {
75
75
  pageURL.searchParams.set("cursor", cursor);
76
76
  }
77
77
  const page = await this.request("GET", `${pageURL.pathname}${pageURL.search}`, undefined, { includeHeaders: true });
78
- if (!Array.isArray(page.data)) {
79
- throw new SynapseAPIError(0, "bad_response", `Expected ${path} to return a JSON array`);
78
+ // Backend currently returns a bare JSON array for every paginated
79
+ // endpoint, but the dashboard already tolerates both `[...]` and
80
+ // `{ <noun>: [...] }` (see `collectPaginated` in dashboard/lib/api.ts).
81
+ // Mirror that resilience here so a future server-side reshape doesn't
82
+ // brick `synapse select` for every CLI user simultaneously.
83
+ const arr = extractListPayload(page.data);
84
+ if (arr === null) {
85
+ const shape = page.data && typeof page.data === "object"
86
+ ? `object with keys [${Object.keys(page.data).join(", ")}]`
87
+ : typeof page.data;
88
+ throw new SynapseAPIError(
89
+ 0,
90
+ "bad_response",
91
+ `Expected ${path} to return a JSON array (got ${shape})`,
92
+ );
80
93
  }
81
- items.push(...page.data);
94
+ items.push(...arr);
82
95
  cursor = page.headers.get("x-next-cursor") || "";
83
96
  } while (cursor);
84
97
  return items;
@@ -113,7 +126,36 @@ class SynapseAPI {
113
126
  }
114
127
  }
115
128
 
129
+ // Known envelope keys, in priority order. We try these explicitly before
130
+ // falling back to a generic "first array-valued property" lookup so a
131
+ // future endpoint that wraps results in `{ items: [...] }` (or similar)
132
+ // keeps working. The fallback handles the unlikely case of a renamed
133
+ // envelope without crashing the CLI.
134
+ const KNOWN_LIST_KEYS = [
135
+ "teams",
136
+ "projects",
137
+ "deployments",
138
+ "members",
139
+ "items",
140
+ "data",
141
+ "results",
142
+ ];
143
+
144
+ function extractListPayload(data) {
145
+ if (Array.isArray(data)) return data;
146
+ if (data && typeof data === "object") {
147
+ for (const key of KNOWN_LIST_KEYS) {
148
+ if (Array.isArray(data[key])) return data[key];
149
+ }
150
+ for (const value of Object.values(data)) {
151
+ if (Array.isArray(value)) return value;
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+
116
157
  module.exports = {
117
158
  SynapseAPI,
118
159
  SynapseAPIError,
160
+ extractListPayload,
119
161
  };
package/lib/colors.js ADDED
@@ -0,0 +1,51 @@
1
+ // Zero-dependency ANSI helpers. Each helper returns its argument as-is when
2
+ // the terminal does not support colour (stdout is not a TTY) or the user
3
+ // opted out via NO_COLOR (https://no-color.org). Checked at call time, not
4
+ // at require time, so tests that toggle `process.env.NO_COLOR` or that run
5
+ // under `node --test` (no TTY) get plain output without further wiring.
6
+ //
7
+ // We deliberately do not depend on `chalk` / `kleur` / etc — the @iann29/
8
+ // synapse package ships zero runtime deps, and a handful of escape codes
9
+ // don't justify breaking that.
10
+
11
+ function enabled(stream = process.stdout) {
12
+ if (process.env.NO_COLOR) return false;
13
+ if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") return true;
14
+ return Boolean(stream && stream.isTTY);
15
+ }
16
+
17
+ function wrap(code) {
18
+ return (value, stream) =>
19
+ enabled(stream) ? `\x1b[${code}m${value}\x1b[0m` : String(value);
20
+ }
21
+
22
+ // Renders the status portion of a deployment row. `running` reads as success,
23
+ // `failed` as error, `provisioning` as in-flight, anything else as dim noise.
24
+ // Falls through to the plain string when colour is off so logs grep cleanly.
25
+ function statusBadge(status, stream) {
26
+ switch (status) {
27
+ case "running":
28
+ return module.exports.green(status, stream);
29
+ case "failed":
30
+ case "errored":
31
+ return module.exports.red(status, stream);
32
+ case "provisioning":
33
+ return module.exports.yellow(status, stream);
34
+ case "stopped":
35
+ case "deleted":
36
+ return module.exports.dim(status, stream);
37
+ default:
38
+ return String(status || "");
39
+ }
40
+ }
41
+
42
+ module.exports = {
43
+ enabled,
44
+ bold: wrap("1"),
45
+ dim: wrap("2"),
46
+ red: wrap("31"),
47
+ green: wrap("32"),
48
+ yellow: wrap("33"),
49
+ cyan: wrap("36"),
50
+ statusBadge,
51
+ };