@iann29/synapse 1.8.7 → 1.8.9
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/lib/commands/_help.js +1 -0
- package/lib/commands/https-doctor.js +73 -0
- package/lib/commands/https-migrate.js +168 -0
- package/lib/commands/https-remove.js +103 -0
- package/lib/commands/https-setup.js +263 -0
- package/lib/commands/https-status.js +154 -0
- package/lib/doctor/checks.js +249 -1
- package/lib/doctor/renderer.js +19 -2
- package/lib/doctor/runner.js +10 -1
- package/lib/https/detect.js +603 -0
- package/lib/https/executor.js +102 -0
- package/lib/https/hosts.js +356 -0
- package/lib/https/mkcert.js +131 -0
- package/lib/https/nextjs.js +91 -0
- package/lib/https/planner.js +412 -0
- package/package.json +1 -1
package/lib/commands/_help.js
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// `synapse https doctor [domain]` — read-only sanity checks that
|
|
2
|
+
// tell the operator WHAT to fix without changing anything. This
|
|
3
|
+
// mirrors `synapse doctor` but is scoped to the HTTPS-dev surface.
|
|
4
|
+
//
|
|
5
|
+
// Useful when an operator runs the wizard and the dev server still
|
|
6
|
+
// doesn't work — `doctor` re-scans + flags every drift from the
|
|
7
|
+
// expected state.
|
|
8
|
+
|
|
9
|
+
const colors = require("../colors");
|
|
10
|
+
const { extractFlags } = require("./_resource");
|
|
11
|
+
const detectMod = require("../https/detect");
|
|
12
|
+
const plannerMod = require("../https/planner");
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
name: "https doctor",
|
|
16
|
+
summary: "Diagnose local-HTTPS setup without changing anything.",
|
|
17
|
+
usage: "synapse https doctor [domain] [--json]",
|
|
18
|
+
description: `Runs the same detection as \`https setup\` but in read-only mode. Prints the plan that WOULD run, plus any warnings (legacy certs in cwd, NSS missing, etc).`,
|
|
19
|
+
|
|
20
|
+
async run(args, ctx) {
|
|
21
|
+
const { flags, rest } = extractFlags(args, {
|
|
22
|
+
string: [],
|
|
23
|
+
boolean: ["json"],
|
|
24
|
+
});
|
|
25
|
+
const domain = rest[0];
|
|
26
|
+
if (rest.length > 1) {
|
|
27
|
+
throw new Error(`Unexpected positional: ${rest[1]}.`);
|
|
28
|
+
}
|
|
29
|
+
if (!domain) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"Usage: synapse https doctor <domain>\n\nExample: synapse https doctor dev.myproject.com",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const detection = await detectMod.scan({ domain, cwd: ctx.cwd });
|
|
35
|
+
const steps = plannerMod.plan(detection, {});
|
|
36
|
+
const blocker = steps.find((s) => s.kind === "blocker");
|
|
37
|
+
const warnings = steps.filter((s) => s.kind === "warn");
|
|
38
|
+
const willExec = steps.filter((s) => s.kind === "exec");
|
|
39
|
+
const willSkip = steps.filter((s) => s.kind === "skip");
|
|
40
|
+
const summary = {
|
|
41
|
+
domain,
|
|
42
|
+
platform: detection.platform.id,
|
|
43
|
+
blocker: blocker ? blocker.blocker || blocker.reason : null,
|
|
44
|
+
warnings: warnings.map((w) => ({ id: w.id, reason: w.reason, hint: w.skipReason })),
|
|
45
|
+
wouldExec: willExec.map((s) => ({ id: s.id, title: s.title, reason: s.reason })),
|
|
46
|
+
skipped: willSkip.map((s) => ({ id: s.id, title: s.title, reason: s.reason })),
|
|
47
|
+
};
|
|
48
|
+
ctx.out.result(summary, (s, { stdout }) => {
|
|
49
|
+
stdout.write(`${colors.bold("HTTPS doctor")} ${colors.dim(`· ${s.domain} · ${s.platform}`)}\n\n`);
|
|
50
|
+
if (s.blocker) {
|
|
51
|
+
stdout.write(colors.red(`Blocker: ${s.blocker}\n`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (s.wouldExec.length === 0) {
|
|
55
|
+
stdout.write(colors.green("Healthy. Nothing to do.\n"));
|
|
56
|
+
} else {
|
|
57
|
+
stdout.write(colors.yellow(`${s.wouldExec.length} step${s.wouldExec.length > 1 ? "s" : ""} would run:\n`));
|
|
58
|
+
for (const x of s.wouldExec) {
|
|
59
|
+
stdout.write(` ${colors.cyan("●")} ${x.title}\n`);
|
|
60
|
+
if (x.reason) stdout.write(colors.dim(` ${x.reason}\n`));
|
|
61
|
+
}
|
|
62
|
+
stdout.write(colors.dim("\nRun `synapse https setup <domain>` to apply.\n"));
|
|
63
|
+
}
|
|
64
|
+
if (s.warnings.length > 0) {
|
|
65
|
+
stdout.write(`\n${colors.yellow("Warnings:")}\n`);
|
|
66
|
+
for (const w of s.warnings) {
|
|
67
|
+
stdout.write(` ${colors.yellow("!")} ${w.reason}\n`);
|
|
68
|
+
if (w.hint) stdout.write(colors.dim(` ${w.hint}\n`));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// `synapse https migrate [--cwd] [--all] [--yes] [--dry-run] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Migrates legacy cert layouts to the canonical ~/.config/dev-certs/
|
|
4
|
+
// store. Two modes:
|
|
5
|
+
//
|
|
6
|
+
// 1. cwd mode (default): scans the current directory for legacy
|
|
7
|
+
// `dev.*.pem` + `-key.pem` pairs and migrates them.
|
|
8
|
+
// 2. --all: scans EVERY known project root (operator-supplied via
|
|
9
|
+
// --root flag, default: ~/Documents and the cwd). This is more
|
|
10
|
+
// ambitious and gated behind explicit opt-in.
|
|
11
|
+
//
|
|
12
|
+
// Migration per cert pair:
|
|
13
|
+
// - Move the .pem files into ~/.config/dev-certs/<domain>/
|
|
14
|
+
// - Set mode 0600 on the key
|
|
15
|
+
// - Rewrite package.json dev:https to point at the new paths
|
|
16
|
+
// - Leave the original files in place ONLY if --keep-old; default
|
|
17
|
+
// is to delete them once they've been moved successfully.
|
|
18
|
+
|
|
19
|
+
const fs = require("node:fs");
|
|
20
|
+
const path = require("node:path");
|
|
21
|
+
const colors = require("../colors");
|
|
22
|
+
const { extractFlags } = require("./_resource");
|
|
23
|
+
const { confirm } = require("../prompts");
|
|
24
|
+
const detectMod = require("../https/detect");
|
|
25
|
+
const nextjsMod = require("../https/nextjs");
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
name: "https migrate",
|
|
29
|
+
summary: "Move legacy cert pairs from project roots into ~/.config/dev-certs/.",
|
|
30
|
+
usage: "synapse https migrate [--cwd | --root=<path>] [--keep-old] [--dry-run] [--yes] [--json]",
|
|
31
|
+
description: `Some projects have their .pem files in the project root (left over from the manual setup era). This command moves them into the canonical ~/.config/dev-certs/<domain>/ store, then rewrites the project's package.json dev:https script to reference the new paths.
|
|
32
|
+
|
|
33
|
+
Flags:
|
|
34
|
+
--cwd only the current directory (default)
|
|
35
|
+
--root=<path> a specific directory to scan
|
|
36
|
+
--keep-old leave the original .pem files in place after moving
|
|
37
|
+
--dry-run show what would happen, don't change anything
|
|
38
|
+
--yes skip the per-pair confirmation
|
|
39
|
+
--json machine-readable output
|
|
40
|
+
|
|
41
|
+
The cwd is scanned non-recursively. Subdirectories are ignored —
|
|
42
|
+
if you want to migrate every project on disk, run this command in
|
|
43
|
+
each project root (or write a small loop).`,
|
|
44
|
+
|
|
45
|
+
async run(args, ctx) {
|
|
46
|
+
const { flags, rest } = extractFlags(args, {
|
|
47
|
+
string: ["root"],
|
|
48
|
+
boolean: ["cwd", "keep-old", "dry-run", "yes", "json"],
|
|
49
|
+
});
|
|
50
|
+
if (rest.length > 0) {
|
|
51
|
+
throw new Error(`Unexpected positional: ${rest[0]}.`);
|
|
52
|
+
}
|
|
53
|
+
const root = typeof flags.root === "string" ? flags.root : ctx.cwd;
|
|
54
|
+
const dryRun = flags["dry-run"] === true;
|
|
55
|
+
const keepOld = flags["keep-old"] === true;
|
|
56
|
+
const yes = flags.yes === true;
|
|
57
|
+
const legacy = detectMod.detectLegacyCertsInCwd(root);
|
|
58
|
+
|
|
59
|
+
if (legacy.length === 0) {
|
|
60
|
+
ctx.out.result(
|
|
61
|
+
{ root, migrated: [], skipped: [], count: 0 },
|
|
62
|
+
() =>
|
|
63
|
+
ctx.out.stdout.write(
|
|
64
|
+
colors.dim(`No legacy cert pairs found in ${root}.\n`),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!ctx.out.json) {
|
|
71
|
+
ctx.out.stdout.write(
|
|
72
|
+
`${colors.bold(`Found ${legacy.length} legacy cert pair${legacy.length > 1 ? "s" : ""} in ${root}:`)}\n`,
|
|
73
|
+
);
|
|
74
|
+
for (const p of legacy) {
|
|
75
|
+
ctx.out.stdout.write(` · ${colors.bold(p.domain)}\n`);
|
|
76
|
+
ctx.out.stdout.write(colors.dim(` ${p.cert}\n`));
|
|
77
|
+
ctx.out.stdout.write(colors.dim(` ${p.key}\n`));
|
|
78
|
+
}
|
|
79
|
+
ctx.out.stdout.write("\n");
|
|
80
|
+
}
|
|
81
|
+
if (dryRun) {
|
|
82
|
+
ctx.out.info("(dry-run — no changes applied)");
|
|
83
|
+
ctx.out.result({ root, found: legacy, dryRun: true }, () => {});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!yes && !ctx.out.json) {
|
|
87
|
+
const ok = await confirm(`Migrate ${legacy.length} pair(s)? [Y/n] `, { defaultAnswer: true });
|
|
88
|
+
if (!ok) {
|
|
89
|
+
ctx.out.info("Aborted.");
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!yes && ctx.out.json) {
|
|
95
|
+
throw new Error("Refusing to migrate in --json mode without --yes.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const migrated = [];
|
|
99
|
+
const skipped = [];
|
|
100
|
+
const failed = [];
|
|
101
|
+
for (const p of legacy) {
|
|
102
|
+
try {
|
|
103
|
+
const target = detectMod.certFilesFor(p.domain);
|
|
104
|
+
// Skip pairs whose canonical location is already populated
|
|
105
|
+
// unless --force (which we don't expose here — re-running
|
|
106
|
+
// setup --force is the right way to regenerate).
|
|
107
|
+
if (fs.existsSync(target.cert) && fs.existsSync(target.key)) {
|
|
108
|
+
skipped.push({ domain: p.domain, reason: "target already exists in cert store" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
fs.mkdirSync(target.dir, { recursive: true, mode: 0o700 });
|
|
112
|
+
fs.copyFileSync(p.cert, target.cert);
|
|
113
|
+
fs.copyFileSync(p.key, target.key);
|
|
114
|
+
try {
|
|
115
|
+
fs.chmodSync(target.key, 0o600);
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
if (!keepOld) {
|
|
120
|
+
fs.rmSync(p.cert, { force: true });
|
|
121
|
+
fs.rmSync(p.key, { force: true });
|
|
122
|
+
}
|
|
123
|
+
// Rewrite package.json dev:https if present + still references
|
|
124
|
+
// the project-root form.
|
|
125
|
+
const pkgPath = path.join(root, "package.json");
|
|
126
|
+
if (fs.existsSync(pkgPath)) {
|
|
127
|
+
const info = nextjsMod.readPackageJson(pkgPath);
|
|
128
|
+
if (info.parsed && info.parsed.scripts && info.parsed.scripts["dev:https"]) {
|
|
129
|
+
const current = info.parsed.scripts["dev:https"];
|
|
130
|
+
// Replace any reference to the project-root pem paths
|
|
131
|
+
// with the canonical ones.
|
|
132
|
+
const newCommand = current
|
|
133
|
+
.replace(p.cert.replace(/\\/g, "/"), target.cert.replace(/\\/g, "/"))
|
|
134
|
+
.replace(p.key.replace(/\\/g, "/"), target.key.replace(/\\/g, "/"))
|
|
135
|
+
.replace(`./${path.basename(p.cert)}`, target.cert.replace(/\\/g, "/"))
|
|
136
|
+
.replace(`./${path.basename(p.key)}`, target.key.replace(/\\/g, "/"));
|
|
137
|
+
if (newCommand !== current) {
|
|
138
|
+
nextjsMod.setDevHttpsScript(pkgPath, newCommand);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
migrated.push({
|
|
143
|
+
domain: p.domain,
|
|
144
|
+
from: { cert: p.cert, key: p.key },
|
|
145
|
+
to: { cert: target.cert, key: target.key },
|
|
146
|
+
});
|
|
147
|
+
} catch (err) {
|
|
148
|
+
failed.push({ domain: p.domain, error: err.message });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ctx.out.result(
|
|
153
|
+
{ root, migrated, skipped, failed, count: migrated.length },
|
|
154
|
+
(_d, { stdout }) => {
|
|
155
|
+
for (const m of migrated) {
|
|
156
|
+
stdout.write(`${colors.green("✓")} ${colors.bold(m.domain)} → ${colors.dim(m.to.cert)}\n`);
|
|
157
|
+
}
|
|
158
|
+
for (const s of skipped) {
|
|
159
|
+
stdout.write(`${colors.dim("○")} ${colors.bold(s.domain)} skipped: ${s.reason}\n`);
|
|
160
|
+
}
|
|
161
|
+
for (const f of failed) {
|
|
162
|
+
stdout.write(`${colors.red("✗")} ${colors.bold(f.domain)}: ${f.error}\n`);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
if (failed.length > 0) process.exitCode = 1;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// `synapse https remove <domain> [--keep-certs] [--keep-script]
|
|
2
|
+
// [--keep-hosts] [--yes] [--json]`
|
|
3
|
+
//
|
|
4
|
+
// Symmetric undo to `https setup`. Removes:
|
|
5
|
+
// - The cert pair at ~/.config/dev-certs/<domain>/
|
|
6
|
+
// - The hosts file entry inside the managed block
|
|
7
|
+
// - The dev:https script from package.json (if it matches the
|
|
8
|
+
// canonical form — never deletes operator-customised scripts)
|
|
9
|
+
|
|
10
|
+
const colors = require("../colors");
|
|
11
|
+
const { extractFlags } = require("./_resource");
|
|
12
|
+
const { confirm } = require("../prompts");
|
|
13
|
+
const detectMod = require("../https/detect");
|
|
14
|
+
const plannerMod = require("../https/planner");
|
|
15
|
+
const executorMod = require("../https/executor");
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
name: "https remove",
|
|
19
|
+
summary: "Undo `synapse https setup` for a domain (cert + hosts + script).",
|
|
20
|
+
usage:
|
|
21
|
+
"synapse https remove <domain> [--keep-certs] [--keep-script] [--keep-hosts] [--yes] [--json]",
|
|
22
|
+
description: `Reverses what \`synapse https setup\` did. Idempotent — running on a domain that was never set up is a no-op.
|
|
23
|
+
|
|
24
|
+
Flags:
|
|
25
|
+
--keep-certs don't delete the cert pair from ~/.config/dev-certs/
|
|
26
|
+
--keep-script don't touch package.json
|
|
27
|
+
--keep-hosts don't touch the hosts file
|
|
28
|
+
--yes skip the y/N confirmation
|
|
29
|
+
--json machine-readable output
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
synapse https remove dev.oldproject.com
|
|
33
|
+
synapse https remove dev.test.com --keep-certs # keep the cert, just untangle hosts + script`,
|
|
34
|
+
|
|
35
|
+
async run(args, ctx) {
|
|
36
|
+
const { flags, rest } = extractFlags(args, {
|
|
37
|
+
string: [],
|
|
38
|
+
boolean: ["keep-certs", "keep-script", "keep-hosts", "yes", "json"],
|
|
39
|
+
});
|
|
40
|
+
const domain = rest[0];
|
|
41
|
+
if (!domain) {
|
|
42
|
+
throw new Error("Usage: synapse https remove <domain>");
|
|
43
|
+
}
|
|
44
|
+
if (rest.length > 1) {
|
|
45
|
+
throw new Error(`Unexpected positional: ${rest[1]}.`);
|
|
46
|
+
}
|
|
47
|
+
const yes = flags.yes === true;
|
|
48
|
+
const detection = await detectMod.scan({ domain, cwd: ctx.cwd });
|
|
49
|
+
const steps = plannerMod.planRemove(detection, {
|
|
50
|
+
keepCerts: flags["keep-certs"] === true,
|
|
51
|
+
keepScript: flags["keep-script"] === true,
|
|
52
|
+
keepHosts: flags["keep-hosts"] === true,
|
|
53
|
+
});
|
|
54
|
+
if (!plannerMod.planIsExecutable(steps)) {
|
|
55
|
+
const blocker = steps.find((s) => s.kind === "blocker");
|
|
56
|
+
throw new Error(blocker?.blocker || "Plan is blocked.");
|
|
57
|
+
}
|
|
58
|
+
const willChange = steps.some((s) => s.kind === "exec");
|
|
59
|
+
if (!willChange) {
|
|
60
|
+
ctx.out.result(
|
|
61
|
+
{ domain, executedAny: false, results: [] },
|
|
62
|
+
() => ctx.out.stdout.write(colors.dim("Nothing to remove.\n")),
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!ctx.out.json) {
|
|
68
|
+
ctx.out.stdout.write(`${colors.bold("Will remove:")}\n`);
|
|
69
|
+
for (const s of steps) {
|
|
70
|
+
if (s.kind === "exec") {
|
|
71
|
+
ctx.out.stdout.write(` ${colors.red("✗")} ${s.title}\n`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
ctx.out.stdout.write("\n");
|
|
75
|
+
}
|
|
76
|
+
if (!yes && !ctx.out.json) {
|
|
77
|
+
const ok = await confirm(
|
|
78
|
+
`Remove HTTPS setup for ${colors.bold(domain)}? [y/N] `,
|
|
79
|
+
{ defaultAnswer: false },
|
|
80
|
+
);
|
|
81
|
+
if (!ok) {
|
|
82
|
+
ctx.out.info("Aborted.");
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!yes && ctx.out.json) {
|
|
88
|
+
throw new Error("Refusing to remove in --json mode without --yes.");
|
|
89
|
+
}
|
|
90
|
+
const { results, failedAny } = await executorMod.execute(steps, { out: ctx.out });
|
|
91
|
+
ctx.out.result(
|
|
92
|
+
{ domain, results, failedAny },
|
|
93
|
+
(_d, { stdout }) => {
|
|
94
|
+
for (const r of results) {
|
|
95
|
+
if (r.kind === "ok") {
|
|
96
|
+
stdout.write(`${colors.green("✓")} ${r.title}\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
if (failedAny) process.exitCode = 1;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// `synapse https setup <domain>` — the wizard.
|
|
2
|
+
//
|
|
3
|
+
// Flow (all idempotent — re-running is a no-op on a healthy machine):
|
|
4
|
+
// 1. SCAN — read 16 detection vars (read-only, ~50ms)
|
|
5
|
+
// 2. PLAN — turn detections into a list of steps
|
|
6
|
+
// 3. PREVIEW — print the plan; ask "ok?" unless --yes
|
|
7
|
+
// 4. EXECUTE — run each step; stop on failure (steps converge on
|
|
8
|
+
// re-run because each is independently idempotent)
|
|
9
|
+
// 5. VERIFY — re-scan + print summary
|
|
10
|
+
//
|
|
11
|
+
// Flags:
|
|
12
|
+
// --force regenerate the cert even if present
|
|
13
|
+
// --yes skip the preview confirm prompt
|
|
14
|
+
// --dry-run SCAN + PLAN + PREVIEW; never execute
|
|
15
|
+
// --skip-hosts skip the hosts file step
|
|
16
|
+
// --skip-script skip the package.json mutation
|
|
17
|
+
// --json machine-readable output
|
|
18
|
+
// --verbose print per-step duration + outcome details
|
|
19
|
+
|
|
20
|
+
const colors = require("../colors");
|
|
21
|
+
const { extractFlags } = require("./_resource");
|
|
22
|
+
const { confirm } = require("../prompts");
|
|
23
|
+
const detectMod = require("../https/detect");
|
|
24
|
+
const plannerMod = require("../https/planner");
|
|
25
|
+
const executorMod = require("../https/executor");
|
|
26
|
+
|
|
27
|
+
function renderPlan(steps, { stdout }) {
|
|
28
|
+
for (const step of steps) {
|
|
29
|
+
const symbol = symbolFor(step.kind);
|
|
30
|
+
stdout.write(` ${symbol} ${step.title}\n`);
|
|
31
|
+
if (step.kind === "blocker") {
|
|
32
|
+
stdout.write(colors.red(` blocker: ${step.blocker || step.reason}\n`));
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (step.reason) {
|
|
36
|
+
stdout.write(colors.dim(` ${step.reason}\n`));
|
|
37
|
+
}
|
|
38
|
+
if (step.skipReason && step.kind !== "exec") {
|
|
39
|
+
stdout.write(colors.dim(` ${step.skipReason}\n`));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function symbolFor(kind) {
|
|
45
|
+
switch (kind) {
|
|
46
|
+
case "exec":
|
|
47
|
+
return colors.cyan("●");
|
|
48
|
+
case "skip":
|
|
49
|
+
return colors.dim("○");
|
|
50
|
+
case "warn":
|
|
51
|
+
return colors.yellow("!");
|
|
52
|
+
case "blocker":
|
|
53
|
+
return colors.red("✗");
|
|
54
|
+
default:
|
|
55
|
+
return " ";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderResults(results, { stdout }) {
|
|
60
|
+
for (const r of results) {
|
|
61
|
+
const sym =
|
|
62
|
+
r.kind === "ok"
|
|
63
|
+
? colors.green("✓")
|
|
64
|
+
: r.kind === "failed"
|
|
65
|
+
? colors.red("✗")
|
|
66
|
+
: r.kind === "blocker"
|
|
67
|
+
? colors.red("✗")
|
|
68
|
+
: r.kind === "warn"
|
|
69
|
+
? colors.yellow("!")
|
|
70
|
+
: colors.dim("○");
|
|
71
|
+
const tail =
|
|
72
|
+
r.kind === "ok"
|
|
73
|
+
? colors.dim(` ${r.durationMs}ms`)
|
|
74
|
+
: r.kind === "failed"
|
|
75
|
+
? colors.red(` ${r.outcome.error || "failed"}`)
|
|
76
|
+
: "";
|
|
77
|
+
stdout.write(` ${sym} ${r.title}${tail}\n`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
name: "https setup",
|
|
83
|
+
summary: "Configure local HTTPS for a Next.js dev domain (mkcert + hosts + package.json).",
|
|
84
|
+
usage:
|
|
85
|
+
"synapse https setup <domain> [--force] [--yes] [--dry-run] [--skip-hosts] [--skip-script] [--verbose] [--json]",
|
|
86
|
+
description: `Sets up self-signed HTTPS for a custom dev domain (e.g. dev.myproject.com) so \`next dev --experimental-https\` works without browser warnings.
|
|
87
|
+
|
|
88
|
+
Does six things in one pass:
|
|
89
|
+
1. Detects mkcert + your OS + DNS state + cwd's package.json
|
|
90
|
+
2. Ensures the local CA is trusted (mkcert -install, idempotent)
|
|
91
|
+
3. Generates a cert pair at ~/.config/dev-certs/<domain>/
|
|
92
|
+
4. Adds a 127.0.0.1 entry to your hosts file (sudo/UAC prompt if needed)
|
|
93
|
+
— skipped automatically if your public DNS already resolves to loopback
|
|
94
|
+
5. Adds/updates the dev:https script in package.json
|
|
95
|
+
6. Verifies + prints "now run \`npm run dev:https\`"
|
|
96
|
+
|
|
97
|
+
Re-running is safe (every step is idempotent). The whole thing works
|
|
98
|
+
identically on Linux, macOS, Windows, and WSL.
|
|
99
|
+
|
|
100
|
+
Flags:
|
|
101
|
+
--force regenerate the cert even if one exists
|
|
102
|
+
--yes skip the preview confirmation prompt
|
|
103
|
+
--dry-run show the plan, don't execute (useful for partners
|
|
104
|
+
on Windows who want to see what will happen first)
|
|
105
|
+
--skip-hosts don't touch the hosts file
|
|
106
|
+
--skip-script don't touch package.json
|
|
107
|
+
--verbose per-step duration + outcome details
|
|
108
|
+
--json machine-readable output for CI
|
|
109
|
+
|
|
110
|
+
Examples:
|
|
111
|
+
synapse https setup dev.myproject.com
|
|
112
|
+
synapse https setup dev.myproject.com --dry-run --verbose
|
|
113
|
+
synapse https setup dev.myproject.com --force --yes`,
|
|
114
|
+
|
|
115
|
+
// Exports for tests
|
|
116
|
+
renderPlan,
|
|
117
|
+
renderResults,
|
|
118
|
+
symbolFor,
|
|
119
|
+
|
|
120
|
+
async run(args, ctx) {
|
|
121
|
+
const { flags, rest } = extractFlags(args, {
|
|
122
|
+
string: [],
|
|
123
|
+
boolean: ["force", "yes", "dry-run", "skip-hosts", "skip-script", "verbose", "json"],
|
|
124
|
+
});
|
|
125
|
+
const domain = rest[0];
|
|
126
|
+
if (!domain) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"Usage: synapse https setup <domain>\n\nExample: synapse https setup dev.myproject.com",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (rest.length > 1) {
|
|
132
|
+
throw new Error(`Unexpected positional: ${rest[1]}.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const force = flags.force === true;
|
|
136
|
+
const yes = flags.yes === true;
|
|
137
|
+
const dryRun = flags["dry-run"] === true;
|
|
138
|
+
const skipHosts = flags["skip-hosts"] === true;
|
|
139
|
+
const skipScript = flags["skip-script"] === true;
|
|
140
|
+
const verbose = flags.verbose === true;
|
|
141
|
+
|
|
142
|
+
// ---- Phase 1: SCAN ------------------------------------------------
|
|
143
|
+
if (!ctx.out.json) ctx.out.info("Scanning environment…");
|
|
144
|
+
const detection = await detectMod.scan({ domain, cwd: ctx.cwd });
|
|
145
|
+
|
|
146
|
+
// ---- Phase 2: PLAN ------------------------------------------------
|
|
147
|
+
const steps = plannerMod.plan(detection, { force, skipHosts, skipScript });
|
|
148
|
+
const executable = plannerMod.planIsExecutable(steps);
|
|
149
|
+
|
|
150
|
+
// ---- Phase 3: PREVIEW --------------------------------------------
|
|
151
|
+
if (!ctx.out.json) {
|
|
152
|
+
ctx.out.stdout.write(
|
|
153
|
+
`\n${colors.bold("Plan")} ${colors.dim(`for ${domain} on ${detection.platform.id}`)}\n`,
|
|
154
|
+
);
|
|
155
|
+
renderPlan(steps, { stdout: ctx.out.stdout });
|
|
156
|
+
ctx.out.stdout.write("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!executable) {
|
|
160
|
+
const blocker = steps.find((s) => s.kind === "blocker");
|
|
161
|
+
if (ctx.out.json) {
|
|
162
|
+
ctx.out.result(
|
|
163
|
+
{ domain, blocked: true, steps, blocker: blocker?.blocker || blocker?.reason },
|
|
164
|
+
() => {},
|
|
165
|
+
);
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
ctx.out.error(blocker?.blocker || "Plan is blocked.");
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (dryRun) {
|
|
175
|
+
if (ctx.out.json) {
|
|
176
|
+
ctx.out.result({ domain, dryRun: true, steps }, () => {});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
ctx.out.info("(dry-run — not executing)");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const willChange = steps.some((s) => s.kind === "exec");
|
|
184
|
+
if (!willChange) {
|
|
185
|
+
if (ctx.out.json) {
|
|
186
|
+
ctx.out.result({ domain, executedAny: false, steps }, () => {});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
ctx.out.stdout.write(
|
|
190
|
+
colors.green("Nothing to do — everything already in the desired state.\n"),
|
|
191
|
+
);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!yes && !ctx.out.json) {
|
|
196
|
+
const ok = await confirm(`Apply this plan? [Y/n] `, { defaultAnswer: true });
|
|
197
|
+
if (!ok) {
|
|
198
|
+
ctx.out.info("Aborted.");
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!yes && ctx.out.json) {
|
|
204
|
+
throw new Error("Refusing to execute in --json mode without --yes.");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- Phase 4: EXECUTE --------------------------------------------
|
|
208
|
+
const { results, failedAny } = await executorMod.execute(steps, {
|
|
209
|
+
out: ctx.out,
|
|
210
|
+
verbose,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---- Phase 5: VERIFY ---------------------------------------------
|
|
214
|
+
const after = await detectMod.scan({ domain, cwd: ctx.cwd });
|
|
215
|
+
|
|
216
|
+
const summary = {
|
|
217
|
+
domain,
|
|
218
|
+
platform: detection.platform.id,
|
|
219
|
+
certPath: after.existingCerts?.certPath ?? null,
|
|
220
|
+
keyPath: after.existingCerts?.keyPath ?? null,
|
|
221
|
+
hostsEntry: after.hosts.matches.length > 0,
|
|
222
|
+
devHttpsScript: after.pkg.existingDevHttps,
|
|
223
|
+
results,
|
|
224
|
+
failedAny,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (ctx.out.json) {
|
|
228
|
+
ctx.out.result(summary, () => {});
|
|
229
|
+
if (failedAny) process.exitCode = 1;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
ctx.out.stdout.write("\n");
|
|
234
|
+
renderResults(results, { stdout: ctx.out.stdout });
|
|
235
|
+
ctx.out.stdout.write("\n");
|
|
236
|
+
if (failedAny) {
|
|
237
|
+
ctx.out.error(
|
|
238
|
+
"One or more steps failed. Fix the cause and re-run — every step is idempotent so safe steps won't re-do.",
|
|
239
|
+
);
|
|
240
|
+
process.exitCode = 1;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
ctx.out.stdout.write(
|
|
244
|
+
`${colors.green("✓")} ${colors.bold(`HTTPS ready for ${domain}`)}\n`,
|
|
245
|
+
);
|
|
246
|
+
if (after.pkg.hasNext) {
|
|
247
|
+
ctx.out.stdout.write(colors.dim(`Next: run \`npm run dev:https\` in ${ctx.cwd}\n`));
|
|
248
|
+
} else {
|
|
249
|
+
ctx.out.stdout.write(
|
|
250
|
+
colors.dim(
|
|
251
|
+
`Cert lives at: ${after.existingCerts?.certPath}\nPair it with:\n next dev --experimental-https --experimental-https-cert <cert> --experimental-https-key <key> --hostname ${domain}\n`,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (after.resolution.source === "dns") {
|
|
256
|
+
ctx.out.stdout.write(
|
|
257
|
+
colors.dim(
|
|
258
|
+
`(Public DNS A record points ${domain} → 127.0.0.1, so no /etc/hosts edit was needed. Partners on other machines also need no setup.)\n`,
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
};
|