@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,356 @@
1
+ // Hosts file reader/writer. Crucial cross-platform piece.
2
+ //
3
+ // Linux/macOS:
4
+ // - Path: /etc/hosts (owned by root, 0644)
5
+ // - Write: needs sudo. We rebuild the file in memory, write to a
6
+ // tmp file with `sudo tee`, then move into place atomically.
7
+ //
8
+ // Windows:
9
+ // - Path: %SystemRoot%\System32\drivers\etc\hosts (owned by
10
+ // TrustedInstaller, ACL'd to Administrators).
11
+ // - Write: needs admin. If the parent process is NOT elevated,
12
+ // we self-re-exec via PowerShell `Start-Process -Verb RunAs`
13
+ // so a UAC prompt appears. Inside that elevated child, the
14
+ // write is a plain fs.writeFileSync.
15
+ //
16
+ // WSL2:
17
+ // - /etc/hosts of the Linux side does NOT affect Windows hosts.
18
+ // If the operator runs the Linux Next.js dev server from WSL2,
19
+ // the browser is on the Windows side — must edit the Windows
20
+ // hosts file via /mnt/c/. We surface this as a second target.
21
+ //
22
+ // Every write goes through `applyPatch` which:
23
+ // 1. Reads current content (post-elevation if needed)
24
+ // 2. Computes the patched content
25
+ // 3. Diffs old-vs-new — bails if equal (idempotent)
26
+ // 4. Writes new content atomically (tmp + rename)
27
+ // 5. Returns { changed: bool, backupPath?: string }
28
+
29
+ const fs = require("node:fs");
30
+ const os = require("node:os");
31
+ const path = require("node:path");
32
+ const { execFileSync } = require("node:child_process");
33
+
34
+ class HostsError extends Error {
35
+ constructor(message, { kind } = {}) {
36
+ super(message);
37
+ this.name = "HostsError";
38
+ this.kind = kind || "unknown"; // permission | not_found | malformed | exec
39
+ }
40
+ }
41
+
42
+ const MANAGED_BLOCK_START = "# === synapse https setup — managed entries ===";
43
+ const MANAGED_BLOCK_END = "# === end synapse https setup ===";
44
+
45
+ // Determine the hosts-file path for the OS. WSL returns the Linux
46
+ // path here; the Windows companion is handled separately.
47
+ function hostsPathForOS() {
48
+ if (process.platform === "win32") {
49
+ const root = process.env.SystemRoot || "C:\\Windows";
50
+ return path.join(root, "System32", "drivers", "etc", "hosts");
51
+ }
52
+ return "/etc/hosts";
53
+ }
54
+
55
+ // Computes the patched hosts content for adding `127.0.0.1 <domain>`.
56
+ // We DO touch existing rows: if a different IP is already mapped to
57
+ // the same domain, we comment it out and append a new managed row.
58
+ // That avoids two-line resolution conflicts.
59
+ //
60
+ // Return { lines, changed: bool, reason }. `reason` explains the
61
+ // decision for the preview UI.
62
+ function planAddEntry(currentContent, domain) {
63
+ const domainLower = String(domain || "").trim().toLowerCase();
64
+ if (!domainLower) {
65
+ throw new HostsError("planAddEntry: domain required", { kind: "malformed" });
66
+ }
67
+ const inputLines = String(currentContent || "").split(/\r?\n/);
68
+ const lines = inputLines.slice();
69
+ let alreadyHas = false;
70
+ let conflict = null; // existing non-loopback row, if any
71
+ for (let i = 0; i < lines.length; i += 1) {
72
+ const raw = lines[i];
73
+ if (!raw || raw.trim().startsWith("#")) continue;
74
+ const parts = raw.trim().split(/\s+/);
75
+ if (parts.length < 2) continue;
76
+ const [address, ...hostnames] = parts;
77
+ const lower = hostnames.map((h) => h.toLowerCase());
78
+ if (lower.includes(domainLower)) {
79
+ if (address === "127.0.0.1" || address === "::1") {
80
+ alreadyHas = true;
81
+ } else {
82
+ conflict = { lineIndex: i, address, raw };
83
+ }
84
+ }
85
+ }
86
+ if (alreadyHas && !conflict) {
87
+ return { lines: inputLines, changed: false, reason: "already mapped to loopback" };
88
+ }
89
+ // Comment out any conflict line so the new mapping wins.
90
+ if (conflict) {
91
+ lines[conflict.lineIndex] = `# ${conflict.raw} # synapse: superseded by loopback`;
92
+ }
93
+ // Append in a "managed by synapse" block. We collapse multiple
94
+ // blocks into one — if a previous run left a START marker, append
95
+ // before the matching END; otherwise create a fresh block.
96
+ const startIdx = lines.indexOf(MANAGED_BLOCK_START);
97
+ const endIdx = lines.indexOf(MANAGED_BLOCK_END);
98
+ const newRow = `127.0.0.1\t${domainLower}`;
99
+ if (startIdx >= 0 && endIdx > startIdx) {
100
+ // Insert before END, skipping if duplicate row exists inside.
101
+ const inner = lines.slice(startIdx + 1, endIdx);
102
+ if (inner.some((l) => l.replace(/\s+/g, " ").trim() === newRow.replace(/\s+/g, " "))) {
103
+ return { lines: inputLines, changed: false, reason: "already in managed block" };
104
+ }
105
+ lines.splice(endIdx, 0, newRow);
106
+ } else {
107
+ // Drop trailing blank lines then append a fresh block.
108
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
109
+ lines.push("");
110
+ lines.push(MANAGED_BLOCK_START);
111
+ lines.push(newRow);
112
+ lines.push(MANAGED_BLOCK_END);
113
+ }
114
+ // Always end the file with a single newline.
115
+ if (lines[lines.length - 1] !== "") lines.push("");
116
+ return {
117
+ lines,
118
+ changed: true,
119
+ reason: conflict
120
+ ? `superseded existing non-loopback row (${conflict.address})`
121
+ : "added new managed entry",
122
+ };
123
+ }
124
+
125
+ // Computes the patched hosts content for removing a domain mapping
126
+ // we previously added. Removes:
127
+ // - The exact `127.0.0.1 <domain>` row inside the managed block
128
+ // - The block markers if the block becomes empty
129
+ // - Does NOT touch rows OUTSIDE the managed block (so the operator
130
+ // can keep their own manually-added entries untouched).
131
+ function planRemoveEntry(currentContent, domain) {
132
+ const domainLower = String(domain || "").trim().toLowerCase();
133
+ if (!domainLower) {
134
+ throw new HostsError("planRemoveEntry: domain required", { kind: "malformed" });
135
+ }
136
+ const inputLines = String(currentContent || "").split(/\r?\n/);
137
+ const startIdx = inputLines.indexOf(MANAGED_BLOCK_START);
138
+ const endIdx = inputLines.indexOf(MANAGED_BLOCK_END);
139
+ if (startIdx < 0 || endIdx < startIdx) {
140
+ return { lines: inputLines, changed: false, reason: "no managed block" };
141
+ }
142
+ const before = inputLines.slice(0, startIdx);
143
+ const inner = inputLines.slice(startIdx + 1, endIdx);
144
+ const after = inputLines.slice(endIdx + 1);
145
+ const filtered = inner.filter((l) => {
146
+ if (!l || l.trim().startsWith("#")) return true;
147
+ const parts = l.trim().split(/\s+/);
148
+ return !parts.slice(1).map((h) => h.toLowerCase()).includes(domainLower);
149
+ });
150
+ if (filtered.length === inner.length) {
151
+ return { lines: inputLines, changed: false, reason: "domain not in managed block" };
152
+ }
153
+ let next;
154
+ if (filtered.length === 0) {
155
+ // Drop the empty markers entirely.
156
+ next = [...before, ...after];
157
+ // Trim a single trailing blank from `before` to avoid two blanks
158
+ // in a row where the block used to be.
159
+ if (next.length > 0 && next[before.length - 1] === "") {
160
+ next.splice(before.length - 1, 1);
161
+ }
162
+ } else {
163
+ next = [...before, MANAGED_BLOCK_START, ...filtered, MANAGED_BLOCK_END, ...after];
164
+ }
165
+ // Always end with a single newline.
166
+ if (next[next.length - 1] !== "") next.push("");
167
+ return { lines: next, changed: true, reason: "removed managed entry" };
168
+ }
169
+
170
+ // Reads a hosts file in a way that survives missing files (returns
171
+ // empty string instead of throwing).
172
+ function readHosts(hostsPath) {
173
+ try {
174
+ return fs.readFileSync(hostsPath, "utf8");
175
+ } catch (err) {
176
+ if (err.code === "ENOENT") return "";
177
+ throw new HostsError(`Could not read ${hostsPath}: ${err.message}`, { kind: "permission" });
178
+ }
179
+ }
180
+
181
+ // Writes content to the hosts file. Three strategies are tried in
182
+ // order based on the platform + the `elevation` knob:
183
+ //
184
+ // 1. Direct fs.writeFileSync (works if the process is already
185
+ // privileged, OR if the file ACL allows the current user)
186
+ // 2. `sudo tee` via execFileSync (Linux/macOS)
187
+ // 3. PowerShell `Start-Process -Verb RunAs` (Windows non-admin)
188
+ //
189
+ // `elevation` can be:
190
+ // - "auto" (default): try direct, fall back to sudo/RunAs
191
+ // - "never": only try direct write; error if it fails
192
+ // - "always": skip the direct write attempt
193
+ //
194
+ // `writeImpl` is injected for tests.
195
+ function writeHosts(hostsPath, content, {
196
+ elevation = "auto",
197
+ writeImpl = fs.writeFileSync,
198
+ execImpl = execFileSync,
199
+ platform = process.platform,
200
+ } = {}) {
201
+ // Try direct write first (fast path).
202
+ if (elevation !== "always") {
203
+ try {
204
+ writeImpl(hostsPath, content);
205
+ return { method: "direct", elevated: false };
206
+ } catch (err) {
207
+ if (elevation === "never") {
208
+ throw new HostsError(
209
+ `Cannot write ${hostsPath} without elevation: ${err.message}`,
210
+ { kind: "permission" },
211
+ );
212
+ }
213
+ // fall through to elevated
214
+ }
215
+ }
216
+
217
+ if (platform === "win32") {
218
+ return writeHostsViaRunAs(hostsPath, content, { execImpl });
219
+ }
220
+ return writeHostsViaSudo(hostsPath, content, { execImpl });
221
+ }
222
+
223
+ // Linux/macOS elevated write. We pipe the new content into `sudo
224
+ // tee <hosts>` via a heredoc-style stdin. `sudo` will prompt for
225
+ // the password interactively if not cached. Backup is created via
226
+ // `cp` BEFORE the write so a Ctrl+C mid-prompt leaves a recoverable
227
+ // state.
228
+ function writeHostsViaSudo(hostsPath, content, { execImpl = execFileSync } = {}) {
229
+ const backupPath = `${hostsPath}.synapse-bak.${Date.now()}`;
230
+ // 1. Backup. Best-effort — failing to backup STOPS the write so
231
+ // we never write without a recovery path.
232
+ try {
233
+ execImpl("sudo", ["cp", hostsPath, backupPath], { stdio: "inherit" });
234
+ } catch (err) {
235
+ throw new HostsError(
236
+ `Could not create backup at ${backupPath} (sudo failed): ${err.message}`,
237
+ { kind: "exec" },
238
+ );
239
+ }
240
+ // 2. Write a temp file in the user's tmpdir, then `sudo install`
241
+ // it over the real hosts file. `install -m 0644` preserves the
242
+ // canonical permissions; `mv` would lose them if /tmp is on a
243
+ // different filesystem with stricter umask.
244
+ const tmpFile = path.join(os.tmpdir(), `synapse-hosts-${Date.now()}.tmp`);
245
+ fs.writeFileSync(tmpFile, content, { mode: 0o644 });
246
+ try {
247
+ execImpl("sudo", ["install", "-m", "0644", tmpFile, hostsPath], { stdio: "inherit" });
248
+ } catch (err) {
249
+ fs.rmSync(tmpFile, { force: true });
250
+ throw new HostsError(
251
+ `sudo install failed: ${err.message}. Backup is at ${backupPath} — restore with: sudo cp ${backupPath} ${hostsPath}`,
252
+ { kind: "exec" },
253
+ );
254
+ }
255
+ fs.rmSync(tmpFile, { force: true });
256
+ return { method: "sudo", elevated: true, backupPath };
257
+ }
258
+
259
+ // Windows elevated write. We can't write to hosts from a non-admin
260
+ // process directly. Solution: spawn a brand-new PowerShell instance
261
+ // elevated via `Start-Process -Verb RunAs`, which triggers the UAC
262
+ // prompt. That elevated PowerShell runs a small script that writes
263
+ // the new content and exits.
264
+ //
265
+ // We pass the content via a temp file because piping it through
266
+ // PowerShell args would mean dealing with command-line length
267
+ // limits + complex quoting.
268
+ function writeHostsViaRunAs(hostsPath, content, { execImpl = execFileSync } = {}) {
269
+ const tmpFile = path.join(os.tmpdir(), `synapse-hosts-${Date.now()}.txt`);
270
+ fs.writeFileSync(tmpFile, content);
271
+ const backupPath = `${hostsPath}.synapse-bak.${Date.now()}`;
272
+ // PowerShell script (single-line, double-quote-friendly):
273
+ // Copy-Item <hosts> <backup>; Move-Item -Force <tmp> <hosts>
274
+ const script = [
275
+ `try {`,
276
+ ` Copy-Item -LiteralPath '${hostsPath}' -Destination '${backupPath}' -ErrorAction Stop;`,
277
+ ` Move-Item -LiteralPath '${tmpFile}' -Destination '${hostsPath}' -Force -ErrorAction Stop;`,
278
+ ` exit 0`,
279
+ `} catch {`,
280
+ ` Write-Error $_.Exception.Message;`,
281
+ ` exit 1`,
282
+ `}`,
283
+ ].join(" ");
284
+ // Use Start-Process so the elevation prompt fires; -Wait so the
285
+ // outer powershell waits for the elevated child to finish. The
286
+ // outer is NOT elevated; only the inner one is.
287
+ try {
288
+ execImpl(
289
+ "powershell",
290
+ [
291
+ "-NoProfile",
292
+ "-NonInteractive",
293
+ "-Command",
294
+ `Start-Process powershell -ArgumentList '-NoProfile','-NonInteractive','-Command','${script.replace(/'/g, "''")}' -Verb RunAs -Wait`,
295
+ ],
296
+ { stdio: "inherit" },
297
+ );
298
+ } catch (err) {
299
+ fs.rmSync(tmpFile, { force: true });
300
+ throw new HostsError(
301
+ `Could not run elevated PowerShell: ${err.message}. Manual fix: edit ${hostsPath} as Administrator and replace its contents from ${tmpFile} (if still present).`,
302
+ { kind: "exec" },
303
+ );
304
+ }
305
+ // The tmp file should be gone after the elevated Move-Item; if
306
+ // the user cancelled UAC the tmp file stays and the original is
307
+ // intact — detect that case by checking the hosts content.
308
+ const after = readHosts(hostsPath);
309
+ if (after !== content) {
310
+ fs.rmSync(tmpFile, { force: true });
311
+ throw new HostsError(
312
+ "Hosts file unchanged after elevated write — UAC may have been cancelled. Re-run from an Administrator PowerShell, or accept the prompt next time.",
313
+ { kind: "permission" },
314
+ );
315
+ }
316
+ return { method: "runas", elevated: true, backupPath };
317
+ }
318
+
319
+ // One-shot "add a domain" patch. Idempotent.
320
+ function addEntry(hostsPath, domain, opts = {}) {
321
+ const current = readHosts(hostsPath);
322
+ const plan = planAddEntry(current, domain);
323
+ if (!plan.changed) {
324
+ return { changed: false, reason: plan.reason };
325
+ }
326
+ const next = plan.lines.join("\n");
327
+ const result = writeHosts(hostsPath, next, opts);
328
+ return { changed: true, reason: plan.reason, ...result };
329
+ }
330
+
331
+ // One-shot "remove a domain" patch. Idempotent.
332
+ function removeEntry(hostsPath, domain, opts = {}) {
333
+ const current = readHosts(hostsPath);
334
+ const plan = planRemoveEntry(current, domain);
335
+ if (!plan.changed) {
336
+ return { changed: false, reason: plan.reason };
337
+ }
338
+ const next = plan.lines.join("\n");
339
+ const result = writeHosts(hostsPath, next, opts);
340
+ return { changed: true, reason: plan.reason, ...result };
341
+ }
342
+
343
+ module.exports = {
344
+ HostsError,
345
+ MANAGED_BLOCK_START,
346
+ MANAGED_BLOCK_END,
347
+ hostsPathForOS,
348
+ planAddEntry,
349
+ planRemoveEntry,
350
+ readHosts,
351
+ writeHosts,
352
+ writeHostsViaSudo,
353
+ writeHostsViaRunAs,
354
+ addEntry,
355
+ removeEntry,
356
+ };
@@ -0,0 +1,131 @@
1
+ // Thin wrapper around the `mkcert` binary. Every function here
2
+ // SHELLS OUT — keep them small, single-purpose, and easy to mock
3
+ // for tests via `spawnImpl`.
4
+ //
5
+ // Why a wrapper instead of inline execFile: most call sites care
6
+ // about stderr-vs-stdout, exit code, and structured errors. A
7
+ // single point of conversion to thrown errors keeps the planner
8
+ // + executor readable.
9
+
10
+ const fs = require("node:fs");
11
+ const path = require("node:path");
12
+ const { spawn, execFileSync } = require("node:child_process");
13
+
14
+ class MkcertError extends Error {
15
+ constructor(message, { exitCode, stderr } = {}) {
16
+ super(message);
17
+ this.name = "MkcertError";
18
+ this.exitCode = exitCode ?? null;
19
+ this.stderr = stderr ?? "";
20
+ }
21
+ }
22
+
23
+ // Streams a child process and returns { code, stdout, stderr }.
24
+ // Resolves on exit (any code); rejects only on spawn failure (e.g.
25
+ // binary missing). Long-running children are unlikely here, but the
26
+ // pattern keeps us out of the execFileSync pit-of-no-control.
27
+ function runProcess(bin, args, { input, env, cwd, spawnImpl = spawn } = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ const child = spawnImpl(bin, args, {
30
+ stdio: ["pipe", "pipe", "pipe"],
31
+ env: env || process.env,
32
+ cwd: cwd || process.cwd(),
33
+ });
34
+ const stdoutChunks = [];
35
+ const stderrChunks = [];
36
+ child.stdout.on("data", (c) => stdoutChunks.push(c));
37
+ child.stderr.on("data", (c) => stderrChunks.push(c));
38
+ child.once("error", reject);
39
+ child.once("close", (code) => {
40
+ resolve({
41
+ code,
42
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
43
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
44
+ });
45
+ });
46
+ if (input !== undefined) {
47
+ child.stdin.end(input);
48
+ } else {
49
+ child.stdin.end();
50
+ }
51
+ });
52
+ }
53
+
54
+ // `mkcert -install` — installs the local CA into the system trust
55
+ // store + browser stores. Idempotent: running on an already-trusted
56
+ // machine is a no-op and exits 0.
57
+ //
58
+ // Returns { ran: bool, output: string }. `ran: true` means we
59
+ // invoked the binary; we don't try to tell whether mkcert actually
60
+ // did work vs short-circuited (mkcert output is the only signal,
61
+ // and parsing it is fragile across versions).
62
+ async function installCA({ spawnImpl } = {}) {
63
+ const { code, stdout, stderr } = await runProcess("mkcert", ["-install"], { spawnImpl });
64
+ if (code !== 0) {
65
+ throw new MkcertError(
66
+ `mkcert -install failed (exit ${code}): ${stderr.trim() || stdout.trim()}`,
67
+ { exitCode: code, stderr },
68
+ );
69
+ }
70
+ return { ran: true, output: (stderr + stdout).trim() };
71
+ }
72
+
73
+ // `mkcert -cert-file <cert> -key-file <key> <domain> [<aliases...>]`
74
+ // Generates a leaf cert + private key for `domain`, writing both
75
+ // files to the requested paths. The PARENT DIRECTORY must exist —
76
+ // mkcert won't mkdir for us. Files are written with default modes;
77
+ // we chmod the key to 0600 post-write because mkcert v1.4.x writes
78
+ // 0644 on Linux/macOS (private key in 0644 is a foot-gun).
79
+ //
80
+ // Pre-existing files at the same paths are silently overwritten by
81
+ // mkcert — that's a caller responsibility to handle (we expose
82
+ // `force` via the planner).
83
+ async function generateCert({ domain, certPath, keyPath, aliases = [], spawnImpl } = {}) {
84
+ // Defensive: ensure dir exists. The planner should have created it
85
+ // already, but doubling up is cheap.
86
+ fs.mkdirSync(path.dirname(certPath), { recursive: true, mode: 0o700 });
87
+ const args = ["-cert-file", certPath, "-key-file", keyPath, domain, ...aliases];
88
+ const { code, stdout, stderr } = await runProcess("mkcert", args, { spawnImpl });
89
+ if (code !== 0) {
90
+ throw new MkcertError(
91
+ `mkcert failed to generate cert for ${domain} (exit ${code}): ${stderr.trim() || stdout.trim()}`,
92
+ { exitCode: code, stderr },
93
+ );
94
+ }
95
+ // Tighten permissions on the private key.
96
+ try {
97
+ fs.chmodSync(keyPath, 0o600);
98
+ } catch {
99
+ // Best-effort on filesystems without POSIX modes (Windows NTFS).
100
+ }
101
+ // Likewise lock down the directory itself.
102
+ try {
103
+ fs.chmodSync(path.dirname(keyPath), 0o700);
104
+ } catch {
105
+ // ignore on non-POSIX
106
+ }
107
+ return { certPath, keyPath, output: (stderr + stdout).trim() };
108
+ }
109
+
110
+ // `mkcert -CAROOT` — returns the directory containing rootCA.pem.
111
+ // Synchronous because the planner calls it during the scan phase
112
+ // (where we deliberately stay synchronous for predictable I/O
113
+ // ordering).
114
+ function caroot() {
115
+ try {
116
+ return execFileSync("mkcert", ["-CAROOT"], {
117
+ encoding: "utf8",
118
+ stdio: ["ignore", "pipe", "ignore"],
119
+ }).trim();
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ module.exports = {
126
+ MkcertError,
127
+ runProcess, // exported for tests + executor (used elsewhere too)
128
+ installCA,
129
+ generateCert,
130
+ caroot,
131
+ };
@@ -0,0 +1,91 @@
1
+ // package.json mutator. Adds / updates / removes the `dev:https`
2
+ // script. Preserves formatting (indentation + trailing newline) so
3
+ // the diff in the operator's git stays readable.
4
+
5
+ const fs = require("node:fs");
6
+
7
+ const SCRIPT_NAME = "dev:https";
8
+
9
+ // Reads cwd/package.json, returns { exists, raw, parsed, indent,
10
+ // finalNewline }. We need the formatting hints so the rewrite
11
+ // doesn't churn whitespace.
12
+ function readPackageJson(filePath) {
13
+ if (!fs.existsSync(filePath)) {
14
+ return { exists: false, raw: null, parsed: null, indent: " ", finalNewline: true };
15
+ }
16
+ const raw = fs.readFileSync(filePath, "utf8");
17
+ let parsed = null;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ } catch {
21
+ parsed = null;
22
+ }
23
+ // Detect indent: look at the first nested key's leading whitespace.
24
+ // Falls back to 2 spaces (npm default).
25
+ let indent = " ";
26
+ const match = raw.match(/\n([ \t]+)"/);
27
+ if (match) indent = match[1];
28
+ const finalNewline = raw.endsWith("\n");
29
+ return { exists: true, raw, parsed, indent, finalNewline };
30
+ }
31
+
32
+ // Writes a JSON object back to disk with the requested indent and
33
+ // trailing newline. Preserves the on-disk shape we read.
34
+ function writePackageJson(filePath, obj, { indent = " ", finalNewline = true } = {}) {
35
+ let body = JSON.stringify(obj, null, indent);
36
+ if (finalNewline) body += "\n";
37
+ fs.writeFileSync(filePath, body);
38
+ }
39
+
40
+ // Sets `scripts["dev:https"] = command`, returning { changed: bool,
41
+ // reason }. If the script already exists with the SAME command (mod
42
+ // whitespace), returns `changed: false` (idempotent).
43
+ function setDevHttpsScript(filePath, command) {
44
+ const info = readPackageJson(filePath);
45
+ if (!info.exists) {
46
+ return { changed: false, reason: "package.json not found", written: false };
47
+ }
48
+ if (!info.parsed) {
49
+ throw new Error(`${filePath} is not valid JSON — refusing to rewrite.`);
50
+ }
51
+ const parsed = info.parsed;
52
+ parsed.scripts = parsed.scripts || {};
53
+ const existing = parsed.scripts[SCRIPT_NAME];
54
+ const norm = (s) => String(s || "").replace(/\s+/g, " ").trim();
55
+ if (existing && norm(existing) === norm(command)) {
56
+ return { changed: false, reason: "script already matches" };
57
+ }
58
+ const before = existing || null;
59
+ parsed.scripts[SCRIPT_NAME] = command;
60
+ writePackageJson(filePath, parsed, info);
61
+ return {
62
+ changed: true,
63
+ reason: existing ? "script updated" : "script added",
64
+ before,
65
+ after: command,
66
+ };
67
+ }
68
+
69
+ // Removes `scripts["dev:https"]`. Returns { changed, reason }.
70
+ function removeDevHttpsScript(filePath) {
71
+ const info = readPackageJson(filePath);
72
+ if (!info.exists || !info.parsed) {
73
+ return { changed: false, reason: "package.json not found or malformed" };
74
+ }
75
+ const parsed = info.parsed;
76
+ if (!parsed.scripts || !(SCRIPT_NAME in parsed.scripts)) {
77
+ return { changed: false, reason: "script not present" };
78
+ }
79
+ const before = parsed.scripts[SCRIPT_NAME];
80
+ delete parsed.scripts[SCRIPT_NAME];
81
+ writePackageJson(filePath, parsed, info);
82
+ return { changed: true, reason: "script removed", before };
83
+ }
84
+
85
+ module.exports = {
86
+ SCRIPT_NAME,
87
+ readPackageJson,
88
+ writePackageJson,
89
+ setDevHttpsScript,
90
+ removeDevHttpsScript,
91
+ };