@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
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// `synapse https setup` — environment detection layer.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions that probe the operator's machine and return a
|
|
4
|
+
// `Detection` record. The planner consumes this record and decides
|
|
5
|
+
// what steps to run; the executor runs the steps. Detection NEVER
|
|
6
|
+
// mutates anything — every function in this file is safe to call
|
|
7
|
+
// from `doctor` and `status` too.
|
|
8
|
+
//
|
|
9
|
+
// Every detection has a typed result. Callers should NEVER catch
|
|
10
|
+
// errors from these functions — anything thrown here is a bug
|
|
11
|
+
// (the helpers all degrade to a known "unknown"/"none" state).
|
|
12
|
+
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const os = require("node:os");
|
|
15
|
+
const path = require("node:path");
|
|
16
|
+
const { execFileSync, execSync } = require("node:child_process");
|
|
17
|
+
const dns = require("node:dns").promises;
|
|
18
|
+
|
|
19
|
+
// Where the operator's per-domain certs live. Centralised so every
|
|
20
|
+
// component agrees on the layout. Linux/macOS: `~/.config/dev-certs/`.
|
|
21
|
+
// Windows: Node's os.homedir() returns `C:\Users\<name>` and path.join
|
|
22
|
+
// produces backslashes — both work.
|
|
23
|
+
function certsRoot() {
|
|
24
|
+
return path.join(os.homedir(), ".config", "dev-certs");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function certDirFor(domain) {
|
|
28
|
+
return path.join(certsRoot(), domain);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function certFilesFor(domain) {
|
|
32
|
+
const dir = certDirFor(domain);
|
|
33
|
+
return {
|
|
34
|
+
dir,
|
|
35
|
+
cert: path.join(dir, `${domain}.pem`),
|
|
36
|
+
key: path.join(dir, `${domain}-key.pem`),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- 1. OS + 2. package manager --------------------------------------
|
|
41
|
+
|
|
42
|
+
// Identifies the host OS in a way the planner can act on. We treat
|
|
43
|
+
// WSL as its own platform because the hosts-file story diverges
|
|
44
|
+
// fundamentally (Linux hosts file doesn't affect Windows browsers).
|
|
45
|
+
function detectPlatform() {
|
|
46
|
+
const platform = process.platform;
|
|
47
|
+
if (platform === "win32") return { id: "windows", node: platform };
|
|
48
|
+
if (platform === "darwin") return { id: "macos", node: platform };
|
|
49
|
+
if (platform === "linux") {
|
|
50
|
+
// WSL exposes itself via /proc/version containing "Microsoft" or
|
|
51
|
+
// "WSL". The detection is read-only and the file is always there.
|
|
52
|
+
try {
|
|
53
|
+
const ver = fs.readFileSync("/proc/version", "utf8").toLowerCase();
|
|
54
|
+
if (ver.includes("microsoft") || ver.includes("wsl")) {
|
|
55
|
+
return { id: "wsl", node: platform };
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// /proc/version unreadable — assume plain Linux.
|
|
59
|
+
}
|
|
60
|
+
return { id: "linux", node: platform };
|
|
61
|
+
}
|
|
62
|
+
return { id: "unknown", node: platform };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Detects available package managers in priority order. We don't try
|
|
66
|
+
// to pick "the" one — the planner just needs to know which tools the
|
|
67
|
+
// operator HAS so it can suggest the right install command. Returns
|
|
68
|
+
// a sorted array of manager ids; empty means "no package manager
|
|
69
|
+
// detected — fall back to binary download instructions".
|
|
70
|
+
function detectPackageManagers() {
|
|
71
|
+
const candidates = {
|
|
72
|
+
linux: ["pacman", "apt-get", "dnf", "yum", "zypper", "apk"],
|
|
73
|
+
macos: ["brew"],
|
|
74
|
+
windows: ["choco", "scoop", "winget"],
|
|
75
|
+
wsl: ["pacman", "apt-get", "dnf"],
|
|
76
|
+
unknown: [],
|
|
77
|
+
};
|
|
78
|
+
const platform = detectPlatform().id;
|
|
79
|
+
const list = candidates[platform] || [];
|
|
80
|
+
return list.filter((bin) => commandExists(bin));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function commandExists(bin) {
|
|
84
|
+
// `which` works on Linux/macOS/WSL. Windows ships `where` instead.
|
|
85
|
+
// Node 16+ has a portable shortcut via try/catch on the binary,
|
|
86
|
+
// but `which`/`where` is fast and avoids hard-coding PATH lookup.
|
|
87
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
88
|
+
try {
|
|
89
|
+
execFileSync(probe, [bin], { stdio: "ignore" });
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- 3. mkcert + 4. CA trusted ---------------------------------------
|
|
97
|
+
|
|
98
|
+
// Detects mkcert: binary path, version, CA root location. The CA
|
|
99
|
+
// root is the directory containing rootCA.pem; `mkcert -CAROOT`
|
|
100
|
+
// prints it.
|
|
101
|
+
function detectMkcert() {
|
|
102
|
+
if (!commandExists("mkcert")) {
|
|
103
|
+
return { present: false, version: null, caroot: null };
|
|
104
|
+
}
|
|
105
|
+
let version = null;
|
|
106
|
+
let caroot = null;
|
|
107
|
+
try {
|
|
108
|
+
// mkcert writes the version to STDERR (!), so we capture both.
|
|
109
|
+
const v = execFileSync("mkcert", ["-version"], {
|
|
110
|
+
encoding: "utf8",
|
|
111
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
112
|
+
}).trim();
|
|
113
|
+
version = v || null;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// Some packages of mkcert print only the bare version; the older
|
|
116
|
+
// ones don't have `-version`. Try without.
|
|
117
|
+
try {
|
|
118
|
+
const v = execFileSync("mkcert", ["--version"], {
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
121
|
+
}).trim();
|
|
122
|
+
version = v || null;
|
|
123
|
+
} catch {
|
|
124
|
+
version = "unknown";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
caroot = execFileSync("mkcert", ["-CAROOT"], {
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
131
|
+
}).trim();
|
|
132
|
+
} catch {
|
|
133
|
+
caroot = null;
|
|
134
|
+
}
|
|
135
|
+
return { present: true, version, caroot };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// "Is the local CA trusted?" — mkcert -install is idempotent and
|
|
139
|
+
// non-destructive, so the safest signal is "rootCA.pem exists in
|
|
140
|
+
// the CAROOT directory". A more pedantic check would parse the
|
|
141
|
+
// system trust store, but the rootCA.pem presence is what mkcert
|
|
142
|
+
// itself uses to detect prior installs.
|
|
143
|
+
function detectCaTrusted(mkcert) {
|
|
144
|
+
if (!mkcert.present || !mkcert.caroot) return false;
|
|
145
|
+
try {
|
|
146
|
+
return fs.existsSync(path.join(mkcert.caroot, "rootCA.pem"));
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- 5. NSS / certutil -----------------------------------------------
|
|
153
|
+
|
|
154
|
+
// Firefox uses its own cert DB (NSS), not the OS trust store. mkcert
|
|
155
|
+
// drives Firefox trust via `certutil` when it's available. Without
|
|
156
|
+
// certutil, mkcert -install still works for Chrome/Safari/Edge but
|
|
157
|
+
// Firefox needs manual import. We surface this so the planner can
|
|
158
|
+
// warn or auto-install libnss3-tools on Linux.
|
|
159
|
+
function detectCertutil() {
|
|
160
|
+
return commandExists("certutil");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- 6. Domain validation --------------------------------------------
|
|
164
|
+
|
|
165
|
+
// Validates the operator-supplied domain. Returns { ok, reason? }.
|
|
166
|
+
// Rules:
|
|
167
|
+
// - hostname-shaped (letters, digits, dots, hyphens)
|
|
168
|
+
// - has at least one dot (so "localhost" is rejected — that path
|
|
169
|
+
// doesn't need mkcert)
|
|
170
|
+
// - not an IP literal (1.2.3.4, ::1, etc)
|
|
171
|
+
// - not "localhost" or "*.localhost"
|
|
172
|
+
// - each label 1-63 chars, total ≤ 253 chars
|
|
173
|
+
//
|
|
174
|
+
// The shape mirrors RFC 1035 hostname grammar, leniently.
|
|
175
|
+
function validateDomain(domain) {
|
|
176
|
+
if (typeof domain !== "string" || domain.trim() === "") {
|
|
177
|
+
return { ok: false, reason: "Domain is empty." };
|
|
178
|
+
}
|
|
179
|
+
const d = domain.trim().toLowerCase();
|
|
180
|
+
if (d === "localhost" || d.endsWith(".localhost")) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
reason:
|
|
184
|
+
"localhost works without HTTPS; you don't need mkcert for it. Use a real domain like `dev.<project>.com`.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// Rough IPv4 check.
|
|
188
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(d)) {
|
|
189
|
+
return { ok: false, reason: "Use a hostname, not an IP." };
|
|
190
|
+
}
|
|
191
|
+
// IPv6-ish.
|
|
192
|
+
if (d.includes(":")) {
|
|
193
|
+
return { ok: false, reason: "Use a hostname, not an IP." };
|
|
194
|
+
}
|
|
195
|
+
if (!d.includes(".")) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
reason: "Domain must include a dot (e.g. `dev.myproject.com`).",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (d.length > 253) {
|
|
202
|
+
return { ok: false, reason: "Domain exceeds 253 characters." };
|
|
203
|
+
}
|
|
204
|
+
const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
205
|
+
for (const label of d.split(".")) {
|
|
206
|
+
if (!LABEL.test(label)) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
reason: `Invalid label "${label}" — letters/digits/hyphens only, no leading/trailing hyphen, ≤63 chars.`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { ok: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- 7. DNS resolution -----------------------------------------------
|
|
217
|
+
|
|
218
|
+
// Does the domain already resolve to 127.0.0.1 via the OS resolver?
|
|
219
|
+
// We hit both DNS (dns.resolve4) and the OS resolver (dns.lookup) —
|
|
220
|
+
// the difference matters: hosts-file entries land in lookup() but
|
|
221
|
+
// NOT in resolve4(). Returns { resolvesToLoopback, source: "dns" |
|
|
222
|
+
// "hosts" | "none", got? }.
|
|
223
|
+
async function detectDomainResolution(domain) {
|
|
224
|
+
// dns.lookup uses the OS resolver, which honours /etc/hosts.
|
|
225
|
+
let lookupResult = null;
|
|
226
|
+
try {
|
|
227
|
+
lookupResult = await dns.lookup(domain, { all: true });
|
|
228
|
+
} catch {
|
|
229
|
+
lookupResult = [];
|
|
230
|
+
}
|
|
231
|
+
const lookupHasLoopback = lookupResult.some(
|
|
232
|
+
(r) => r.address === "127.0.0.1" || r.address === "::1",
|
|
233
|
+
);
|
|
234
|
+
// dns.resolve4 hits the public DNS. If the public A record points
|
|
235
|
+
// at 127.0.0.1 (legitimate trick — saves operators a hosts edit),
|
|
236
|
+
// this returns it.
|
|
237
|
+
let dnsResult = null;
|
|
238
|
+
try {
|
|
239
|
+
dnsResult = await dns.resolve4(domain);
|
|
240
|
+
} catch {
|
|
241
|
+
dnsResult = [];
|
|
242
|
+
}
|
|
243
|
+
const dnsHasLoopback = dnsResult.includes("127.0.0.1");
|
|
244
|
+
|
|
245
|
+
if (dnsHasLoopback) {
|
|
246
|
+
return { resolvesToLoopback: true, source: "dns", got: dnsResult };
|
|
247
|
+
}
|
|
248
|
+
if (lookupHasLoopback) {
|
|
249
|
+
return { resolvesToLoopback: true, source: "hosts", got: lookupResult.map((r) => r.address) };
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
resolvesToLoopback: false,
|
|
253
|
+
source: "none",
|
|
254
|
+
got: [...new Set([...dnsResult, ...lookupResult.map((r) => r.address)])],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- 8/9/10. Hosts file ----------------------------------------------
|
|
259
|
+
|
|
260
|
+
// Returns the canonical hosts file path for the running OS. WSL is
|
|
261
|
+
// special: /etc/hosts only affects the Linux side. To make a domain
|
|
262
|
+
// resolve in Windows browsers from inside WSL2, the operator would
|
|
263
|
+
// also need to edit `C:\Windows\System32\drivers\etc\hosts` (and
|
|
264
|
+
// our writer would need to reach it via the `/mnt/c/` mount).
|
|
265
|
+
function detectHostsPath() {
|
|
266
|
+
const platform = detectPlatform().id;
|
|
267
|
+
if (platform === "windows") {
|
|
268
|
+
const root = process.env.SystemRoot || "C:\\Windows";
|
|
269
|
+
return path.join(root, "System32", "drivers", "etc", "hosts");
|
|
270
|
+
}
|
|
271
|
+
// WSL: return the Linux path; the WSL companion path is reported
|
|
272
|
+
// separately via detectWslWindowsHosts().
|
|
273
|
+
return "/etc/hosts";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// For WSL: probe the Windows-side hosts file via /mnt/c/. Returns
|
|
277
|
+
// the path if accessible; null otherwise.
|
|
278
|
+
function detectWslWindowsHosts() {
|
|
279
|
+
if (detectPlatform().id !== "wsl") return null;
|
|
280
|
+
const candidates = [
|
|
281
|
+
"/mnt/c/Windows/System32/drivers/etc/hosts",
|
|
282
|
+
"/mnt/c/WINDOWS/System32/drivers/etc/hosts",
|
|
283
|
+
];
|
|
284
|
+
for (const p of candidates) {
|
|
285
|
+
try {
|
|
286
|
+
if (fs.existsSync(p)) return p;
|
|
287
|
+
} catch {
|
|
288
|
+
// skip
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Reads the hosts file and reports:
|
|
295
|
+
// - exists: bool
|
|
296
|
+
// - readable: bool
|
|
297
|
+
// - writable: bool (process can write WITHOUT elevation)
|
|
298
|
+
// - matches: array of { line, address, hostnames[] } whose
|
|
299
|
+
// hostnames include the queried domain
|
|
300
|
+
// - allLines: raw lines (planner uses them to compute the patch)
|
|
301
|
+
function detectHostsForDomain(domain, hostsPath) {
|
|
302
|
+
const result = {
|
|
303
|
+
path: hostsPath,
|
|
304
|
+
exists: false,
|
|
305
|
+
readable: false,
|
|
306
|
+
writable: false,
|
|
307
|
+
matches: [],
|
|
308
|
+
allLines: [],
|
|
309
|
+
};
|
|
310
|
+
try {
|
|
311
|
+
if (!fs.existsSync(hostsPath)) return result;
|
|
312
|
+
result.exists = true;
|
|
313
|
+
const content = fs.readFileSync(hostsPath, "utf8");
|
|
314
|
+
result.readable = true;
|
|
315
|
+
result.allLines = content.split(/\r?\n/);
|
|
316
|
+
for (const raw of result.allLines) {
|
|
317
|
+
if (!raw || raw.trim().startsWith("#")) continue;
|
|
318
|
+
// hosts file rows: <address> <hostname> [<hostname> ...]
|
|
319
|
+
const parts = raw.trim().split(/\s+/);
|
|
320
|
+
if (parts.length < 2) continue;
|
|
321
|
+
const [address, ...hostnames] = parts;
|
|
322
|
+
if (hostnames.map((h) => h.toLowerCase()).includes(domain.toLowerCase())) {
|
|
323
|
+
result.matches.push({ line: raw, address, hostnames });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
// permission denied or otherwise — readable stays false.
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
fs.accessSync(hostsPath, fs.constants.W_OK);
|
|
331
|
+
result.writable = true;
|
|
332
|
+
} catch {
|
|
333
|
+
// Not writable without elevation.
|
|
334
|
+
}
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- 11/12/13. package.json + Next.js + dev:https script ------------
|
|
339
|
+
|
|
340
|
+
// Reads cwd/package.json (read-only). Returns:
|
|
341
|
+
// - present, parsed (object | null)
|
|
342
|
+
// - hasNext, nextVersion (semver string or null)
|
|
343
|
+
// - existingDevHttps (string | null) — the current value of
|
|
344
|
+
// scripts["dev:https"], if any.
|
|
345
|
+
function detectPackageJson(cwd) {
|
|
346
|
+
const file = path.join(cwd, "package.json");
|
|
347
|
+
const result = {
|
|
348
|
+
path: file,
|
|
349
|
+
present: false,
|
|
350
|
+
parsed: null,
|
|
351
|
+
hasNext: false,
|
|
352
|
+
nextVersion: null,
|
|
353
|
+
existingDevHttps: null,
|
|
354
|
+
};
|
|
355
|
+
if (!fs.existsSync(file)) return result;
|
|
356
|
+
result.present = true;
|
|
357
|
+
let parsed;
|
|
358
|
+
try {
|
|
359
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
360
|
+
} catch {
|
|
361
|
+
return result; // malformed JSON — leave parsed null
|
|
362
|
+
}
|
|
363
|
+
result.parsed = parsed;
|
|
364
|
+
const allDeps = {
|
|
365
|
+
...(parsed.dependencies || {}),
|
|
366
|
+
...(parsed.devDependencies || {}),
|
|
367
|
+
...(parsed.peerDependencies || {}),
|
|
368
|
+
};
|
|
369
|
+
if (allDeps.next) {
|
|
370
|
+
result.hasNext = true;
|
|
371
|
+
result.nextVersion = allDeps.next;
|
|
372
|
+
}
|
|
373
|
+
if (parsed.scripts && typeof parsed.scripts["dev:https"] === "string") {
|
|
374
|
+
result.existingDevHttps = parsed.scripts["dev:https"];
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Compares two `dev:https` script strings, ignoring whitespace
|
|
380
|
+
// differences. We use this to decide whether the existing script
|
|
381
|
+
// matches what we'd write — if it does, the step is a no-op.
|
|
382
|
+
function devHttpsScriptsEqual(a, b) {
|
|
383
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
384
|
+
return a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Build the canonical `dev:https` script for a given domain. This is
|
|
388
|
+
// the source of truth — both `setup` (when writing) and `status`
|
|
389
|
+
// (when comparing) call this. Path uses POSIX forward slashes even
|
|
390
|
+
// on Windows because Node's path resolution accepts them.
|
|
391
|
+
function buildDevHttpsScript(domain, certFile, keyFile) {
|
|
392
|
+
return `next dev --turbopack --hostname ${domain} --experimental-https --experimental-https-key ${keyFile} --experimental-https-cert ${certFile}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---- 14/15. Existing cert state --------------------------------------
|
|
396
|
+
|
|
397
|
+
// Probes the cert directory for an existing pair. Reports presence
|
|
398
|
+
// + best-effort expiry (we shell out to openssl when available; if
|
|
399
|
+
// not, just presence is enough — mkcert default expiry is 825 days
|
|
400
|
+
// and the planner errs on the side of "regenerate if asked").
|
|
401
|
+
function detectExistingCerts(domain) {
|
|
402
|
+
const { dir, cert, key } = certFilesFor(domain);
|
|
403
|
+
const present = fs.existsSync(cert) && fs.existsSync(key);
|
|
404
|
+
let expiry = null;
|
|
405
|
+
if (present && commandExists("openssl")) {
|
|
406
|
+
try {
|
|
407
|
+
const out = execFileSync(
|
|
408
|
+
"openssl",
|
|
409
|
+
["x509", "-in", cert, "-noout", "-enddate"],
|
|
410
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
411
|
+
);
|
|
412
|
+
// Format: notAfter=Aug 1 12:00:00 2027 GMT
|
|
413
|
+
const m = out.match(/notAfter=(.+)/);
|
|
414
|
+
if (m) expiry = new Date(m[1]).toISOString();
|
|
415
|
+
} catch {
|
|
416
|
+
// openssl unavailable or cert unparseable — leave expiry null.
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return { dir, certPath: cert, keyPath: key, present, expiry };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Scans `dir` for cert pairs at the top level (non-recursive). Two
|
|
423
|
+
// historical key-file conventions exist in the wild:
|
|
424
|
+
// - `<domain>-key.pem` (mkcert default + most online tutorials)
|
|
425
|
+
// - `<domain>.key.pem` (operator's hand-rolled convention)
|
|
426
|
+
//
|
|
427
|
+
// We pair `<domain>.pem` with EITHER variant — whichever exists.
|
|
428
|
+
// Returns array of { domain, cert, key, keyShape: "dash"|"dot" }.
|
|
429
|
+
function findCertPairsInDir(dir) {
|
|
430
|
+
let files;
|
|
431
|
+
try {
|
|
432
|
+
files = fs.readdirSync(dir);
|
|
433
|
+
} catch {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
const fileSet = new Set(files);
|
|
437
|
+
const out = [];
|
|
438
|
+
const seen = new Set();
|
|
439
|
+
for (const f of files) {
|
|
440
|
+
if (!f.endsWith(".pem")) continue;
|
|
441
|
+
if (f.endsWith("-key.pem") || f.endsWith(".key.pem")) continue;
|
|
442
|
+
const domain = f.slice(0, -".pem".length);
|
|
443
|
+
if (seen.has(domain)) continue;
|
|
444
|
+
const dashKey = `${domain}-key.pem`;
|
|
445
|
+
const dotKey = `${domain}.key.pem`;
|
|
446
|
+
if (fileSet.has(dashKey)) {
|
|
447
|
+
out.push({
|
|
448
|
+
domain,
|
|
449
|
+
cert: path.join(dir, f),
|
|
450
|
+
key: path.join(dir, dashKey),
|
|
451
|
+
keyShape: "dash",
|
|
452
|
+
});
|
|
453
|
+
seen.add(domain);
|
|
454
|
+
} else if (fileSet.has(dotKey)) {
|
|
455
|
+
out.push({
|
|
456
|
+
domain,
|
|
457
|
+
cert: path.join(dir, f),
|
|
458
|
+
key: path.join(dir, dotKey),
|
|
459
|
+
keyShape: "dot",
|
|
460
|
+
});
|
|
461
|
+
seen.add(domain);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Cwd-only wrapper (back-compat name).
|
|
468
|
+
function detectLegacyCertsInCwd(cwd) {
|
|
469
|
+
return findCertPairsInDir(cwd);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Scans the canonical cert store for "flat" pairs (cert+key directly
|
|
473
|
+
// in the root instead of in a per-domain subdirectory). Useful for
|
|
474
|
+
// `https migrate --root` to reorganise an operator's existing setup
|
|
475
|
+
// without losing data.
|
|
476
|
+
function detectFlatCertsInStore() {
|
|
477
|
+
return findCertPairsInDir(certsRoot());
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---- 16. Cross-project domain conflict -------------------------------
|
|
481
|
+
|
|
482
|
+
// Returns true if `~/.config/dev-certs/` already has a directory for
|
|
483
|
+
// this domain owned by a different project. We can't actually tell
|
|
484
|
+
// "ownership" without scanning every package.json on disk, so we
|
|
485
|
+
// just report "cert exists under canonical path" — the planner uses
|
|
486
|
+
// this to warn that the cert will be reused.
|
|
487
|
+
function detectDomainAlreadyInCertStore(domain) {
|
|
488
|
+
try {
|
|
489
|
+
return fs.existsSync(certDirFor(domain));
|
|
490
|
+
} catch {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---- elevation availability ------------------------------------------
|
|
496
|
+
|
|
497
|
+
// Best-effort: can we elevate? `sudo -n true` returns 0 if NOPASSWD
|
|
498
|
+
// is configured (no interactive prompt needed); non-zero otherwise.
|
|
499
|
+
// We don't actually run sudo here — the executor decides whether to
|
|
500
|
+
// prompt. This signal only feeds the preview ("you'll be prompted
|
|
501
|
+
// for your password" vs "elevation already cached").
|
|
502
|
+
function detectSudoCached() {
|
|
503
|
+
if (detectPlatform().id === "windows") return false;
|
|
504
|
+
try {
|
|
505
|
+
execFileSync("sudo", ["-n", "true"], { stdio: "ignore" });
|
|
506
|
+
return true;
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Windows: is the current process running elevated? `net session`
|
|
513
|
+
// returns 0 from an admin shell; non-zero from a normal one. We use
|
|
514
|
+
// it as the "are we admin?" probe.
|
|
515
|
+
function detectWindowsAdmin() {
|
|
516
|
+
if (detectPlatform().id !== "windows") return false;
|
|
517
|
+
try {
|
|
518
|
+
execSync("net session > nul 2>&1");
|
|
519
|
+
return true;
|
|
520
|
+
} catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---- Aggregate scan --------------------------------------------------
|
|
526
|
+
|
|
527
|
+
// The single entrypoint commands call. Builds a Detection object
|
|
528
|
+
// with everything the planner needs. Pure: zero mutations.
|
|
529
|
+
async function scan({ domain, cwd = process.cwd() } = {}) {
|
|
530
|
+
const platform = detectPlatform();
|
|
531
|
+
const packageManagers = detectPackageManagers();
|
|
532
|
+
const mkcert = detectMkcert();
|
|
533
|
+
const caTrusted = detectCaTrusted(mkcert);
|
|
534
|
+
const hasCertutil = detectCertutil();
|
|
535
|
+
|
|
536
|
+
const validation = domain ? validateDomain(domain) : { ok: false, reason: "no domain" };
|
|
537
|
+
const resolution = domain && validation.ok
|
|
538
|
+
? await detectDomainResolution(domain)
|
|
539
|
+
: { resolvesToLoopback: false, source: "none", got: [] };
|
|
540
|
+
|
|
541
|
+
const hostsPath = detectHostsPath();
|
|
542
|
+
const hostsState = detectHostsForDomain(domain || "", hostsPath);
|
|
543
|
+
const wslWindowsHostsPath = detectWslWindowsHosts();
|
|
544
|
+
const wslWindowsHostsState = wslWindowsHostsPath
|
|
545
|
+
? detectHostsForDomain(domain || "", wslWindowsHostsPath)
|
|
546
|
+
: null;
|
|
547
|
+
|
|
548
|
+
const pkg = detectPackageJson(cwd);
|
|
549
|
+
const existingCerts = domain ? detectExistingCerts(domain) : null;
|
|
550
|
+
const legacyCerts = detectLegacyCertsInCwd(cwd);
|
|
551
|
+
const certStoreClash = domain ? detectDomainAlreadyInCertStore(domain) : false;
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
domain: domain || null,
|
|
555
|
+
cwd,
|
|
556
|
+
platform,
|
|
557
|
+
packageManagers,
|
|
558
|
+
mkcert,
|
|
559
|
+
caTrusted,
|
|
560
|
+
hasCertutil,
|
|
561
|
+
validation,
|
|
562
|
+
resolution,
|
|
563
|
+
hosts: hostsState,
|
|
564
|
+
wslWindowsHosts: wslWindowsHostsState,
|
|
565
|
+
pkg,
|
|
566
|
+
existingCerts,
|
|
567
|
+
legacyCerts,
|
|
568
|
+
certStoreClash,
|
|
569
|
+
sudoCached: detectSudoCached(),
|
|
570
|
+
windowsAdmin: detectWindowsAdmin(),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
module.exports = {
|
|
575
|
+
// Paths
|
|
576
|
+
certsRoot,
|
|
577
|
+
certDirFor,
|
|
578
|
+
certFilesFor,
|
|
579
|
+
// Detections (granular — exported for tests)
|
|
580
|
+
detectPlatform,
|
|
581
|
+
detectPackageManagers,
|
|
582
|
+
detectMkcert,
|
|
583
|
+
detectCaTrusted,
|
|
584
|
+
detectCertutil,
|
|
585
|
+
validateDomain,
|
|
586
|
+
detectDomainResolution,
|
|
587
|
+
detectHostsPath,
|
|
588
|
+
detectWslWindowsHosts,
|
|
589
|
+
detectHostsForDomain,
|
|
590
|
+
detectPackageJson,
|
|
591
|
+
detectExistingCerts,
|
|
592
|
+
detectLegacyCertsInCwd,
|
|
593
|
+
detectFlatCertsInStore,
|
|
594
|
+
findCertPairsInDir,
|
|
595
|
+
detectDomainAlreadyInCertStore,
|
|
596
|
+
detectSudoCached,
|
|
597
|
+
detectWindowsAdmin,
|
|
598
|
+
commandExists,
|
|
599
|
+
buildDevHttpsScript,
|
|
600
|
+
devHttpsScriptsEqual,
|
|
601
|
+
// Aggregate
|
|
602
|
+
scan,
|
|
603
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Step executor. Runs the steps returned by planner.plan() in
|
|
2
|
+
// order, capturing per-step status + duration. Refuses to run if
|
|
3
|
+
// any step has `kind: blocker`.
|
|
4
|
+
//
|
|
5
|
+
// We INTENTIONALLY don't try to be transactional across steps —
|
|
6
|
+
// each step is independently idempotent, so re-running the wizard
|
|
7
|
+
// after a partial failure converges. The "transactional" guarantees
|
|
8
|
+
// live inside individual writers (hosts.js writes via tmp+rename,
|
|
9
|
+
// nextjs.js writes the full file atomically, mkcert.generateCert
|
|
10
|
+
// only chmods on success).
|
|
11
|
+
|
|
12
|
+
const colors = require("../colors");
|
|
13
|
+
|
|
14
|
+
async function execute(steps, { out, verbose = false } = {}) {
|
|
15
|
+
const results = [];
|
|
16
|
+
let executedAny = false;
|
|
17
|
+
let failedAny = false;
|
|
18
|
+
|
|
19
|
+
for (const step of steps) {
|
|
20
|
+
if (step.kind === "blocker") {
|
|
21
|
+
results.push({
|
|
22
|
+
id: step.id,
|
|
23
|
+
kind: "blocker",
|
|
24
|
+
title: step.title,
|
|
25
|
+
durationMs: 0,
|
|
26
|
+
outcome: { blocker: step.blocker || step.reason },
|
|
27
|
+
});
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (step.kind === "skip" || step.kind === "warn") {
|
|
31
|
+
results.push({
|
|
32
|
+
id: step.id,
|
|
33
|
+
kind: step.kind,
|
|
34
|
+
title: step.title,
|
|
35
|
+
durationMs: 0,
|
|
36
|
+
outcome: { reason: step.reason, skipReason: step.skipReason },
|
|
37
|
+
});
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// kind === "exec"
|
|
41
|
+
if (!step.run || typeof step.run !== "function") {
|
|
42
|
+
results.push({
|
|
43
|
+
id: step.id,
|
|
44
|
+
kind: "failed",
|
|
45
|
+
title: step.title,
|
|
46
|
+
durationMs: 0,
|
|
47
|
+
outcome: { error: "step has no run() function (planner bug)" },
|
|
48
|
+
});
|
|
49
|
+
failedAny = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (out && !out.json) {
|
|
53
|
+
out.info(`→ ${step.title}`);
|
|
54
|
+
}
|
|
55
|
+
const t0 = Date.now();
|
|
56
|
+
try {
|
|
57
|
+
const outcome = await step.run();
|
|
58
|
+
const durationMs = Date.now() - t0;
|
|
59
|
+
results.push({
|
|
60
|
+
id: step.id,
|
|
61
|
+
kind: "ok",
|
|
62
|
+
title: step.title,
|
|
63
|
+
durationMs,
|
|
64
|
+
outcome: outcome || {},
|
|
65
|
+
});
|
|
66
|
+
executedAny = true;
|
|
67
|
+
if (verbose && out && !out.json) {
|
|
68
|
+
out.info(
|
|
69
|
+
` ${colors.dim(`done in ${durationMs}ms${outcome && outcome.reason ? ` · ${outcome.reason}` : ""}`)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const durationMs = Date.now() - t0;
|
|
74
|
+
results.push({
|
|
75
|
+
id: step.id,
|
|
76
|
+
kind: "failed",
|
|
77
|
+
title: step.title,
|
|
78
|
+
durationMs,
|
|
79
|
+
outcome: {
|
|
80
|
+
error: err && err.message ? err.message : String(err),
|
|
81
|
+
kind: err && err.kind ? err.kind : undefined,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
failedAny = true;
|
|
85
|
+
if (out && !out.json) {
|
|
86
|
+
out.error(`step "${step.id}" failed: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
// Stop on first failure — subsequent steps may depend on this
|
|
89
|
+
// one (e.g. dev:https script needs the cert to exist). The
|
|
90
|
+
// operator re-runs after fixing the cause.
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { results, executedAny, failedAny };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Returns true iff the plan contains at least one blocker.
|
|
98
|
+
function hasBlocker(steps) {
|
|
99
|
+
return steps.some((s) => s.kind === "blocker");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { execute, hasBlocker };
|