mk-https 1.0.0
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/LICENSE +21 -0
- package/README.md +256 -0
- package/dist/index.js +1109 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
|
+
import chalk8 from "chalk";
|
|
6
|
+
|
|
7
|
+
// src/commands/init.ts
|
|
8
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
9
|
+
import path7 from "path";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import ora2 from "ora";
|
|
12
|
+
import { input } from "@inquirer/prompts";
|
|
13
|
+
|
|
14
|
+
// src/lib/elevation.ts
|
|
15
|
+
import { writeFile, unlink } from "fs/promises";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import os from "os";
|
|
18
|
+
import { execa } from "execa";
|
|
19
|
+
var ELEVATED_FLAG = "--elevated";
|
|
20
|
+
async function isElevated() {
|
|
21
|
+
if (process.platform === "win32") {
|
|
22
|
+
try {
|
|
23
|
+
const result = await execa("net", ["session"], { reject: false, all: true });
|
|
24
|
+
return result.exitCode === 0;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return process.getuid !== void 0 && process.getuid() === 0;
|
|
30
|
+
}
|
|
31
|
+
async function elevateUnix() {
|
|
32
|
+
const scriptPath = process.argv[1];
|
|
33
|
+
if (!scriptPath) {
|
|
34
|
+
throw new Error("Cannot determine script path for sudo re-invocation.");
|
|
35
|
+
}
|
|
36
|
+
const args = process.argv.slice(2).filter((a) => a !== ELEVATED_FLAG);
|
|
37
|
+
try {
|
|
38
|
+
await execa("sudo", [scriptPath, ...args, ELEVATED_FLAG], {
|
|
39
|
+
stdio: "inherit"
|
|
40
|
+
});
|
|
41
|
+
} catch {
|
|
42
|
+
process.stderr.write(
|
|
43
|
+
`sudo failed. To run manually with elevated privileges:
|
|
44
|
+
sudo ${scriptPath} ${args.join(" ")} ${ELEVATED_FLAG}
|
|
45
|
+
`
|
|
46
|
+
);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
async function elevateWindows() {
|
|
52
|
+
const scriptPath = process.argv[1];
|
|
53
|
+
if (!scriptPath) {
|
|
54
|
+
throw new Error("Cannot determine script path for UAC re-invocation.");
|
|
55
|
+
}
|
|
56
|
+
const args = process.argv.slice(2).filter((a) => a !== ELEVATED_FLAG);
|
|
57
|
+
const cmdLine = [scriptPath, ...args, ELEVATED_FLAG].map((a) => `"${a.replace(/"/g, '\\"')}"`).join(" ");
|
|
58
|
+
const ps1Path = path.join(os.tmpdir(), `mk-https-elevate-${process.pid}.ps1`);
|
|
59
|
+
const script = `Start-Process node -Verb RunAs -ArgumentList ${cmdLine} -Wait
|
|
60
|
+
`;
|
|
61
|
+
await writeFile(ps1Path, script, "utf-8");
|
|
62
|
+
try {
|
|
63
|
+
await execa("powershell", [
|
|
64
|
+
"-ExecutionPolicy",
|
|
65
|
+
"Bypass",
|
|
66
|
+
"-NonInteractive",
|
|
67
|
+
"-File",
|
|
68
|
+
ps1Path
|
|
69
|
+
]);
|
|
70
|
+
} finally {
|
|
71
|
+
await unlink(ps1Path).catch(() => void 0);
|
|
72
|
+
}
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
async function requireElevation() {
|
|
76
|
+
if (await isElevated()) return;
|
|
77
|
+
process.stderr.write(
|
|
78
|
+
"This operation modifies /etc/hosts and requires administrator privileges.\n"
|
|
79
|
+
);
|
|
80
|
+
if (process.platform === "win32") {
|
|
81
|
+
await elevateWindows();
|
|
82
|
+
} else {
|
|
83
|
+
await elevateUnix();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/lib/hosts.ts
|
|
88
|
+
import { readFile, writeFile as writeFile2 } from "fs/promises";
|
|
89
|
+
import path2 from "path";
|
|
90
|
+
var MARKER_START = "# mk-https:start";
|
|
91
|
+
var MARKER_END = "# mk-https:end";
|
|
92
|
+
var ALLOWED_TLDS = [".local", ".test", ".localhost", ".internal"];
|
|
93
|
+
function hostsPath() {
|
|
94
|
+
if (process.platform === "win32") {
|
|
95
|
+
return path2.join(
|
|
96
|
+
process.env["SystemRoot"] ?? "C:\\Windows",
|
|
97
|
+
"System32",
|
|
98
|
+
"drivers",
|
|
99
|
+
"etc",
|
|
100
|
+
"hosts"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return "/etc/hosts";
|
|
104
|
+
}
|
|
105
|
+
function validateDomain(domain) {
|
|
106
|
+
if (!ALLOWED_TLDS.some((tld) => domain.endsWith(tld))) return false;
|
|
107
|
+
const LABEL_REGEX = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
108
|
+
const labels = domain.split(".");
|
|
109
|
+
if (labels.length < 2) return false;
|
|
110
|
+
return labels.every((label) => LABEL_REGEX.test(label));
|
|
111
|
+
}
|
|
112
|
+
async function readHostsContent() {
|
|
113
|
+
return readFile(hostsPath(), "utf-8");
|
|
114
|
+
}
|
|
115
|
+
async function writeHostsContent(content) {
|
|
116
|
+
await writeFile2(hostsPath(), content, "utf-8");
|
|
117
|
+
}
|
|
118
|
+
function hasMarkers(content) {
|
|
119
|
+
return content.includes(MARKER_START) && content.includes(MARKER_END);
|
|
120
|
+
}
|
|
121
|
+
function buildDomainLines(domains) {
|
|
122
|
+
return domains.flatMap((d) => [`127.0.0.1 ${d}`, `::1 ${d}`]).join("\n");
|
|
123
|
+
}
|
|
124
|
+
function insertMarkersBlock(content, domains) {
|
|
125
|
+
const separator = content.endsWith("\n") ? "" : "\n";
|
|
126
|
+
const block = `${MARKER_START}
|
|
127
|
+
${buildDomainLines(domains)}
|
|
128
|
+
${MARKER_END}
|
|
129
|
+
`;
|
|
130
|
+
return `${content}${separator}${block}`;
|
|
131
|
+
}
|
|
132
|
+
function replaceMarkersBlock(content, domains) {
|
|
133
|
+
const startIdx = content.indexOf(MARKER_START);
|
|
134
|
+
const endIdx = content.indexOf(MARKER_END);
|
|
135
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
136
|
+
return insertMarkersBlock(content, domains);
|
|
137
|
+
}
|
|
138
|
+
const before = content.slice(0, startIdx);
|
|
139
|
+
const after = content.slice(endIdx + MARKER_END.length);
|
|
140
|
+
const block = `${MARKER_START}
|
|
141
|
+
${buildDomainLines(domains)}
|
|
142
|
+
${MARKER_END}`;
|
|
143
|
+
return `${before}${block}${after}`;
|
|
144
|
+
}
|
|
145
|
+
function stripMarkersBlock(content) {
|
|
146
|
+
const startIdx = content.indexOf(MARKER_START);
|
|
147
|
+
const endIdx = content.indexOf(MARKER_END);
|
|
148
|
+
if (startIdx === -1 || endIdx === -1) return content;
|
|
149
|
+
const before = content.slice(0, startIdx);
|
|
150
|
+
const afterMarker = content.slice(endIdx + MARKER_END.length);
|
|
151
|
+
const after = afterMarker.startsWith("\n") ? afterMarker.slice(1) : afterMarker;
|
|
152
|
+
return `${before}${after}`;
|
|
153
|
+
}
|
|
154
|
+
async function syncHostsEntries(domains) {
|
|
155
|
+
let content;
|
|
156
|
+
try {
|
|
157
|
+
content = await readHostsContent();
|
|
158
|
+
} catch {
|
|
159
|
+
content = "";
|
|
160
|
+
}
|
|
161
|
+
const updated = hasMarkers(content) ? replaceMarkersBlock(content, domains) : insertMarkersBlock(content, domains);
|
|
162
|
+
await writeHostsContent(updated);
|
|
163
|
+
}
|
|
164
|
+
async function removeHostsEntries() {
|
|
165
|
+
let content;
|
|
166
|
+
try {
|
|
167
|
+
content = await readHostsContent();
|
|
168
|
+
} catch {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await writeHostsContent(stripMarkersBlock(content));
|
|
172
|
+
}
|
|
173
|
+
async function getHostedDomains() {
|
|
174
|
+
let content;
|
|
175
|
+
try {
|
|
176
|
+
content = await readHostsContent();
|
|
177
|
+
} catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
if (!hasMarkers(content)) return [];
|
|
181
|
+
const startIdx = content.indexOf(MARKER_START);
|
|
182
|
+
const endIdx = content.indexOf(MARKER_END);
|
|
183
|
+
const block = content.slice(startIdx + MARKER_START.length, endIdx);
|
|
184
|
+
return block.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("127.0.0.1 ") || line.startsWith("::1 ")).map((line) => {
|
|
185
|
+
if (line.startsWith("127.0.0.1 ")) return line.slice("127.0.0.1 ".length).trim();
|
|
186
|
+
return line.slice("::1 ".length).trim();
|
|
187
|
+
}).filter((domain, index, arr) => arr.indexOf(domain) === index);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/lib/config.ts
|
|
191
|
+
import { readFile as readFile2, writeFile as writeFile3, mkdir } from "fs/promises";
|
|
192
|
+
import { existsSync } from "fs";
|
|
193
|
+
import path3 from "path";
|
|
194
|
+
var CONFIG_VERSION = "1.0.0";
|
|
195
|
+
var CONFIG_DIR = ".mk-https";
|
|
196
|
+
var CONFIG_FILE = path3.join(CONFIG_DIR, "mk-https.json");
|
|
197
|
+
function resolveConfigPath() {
|
|
198
|
+
return path3.resolve(process.cwd(), CONFIG_FILE);
|
|
199
|
+
}
|
|
200
|
+
function resolveConfigDir() {
|
|
201
|
+
return path3.resolve(process.cwd(), CONFIG_DIR);
|
|
202
|
+
}
|
|
203
|
+
function configExists() {
|
|
204
|
+
return existsSync(resolveConfigPath());
|
|
205
|
+
}
|
|
206
|
+
function isValidConfig(obj) {
|
|
207
|
+
if (typeof obj !== "object" || obj === null) return false;
|
|
208
|
+
const c = obj;
|
|
209
|
+
return typeof c["version"] === "string" && Array.isArray(c["domains"]) && c["domains"].every((d) => typeof d === "string") && typeof c["certPath"] === "string" && typeof c["keyPath"] === "string" && (c["framework"] === null || typeof c["framework"] === "string") && typeof c["createdAt"] === "string" && typeof c["updatedAt"] === "string";
|
|
210
|
+
}
|
|
211
|
+
async function readConfig() {
|
|
212
|
+
let raw;
|
|
213
|
+
try {
|
|
214
|
+
raw = await readFile2(resolveConfigPath(), "utf-8");
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error(
|
|
217
|
+
"No mk-https.json found. Run `mk-https init` to set up HTTPS for this project."
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
let parsed;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(raw);
|
|
223
|
+
} catch {
|
|
224
|
+
throw new Error(
|
|
225
|
+
"mk-https.json contains invalid JSON. Delete the .mk-https/ directory and run `mk-https init` to start fresh."
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (!isValidConfig(parsed)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
"mk-https.json has an unrecognized schema. Delete the .mk-https/ directory and run `mk-https init` to start fresh."
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (parsed.version !== CONFIG_VERSION) {
|
|
234
|
+
process.stderr.write(
|
|
235
|
+
`Warning: mk-https.json schema version ${parsed.version} differs from expected ${CONFIG_VERSION}. Some features may behave unexpectedly. Consider running \`mk-https clean && mk-https init\`.
|
|
236
|
+
`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return parsed;
|
|
240
|
+
}
|
|
241
|
+
async function writeConfig(config) {
|
|
242
|
+
await mkdir(resolveConfigDir(), { recursive: true });
|
|
243
|
+
const toWrite = {
|
|
244
|
+
...config,
|
|
245
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
246
|
+
};
|
|
247
|
+
await writeFile3(
|
|
248
|
+
resolveConfigPath(),
|
|
249
|
+
JSON.stringify(toWrite, null, 2) + "\n",
|
|
250
|
+
"utf-8"
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/lib/gitignore.ts
|
|
255
|
+
import { readFile as readFile3, writeFile as writeFile4 } from "fs/promises";
|
|
256
|
+
import { existsSync as existsSync2 } from "fs";
|
|
257
|
+
import path4 from "path";
|
|
258
|
+
var MARKER_START2 = "# mk-https:start";
|
|
259
|
+
var MARKER_END2 = "# mk-https:end";
|
|
260
|
+
var GITIGNORE_ENTRY = ".mk-https/";
|
|
261
|
+
function resolveGitignorePath() {
|
|
262
|
+
return path4.resolve(process.cwd(), ".gitignore");
|
|
263
|
+
}
|
|
264
|
+
function isCoveredByPattern(line) {
|
|
265
|
+
const trimmed = line.trim();
|
|
266
|
+
if (trimmed === GITIGNORE_ENTRY || trimmed === ".mk-https") return true;
|
|
267
|
+
if (trimmed === ".*" || trimmed === ".*/" || trimmed === ".*/**") return true;
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
async function isAlreadyIgnored() {
|
|
271
|
+
const gitignorePath = resolveGitignorePath();
|
|
272
|
+
if (!existsSync2(gitignorePath)) return false;
|
|
273
|
+
let content;
|
|
274
|
+
try {
|
|
275
|
+
content = await readFile3(gitignorePath, "utf-8");
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
return content.split("\n").some(isCoveredByPattern);
|
|
280
|
+
}
|
|
281
|
+
async function addToGitignore() {
|
|
282
|
+
if (await isAlreadyIgnored()) return;
|
|
283
|
+
const gitignorePath = resolveGitignorePath();
|
|
284
|
+
let existing = "";
|
|
285
|
+
if (existsSync2(gitignorePath)) {
|
|
286
|
+
try {
|
|
287
|
+
existing = await readFile3(gitignorePath, "utf-8");
|
|
288
|
+
} catch {
|
|
289
|
+
existing = "";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const separator = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
293
|
+
const block = `${MARKER_START2}
|
|
294
|
+
${GITIGNORE_ENTRY}
|
|
295
|
+
${MARKER_END2}
|
|
296
|
+
`;
|
|
297
|
+
await writeFile4(gitignorePath, `${existing}${separator}${block}`, "utf-8");
|
|
298
|
+
}
|
|
299
|
+
async function removeFromGitignore() {
|
|
300
|
+
const gitignorePath = resolveGitignorePath();
|
|
301
|
+
if (!existsSync2(gitignorePath)) return;
|
|
302
|
+
let content;
|
|
303
|
+
try {
|
|
304
|
+
content = await readFile3(gitignorePath, "utf-8");
|
|
305
|
+
} catch {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const startIdx = content.indexOf(MARKER_START2);
|
|
309
|
+
const endIdx = content.indexOf(MARKER_END2);
|
|
310
|
+
if (startIdx === -1 || endIdx === -1) return;
|
|
311
|
+
const before = content.slice(0, startIdx);
|
|
312
|
+
const afterMarker = content.slice(endIdx + MARKER_END2.length);
|
|
313
|
+
const after = afterMarker.startsWith("\n") ? afterMarker.slice(1) : afterMarker;
|
|
314
|
+
await writeFile4(gitignorePath, `${before}${after}`, "utf-8");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/lib/mkcert.ts
|
|
318
|
+
import { chown } from "fs/promises";
|
|
319
|
+
import { existsSync as existsSync3 } from "fs";
|
|
320
|
+
import path5 from "path";
|
|
321
|
+
import { execa as execa2 } from "execa";
|
|
322
|
+
import ora from "ora";
|
|
323
|
+
var MKCERT_INSTALL_URL = "https://github.com/FiloSottile/mkcert#installation";
|
|
324
|
+
async function which(bin) {
|
|
325
|
+
const cmd = process.platform === "win32" ? "where" : "which";
|
|
326
|
+
try {
|
|
327
|
+
const result = await execa2(cmd, [bin], { reject: false });
|
|
328
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
329
|
+
return result.stdout.trim().split("\n")[0]?.trim() ?? null;
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function hasBin(name) {
|
|
337
|
+
return await which(name) !== null;
|
|
338
|
+
}
|
|
339
|
+
async function detectMkcert() {
|
|
340
|
+
return which("mkcert");
|
|
341
|
+
}
|
|
342
|
+
async function installOnMacos() {
|
|
343
|
+
if (!await hasBin("brew")) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`Homebrew is not installed. Install mkcert manually:
|
|
346
|
+
${MKCERT_INSTALL_URL}`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
await execa2("brew", ["install", "mkcert"], { stdio: "inherit" });
|
|
350
|
+
}
|
|
351
|
+
async function installOnLinux() {
|
|
352
|
+
if (await hasBin("brew")) {
|
|
353
|
+
await execa2("brew", ["install", "mkcert"], { stdio: "inherit" });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (await hasBin("snap")) {
|
|
357
|
+
await execa2("snap", ["install", "mkcert"], { stdio: "inherit" });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (await hasBin("apt-get")) {
|
|
361
|
+
await execa2("apt-get", ["install", "-y", "libnss3-tools"], { stdio: "inherit" });
|
|
362
|
+
try {
|
|
363
|
+
await execa2("apt-get", ["install", "-y", "mkcert"], { stdio: "inherit" });
|
|
364
|
+
return;
|
|
365
|
+
} catch {
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
throw new Error(
|
|
369
|
+
`No supported package manager found (brew, snap, apt-get).
|
|
370
|
+
Install mkcert manually:
|
|
371
|
+
${MKCERT_INSTALL_URL}`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
async function installOnWindows() {
|
|
375
|
+
if (await hasBin("choco")) {
|
|
376
|
+
await execa2("choco", ["install", "mkcert", "-y"], { stdio: "inherit" });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (await hasBin("scoop")) {
|
|
380
|
+
await execa2("scoop", ["install", "mkcert"], { stdio: "inherit" });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
throw new Error(
|
|
384
|
+
`No supported package manager found (choco, scoop).
|
|
385
|
+
Install mkcert manually:
|
|
386
|
+
${MKCERT_INSTALL_URL}`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
async function installMkcert() {
|
|
390
|
+
const spinner = ora("Installing mkcert...").start();
|
|
391
|
+
try {
|
|
392
|
+
if (process.platform === "darwin") {
|
|
393
|
+
spinner.stop();
|
|
394
|
+
await installOnMacos();
|
|
395
|
+
} else if (process.platform === "linux") {
|
|
396
|
+
spinner.stop();
|
|
397
|
+
await installOnLinux();
|
|
398
|
+
} else if (process.platform === "win32") {
|
|
399
|
+
spinner.stop();
|
|
400
|
+
await installOnWindows();
|
|
401
|
+
} else {
|
|
402
|
+
spinner.fail(`Unsupported platform: ${process.platform}`);
|
|
403
|
+
throw new Error(
|
|
404
|
+
`Unsupported platform. Install mkcert manually:
|
|
405
|
+
${MKCERT_INSTALL_URL}`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
spinner.fail("mkcert installation failed");
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async function ensureMkcert() {
|
|
414
|
+
const existing = await detectMkcert();
|
|
415
|
+
if (existing) return existing;
|
|
416
|
+
process.stdout.write("mkcert not found. Attempting to install...\n");
|
|
417
|
+
await installMkcert();
|
|
418
|
+
const installed = await detectMkcert();
|
|
419
|
+
if (!installed) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`mkcert was not found after installation. Install it manually:
|
|
422
|
+
${MKCERT_INSTALL_URL}`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return installed;
|
|
426
|
+
}
|
|
427
|
+
async function getCARootPath() {
|
|
428
|
+
const result = await execa2("mkcert", ["-CAROOT"]);
|
|
429
|
+
return result.stdout.trim();
|
|
430
|
+
}
|
|
431
|
+
async function caRootExists() {
|
|
432
|
+
try {
|
|
433
|
+
const caRoot = await getCARootPath();
|
|
434
|
+
return existsSync3(path5.join(caRoot, "rootCA.pem")) && existsSync3(path5.join(caRoot, "rootCA-key.pem"));
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async function installCA() {
|
|
440
|
+
const spinner = ora("Installing local CA (may prompt for password)...").start();
|
|
441
|
+
try {
|
|
442
|
+
await execa2("mkcert", ["-install"], { stdio: "inherit" });
|
|
443
|
+
spinner.succeed("Local CA installed and trusted");
|
|
444
|
+
} catch {
|
|
445
|
+
spinner.fail("Failed to install local CA");
|
|
446
|
+
throw new Error(
|
|
447
|
+
"Could not install the local CA. Try running `mkcert -install` manually."
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function getSudoOriginalUser() {
|
|
452
|
+
const uid = parseInt(process.env["SUDO_UID"] ?? "", 10);
|
|
453
|
+
const gid = parseInt(process.env["SUDO_GID"] ?? "", 10);
|
|
454
|
+
if (isNaN(uid) || isNaN(gid)) return null;
|
|
455
|
+
if (typeof process.getuid === "function" && process.getuid() === uid) return null;
|
|
456
|
+
return { uid, gid };
|
|
457
|
+
}
|
|
458
|
+
async function grantReadToUsersWindows(filePath) {
|
|
459
|
+
await execa2("icacls", [filePath, "/grant", "Users:R"], { reject: false });
|
|
460
|
+
}
|
|
461
|
+
async function generateCert(domains, certPath, keyPath) {
|
|
462
|
+
await execa2(
|
|
463
|
+
"mkcert",
|
|
464
|
+
["-cert-file", certPath, "-key-file", keyPath, ...domains],
|
|
465
|
+
{ stdio: "inherit" }
|
|
466
|
+
);
|
|
467
|
+
if (process.platform === "win32") {
|
|
468
|
+
await Promise.all([
|
|
469
|
+
grantReadToUsersWindows(certPath),
|
|
470
|
+
grantReadToUsersWindows(keyPath)
|
|
471
|
+
]);
|
|
472
|
+
} else {
|
|
473
|
+
const originalUser = getSudoOriginalUser();
|
|
474
|
+
if (originalUser) {
|
|
475
|
+
await Promise.all([
|
|
476
|
+
chown(certPath, originalUser.uid, originalUser.gid),
|
|
477
|
+
chown(keyPath, originalUser.uid, originalUser.gid)
|
|
478
|
+
]);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/lib/frameworks.ts
|
|
484
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
485
|
+
import { existsSync as existsSync4 } from "fs";
|
|
486
|
+
import path6 from "path";
|
|
487
|
+
import semver from "semver";
|
|
488
|
+
function fileExists(...segments) {
|
|
489
|
+
return existsSync4(path6.resolve(process.cwd(), ...segments));
|
|
490
|
+
}
|
|
491
|
+
async function readJson(filePath) {
|
|
492
|
+
try {
|
|
493
|
+
const raw = await readFile4(path6.resolve(process.cwd(), filePath), "utf-8");
|
|
494
|
+
const parsed = JSON.parse(raw);
|
|
495
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
496
|
+
return parsed;
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function detectFramework() {
|
|
504
|
+
const nextConfigs = ["next.config.ts", "next.config.js", "next.config.mjs", "next.config.cjs"];
|
|
505
|
+
for (const f of nextConfigs) {
|
|
506
|
+
if (fileExists(f)) return "nextjs";
|
|
507
|
+
}
|
|
508
|
+
const viteConfigs = ["vite.config.ts", "vite.config.js", "vite.config.mjs"];
|
|
509
|
+
for (const f of viteConfigs) {
|
|
510
|
+
if (fileExists(f)) return "vite";
|
|
511
|
+
}
|
|
512
|
+
if (fileExists("angular.json")) return "angular";
|
|
513
|
+
const pkg = await readJson("package.json");
|
|
514
|
+
if (pkg) {
|
|
515
|
+
const deps = {
|
|
516
|
+
...pkg["dependencies"],
|
|
517
|
+
...pkg["devDependencies"]
|
|
518
|
+
};
|
|
519
|
+
if ("next" in deps) return "nextjs";
|
|
520
|
+
if ("vite" in deps) return "vite";
|
|
521
|
+
if ("express" in deps) return "express";
|
|
522
|
+
if ("@angular/core" in deps) return "angular";
|
|
523
|
+
}
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
async function getNextjsVersion() {
|
|
527
|
+
const pkg = await readJson("package.json");
|
|
528
|
+
if (!pkg) return null;
|
|
529
|
+
const deps = {
|
|
530
|
+
...pkg["dependencies"],
|
|
531
|
+
...pkg["devDependencies"]
|
|
532
|
+
};
|
|
533
|
+
const raw = deps["next"];
|
|
534
|
+
if (!raw) return null;
|
|
535
|
+
return semver.coerce(raw)?.version ?? null;
|
|
536
|
+
}
|
|
537
|
+
function getConfigSnippet(framework, certPath, keyPath, nextjsVersion) {
|
|
538
|
+
switch (framework) {
|
|
539
|
+
case "nextjs":
|
|
540
|
+
return getNextjsSnippet(certPath, keyPath, nextjsVersion ?? null);
|
|
541
|
+
case "vite":
|
|
542
|
+
return getViteSnippet(certPath, keyPath);
|
|
543
|
+
case "express":
|
|
544
|
+
return getExpressSnippet(certPath, keyPath);
|
|
545
|
+
case "angular":
|
|
546
|
+
return getAngularSnippet(certPath, keyPath);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function getNextjsSnippet(certPath, keyPath, version) {
|
|
550
|
+
const isGte14 = version ? semver.gte(version, "14.0.0") : false;
|
|
551
|
+
const isGte131 = version ? semver.gte(version, "13.1.0") : true;
|
|
552
|
+
const caveat = isGte14 ? "\n// Note (Next.js 14+ App Router): local HTTPS dev server works with experimental.https,\n// but some middleware and edge features may behave differently over HTTPS." : "";
|
|
553
|
+
if (isGte131) {
|
|
554
|
+
return `// next.config.ts (or next.config.js)${caveat}
|
|
555
|
+
const nextConfig = {
|
|
556
|
+
experimental: {
|
|
557
|
+
https: {
|
|
558
|
+
key: '${keyPath}',
|
|
559
|
+
cert: '${certPath}',
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
export default nextConfig;`;
|
|
565
|
+
}
|
|
566
|
+
return `// next.config.js (Next.js < 13.1 \u2014 requires a custom HTTPS server)
|
|
567
|
+
// Create a server.js at your project root:
|
|
568
|
+
//
|
|
569
|
+
// import https from 'https';
|
|
570
|
+
// import { readFileSync } from 'fs';
|
|
571
|
+
// import { createServer } from 'http';
|
|
572
|
+
// import next from 'next';
|
|
573
|
+
//
|
|
574
|
+
// const app = next({ dev: process.env.NODE_ENV !== 'production' });
|
|
575
|
+
// const handle = app.getRequestHandler();
|
|
576
|
+
// const opts = { key: readFileSync('${keyPath}'), cert: readFileSync('${certPath}') };
|
|
577
|
+
//
|
|
578
|
+
// app.prepare().then(() => {
|
|
579
|
+
// https.createServer(opts, (req, res) => handle(req, res)).listen(3000);
|
|
580
|
+
// });`;
|
|
581
|
+
}
|
|
582
|
+
function getViteSnippet(certPath, keyPath) {
|
|
583
|
+
return `// vite.config.ts
|
|
584
|
+
import { defineConfig } from 'vite';
|
|
585
|
+
import fs from 'fs';
|
|
586
|
+
|
|
587
|
+
export default defineConfig({
|
|
588
|
+
server: {
|
|
589
|
+
https: {
|
|
590
|
+
key: fs.readFileSync('${keyPath}'),
|
|
591
|
+
cert: fs.readFileSync('${certPath}'),
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
});`;
|
|
595
|
+
}
|
|
596
|
+
function getExpressSnippet(certPath, keyPath) {
|
|
597
|
+
return `// In your server entry file (e.g. server.js / index.js)
|
|
598
|
+
import https from 'https';
|
|
599
|
+
import { readFileSync } from 'fs';
|
|
600
|
+
import app from './app.js'; // your Express app
|
|
601
|
+
|
|
602
|
+
const server = https.createServer(
|
|
603
|
+
{
|
|
604
|
+
key: readFileSync('${keyPath}'),
|
|
605
|
+
cert: readFileSync('${certPath}'),
|
|
606
|
+
},
|
|
607
|
+
app,
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
server.listen(443, () => {
|
|
611
|
+
console.log('HTTPS server listening on port 443');
|
|
612
|
+
});`;
|
|
613
|
+
}
|
|
614
|
+
function getAngularSnippet(certPath, keyPath) {
|
|
615
|
+
return `// In angular.json, under projects.<your-project>.architect.serve.options:
|
|
616
|
+
{
|
|
617
|
+
"ssl": true,
|
|
618
|
+
"sslKey": "${keyPath}",
|
|
619
|
+
"sslCert": "${certPath}"
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Or pass flags directly:
|
|
623
|
+
// ng serve --ssl --ssl-key ${keyPath} --ssl-cert ${certPath}`;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/commands/init.ts
|
|
627
|
+
var CERT_PATH = path7.join(CONFIG_DIR, "cert.pem");
|
|
628
|
+
var KEY_PATH = path7.join(CONFIG_DIR, "key.pem");
|
|
629
|
+
async function promptDomains() {
|
|
630
|
+
const domain = await input({
|
|
631
|
+
message: "Primary domain (e.g. myapp.local):",
|
|
632
|
+
validate: (raw) => validateDomain(raw.trim()) || "Must be a valid local domain (e.g. myapp.local, myapp.test)"
|
|
633
|
+
});
|
|
634
|
+
const extra = await input({
|
|
635
|
+
message: "Additional domains? (comma-separated, leave blank to skip):",
|
|
636
|
+
default: ""
|
|
637
|
+
});
|
|
638
|
+
const additional = extra.split(",").map((d) => d.trim()).filter((d) => d.length > 0);
|
|
639
|
+
const invalid = additional.filter((d) => !validateDomain(d));
|
|
640
|
+
if (invalid.length) {
|
|
641
|
+
process.stderr.write(
|
|
642
|
+
chalk.yellow(`Skipping invalid domains: ${invalid.join(", ")}
|
|
643
|
+
`)
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
return [domain.trim(), ...additional.filter((d) => validateDomain(d))];
|
|
647
|
+
}
|
|
648
|
+
async function initCommand(options) {
|
|
649
|
+
if (!options.elevated) {
|
|
650
|
+
await requireElevation();
|
|
651
|
+
}
|
|
652
|
+
let domains;
|
|
653
|
+
let framework;
|
|
654
|
+
let fromExisting = false;
|
|
655
|
+
let existing;
|
|
656
|
+
if (configExists()) {
|
|
657
|
+
existing = await readConfig();
|
|
658
|
+
domains = existing.domains;
|
|
659
|
+
framework = existing.framework;
|
|
660
|
+
fromExisting = true;
|
|
661
|
+
process.stdout.write(
|
|
662
|
+
chalk.dim(`Using existing config: ${domains.join(", ")}
|
|
663
|
+
`)
|
|
664
|
+
);
|
|
665
|
+
} else if (options.domains?.length) {
|
|
666
|
+
const invalid = options.domains.filter((d) => !validateDomain(d));
|
|
667
|
+
if (invalid.length) {
|
|
668
|
+
throw new Error(`Invalid domain(s): ${invalid.join(", ")}`);
|
|
669
|
+
}
|
|
670
|
+
domains = options.domains;
|
|
671
|
+
framework = await detectFramework();
|
|
672
|
+
if (framework) {
|
|
673
|
+
process.stdout.write(chalk.dim(`Detected framework: ${framework}
|
|
674
|
+
`));
|
|
675
|
+
}
|
|
676
|
+
} else {
|
|
677
|
+
domains = await promptDomains();
|
|
678
|
+
framework = await detectFramework();
|
|
679
|
+
if (framework) {
|
|
680
|
+
process.stdout.write(chalk.dim(`Detected framework: ${framework}
|
|
681
|
+
`));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
await ensureMkcert();
|
|
685
|
+
if (!await caRootExists()) {
|
|
686
|
+
process.stdout.write(
|
|
687
|
+
chalk.yellow("Local CA not found \u2014 installing (this runs mkcert -install)...\n")
|
|
688
|
+
);
|
|
689
|
+
await installCA();
|
|
690
|
+
}
|
|
691
|
+
await mkdir2(path7.resolve(process.cwd(), CONFIG_DIR), { recursive: true });
|
|
692
|
+
const certAbsPath = path7.resolve(process.cwd(), CERT_PATH);
|
|
693
|
+
const keyAbsPath = path7.resolve(process.cwd(), KEY_PATH);
|
|
694
|
+
const spinner = ora2(`Generating certificate for ${domains.join(", ")}...`).start();
|
|
695
|
+
try {
|
|
696
|
+
await generateCert(domains, certAbsPath, keyAbsPath);
|
|
697
|
+
spinner.succeed(`Certificate generated \u2192 ${CERT_PATH}`);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
spinner.fail("Certificate generation failed");
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
const hostsSpinner = ora2("Updating /etc/hosts...").start();
|
|
703
|
+
try {
|
|
704
|
+
await syncHostsEntries(domains);
|
|
705
|
+
hostsSpinner.succeed("/etc/hosts updated");
|
|
706
|
+
} catch (err) {
|
|
707
|
+
hostsSpinner.fail("Failed to update /etc/hosts");
|
|
708
|
+
throw err;
|
|
709
|
+
}
|
|
710
|
+
await addToGitignore();
|
|
711
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
712
|
+
const config = fromExisting ? { ...existing, updatedAt: now } : {
|
|
713
|
+
version: CONFIG_VERSION,
|
|
714
|
+
domains,
|
|
715
|
+
certPath: CERT_PATH,
|
|
716
|
+
keyPath: KEY_PATH,
|
|
717
|
+
framework,
|
|
718
|
+
createdAt: now,
|
|
719
|
+
updatedAt: now
|
|
720
|
+
};
|
|
721
|
+
await writeConfig(config);
|
|
722
|
+
process.stdout.write("\n" + chalk.green("\u2714 HTTPS is ready.\n\n"));
|
|
723
|
+
if (framework) {
|
|
724
|
+
let snippet;
|
|
725
|
+
if (framework === "nextjs") {
|
|
726
|
+
const version = await getNextjsVersion();
|
|
727
|
+
snippet = getNextjsSnippet(CERT_PATH, KEY_PATH, version);
|
|
728
|
+
} else {
|
|
729
|
+
snippet = getConfigSnippet(framework, CERT_PATH, KEY_PATH);
|
|
730
|
+
}
|
|
731
|
+
process.stdout.write(chalk.bold("Add this to your framework config:\n\n"));
|
|
732
|
+
process.stdout.write(chalk.cyan(snippet) + "\n\n");
|
|
733
|
+
}
|
|
734
|
+
const domainList = domains.map((d) => chalk.underline(`https://${d}`)).join(", ");
|
|
735
|
+
process.stdout.write(`Your app will be available at: ${domainList}
|
|
736
|
+
`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// src/commands/add.ts
|
|
740
|
+
import path8 from "path";
|
|
741
|
+
import chalk2 from "chalk";
|
|
742
|
+
import ora3 from "ora";
|
|
743
|
+
async function addCommand(domain, options) {
|
|
744
|
+
if (!options.elevated) {
|
|
745
|
+
await requireElevation();
|
|
746
|
+
}
|
|
747
|
+
const trimmed = domain.trim();
|
|
748
|
+
if (!validateDomain(trimmed)) {
|
|
749
|
+
process.stderr.write(
|
|
750
|
+
chalk2.red(
|
|
751
|
+
`Invalid domain: "${trimmed}". Domain must be a valid local hostname (e.g. api.myapp.local, api.myapp.test).
|
|
752
|
+
`
|
|
753
|
+
)
|
|
754
|
+
);
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
const config = await readConfig();
|
|
758
|
+
if (config.domains.includes(trimmed)) {
|
|
759
|
+
process.stdout.write(chalk2.yellow(`Domain "${trimmed}" is already in your config.
|
|
760
|
+
`));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const newDomains = [...config.domains, trimmed];
|
|
764
|
+
const certAbsPath = path8.resolve(process.cwd(), config.certPath);
|
|
765
|
+
const keyAbsPath = path8.resolve(process.cwd(), config.keyPath);
|
|
766
|
+
const certSpinner = ora3(`Regenerating certificate for ${newDomains.join(", ")}...`).start();
|
|
767
|
+
try {
|
|
768
|
+
await generateCert(newDomains, certAbsPath, keyAbsPath);
|
|
769
|
+
certSpinner.succeed("Certificate regenerated");
|
|
770
|
+
} catch (err) {
|
|
771
|
+
certSpinner.fail("Certificate generation failed");
|
|
772
|
+
throw err;
|
|
773
|
+
}
|
|
774
|
+
const hostsSpinner = ora3(`Adding ${trimmed} to /etc/hosts...`).start();
|
|
775
|
+
try {
|
|
776
|
+
await syncHostsEntries(newDomains);
|
|
777
|
+
hostsSpinner.succeed("/etc/hosts updated");
|
|
778
|
+
} catch (err) {
|
|
779
|
+
hostsSpinner.fail("Failed to update /etc/hosts");
|
|
780
|
+
throw err;
|
|
781
|
+
}
|
|
782
|
+
await writeConfig({ ...config, domains: newDomains });
|
|
783
|
+
process.stdout.write(chalk2.green(`\u2714 Added ${trimmed}
|
|
784
|
+
`));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/commands/remove.ts
|
|
788
|
+
import path9 from "path";
|
|
789
|
+
import chalk3 from "chalk";
|
|
790
|
+
import ora4 from "ora";
|
|
791
|
+
async function removeCommand(domain, options) {
|
|
792
|
+
if (!options.elevated) {
|
|
793
|
+
await requireElevation();
|
|
794
|
+
}
|
|
795
|
+
const trimmed = domain.trim();
|
|
796
|
+
const config = await readConfig();
|
|
797
|
+
if (!config.domains.includes(trimmed)) {
|
|
798
|
+
process.stdout.write(
|
|
799
|
+
chalk3.yellow(`Domain "${trimmed}" is not in your config. Nothing to remove.
|
|
800
|
+
`)
|
|
801
|
+
);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const newDomains = config.domains.filter((d) => d !== trimmed);
|
|
805
|
+
if (newDomains.length === 0) {
|
|
806
|
+
process.stdout.write(
|
|
807
|
+
chalk3.yellow(
|
|
808
|
+
"Removing this domain would leave no domains configured. Run `mk-https clean` to remove all HTTPS config, or add another domain first.\n"
|
|
809
|
+
)
|
|
810
|
+
);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const certAbsPath = path9.resolve(process.cwd(), config.certPath);
|
|
814
|
+
const keyAbsPath = path9.resolve(process.cwd(), config.keyPath);
|
|
815
|
+
const certSpinner = ora4(`Regenerating certificate for ${newDomains.join(", ")}...`).start();
|
|
816
|
+
try {
|
|
817
|
+
await generateCert(newDomains, certAbsPath, keyAbsPath);
|
|
818
|
+
certSpinner.succeed("Certificate regenerated");
|
|
819
|
+
} catch (err) {
|
|
820
|
+
certSpinner.fail("Certificate generation failed");
|
|
821
|
+
throw err;
|
|
822
|
+
}
|
|
823
|
+
const hostsSpinner = ora4(`Removing ${trimmed} from /etc/hosts...`).start();
|
|
824
|
+
try {
|
|
825
|
+
await syncHostsEntries(newDomains);
|
|
826
|
+
hostsSpinner.succeed("/etc/hosts updated");
|
|
827
|
+
} catch (err) {
|
|
828
|
+
hostsSpinner.fail("Failed to update /etc/hosts");
|
|
829
|
+
throw err;
|
|
830
|
+
}
|
|
831
|
+
await writeConfig({ ...config, domains: newDomains });
|
|
832
|
+
process.stdout.write(chalk3.green(`\u2714 Removed ${trimmed}
|
|
833
|
+
`));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// src/commands/status.ts
|
|
837
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
838
|
+
import { existsSync as existsSync5 } from "fs";
|
|
839
|
+
import path10 from "path";
|
|
840
|
+
import { X509Certificate } from "crypto";
|
|
841
|
+
import chalk4 from "chalk";
|
|
842
|
+
var WARN_DAYS = 30;
|
|
843
|
+
async function parseCertExpiry(certPath) {
|
|
844
|
+
try {
|
|
845
|
+
const pem = await readFile5(certPath, "utf-8");
|
|
846
|
+
const cert = new X509Certificate(pem);
|
|
847
|
+
return new Date(cert.validTo);
|
|
848
|
+
} catch {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async function statusCommand() {
|
|
853
|
+
const config = await readConfig();
|
|
854
|
+
process.stdout.write("\n");
|
|
855
|
+
process.stdout.write(chalk4.bold("mk-https status\n\n"));
|
|
856
|
+
process.stdout.write(chalk4.dim("Domains:\n"));
|
|
857
|
+
for (const domain of config.domains) {
|
|
858
|
+
process.stdout.write(` \u2022 https://${domain}
|
|
859
|
+
`);
|
|
860
|
+
}
|
|
861
|
+
process.stdout.write("\n");
|
|
862
|
+
const certAbsPath = path10.resolve(process.cwd(), config.certPath);
|
|
863
|
+
if (!existsSync5(certAbsPath)) {
|
|
864
|
+
process.stdout.write(
|
|
865
|
+
chalk4.red(`Cert file not found: ${config.certPath}
|
|
866
|
+
`) + "Run `mk-https renew` to regenerate.\n\n"
|
|
867
|
+
);
|
|
868
|
+
} else {
|
|
869
|
+
const expiry = await parseCertExpiry(certAbsPath);
|
|
870
|
+
if (expiry) {
|
|
871
|
+
const now = Date.now();
|
|
872
|
+
const daysLeft = Math.floor((expiry.getTime() - now) / 864e5);
|
|
873
|
+
if (daysLeft < 0) {
|
|
874
|
+
process.stdout.write(
|
|
875
|
+
chalk4.red(`Cert expired on ${expiry.toDateString()}. Run \`mk-https renew\`.
|
|
876
|
+
|
|
877
|
+
`)
|
|
878
|
+
);
|
|
879
|
+
} else if (daysLeft < WARN_DAYS) {
|
|
880
|
+
process.stdout.write(
|
|
881
|
+
chalk4.yellow(
|
|
882
|
+
`Cert expires in ${daysLeft} day${daysLeft === 1 ? "" : "s"} (${expiry.toDateString()}). Run \`mk-https renew\` soon.
|
|
883
|
+
|
|
884
|
+
`
|
|
885
|
+
)
|
|
886
|
+
);
|
|
887
|
+
} else {
|
|
888
|
+
process.stdout.write(
|
|
889
|
+
chalk4.green(`Cert valid until ${expiry.toDateString()} (${daysLeft} days)
|
|
890
|
+
|
|
891
|
+
`)
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
} else {
|
|
895
|
+
process.stdout.write(chalk4.yellow("Could not parse cert expiry date.\n\n"));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const caOk = await caRootExists();
|
|
899
|
+
process.stdout.write(
|
|
900
|
+
caOk ? chalk4.green("\u2714 Local CA trusted\n") : chalk4.red("\u2718 Local CA not trusted \u2014 run `mkcert -install`\n")
|
|
901
|
+
);
|
|
902
|
+
const hostedDomains = await getHostedDomains();
|
|
903
|
+
const allPresent = config.domains.every((d) => hostedDomains.includes(d));
|
|
904
|
+
const missing = config.domains.filter((d) => !hostedDomains.includes(d));
|
|
905
|
+
if (allPresent) {
|
|
906
|
+
process.stdout.write(chalk4.green("\u2714 /etc/hosts entries present\n"));
|
|
907
|
+
} else {
|
|
908
|
+
process.stdout.write(
|
|
909
|
+
chalk4.red(`\u2718 /etc/hosts missing entries: ${missing.join(", ")}
|
|
910
|
+
`) + " Run `mk-https init` to repair.\n"
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
process.stdout.write("\n");
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/commands/clean.ts
|
|
917
|
+
import { rm } from "fs/promises";
|
|
918
|
+
import chalk5 from "chalk";
|
|
919
|
+
import ora5 from "ora";
|
|
920
|
+
async function cleanCommand(options) {
|
|
921
|
+
if (!options.elevated) {
|
|
922
|
+
await requireElevation();
|
|
923
|
+
}
|
|
924
|
+
const hostsSpinner = ora5("Removing /etc/hosts entries...").start();
|
|
925
|
+
try {
|
|
926
|
+
await removeHostsEntries();
|
|
927
|
+
hostsSpinner.succeed("/etc/hosts entries removed");
|
|
928
|
+
} catch (err) {
|
|
929
|
+
hostsSpinner.fail("Failed to remove /etc/hosts entries");
|
|
930
|
+
throw err;
|
|
931
|
+
}
|
|
932
|
+
const dirSpinner = ora5("Removing .mk-https/ directory...").start();
|
|
933
|
+
try {
|
|
934
|
+
await rm(resolveConfigDir(), { recursive: true, force: true });
|
|
935
|
+
dirSpinner.succeed(".mk-https/ removed");
|
|
936
|
+
} catch (err) {
|
|
937
|
+
dirSpinner.fail("Failed to remove .mk-https/");
|
|
938
|
+
throw err;
|
|
939
|
+
}
|
|
940
|
+
await removeFromGitignore();
|
|
941
|
+
process.stdout.write(chalk5.green("\n\u2714 Cleaned up. Run `mk-https init` to start fresh.\n"));
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/commands/renew.ts
|
|
945
|
+
import path11 from "path";
|
|
946
|
+
import chalk6 from "chalk";
|
|
947
|
+
import ora6 from "ora";
|
|
948
|
+
async function renewCommand() {
|
|
949
|
+
const config = await readConfig();
|
|
950
|
+
const certAbsPath = path11.resolve(process.cwd(), config.certPath);
|
|
951
|
+
const keyAbsPath = path11.resolve(process.cwd(), config.keyPath);
|
|
952
|
+
const spinner = ora6(
|
|
953
|
+
`Renewing certificate for ${config.domains.join(", ")}...`
|
|
954
|
+
).start();
|
|
955
|
+
try {
|
|
956
|
+
await generateCert(config.domains, certAbsPath, keyAbsPath);
|
|
957
|
+
spinner.succeed("Certificate renewed");
|
|
958
|
+
} catch (err) {
|
|
959
|
+
spinner.fail("Certificate renewal failed");
|
|
960
|
+
throw err;
|
|
961
|
+
}
|
|
962
|
+
await writeConfig(config);
|
|
963
|
+
process.stdout.write(chalk6.green("\u2714 Certificate renewed successfully.\n"));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// src/lib/doctor.ts
|
|
967
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
968
|
+
import { existsSync as existsSync6 } from "fs";
|
|
969
|
+
import path12 from "path";
|
|
970
|
+
import { X509Certificate as X509Certificate2 } from "crypto";
|
|
971
|
+
import chalk7 from "chalk";
|
|
972
|
+
import { execa as execa3 } from "execa";
|
|
973
|
+
var WARN_DAYS2 = 30;
|
|
974
|
+
function check(ok, label, detail) {
|
|
975
|
+
const icon = ok ? chalk7.green("\u2714") : chalk7.red("\u2718");
|
|
976
|
+
const text = ok ? chalk7.white(label) : chalk7.red(label);
|
|
977
|
+
const suffix = detail ? chalk7.dim(` \u2014 ${detail}`) : "";
|
|
978
|
+
process.stdout.write(` ${icon} ${text}${suffix}
|
|
979
|
+
`);
|
|
980
|
+
}
|
|
981
|
+
async function hasBin2(name) {
|
|
982
|
+
const cmd = process.platform === "win32" ? "where" : "which";
|
|
983
|
+
try {
|
|
984
|
+
const result = await execa3(cmd, [name], { reject: false });
|
|
985
|
+
return result.exitCode === 0;
|
|
986
|
+
} catch {
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
async function getCertInfo(certPath) {
|
|
991
|
+
if (!existsSync6(certPath)) return { exists: false, expiry: null, domains: [] };
|
|
992
|
+
try {
|
|
993
|
+
const pem = await readFile6(certPath, "utf-8");
|
|
994
|
+
const cert = new X509Certificate2(pem);
|
|
995
|
+
const expiry = new Date(cert.validTo);
|
|
996
|
+
const san = cert.subjectAltName ?? "";
|
|
997
|
+
const domains = san.split(", ").filter((s) => s.startsWith("DNS:")).map((s) => s.slice(4));
|
|
998
|
+
return { exists: true, expiry, domains };
|
|
999
|
+
} catch {
|
|
1000
|
+
return { exists: true, expiry: null, domains: [] };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
async function runDoctor(config) {
|
|
1004
|
+
process.stdout.write(chalk7.bold("\nmk-https doctor\n\n"));
|
|
1005
|
+
let cfg = config;
|
|
1006
|
+
if (!cfg) {
|
|
1007
|
+
try {
|
|
1008
|
+
cfg = await readConfig();
|
|
1009
|
+
} catch {
|
|
1010
|
+
cfg = void 0;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const mkcertPath = await detectMkcert();
|
|
1014
|
+
check(mkcertPath !== null, "mkcert installed", mkcertPath ?? void 0);
|
|
1015
|
+
const caOk = await caRootExists();
|
|
1016
|
+
let caDetail;
|
|
1017
|
+
if (caOk) {
|
|
1018
|
+
try {
|
|
1019
|
+
caDetail = await getCARootPath();
|
|
1020
|
+
} catch {
|
|
1021
|
+
caDetail = void 0;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
check(caOk, "Local CA trusted (OS cert store)", caDetail);
|
|
1025
|
+
const certutilOk = await hasBin2("certutil");
|
|
1026
|
+
check(
|
|
1027
|
+
certutilOk,
|
|
1028
|
+
"Firefox trust (NSS certutil present)",
|
|
1029
|
+
certutilOk ? void 0 : "install libnss3-tools (Linux) or nss (macOS/brew) for Firefox support"
|
|
1030
|
+
);
|
|
1031
|
+
if (!cfg) {
|
|
1032
|
+
check(false, "mk-https.json found", "run `mk-https init` to set up this project");
|
|
1033
|
+
process.stdout.write("\n");
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const certAbsPath = path12.resolve(process.cwd(), cfg.certPath);
|
|
1037
|
+
const keyAbsPath = path12.resolve(process.cwd(), cfg.keyPath);
|
|
1038
|
+
const certInfo = await getCertInfo(certAbsPath);
|
|
1039
|
+
check(certInfo.exists, "Cert file exists", cfg.certPath);
|
|
1040
|
+
check(existsSync6(keyAbsPath), "Key file exists", cfg.keyPath);
|
|
1041
|
+
const allCovered = certInfo.exists && cfg.domains.every((d) => certInfo.domains.includes(d));
|
|
1042
|
+
const uncovered = cfg.domains.filter((d) => !certInfo.domains.includes(d));
|
|
1043
|
+
check(
|
|
1044
|
+
allCovered,
|
|
1045
|
+
"Cert covers all configured domains",
|
|
1046
|
+
uncovered.length ? `missing: ${uncovered.join(", ")}` : void 0
|
|
1047
|
+
);
|
|
1048
|
+
if (certInfo.expiry) {
|
|
1049
|
+
const now = Date.now();
|
|
1050
|
+
const msLeft = certInfo.expiry.getTime() - now;
|
|
1051
|
+
const daysLeft = Math.floor(msLeft / 864e5);
|
|
1052
|
+
const expired = daysLeft < 0;
|
|
1053
|
+
const expiring = daysLeft >= 0 && daysLeft < WARN_DAYS2;
|
|
1054
|
+
const ok = !expired && !expiring;
|
|
1055
|
+
const detail = expired ? "run `mk-https renew`" : expiring ? `expires in ${daysLeft} days \u2014 run \`mk-https renew\`` : `expires ${certInfo.expiry.toDateString()}`;
|
|
1056
|
+
check(ok, expired ? "Cert not expired" : "Cert validity", detail);
|
|
1057
|
+
} else {
|
|
1058
|
+
check(false, "Cert not expired", "could not parse cert");
|
|
1059
|
+
}
|
|
1060
|
+
const hostedDomains = await getHostedDomains();
|
|
1061
|
+
const allHosted = cfg.domains.every((d) => hostedDomains.includes(d));
|
|
1062
|
+
const missingHosts = cfg.domains.filter((d) => !hostedDomains.includes(d));
|
|
1063
|
+
check(
|
|
1064
|
+
allHosted,
|
|
1065
|
+
"/etc/hosts entries present",
|
|
1066
|
+
missingHosts.length ? `missing: ${missingHosts.join(", ")}` : void 0
|
|
1067
|
+
);
|
|
1068
|
+
const versionOk = cfg.version === CONFIG_VERSION;
|
|
1069
|
+
check(
|
|
1070
|
+
versionOk,
|
|
1071
|
+
"mk-https.json schema version current",
|
|
1072
|
+
versionOk ? cfg.version : `found ${cfg.version}, expected ${CONFIG_VERSION}`
|
|
1073
|
+
);
|
|
1074
|
+
process.stdout.write("\n");
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/index.ts
|
|
1078
|
+
var program = new Command();
|
|
1079
|
+
program.name("mk-https").description("Zero to https://myapp.local in one command").version("1.0.0");
|
|
1080
|
+
var elevatedOption = new Option("--elevated").hideHelp();
|
|
1081
|
+
function handleError(err) {
|
|
1082
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1083
|
+
process.stderr.write(chalk8.red(`
|
|
1084
|
+
Error: ${message}
|
|
1085
|
+
`));
|
|
1086
|
+
process.exit(1);
|
|
1087
|
+
}
|
|
1088
|
+
program.command("init").description("Set up HTTPS for this project").option("-d, --domain <domain...>", "domain(s) to configure (skips interactive prompt)").addOption(elevatedOption).action(async (opts) => {
|
|
1089
|
+
await initCommand({ elevated: opts.elevated ?? false, domains: opts.domain }).catch(handleError);
|
|
1090
|
+
});
|
|
1091
|
+
program.command("add <domain>").description("Add a domain to your local HTTPS config").addOption(elevatedOption).action(async (domain, opts) => {
|
|
1092
|
+
await addCommand(domain, { elevated: opts.elevated ?? false }).catch(handleError);
|
|
1093
|
+
});
|
|
1094
|
+
program.command("remove <domain>").description("Remove a domain from your local HTTPS config").addOption(elevatedOption).action(async (domain, opts) => {
|
|
1095
|
+
await removeCommand(domain, { elevated: opts.elevated ?? false }).catch(handleError);
|
|
1096
|
+
});
|
|
1097
|
+
program.command("status").description("Show cert and hosts status for this project").action(async () => {
|
|
1098
|
+
await statusCommand().catch(handleError);
|
|
1099
|
+
});
|
|
1100
|
+
program.command("clean").description("Remove all mk-https configuration for this project").addOption(elevatedOption).action(async (opts) => {
|
|
1101
|
+
await cleanCommand({ elevated: opts.elevated ?? false }).catch(handleError);
|
|
1102
|
+
});
|
|
1103
|
+
program.command("renew").description("Renew the certificate without changing /etc/hosts").action(async () => {
|
|
1104
|
+
await renewCommand().catch(handleError);
|
|
1105
|
+
});
|
|
1106
|
+
program.command("doctor").description("Check the full mk-https environment").action(async () => {
|
|
1107
|
+
await runDoctor().catch(handleError);
|
|
1108
|
+
});
|
|
1109
|
+
program.parse();
|