@iann29/synapse 1.8.6 → 1.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ // `synapse env set NAME=value [NAME2=value2 ...] [--for=dev,prod] [--project=<id>] [--json]`
2
+ //
3
+ // Batch-sets project-default env vars. Multiple positionals = single
4
+ // transactional update on the backend (the handler wraps the whole
5
+ // `changes` array in a BEGIN/COMMIT). If any value is rejected the
6
+ // whole batch rolls back.
7
+ //
8
+ // Value parsing: split on the FIRST `=`. So `FOO=a=b` sets FOO to "a=b".
9
+ // Names must match /^[A-Z_][A-Z0-9_]*$/ — same shape the Convex backend
10
+ // accepts (uppercase letters, digits, underscore; leading non-digit).
11
+
12
+ const colors = require("../colors");
13
+ const { extractFlags, resolveProject } = require("./_resource");
14
+
15
+ const NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
16
+ const VALID_FORS = new Set(["dev", "prod", "preview"]);
17
+
18
+ // Splits `NAME=value` on the FIRST `=`. Returns { name, value } or
19
+ // throws when the shape is wrong. Empty value is OK ("FOO=" sets to "").
20
+ function parsePair(arg) {
21
+ const eq = arg.indexOf("=");
22
+ if (eq <= 0) {
23
+ throw new Error(
24
+ `Invalid env assignment: ${JSON.stringify(arg)}. Expected NAME=value.`,
25
+ );
26
+ }
27
+ const name = arg.slice(0, eq);
28
+ const value = arg.slice(eq + 1);
29
+ if (!NAME_RE.test(name)) {
30
+ throw new Error(
31
+ `Invalid env name: ${JSON.stringify(name)}. Names must match [A-Z_][A-Z0-9_]*.`,
32
+ );
33
+ }
34
+ return { name, value };
35
+ }
36
+
37
+ // Comma-split + trim + dedupe a `--for=dev,prod` argument. Empty
38
+ // returns null (= "use default"). Errors on unknown types.
39
+ function parseFor(raw) {
40
+ if (raw === undefined || raw === null || raw === true) return null;
41
+ const trimmed = String(raw).trim();
42
+ if (trimmed === "") return null;
43
+ const parts = [
44
+ ...new Set(trimmed.split(",").map((s) => s.trim().toLowerCase())),
45
+ ].filter(Boolean);
46
+ for (const p of parts) {
47
+ if (!VALID_FORS.has(p)) {
48
+ throw new Error(
49
+ `Invalid --for entry: ${p}. Must be one of: ${[...VALID_FORS].join(", ")}.`,
50
+ );
51
+ }
52
+ }
53
+ return parts;
54
+ }
55
+
56
+ module.exports = {
57
+ name: "env set",
58
+ summary: "Set one or more project-default environment variables.",
59
+ usage:
60
+ "synapse env set NAME=value [NAME2=value2 ...] [--for=dev,prod] [--project=<id>] [--json]",
61
+ description: `Calls POST /v1/projects/{id}/update_default_environment_variables with op=set. Multiple pairs are applied in a single transaction. Names follow the [A-Z_][A-Z0-9_]* convention; values are byte-preserved (whitespace, quotes, '=' all OK).
62
+
63
+ Flags:
64
+ --for=dev,prod Limit the var to specific deploymentTypes
65
+ (comma-separated). Omitted: backend defaults
66
+ (typically: all types).
67
+ --project=<id> Override the linked project.
68
+ --json Machine-readable output.
69
+
70
+ Examples:
71
+ synapse env set OPENAI_API_KEY=sk-abc...
72
+ synapse env set FOO=bar BAZ=qux --for=prod
73
+ synapse env set REDIS_URL='rediss://user:pass@host:6379/0'`,
74
+
75
+ // Re-exports for tests.
76
+ parsePair,
77
+ parseFor,
78
+ NAME_RE,
79
+
80
+ async run(args, ctx) {
81
+ const { flags, rest } = extractFlags(args, {
82
+ string: ["for", "project"],
83
+ boolean: ["json"],
84
+ });
85
+ if (rest.length === 0) {
86
+ throw new Error(
87
+ "Usage: synapse env set NAME=value [NAME2=value2 ...] [--for=...]",
88
+ );
89
+ }
90
+ const forList = parseFor(flags.for);
91
+ const pairs = rest.map(parsePair);
92
+
93
+ const resolveArgs = flags.project ? [`--project=${flags.project}`] : [];
94
+ const { projectId, source } = resolveProject(ctx, resolveArgs);
95
+
96
+ const changes = pairs.map(({ name, value }) => ({
97
+ op: "set",
98
+ name,
99
+ value,
100
+ ...(forList ? { deploymentTypes: forList } : {}),
101
+ }));
102
+
103
+ if (!ctx.out.json) {
104
+ ctx.out.info(
105
+ `Setting ${pairs.length} env var${pairs.length > 1 ? "s" : ""} on project ${projectId}${forList ? ` (for: ${forList.join(",")})` : ""}…`,
106
+ );
107
+ }
108
+ await ctx.api.updateProjectEnvVars(projectId, changes);
109
+
110
+ ctx.out.result(
111
+ {
112
+ projectId,
113
+ projectSource: source,
114
+ applied: changes.length,
115
+ changes: changes.map((c) => ({ name: c.name, deploymentTypes: c.deploymentTypes ?? null })),
116
+ },
117
+ (_d, { stdout }) => {
118
+ for (const { name } of pairs) {
119
+ stdout.write(`${colors.green("+")} ${colors.bold(name)}\n`);
120
+ }
121
+ stdout.write(
122
+ colors.dim(
123
+ "These defaults are injected into NEW deployments. To apply to existing deployments, run `synapse convex env set` per deployment, or re-create them.\n",
124
+ ),
125
+ );
126
+ },
127
+ );
128
+ },
129
+ };
@@ -0,0 +1,77 @@
1
+ // `synapse env unset NAME [NAME2 ...] [--project=<id>] [--json]`
2
+ //
3
+ // Batch-deletes project-default env vars. Sends op=delete for each
4
+ // name in a single transactional update. Unknown names are not an
5
+ // error server-side (the DELETE is idempotent), but we surface a
6
+ // warning when ALL passed names were unknown — usually a typo.
7
+
8
+ const colors = require("../colors");
9
+ const { extractFlags, resolveProject } = require("./_resource");
10
+
11
+ const NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
12
+
13
+ module.exports = {
14
+ name: "env unset",
15
+ summary: "Delete one or more project-default environment variables.",
16
+ usage: "synapse env unset NAME [NAME2 ...] [--project=<id>] [--json]",
17
+ description: `Calls POST /v1/projects/{id}/update_default_environment_variables with op=delete. Idempotent — deleting a name that doesn't exist is silent.
18
+
19
+ Flags:
20
+ --project=<id> Override the linked project.
21
+ --json Machine-readable output.
22
+
23
+ Examples:
24
+ synapse env unset OLD_API_KEY
25
+ synapse env unset FOO BAR BAZ`,
26
+
27
+ async run(args, ctx) {
28
+ const { flags, rest } = extractFlags(args, {
29
+ string: ["project"],
30
+ boolean: ["json"],
31
+ });
32
+ if (rest.length === 0) {
33
+ throw new Error("Usage: synapse env unset NAME [NAME2 ...]");
34
+ }
35
+ for (const name of rest) {
36
+ if (!NAME_RE.test(name)) {
37
+ throw new Error(
38
+ `Invalid env name: ${JSON.stringify(name)}. Names must match [A-Z_][A-Z0-9_]*.`,
39
+ );
40
+ }
41
+ }
42
+
43
+ const resolveArgs = flags.project ? [`--project=${flags.project}`] : [];
44
+ const { projectId, source } = resolveProject(ctx, resolveArgs);
45
+
46
+ // We could pre-fetch the existing var list to warn about unknown
47
+ // names. Skipping: an extra GET on every unset doubles latency for
48
+ // the common case where the operator typed the right name. The
49
+ // backend's op=delete is idempotent.
50
+ const changes = rest.map((name) => ({ op: "delete", name }));
51
+ if (!ctx.out.json) {
52
+ ctx.out.info(
53
+ `Unsetting ${rest.length} env var${rest.length > 1 ? "s" : ""} on project ${projectId}…`,
54
+ );
55
+ }
56
+ await ctx.api.updateProjectEnvVars(projectId, changes);
57
+
58
+ ctx.out.result(
59
+ {
60
+ projectId,
61
+ projectSource: source,
62
+ deleted: rest.length,
63
+ names: rest,
64
+ },
65
+ (_d, { stdout }) => {
66
+ for (const name of rest) {
67
+ stdout.write(`${colors.red("-")} ${colors.bold(name)}\n`);
68
+ }
69
+ stdout.write(
70
+ colors.dim(
71
+ "Existing deployments keep their cached value until they're recreated.\n",
72
+ ),
73
+ );
74
+ },
75
+ );
76
+ },
77
+ };
@@ -0,0 +1,186 @@
1
+ // `synapse list [deployments|projects|teams]` — read-only catalog
2
+ // dumper. Single-arg subcommand that mirrors what you'd see in the
3
+ // dashboard, but scriptable (every subcommand supports --json).
4
+ //
5
+ // Resolution rules:
6
+ // - `list teams` → no flag needed (always lists the operator's teams)
7
+ // - `list projects` → uses linked team OR --team=<slug|id>
8
+ // - `list deployments` → uses linked project OR --project=<id>
9
+
10
+ const colors = require("../colors");
11
+ const { extractTeamFlag, extractProjectFlag } = require("./_resource");
12
+
13
+ const KINDS = ["deployments", "projects", "teams"];
14
+
15
+ async function listTeams(ctx) {
16
+ const teams = await ctx.api.teams();
17
+ ctx.out.result(
18
+ { kind: "teams", count: teams.length, teams },
19
+ (_d, { stdout }) => {
20
+ if (teams.length === 0) {
21
+ stdout.write(colors.dim("(no teams — create one in the dashboard)\n"));
22
+ return;
23
+ }
24
+ ctx.out.table(
25
+ teams.map((t) => ({
26
+ name: colors.bold(t.name || t.slug || t.id),
27
+ slug: t.slug || colors.dim("?"),
28
+ id: colors.dim(t.id),
29
+ })),
30
+ [
31
+ { key: "name", header: "NAME" },
32
+ { key: "slug", header: "SLUG" },
33
+ { key: "id", header: "ID" },
34
+ ],
35
+ );
36
+ },
37
+ );
38
+ }
39
+
40
+ async function listProjects(ctx, args) {
41
+ const { teamRef: explicit, rest } = extractTeamFlag(args);
42
+ if (rest.length > 0) {
43
+ throw new Error(
44
+ `Unexpected positional: ${rest[0]}. Usage: synapse list projects [--team=<slug|id>]`,
45
+ );
46
+ }
47
+ // Linked-project fallback uses ctx.projectConfig.team.slug.
48
+ let teamRef = explicit;
49
+ let source = "flag";
50
+ if (!teamRef) {
51
+ const linked = ctx.projectConfig;
52
+ if (linked && linked.team && (linked.team.slug || linked.team.id)) {
53
+ teamRef = linked.team.slug || linked.team.id;
54
+ source = "linked";
55
+ }
56
+ }
57
+ if (!teamRef) {
58
+ throw new Error(
59
+ "No linked team. Pass --team=<slug|id> or run `synapse select` first.",
60
+ );
61
+ }
62
+ const projects = await ctx.api.projects(teamRef);
63
+ ctx.out.result(
64
+ { kind: "projects", team: teamRef, teamSource: source, count: projects.length, projects },
65
+ (_d, { stdout }) => {
66
+ stdout.write(colors.dim(`Team: ${teamRef}\n`));
67
+ if (projects.length === 0) {
68
+ stdout.write(colors.dim("(no projects)\n"));
69
+ return;
70
+ }
71
+ ctx.out.table(
72
+ projects.map((p) => ({
73
+ name: colors.bold(p.name || p.slug),
74
+ slug: p.slug || colors.dim("?"),
75
+ id: colors.dim(p.id),
76
+ })),
77
+ [
78
+ { key: "name", header: "NAME" },
79
+ { key: "slug", header: "SLUG" },
80
+ { key: "id", header: "ID" },
81
+ ],
82
+ );
83
+ },
84
+ );
85
+ }
86
+
87
+ async function listDeployments(ctx, args) {
88
+ const { projectId: explicit, rest } = extractProjectFlag(args);
89
+ if (rest.length > 0) {
90
+ throw new Error(
91
+ `Unexpected positional: ${rest[0]}. Usage: synapse list deployments [--project=<id>]`,
92
+ );
93
+ }
94
+ let projectId = explicit;
95
+ let source = "flag";
96
+ if (!projectId) {
97
+ const linked = ctx.projectConfig;
98
+ if (linked && linked.project && linked.project.id) {
99
+ projectId = linked.project.id;
100
+ source = "linked";
101
+ }
102
+ }
103
+ if (!projectId) {
104
+ throw new Error(
105
+ "No linked project. Pass --project=<id> or run `synapse select` first.",
106
+ );
107
+ }
108
+ const deployments = await ctx.api.deployments(projectId);
109
+ ctx.out.result(
110
+ { kind: "deployments", projectId, projectSource: source, count: deployments.length, deployments },
111
+ (_d, { stdout }) => {
112
+ stdout.write(colors.dim(`Project: ${projectId}\n`));
113
+ if (deployments.length === 0) {
114
+ stdout.write(colors.dim("(no deployments — create one with `synapse deployment create`)\n"));
115
+ return;
116
+ }
117
+ ctx.out.table(
118
+ deployments.map((d) => ({
119
+ name: colors.bold(d.name),
120
+ type: typeBadge(d.deploymentType || d.type),
121
+ status: colors.statusBadge(d.status || ""),
122
+ url: d.deploymentUrl || d.url || colors.dim("(no url)"),
123
+ })),
124
+ [
125
+ { key: "name", header: "NAME" },
126
+ { key: "type", header: "TYPE" },
127
+ { key: "status", header: "STATUS" },
128
+ { key: "url", header: "URL" },
129
+ ],
130
+ );
131
+ },
132
+ );
133
+ }
134
+
135
+ function typeBadge(t) {
136
+ switch (t) {
137
+ case "prod":
138
+ return colors.yellow((t || "").toUpperCase());
139
+ case "dev":
140
+ return colors.cyan ? colors.cyan(t.toUpperCase()) : t.toUpperCase();
141
+ case "preview":
142
+ return colors.dim((t || "").toUpperCase());
143
+ default:
144
+ return t ? colors.dim(t) : "";
145
+ }
146
+ }
147
+
148
+ module.exports = {
149
+ name: "list",
150
+ summary: "List teams, projects, or deployments visible to your session.",
151
+ usage: "synapse list <deployments|projects|teams> [--project=<id>] [--team=<slug>] [--json]",
152
+ description: `Catalogs the resource of the chosen kind. All subcommands support --json for scripting.
153
+
154
+ Resource resolution:
155
+ list teams always lists every team you're a member of
156
+ list projects uses the linked team if no --team flag
157
+ list deployments uses the linked project if no --project flag
158
+
159
+ Examples:
160
+ synapse list teams
161
+ synapse list projects --team=amageia
162
+ synapse list deployments --project=00000000-0000-0000-0000-000000000000
163
+ synapse list deployments --json | jq '.deployments[] | .name'`,
164
+
165
+ // Exports for tests.
166
+ KINDS,
167
+ listTeams,
168
+ listProjects,
169
+ listDeployments,
170
+
171
+ async run(args, ctx) {
172
+ const kind = args[0];
173
+ if (!kind) {
174
+ throw new Error(`Usage: synapse list <${KINDS.join("|")}>`);
175
+ }
176
+ if (!KINDS.includes(kind)) {
177
+ throw new Error(
178
+ `Unknown list kind: ${kind}. Try: ${KINDS.join(" | ")}.`,
179
+ );
180
+ }
181
+ const rest = args.slice(1);
182
+ if (kind === "teams") return listTeams(ctx);
183
+ if (kind === "projects") return listProjects(ctx, rest);
184
+ return listDeployments(ctx, rest);
185
+ },
186
+ };
package/lib/prompts.js CHANGED
@@ -187,6 +187,55 @@ async function choose(label, choices, {
187
187
  }
188
188
  }
189
189
 
190
+ // typedConfirm prompts the user to literally re-type a string (typically
191
+ // the name of a resource about to be destroyed) before proceeding. Match
192
+ // is case-sensitive and exact — `--yes` does NOT bypass this prompt,
193
+ // only an explicit pre-supplied `typed` answer does (for non-interactive
194
+ // CI where the operator already scripted the confirmation).
195
+ //
196
+ // Why we need this in addition to `confirm`: y/n prompts get muscle-
197
+ // memory-yes'd. Typing the literal name of a prod deployment makes the
198
+ // operator look at what they're deleting one more time. Mirrors the
199
+ // dashboard's `delete-deployment` flow shipped in v1.7.2.
200
+ async function typedConfirm(label, expected, {
201
+ input = process.stdin,
202
+ output = process.stderr,
203
+ typed = null,
204
+ maxAttempts = 3,
205
+ } = {}) {
206
+ if (typeof expected !== "string" || expected === "") {
207
+ throw new Error("typedConfirm requires a non-empty `expected` string");
208
+ }
209
+ // Non-interactive shortcut: caller pre-supplied the typed string via
210
+ // a flag (`--confirm=<name>`). We still require exact match; passing
211
+ // the wrong value should NOT proceed even if the caller meant well.
212
+ if (typed !== null) {
213
+ return typed === expected;
214
+ }
215
+ if (!input.isTTY) {
216
+ throw new Error(
217
+ `Refusing to ${label} without a TTY. Re-run with --confirm=${expected} to confirm non-interactively.`,
218
+ );
219
+ }
220
+ const rl = readline.createInterface({ input, output });
221
+ try {
222
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
223
+ const raw = await new Promise((resolve) =>
224
+ rl.question(`Type ${expected} to confirm ${label}: `, resolve),
225
+ );
226
+ const answer = raw.trim();
227
+ if (answer === expected) return true;
228
+ if (answer === "" || answer.toLowerCase() === "n" || answer.toLowerCase() === "no") {
229
+ return false;
230
+ }
231
+ output.write(`Doesn't match. Type the exact name "${expected}" or press Enter to cancel.\n`);
232
+ }
233
+ return false;
234
+ } finally {
235
+ rl.close();
236
+ }
237
+ }
238
+
190
239
  module.exports = {
191
240
  BACK,
192
241
  ask,
@@ -195,4 +244,5 @@ module.exports = {
195
244
  choose,
196
245
  confirm,
197
246
  parseCredentialsInput,
247
+ typedConfirm,
198
248
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iann29/synapse",
3
- "version": "1.8.6",
3
+ "version": "1.8.7",
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": {