@iann29/synapse 1.8.7 → 1.8.8

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,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 };