@iann29/synapse 1.8.5 → 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,248 @@
1
+ // `synapse env push [--from=<path>] [--for=<types>] [--project=<id>]
2
+ // [--dry-run] [--yes] [--json]`
3
+ //
4
+ // Reads a .env-shaped file and applies it to the project's defaults
5
+ // via op=set on every entry. `--dry-run` prints the diff and exits
6
+ // without calling the backend (RECOMMENDED on first push).
7
+ //
8
+ // Safety:
9
+ // - Refuses to push CONVEX_SELF_HOSTED_URL / CONVEX_SELF_HOSTED_ADMIN_KEY
10
+ // (those belong in the operator's local .env.local, not in the
11
+ // project-default surface that gets injected into containers).
12
+ // - Refuses to push NEXT_PUBLIC_CONVEX_URL / SITE_URL for the same
13
+ // reason — they're managed by `synapse select`.
14
+ // - Without --yes the command shows a diff and prompts.
15
+
16
+ const fs = require("node:fs");
17
+ const colors = require("../colors");
18
+ const { extractFlags, resolveProject } = require("./_resource");
19
+ const { parseEnvContent } = require("../env-file");
20
+ const { confirm } = require("../prompts");
21
+
22
+ // Names that MUST NEVER land in the project-default surface. A push
23
+ // that includes any of these is rejected before any backend call.
24
+ const DENY_LIST = new Set([
25
+ "CONVEX_SELF_HOSTED_URL",
26
+ "CONVEX_SELF_HOSTED_ADMIN_KEY",
27
+ "CONVEX_DEPLOYMENT",
28
+ "NEXT_PUBLIC_CONVEX_URL",
29
+ "NEXT_PUBLIC_CONVEX_SITE_URL",
30
+ ]);
31
+
32
+ const NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
33
+ const VALID_FORS = new Set(["dev", "prod", "preview"]);
34
+
35
+ function diffEnv(currentConfigs, parsed) {
36
+ const current = new Map();
37
+ for (const c of currentConfigs) current.set(c.name, c.value);
38
+ const added = [];
39
+ const changed = [];
40
+ const unchanged = [];
41
+ for (const [name, value] of Object.entries(parsed)) {
42
+ if (!current.has(name)) {
43
+ added.push({ name, value });
44
+ continue;
45
+ }
46
+ if (current.get(name) !== value) {
47
+ changed.push({ name, value, from: current.get(name) });
48
+ continue;
49
+ }
50
+ unchanged.push({ name, value });
51
+ }
52
+ return { added, changed, unchanged };
53
+ }
54
+
55
+ module.exports = {
56
+ name: "env push",
57
+ summary: "Apply a .env-shaped file to project-default env vars.",
58
+ usage:
59
+ "synapse env push [--from=<path>] [--for=<types>] [--project=<id>] [--dry-run] [--yes] [--json]",
60
+ description: `Reads a .env file and sets each NAME=value as a project-default env var (op=set in batch). Best practice: run with --dry-run FIRST to see what would change.
61
+
62
+ The file format is the same one \`synapse env pull\` writes. Quoted values, comments, blank lines, and \`export\` prefixes are all tolerated.
63
+
64
+ The following names are REJECTED with an error (they live in .env.local, not in the project-default surface):
65
+ CONVEX_SELF_HOSTED_URL, CONVEX_SELF_HOSTED_ADMIN_KEY,
66
+ CONVEX_DEPLOYMENT, NEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_CONVEX_SITE_URL
67
+
68
+ Flags:
69
+ --from=<path> File to read (default: .env in current dir).
70
+ --for=dev,prod Stamp the listed deploymentTypes on each var.
71
+ --project=<id> Override the linked project.
72
+ --dry-run Show the diff and exit; no backend call.
73
+ --yes Skip the y/N confirmation prompt.
74
+ --json Machine-readable output.
75
+
76
+ Examples:
77
+ synapse env push --from=.env.synapse --dry-run
78
+ synapse env push --from=.env.prod --for=prod --yes`,
79
+
80
+ // Re-exports for tests.
81
+ diffEnv,
82
+ DENY_LIST,
83
+
84
+ async run(args, ctx) {
85
+ const { flags, rest } = extractFlags(args, {
86
+ string: ["from", "for", "project"],
87
+ boolean: ["dry-run", "yes", "json"],
88
+ });
89
+ if (rest.length > 0) {
90
+ throw new Error(`Unexpected positional: ${rest[0]}.`);
91
+ }
92
+ const path = typeof flags.from === "string" ? flags.from : ".env";
93
+ if (!fs.existsSync(path)) {
94
+ throw new Error(
95
+ `File not found: ${path}. Pass --from=<path> or create one with \`synapse env pull\`.`,
96
+ );
97
+ }
98
+ const dryRun = flags["dry-run"] === true || flags["dry-run"] === "true";
99
+ const yes = flags.yes === true || flags.yes === "true";
100
+
101
+ // Parse --for. Empty = backend default behaviour (no array sent).
102
+ let forList = null;
103
+ if (typeof flags.for === "string" && flags.for.trim() !== "") {
104
+ forList = [
105
+ ...new Set(flags.for.split(",").map((s) => s.trim().toLowerCase())),
106
+ ].filter(Boolean);
107
+ for (const f of forList) {
108
+ if (!VALID_FORS.has(f)) {
109
+ throw new Error(
110
+ `Invalid --for entry: ${f}. Must be one of: ${[...VALID_FORS].join(", ")}.`,
111
+ );
112
+ }
113
+ }
114
+ }
115
+
116
+ const parsed = parseEnvContent(fs.readFileSync(path, "utf8"));
117
+ // Reject denied names BEFORE any backend call. Surface ALL of them
118
+ // so the operator can fix the file once instead of N times.
119
+ const violations = Object.keys(parsed).filter((n) => DENY_LIST.has(n));
120
+ if (violations.length > 0) {
121
+ throw new Error(
122
+ `Refusing to push: ${violations.join(", ")} are reserved (live in .env.local, not in project-default env). Remove them from ${path} first.`,
123
+ );
124
+ }
125
+ // Validate name shape.
126
+ for (const name of Object.keys(parsed)) {
127
+ if (!NAME_RE.test(name)) {
128
+ throw new Error(
129
+ `Invalid env name in ${path}: ${JSON.stringify(name)}. Must match [A-Z_][A-Z0-9_]*.`,
130
+ );
131
+ }
132
+ }
133
+
134
+ const resolveArgs = flags.project ? [`--project=${flags.project}`] : [];
135
+ const { projectId, source } = resolveProject(ctx, resolveArgs);
136
+
137
+ const current = await ctx.api.listProjectEnvVars(projectId);
138
+ const diff = diffEnv(current.configs || [], parsed);
139
+
140
+ // Dry-run: show diff and exit.
141
+ if (dryRun) {
142
+ ctx.out.result(
143
+ {
144
+ projectId,
145
+ projectSource: source,
146
+ file: path,
147
+ dryRun: true,
148
+ ...diff,
149
+ },
150
+ (_d, { stdout }) => {
151
+ stdout.write(colors.dim(`Project: ${projectId} · source: ${path}\n`));
152
+ if (diff.added.length === 0 && diff.changed.length === 0) {
153
+ stdout.write(colors.dim("(nothing to do — file matches current state)\n"));
154
+ return;
155
+ }
156
+ for (const a of diff.added) {
157
+ stdout.write(`${colors.green("+")} ${colors.bold(a.name)}\n`);
158
+ }
159
+ for (const c of diff.changed) {
160
+ stdout.write(`${colors.yellow("~")} ${colors.bold(c.name)}\n`);
161
+ }
162
+ if (diff.unchanged.length > 0) {
163
+ stdout.write(colors.dim(`(${diff.unchanged.length} unchanged)\n`));
164
+ }
165
+ stdout.write(colors.dim("\nDry-run — no changes applied. Re-run without --dry-run to push.\n"));
166
+ },
167
+ );
168
+ return;
169
+ }
170
+
171
+ // Real push. Confirm in human mode unless --yes; refuse in
172
+ // --json mode unless --yes (CI must opt in explicitly).
173
+ const toApply = diff.added.length + diff.changed.length;
174
+ if (toApply === 0) {
175
+ ctx.out.result(
176
+ {
177
+ projectId,
178
+ projectSource: source,
179
+ file: path,
180
+ applied: 0,
181
+ ...diff,
182
+ },
183
+ (_d, { stdout }) =>
184
+ stdout.write(colors.dim("Nothing to push — file matches current state.\n")),
185
+ );
186
+ return;
187
+ }
188
+ if (!yes && ctx.out.json) {
189
+ throw new Error(
190
+ `Refusing to push ${toApply} change${toApply > 1 ? "s" : ""} in --json mode without --yes.`,
191
+ );
192
+ }
193
+ if (!yes && !ctx.out.json) {
194
+ ctx.out.info(`About to apply ${toApply} change${toApply > 1 ? "s" : ""}:`);
195
+ for (const a of diff.added) ctx.out.info(` + ${a.name}`);
196
+ for (const c of diff.changed) ctx.out.info(` ~ ${c.name}`);
197
+ const confirmed = await confirm(
198
+ `Push to project ${projectId}? [y/N] `,
199
+ { defaultAnswer: false },
200
+ );
201
+ if (!confirmed) {
202
+ ctx.out.info("Aborted.");
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+ }
207
+
208
+ const changes = [];
209
+ for (const a of diff.added) {
210
+ changes.push({
211
+ op: "set",
212
+ name: a.name,
213
+ value: a.value,
214
+ ...(forList ? { deploymentTypes: forList } : {}),
215
+ });
216
+ }
217
+ for (const c of diff.changed) {
218
+ changes.push({
219
+ op: "set",
220
+ name: c.name,
221
+ value: c.value,
222
+ ...(forList ? { deploymentTypes: forList } : {}),
223
+ });
224
+ }
225
+ await ctx.api.updateProjectEnvVars(projectId, changes);
226
+
227
+ ctx.out.result(
228
+ {
229
+ projectId,
230
+ projectSource: source,
231
+ file: path,
232
+ applied: changes.length,
233
+ ...diff,
234
+ },
235
+ (_d, { stdout }) => {
236
+ for (const a of diff.added) {
237
+ stdout.write(`${colors.green("+")} ${colors.bold(a.name)}\n`);
238
+ }
239
+ for (const c of diff.changed) {
240
+ stdout.write(`${colors.yellow("~")} ${colors.bold(c.name)}\n`);
241
+ }
242
+ if (diff.unchanged.length > 0) {
243
+ stdout.write(colors.dim(`(${diff.unchanged.length} unchanged)\n`));
244
+ }
245
+ },
246
+ );
247
+ },
248
+ };
@@ -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
+ };
@@ -37,7 +37,20 @@ The saved session carries both the access token (1h TTL) and a refresh token (30
37
37
  });
38
38
  ctx.out.result(
39
39
  { baseUrl, user: session.user || null, configPath: file },
40
- () => ctx.out.info(`Saved Synapse session to ${file}`),
40
+ () => {
41
+ ctx.out.info(`Saved Synapse session to ${file}`);
42
+ // v1.8.6 (A2): post-login next-step hint. Without this, the
43
+ // operator just sees "saved" and stares at the prompt — login
44
+ // is step 1 of a 3-step onboarding (login → select → dev),
45
+ // and the absence of step 2 was the most common first-run
46
+ // dead-end reported.
47
+ ctx.out.info(
48
+ `\nNext step: \`cd\` into your app directory and run \`synapse select\` to link it to a project + deployment.`,
49
+ );
50
+ ctx.out.info(
51
+ `Then run \`synapse doctor\` to confirm everything is healthy, or \`synapse open\` to browse the dashboard.`,
52
+ );
53
+ },
41
54
  );
42
55
  },
43
56
  };