@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.
- package/bin/synapse.js +18 -1
- package/lib/api.js +54 -0
- package/lib/commands/_context.js +58 -6
- package/lib/commands/_help.js +1 -1
- package/lib/commands/_resource.js +152 -0
- package/lib/commands/convex.js +17 -0
- package/lib/commands/credentials.js +22 -1
- package/lib/commands/deployment-create.js +122 -0
- package/lib/commands/deployment-delete.js +117 -0
- package/lib/commands/deployment-rotate-key.js +173 -0
- package/lib/commands/deployment-status.js +143 -0
- package/lib/commands/env-list.js +105 -0
- package/lib/commands/env-pull.js +117 -0
- package/lib/commands/env-push.js +248 -0
- package/lib/commands/env-set.js +129 -0
- package/lib/commands/env-unset.js +77 -0
- package/lib/commands/list.js +186 -0
- package/lib/commands/login.js +14 -1
- package/lib/prompts.js +50 -0
- package/package.json +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// `synapse deployment rotate-key <name> [--yes] [--confirm=<name>] [--write] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Calls POST /v1/deployments/{name}/reissue_admin_key. Re-mints the
|
|
4
|
+
// admin_key from the current INSTANCE_SECRET (no container restart).
|
|
5
|
+
//
|
|
6
|
+
// What this does NOT do:
|
|
7
|
+
// - It does NOT rotate INSTANCE_SECRET (so existing deploy keys keep
|
|
8
|
+
// working). To actually invalidate a leaked credential you'd need
|
|
9
|
+
// to recreate the deployment.
|
|
10
|
+
// - It does NOT auto-update the operator's .env.local. We surface a
|
|
11
|
+
// --write flag that opt-in does it (using the same writer as
|
|
12
|
+
// `synapse select`), but the default is "show the key and let the
|
|
13
|
+
// operator paste it".
|
|
14
|
+
//
|
|
15
|
+
// Safety:
|
|
16
|
+
// - prod deployments REQUIRE typed-confirm (--confirm=<name>) since
|
|
17
|
+
// rotating mid-flight breaks the iframed Convex Dashboard until the
|
|
18
|
+
// operator hits "Refresh credentials".
|
|
19
|
+
// - dev: `--yes` suffices.
|
|
20
|
+
|
|
21
|
+
const colors = require("../colors");
|
|
22
|
+
const { extractFlags } = require("./_resource");
|
|
23
|
+
const { confirm, typedConfirm } = require("../prompts");
|
|
24
|
+
const { SynapseAPIError } = require("../api");
|
|
25
|
+
const { writeProjectEnv } = require("../env-file");
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
name: "deployment rotate-key",
|
|
29
|
+
summary: "Re-mint a deployment's admin key (cures stale credentials).",
|
|
30
|
+
usage:
|
|
31
|
+
"synapse deployment rotate-key <name> [--yes] [--confirm=<name>] [--write] [--json]",
|
|
32
|
+
description: `Calls POST /v1/deployments/{name}/reissue_admin_key. Used when the stored admin_key drifts out of sync with the running container (symptom: "deployment URL or admin key is invalid" inside the Convex Dashboard iframe).
|
|
33
|
+
|
|
34
|
+
The deployment's INSTANCE_SECRET is NOT rotated — existing deploy keys keep working. If you need to invalidate a leaked credential, delete + recreate the deployment.
|
|
35
|
+
|
|
36
|
+
Flags:
|
|
37
|
+
--yes Skip the y/N prompt (dev only).
|
|
38
|
+
--confirm=<name> Pre-supply the typed confirmation for prod.
|
|
39
|
+
--write Also rewrite .env.local in the current directory
|
|
40
|
+
IF this is the linked dev deployment.
|
|
41
|
+
--json Machine-readable output.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
synapse deployment rotate-key brave-dolphin-1060 --yes
|
|
45
|
+
synapse deployment rotate-key wise-otter-2200 --confirm=wise-otter-2200 --write`,
|
|
46
|
+
|
|
47
|
+
async run(args, ctx) {
|
|
48
|
+
const { flags, rest } = extractFlags(args, {
|
|
49
|
+
string: ["confirm"],
|
|
50
|
+
boolean: ["yes", "write", "json"],
|
|
51
|
+
});
|
|
52
|
+
const name = rest[0];
|
|
53
|
+
if (!name) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"Usage: synapse deployment rotate-key <name> [--yes|--confirm=<name>]",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (rest.length > 1) {
|
|
59
|
+
throw new Error(`Unexpected positional: ${rest[1]}.`);
|
|
60
|
+
}
|
|
61
|
+
const yes = flags.yes === true || flags.yes === "true";
|
|
62
|
+
const write = flags.write === true || flags.write === "true";
|
|
63
|
+
const typed = typeof flags.confirm === "string" ? flags.confirm : null;
|
|
64
|
+
|
|
65
|
+
let dep;
|
|
66
|
+
try {
|
|
67
|
+
dep = await ctx.api.getDeployment(name);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err instanceof SynapseAPIError && err.status === 404) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Deployment "${name}" not found. Run \`synapse list deployments\` to see your deployments.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
const type = dep.deploymentType || dep.type || "unknown";
|
|
77
|
+
|
|
78
|
+
// Adopted deployments can't be rotated — backend will 400, but we
|
|
79
|
+
// catch it earlier with a clearer message.
|
|
80
|
+
if (dep.adopted) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Cannot rotate key for "${name}" — it's an adopted (external) deployment. Rotate the admin key on the source side and re-adopt.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (type === "prod") {
|
|
87
|
+
if (!typed && ctx.out.json) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Refusing to rotate prod key for "${name}" in --json mode without --confirm=${name}.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const ok = await typedConfirm("rotate-key", name, { typed });
|
|
93
|
+
if (!ok) {
|
|
94
|
+
ctx.out.info(`Aborted (confirmation didn't match "${name}").`);
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
if (!yes && !typed && !ctx.out.json) {
|
|
100
|
+
const confirmed = await confirm(
|
|
101
|
+
`Rotate admin key for ${type} deployment ${colors.bold(name)}? [y/N] `,
|
|
102
|
+
{ defaultAnswer: false },
|
|
103
|
+
);
|
|
104
|
+
if (!confirmed) {
|
|
105
|
+
ctx.out.info("Aborted.");
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!yes && !typed && ctx.out.json) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Refusing to rotate "${name}" in --json mode without --yes.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!ctx.out.json) {
|
|
118
|
+
ctx.out.info(`Rotating admin key for ${name}…`);
|
|
119
|
+
}
|
|
120
|
+
const res = await ctx.api.reissueAdminKey(name);
|
|
121
|
+
|
|
122
|
+
// Optional .env.local rewrite. Only fires when:
|
|
123
|
+
// - --write was passed
|
|
124
|
+
// - the rotated deployment is the linked dev deployment in CWD
|
|
125
|
+
// - we can fetch fresh credentials to wire in the new url+key
|
|
126
|
+
let envWritten = null;
|
|
127
|
+
if (write) {
|
|
128
|
+
const linked = ctx.projectConfig;
|
|
129
|
+
const matchedDev = linked?.deployments?.dev?.name === name;
|
|
130
|
+
if (!matchedDev) {
|
|
131
|
+
if (!ctx.out.json) {
|
|
132
|
+
ctx.out.warn(
|
|
133
|
+
"--write skipped: this is not the linked dev deployment in the current directory.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
const creds = await ctx.api.cliCredentials(name);
|
|
138
|
+
envWritten = writeProjectEnv(ctx.cwd, creds, {
|
|
139
|
+
team: { slug: linked.team?.slug, name: linked.team?.name },
|
|
140
|
+
project: { slug: linked.project?.slug, name: linked.project?.name },
|
|
141
|
+
target: "dev",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
ctx.out.result(
|
|
147
|
+
{
|
|
148
|
+
rotated: true,
|
|
149
|
+
deploymentName: res.deploymentName || name,
|
|
150
|
+
prefix: res.prefix,
|
|
151
|
+
// Full new key. Same exposure profile as `synapse credentials`
|
|
152
|
+
// — operator already had this on disk before rotation; the
|
|
153
|
+
// surface is unchanged.
|
|
154
|
+
adminKey: res.adminKey,
|
|
155
|
+
envWritten,
|
|
156
|
+
},
|
|
157
|
+
(_d, { stdout }) => {
|
|
158
|
+
stdout.write(
|
|
159
|
+
`${colors.green("Rotated")} ${colors.bold(name)} ${colors.dim(`(prefix ${res.prefix})`)}\n`,
|
|
160
|
+
);
|
|
161
|
+
stdout.write(colors.dim("New admin key (paste into .env.local or pass --write):\n"));
|
|
162
|
+
stdout.write(res.adminKey + "\n");
|
|
163
|
+
if (envWritten) {
|
|
164
|
+
stdout.write(colors.green(`Updated ${envWritten}\n`));
|
|
165
|
+
} else if (write) {
|
|
166
|
+
stdout.write(
|
|
167
|
+
colors.dim("Hint: pass --write only when rotating the linked dev deployment.\n"),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// `synapse deployment status <name> [--watch[=interval]] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Single-deployment snapshot. Hits GET /v1/deployments/{name}/ +
|
|
4
|
+
// /backend_version (best-effort). `--watch` polls every 2s by default
|
|
5
|
+
// (configurable via --watch=<seconds>) until either:
|
|
6
|
+
// - the deployment reaches a terminal state (running / failed / deleted)
|
|
7
|
+
// - the operator hits Ctrl+C
|
|
8
|
+
// - --json mode is enabled (then we emit ONE snapshot, no polling)
|
|
9
|
+
|
|
10
|
+
const colors = require("../colors");
|
|
11
|
+
const { extractFlags } = require("./_resource");
|
|
12
|
+
const { SynapseAPIError } = require("../api");
|
|
13
|
+
|
|
14
|
+
const TERMINAL_STATES = new Set(["running", "failed", "errored", "deleted", "stopped"]);
|
|
15
|
+
|
|
16
|
+
async function fetchSnapshot(api, name) {
|
|
17
|
+
const dep = await api.getDeployment(name);
|
|
18
|
+
let backendVersion = null;
|
|
19
|
+
// Backend version is only meaningful once the container is running.
|
|
20
|
+
// We don't await this on every poll if we already saw a transient
|
|
21
|
+
// 503 — it's not load-bearing for the status check.
|
|
22
|
+
if (dep.status === "running") {
|
|
23
|
+
try {
|
|
24
|
+
const v = await api.getDeploymentBackendVersion(name);
|
|
25
|
+
backendVersion = v.version || null;
|
|
26
|
+
} catch {
|
|
27
|
+
backendVersion = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { dep, backendVersion };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderSnapshot(snap, { stdout }) {
|
|
34
|
+
const { dep, backendVersion } = snap;
|
|
35
|
+
const type = (dep.deploymentType || dep.type || "?").toUpperCase();
|
|
36
|
+
const status = dep.status || "?";
|
|
37
|
+
stdout.write(
|
|
38
|
+
`${colors.bold(dep.name)} ${colors.dim(type)} ${colors.statusBadge(status)}\n`,
|
|
39
|
+
);
|
|
40
|
+
if (dep.deploymentUrl || dep.url) {
|
|
41
|
+
stdout.write(` URL: ${dep.deploymentUrl || dep.url}\n`);
|
|
42
|
+
}
|
|
43
|
+
if (dep.haEnabled) {
|
|
44
|
+
stdout.write(
|
|
45
|
+
` HA: enabled${dep.replicaCount ? ` (${dep.replicaCount} replicas)` : ""}\n`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (backendVersion) {
|
|
49
|
+
stdout.write(colors.dim(` Convex backend: ${backendVersion}\n`));
|
|
50
|
+
}
|
|
51
|
+
if (dep.isDefault) {
|
|
52
|
+
stdout.write(colors.dim(` Default for the project.\n`));
|
|
53
|
+
}
|
|
54
|
+
if (dep.adopted) {
|
|
55
|
+
stdout.write(colors.dim(` Adopted (external — Synapse does not manage credentials).\n`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
name: "deployment status",
|
|
61
|
+
summary: "Show a single deployment's status, URL, and HA shape.",
|
|
62
|
+
usage:
|
|
63
|
+
"synapse deployment status <name> [--watch[=<seconds>]] [--json]",
|
|
64
|
+
description: `Fetches GET /v1/deployments/{name} plus a best-effort backend_version probe. --watch polls until the deployment reaches a terminal state (running/failed/deleted/stopped).
|
|
65
|
+
|
|
66
|
+
Flags:
|
|
67
|
+
--watch[=<seconds>] Poll until terminal. Default interval: 2s.
|
|
68
|
+
--json One-shot snapshot, no polling.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
synapse deployment status brave-dolphin-1060
|
|
72
|
+
synapse deployment status fresh-newt-1234 --watch # tail until ready
|
|
73
|
+
synapse deployment status brave-dolphin-1060 --json | jq .dep.status`,
|
|
74
|
+
|
|
75
|
+
// Re-exports for tests.
|
|
76
|
+
fetchSnapshot,
|
|
77
|
+
renderSnapshot,
|
|
78
|
+
TERMINAL_STATES,
|
|
79
|
+
|
|
80
|
+
async run(args, ctx) {
|
|
81
|
+
const { flags, rest } = extractFlags(args, {
|
|
82
|
+
// `--watch` alone → true (default interval); `--watch=5` → "5"
|
|
83
|
+
// (string parsed to number below). Both work because the
|
|
84
|
+
// boolean branch in extractFlags fires only for the no-`=` form.
|
|
85
|
+
boolean: ["watch", "json"],
|
|
86
|
+
});
|
|
87
|
+
const name = rest[0];
|
|
88
|
+
if (!name) {
|
|
89
|
+
throw new Error("Usage: synapse deployment status <name> [--watch]");
|
|
90
|
+
}
|
|
91
|
+
if (rest.length > 1) {
|
|
92
|
+
throw new Error(`Unexpected positional: ${rest[1]}.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const watch = flags.watch === true || flags.watch === "true"
|
|
96
|
+
? 2
|
|
97
|
+
: typeof flags.watch === "string"
|
|
98
|
+
? Math.max(1, Number.parseInt(flags.watch, 10) || 2)
|
|
99
|
+
: 0;
|
|
100
|
+
|
|
101
|
+
// --json + --watch combination doesn't make sense (we'd emit a
|
|
102
|
+
// never-ending stream of JSON objects). Fail closed.
|
|
103
|
+
if (ctx.out.json && watch > 0) {
|
|
104
|
+
throw new Error("--watch is incompatible with --json (would emit an unbounded stream).");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let snap;
|
|
108
|
+
try {
|
|
109
|
+
snap = await fetchSnapshot(ctx.api, name);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err instanceof SynapseAPIError && err.status === 404) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Deployment "${name}" not found. Run \`synapse list deployments\` to see your deployments.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (watch === 0) {
|
|
120
|
+
ctx.out.result(snap, renderSnapshot);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Watch mode (human only — we already barred --json above).
|
|
125
|
+
renderSnapshot(snap, { stdout: ctx.out.stdout });
|
|
126
|
+
while (!TERMINAL_STATES.has(snap.dep.status)) {
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, watch * 1000));
|
|
128
|
+
try {
|
|
129
|
+
snap = await fetchSnapshot(ctx.api, name);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof SynapseAPIError && err.status === 404) {
|
|
132
|
+
ctx.out.info(`Deployment "${name}" disappeared mid-watch.`);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
// Transient errors — log + continue. Operators see one stderr
|
|
136
|
+
// line per blip; persistent failures escalate via process exit.
|
|
137
|
+
ctx.out.warn(`probe failed (${err.message}); retrying`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
renderSnapshot(snap, { stdout: ctx.out.stdout });
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// `synapse env list [--for=dev|prod|preview] [--project=<id>] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Lists project-level (default) environment variables. These are
|
|
4
|
+
// injected into the Convex backend container at provision time;
|
|
5
|
+
// changing one only affects deployments that haven't been created yet
|
|
6
|
+
// UNLESS a separate `sync_env_to_deployments` job is triggered (see
|
|
7
|
+
// the dashboard's env panel).
|
|
8
|
+
//
|
|
9
|
+
// The `--for=<type>` filter narrows to vars whose deploymentTypes
|
|
10
|
+
// array includes the type. Omitted = show every var with its type set.
|
|
11
|
+
|
|
12
|
+
const colors = require("../colors");
|
|
13
|
+
const { extractFlags, resolveProject } = require("./_resource");
|
|
14
|
+
|
|
15
|
+
const VALID_FORS = new Set(["dev", "prod", "preview"]);
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
name: "env list",
|
|
19
|
+
summary: "List project-default environment variables.",
|
|
20
|
+
usage:
|
|
21
|
+
"synapse env list [--for=<dev|prod|preview>] [--project=<id>] [--mask] [--json]",
|
|
22
|
+
description: `Calls GET /v1/projects/{id}/list_default_environment_variables. Default behaviour shows the variable VALUES — pass --mask to redact them (useful when sharing a terminal recording).
|
|
23
|
+
|
|
24
|
+
Flags:
|
|
25
|
+
--for=<type> Filter to vars whose deploymentTypes include <type>.
|
|
26
|
+
--project=<id> Override the linked project.
|
|
27
|
+
--mask Redact values to "********" (length-preserving).
|
|
28
|
+
--json Machine-readable output.
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
synapse env list
|
|
32
|
+
synapse env list --for=prod --mask
|
|
33
|
+
synapse env list --json | jq '.configs[].name'`,
|
|
34
|
+
|
|
35
|
+
async run(args, ctx) {
|
|
36
|
+
const { flags, rest } = extractFlags(args, {
|
|
37
|
+
string: ["for", "project"],
|
|
38
|
+
boolean: ["mask", "json"],
|
|
39
|
+
});
|
|
40
|
+
if (rest.length > 0) {
|
|
41
|
+
throw new Error(`Unexpected positional: ${rest[0]}.`);
|
|
42
|
+
}
|
|
43
|
+
const filter = typeof flags.for === "string" ? flags.for.toLowerCase() : null;
|
|
44
|
+
if (filter && !VALID_FORS.has(filter)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Invalid --for: ${filter}. Must be one of: ${[...VALID_FORS].join(", ")}.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const mask = flags.mask === true || flags.mask === "true";
|
|
50
|
+
|
|
51
|
+
const resolveArgs = flags.project ? [`--project=${flags.project}`] : [];
|
|
52
|
+
const { projectId, source } = resolveProject(ctx, resolveArgs);
|
|
53
|
+
|
|
54
|
+
const resp = await ctx.api.listProjectEnvVars(projectId);
|
|
55
|
+
let configs = resp.configs || [];
|
|
56
|
+
if (filter) {
|
|
57
|
+
configs = configs.filter(
|
|
58
|
+
(c) => Array.isArray(c.deploymentTypes) && c.deploymentTypes.includes(filter),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
// Stable order for piping into diff tools.
|
|
62
|
+
configs.sort((a, b) => a.name.localeCompare(b.name));
|
|
63
|
+
|
|
64
|
+
ctx.out.result(
|
|
65
|
+
{
|
|
66
|
+
projectId,
|
|
67
|
+
projectSource: source,
|
|
68
|
+
count: configs.length,
|
|
69
|
+
filter,
|
|
70
|
+
masked: mask,
|
|
71
|
+
configs: mask
|
|
72
|
+
? configs.map((c) => ({ ...c, value: maskValue(c.value) }))
|
|
73
|
+
: configs,
|
|
74
|
+
},
|
|
75
|
+
(_d, { stdout }) => {
|
|
76
|
+
stdout.write(colors.dim(`Project: ${projectId}${filter ? ` · filter: ${filter}` : ""}\n`));
|
|
77
|
+
if (configs.length === 0) {
|
|
78
|
+
stdout.write(colors.dim("(no env vars)\n"));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
ctx.out.table(
|
|
82
|
+
configs.map((c) => ({
|
|
83
|
+
name: colors.bold(c.name),
|
|
84
|
+
value: mask ? colors.dim(maskValue(c.value)) : c.value,
|
|
85
|
+
types: (c.deploymentTypes || []).join(",") || colors.dim("(all)"),
|
|
86
|
+
})),
|
|
87
|
+
[
|
|
88
|
+
{ key: "name", header: "NAME" },
|
|
89
|
+
{ key: "value", header: "VALUE" },
|
|
90
|
+
{ key: "types", header: "DEPLOYMENT_TYPES" },
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Length-preserving redaction. Operators sometimes screenshot the
|
|
99
|
+
// output to share — a fixed-length blob would leak the length.
|
|
100
|
+
function maskValue(v) {
|
|
101
|
+
if (v === undefined || v === null || v === "") return "";
|
|
102
|
+
return "*".repeat(String(v).length);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports.maskValue = maskValue;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// `synapse env pull [--out=<path>] [--for=<type>] [--project=<id>] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Reads project-default env vars + writes them as a .env-shaped file.
|
|
4
|
+
// Output defaults to stdout (so `synapse env pull >> .env.production`
|
|
5
|
+
// works). `--out=<path>` writes to disk with mode 0600.
|
|
6
|
+
//
|
|
7
|
+
// Values are quoted using the same `quoteEnvValue` helper that
|
|
8
|
+
// `.env.local` uses (handles backslashes, double quotes, newlines).
|
|
9
|
+
|
|
10
|
+
const fs = require("node:fs");
|
|
11
|
+
const colors = require("../colors");
|
|
12
|
+
const { extractFlags, resolveProject } = require("./_resource");
|
|
13
|
+
const { quoteEnvValue } = require("../env-file");
|
|
14
|
+
|
|
15
|
+
const VALID_FORS = new Set(["dev", "prod", "preview"]);
|
|
16
|
+
|
|
17
|
+
function formatDotenv(configs, { header = true } = {}) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
if (header) {
|
|
20
|
+
lines.push("# Synapse-managed project-default env vars");
|
|
21
|
+
lines.push("# Generated by `synapse env pull` — do not edit by hand;");
|
|
22
|
+
lines.push("# re-run the command after changes upstream.");
|
|
23
|
+
}
|
|
24
|
+
for (const c of configs) {
|
|
25
|
+
lines.push(`${c.name}=${quoteEnvValue(c.value)}`);
|
|
26
|
+
}
|
|
27
|
+
return lines.join("\n") + (lines.length > 0 ? "\n" : "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
name: "env pull",
|
|
32
|
+
summary: "Print or write project-default env vars in .env format.",
|
|
33
|
+
usage:
|
|
34
|
+
"synapse env pull [--out=<path>] [--for=<dev|prod|preview>] [--project=<id>] [--json]",
|
|
35
|
+
description: `Reads the project's default env vars and emits them as KEY="value" lines. Default: writes to stdout. With --out=<path>: writes to that file with mode 0600 (overwrites if exists).
|
|
36
|
+
|
|
37
|
+
Flags:
|
|
38
|
+
--out=<path> Write to a file with mode 0600 instead of stdout.
|
|
39
|
+
--for=<type> Filter to vars whose deploymentTypes include <type>.
|
|
40
|
+
--project=<id> Override the linked project.
|
|
41
|
+
--json Emits { count, body } instead of raw .env text.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
synapse env pull
|
|
45
|
+
synapse env pull --out=.env.synapse
|
|
46
|
+
synapse env pull --for=prod > .env.prod`,
|
|
47
|
+
|
|
48
|
+
// Re-exports for tests.
|
|
49
|
+
formatDotenv,
|
|
50
|
+
|
|
51
|
+
async run(args, ctx) {
|
|
52
|
+
const { flags, rest } = extractFlags(args, {
|
|
53
|
+
string: ["out", "for", "project"],
|
|
54
|
+
boolean: ["json"],
|
|
55
|
+
});
|
|
56
|
+
if (rest.length > 0) {
|
|
57
|
+
throw new Error(`Unexpected positional: ${rest[0]}.`);
|
|
58
|
+
}
|
|
59
|
+
const filter = typeof flags.for === "string" ? flags.for.toLowerCase() : null;
|
|
60
|
+
if (filter && !VALID_FORS.has(filter)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Invalid --for: ${filter}. Must be one of: ${[...VALID_FORS].join(", ")}.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const outPath = typeof flags.out === "string" ? flags.out : null;
|
|
66
|
+
|
|
67
|
+
const resolveArgs = flags.project ? [`--project=${flags.project}`] : [];
|
|
68
|
+
const { projectId, source } = resolveProject(ctx, resolveArgs);
|
|
69
|
+
|
|
70
|
+
const resp = await ctx.api.listProjectEnvVars(projectId);
|
|
71
|
+
let configs = resp.configs || [];
|
|
72
|
+
if (filter) {
|
|
73
|
+
configs = configs.filter(
|
|
74
|
+
(c) => Array.isArray(c.deploymentTypes) && c.deploymentTypes.includes(filter),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
configs.sort((a, b) => a.name.localeCompare(b.name));
|
|
78
|
+
|
|
79
|
+
const body = formatDotenv(configs);
|
|
80
|
+
|
|
81
|
+
if (outPath) {
|
|
82
|
+
fs.writeFileSync(outPath, body, { mode: 0o600 });
|
|
83
|
+
try {
|
|
84
|
+
fs.chmodSync(outPath, 0o600);
|
|
85
|
+
} catch {
|
|
86
|
+
// best-effort on systems without POSIX modes
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
ctx.out.result(
|
|
91
|
+
{
|
|
92
|
+
projectId,
|
|
93
|
+
projectSource: source,
|
|
94
|
+
filter,
|
|
95
|
+
count: configs.length,
|
|
96
|
+
outPath,
|
|
97
|
+
body,
|
|
98
|
+
},
|
|
99
|
+
(data, { stdout }) => {
|
|
100
|
+
if (outPath) {
|
|
101
|
+
ctx.out.info(
|
|
102
|
+
`Wrote ${data.count} env var${data.count === 1 ? "" : "s"} to ${outPath} (mode 0600).`,
|
|
103
|
+
);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Plain .env text to stdout — no headers, no colours. The
|
|
107
|
+
// operator may be piping into another file.
|
|
108
|
+
stdout.write(body);
|
|
109
|
+
if (configs.length === 0) {
|
|
110
|
+
// The body is empty; tell the operator on stderr so they
|
|
111
|
+
// notice (stdout pipe wouldn't show anything).
|
|
112
|
+
ctx.out.info(colors.dim(`(no vars matched${filter ? ` --for=${filter}` : ""})`));
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
};
|