@vlandoss/localproxy 0.0.10 → 0.0.11-git-06df4ba.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.
Files changed (3) hide show
  1. package/bin.ts +2 -1
  2. package/dist/bin.mjs +416 -0
  3. package/package.json +16 -7
package/bin.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { main } from "./src/main";
5
6
 
6
7
  main({
7
- binDir: __dirname,
8
+ binDir: path.dirname(fileURLToPath(import.meta.url)),
8
9
  installDir: path.join(homedir(), ".localproxy"),
9
10
  });
package/dist/bin.mjs ADDED
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createPkgService, createShellService, getVersion, palette } from "@vlandoss/clibuddy";
6
+ import { createCommand } from "commander";
7
+ import * as fs$2 from "node:fs";
8
+ import { createLoggy } from "@vlandoss/loggy";
9
+ import * as fs$1 from "node:fs/promises";
10
+ import fs from "node:fs/promises";
11
+ import { editor, password } from "@inquirer/prompts";
12
+ //#region src/services/logger.ts
13
+ const logger = createLoggy({ namespace: "localproxy" });
14
+ //#endregion
15
+ //#region src/services/shell.ts
16
+ const quietShell = createShellService({
17
+ quiet: true,
18
+ verbose: false
19
+ });
20
+ const silentShell = quietShell.child({ stdio: "ignore" });
21
+ const verboseShell = quietShell.child({
22
+ quiet: false,
23
+ verbose: true,
24
+ stdio: "inherit"
25
+ });
26
+ //#endregion
27
+ //#region src/services/caddy.ts
28
+ const debug$4 = logger.subdebug("caddy");
29
+ var CaddyService = class {
30
+ #configPath;
31
+ #pidFilePath;
32
+ constructor(configPath) {
33
+ this.#configPath = configPath;
34
+ this.#pidFilePath = path.join(path.dirname(configPath), "caddy.pid");
35
+ this.#initCaddyPid();
36
+ }
37
+ #initCaddyPid() {
38
+ if (!this.#hasCaddyPid()) fs$2.writeFileSync(this.#pidFilePath, "");
39
+ }
40
+ #hasCaddyPid() {
41
+ return fs$2.existsSync(this.#pidFilePath);
42
+ }
43
+ #deleteCaddyPid() {
44
+ if (this.#hasCaddyPid()) fs$2.rmSync(this.#pidFilePath);
45
+ }
46
+ #shell({ verbose }) {
47
+ return verbose ? verboseShell : silentShell;
48
+ }
49
+ #getPID() {
50
+ return this.#hasCaddyPid() ? fs$2.readFileSync(this.#pidFilePath, "utf-8").trim() : null;
51
+ }
52
+ async reboot(options) {
53
+ if (await this.isRunning()) await this.stop(options);
54
+ await this.start(options);
55
+ }
56
+ async start({ verbose }) {
57
+ if (await this.isRunning()) {
58
+ logger.warn("Caddy is already running. PID: %d", this.#getPID());
59
+ return;
60
+ }
61
+ const { $ } = this.#shell({ verbose });
62
+ try {
63
+ logger.start("Starting Caddy");
64
+ await $`caddy start -c ${this.#configPath} --pidfile ${this.#pidFilePath} > /dev/null 2>&1`;
65
+ logger.success("Caddy started");
66
+ } catch {
67
+ logger.error("Can't start Caddy");
68
+ process.exit(1);
69
+ }
70
+ }
71
+ async stop({ verbose }) {
72
+ const { $ } = this.#shell({ verbose });
73
+ try {
74
+ logger.start("Stopping Caddy");
75
+ await $`caddy stop -c ${this.#configPath}`;
76
+ this.#deleteCaddyPid();
77
+ logger.success("Caddy stopped");
78
+ } catch {
79
+ logger.error("Can't stop Caddy");
80
+ process.exit(1);
81
+ }
82
+ }
83
+ async isRunning() {
84
+ if (!this.#hasCaddyPid()) return false;
85
+ const pid = this.#getPID();
86
+ debug$4("caddy PID: %d", pid);
87
+ const { exitCode } = await quietShell.$`kill -0 ${pid}`.nothrow();
88
+ const isRunning = exitCode === 0;
89
+ debug$4("caddy is %s", isRunning ? "running" : "stopped");
90
+ return isRunning;
91
+ }
92
+ };
93
+ //#endregion
94
+ //#region src/services/caddyfile/parser.ts
95
+ var CaddyfileParser = class {
96
+ #content;
97
+ constructor(content) {
98
+ this.#content = content;
99
+ }
100
+ parse() {
101
+ return { siteBlocks: this.#extractSiteBlocks(this.#content) };
102
+ }
103
+ #extractSiteBlocks(content) {
104
+ const siteBlocks = [];
105
+ const bracedBlockRegex = /([^{]+)\s*\{([^}]*)\}/gs;
106
+ let match;
107
+ const processedContent = /* @__PURE__ */ new Set();
108
+ while ((match = bracedBlockRegex.exec(content)) !== null) {
109
+ const [fullMatch, sitesStr, directivesStr] = match;
110
+ processedContent.add(fullMatch.trim());
111
+ const sites = sitesStr ? this.#parseSites(sitesStr.trim()) : [];
112
+ const directives = directivesStr ? this.#parseDirectives(directivesStr.trim()) : [];
113
+ if (sites.length > 0) siteBlocks.push({
114
+ sites,
115
+ directives
116
+ });
117
+ }
118
+ return siteBlocks;
119
+ }
120
+ #parseSites(sitesStr) {
121
+ if (!sitesStr.trim()) return [];
122
+ return sitesStr.split(/[,\s]+/).map((site) => site.trim()).filter((site) => site.length > 0);
123
+ }
124
+ #parseDirectives(directivesStr) {
125
+ const directives = [];
126
+ if (!directivesStr.trim()) return directives;
127
+ const lines = directivesStr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
128
+ for (const line of lines) {
129
+ const directive = this.#parseDirectiveLine(line);
130
+ if (directive) directives.push(directive);
131
+ }
132
+ return directives;
133
+ }
134
+ #parseDirectiveLine(line) {
135
+ const match = line.match(/^(\w+)(?:\s+(.+))?$/);
136
+ if (!match) return null;
137
+ const [, directiveType, argsStr] = match;
138
+ const directive = {
139
+ type: directiveType,
140
+ arguments: []
141
+ };
142
+ if (argsStr) {
143
+ const args = argsStr.split(/\s+/).filter((arg) => arg.length > 0);
144
+ if (args.length > 0 && this.#looksLikeMatcher(args[0])) {
145
+ directive.matchToken = args[0];
146
+ directive.arguments = args.slice(1);
147
+ } else directive.arguments = args;
148
+ }
149
+ return directive;
150
+ }
151
+ #looksLikeMatcher(token) {
152
+ return token === "*" || token.startsWith("/") || token.startsWith("@");
153
+ }
154
+ };
155
+ //#endregion
156
+ //#region src/services/caddyfile/service.ts
157
+ const debug$3 = logger.subdebug("caddyfile-service");
158
+ const LOCALHOST_REGEX = /localhost(:\d+)?/;
159
+ var CaddyfileService = class {
160
+ #filepath;
161
+ constructor(filepath) {
162
+ this.#filepath = filepath;
163
+ }
164
+ async getLocalDomains() {
165
+ const domains = new CaddyfileParser(await fs$1.readFile(this.#filepath, "utf-8")).parse().siteBlocks.flatMap((block) => {
166
+ const ports = block.directives.filter((d) => d.type === "reverse_proxy" && d.arguments.some((arg) => this.#isLocalhost(arg))).flatMap((d) => d.arguments).filter((arg) => this.#isLocalhost(arg)).map((arg) => arg.split(":")[1]);
167
+ return block.sites.map((site) => {
168
+ return {
169
+ hostname: site,
170
+ ports
171
+ };
172
+ });
173
+ });
174
+ debug$3("detected domains: %o", domains);
175
+ return domains;
176
+ }
177
+ #isLocalhost(arg) {
178
+ return LOCALHOST_REGEX.test(arg);
179
+ }
180
+ };
181
+ //#endregion
182
+ //#region src/services/sudo.ts
183
+ const debug$2 = logger.subdebug("sudo");
184
+ var SudoService = class {
185
+ #shell;
186
+ constructor() {
187
+ this.#shell = silentShell.child({ stdio: [
188
+ "ignore",
189
+ "ignore",
190
+ "pipe"
191
+ ] });
192
+ }
193
+ async auth() {
194
+ if (!await this.isAuthorized()) await this.authenticate();
195
+ }
196
+ async isAuthorized() {
197
+ const { exitCode } = await this.#shell.$`sudo -v -n`.nothrow();
198
+ return exitCode === 0;
199
+ }
200
+ async authenticate() {
201
+ let intent = 1;
202
+ let exitCode = null;
203
+ await password({
204
+ message: "Enter sudo password to manage hosts",
205
+ mask: true,
206
+ validate: async (value) => {
207
+ debug$2("Attempting sudo authentication %o", { intent });
208
+ const output = await this.#shell.$`echo "${value}" | sudo -S -v`.nothrow();
209
+ exitCode = output.exitCode;
210
+ debug$2("Sudo authentication exitCode(%d)", exitCode);
211
+ if (intent >= 3) return true;
212
+ intent++;
213
+ return output.exitCode === 0 ? true : "Invalid password";
214
+ }
215
+ });
216
+ if (exitCode !== 0) {
217
+ logger.error("`sudo` authentication failed");
218
+ process.exit(1);
219
+ }
220
+ }
221
+ };
222
+ //#endregion
223
+ //#region src/services/hosts.ts
224
+ const debug$1 = logger.subdebug("hosts");
225
+ var HostsService = class {
226
+ #sudo;
227
+ constructor() {
228
+ this.#sudo = new SudoService();
229
+ }
230
+ #shell({ verbose }) {
231
+ return verbose ? verboseShell : silentShell;
232
+ }
233
+ async setup(options) {
234
+ const { hostnames } = options;
235
+ logger.start("Setting up hosts");
236
+ await this.#sudo.auth();
237
+ const { $ } = this.#shell(options);
238
+ await $`sudo hosts backups create`;
239
+ for (const host of hostnames) await this.addHost(host, options);
240
+ logger.success("Hosts ready");
241
+ }
242
+ async clean(options) {
243
+ const { hostnames } = options;
244
+ await this.#sudo.auth();
245
+ for (const host of hostnames) await this.removeHost(host, options);
246
+ }
247
+ async findHost(host) {
248
+ const { exitCode } = await quietShell.$`hosts show "${host}"`.nothrow();
249
+ const found = exitCode === 0;
250
+ debug$1("Host %s is %s", host, found ? "present" : "absent");
251
+ return found;
252
+ }
253
+ async addHost(host, options) {
254
+ const { $ } = this.#shell(options);
255
+ if (!await this.findHost(host)) await $`sudo hosts add 127.0.0.1 ${host}`;
256
+ }
257
+ async removeHost(host, options) {
258
+ const { $ } = this.#shell(options);
259
+ if (!await this.findHost(host)) await $`sudo hosts remove ${host}`;
260
+ }
261
+ async getEnabledHosts() {
262
+ return (await quietShell.$`hosts list enabled`.text()).split("\n").map((line) => {
263
+ const [ip, hostname] = line.split(/\s+/);
264
+ if (!ip || !hostname) return null;
265
+ return {
266
+ ip,
267
+ hostname
268
+ };
269
+ }).filter(Boolean);
270
+ }
271
+ };
272
+ //#endregion
273
+ //#region src/commands/clean.ts
274
+ function createCleanCommand({ caddyfilePath }) {
275
+ return createCommand("clean").description("clean up config files").option("--verbose", "verbose mode, show background output", false).action(async function cleanAction(options) {
276
+ const { verbose } = options;
277
+ await new CaddyService(caddyfilePath).stop({ verbose });
278
+ const hostnames = (await new CaddyfileService(caddyfilePath).getLocalDomains()).map((d) => d.hostname);
279
+ await new HostsService().clean({
280
+ verbose,
281
+ hostnames
282
+ });
283
+ logger.success("Clean completed!");
284
+ });
285
+ }
286
+ //#endregion
287
+ //#region src/services/file.ts
288
+ var FileService = class {
289
+ #filePath;
290
+ constructor(filePath) {
291
+ this.#filePath = filePath;
292
+ }
293
+ async print() {
294
+ const fileContent = (await fs.readFile(this.#filePath)).toString();
295
+ console.log(`${this.#filePath}:\n`);
296
+ console.log(fileContent.trim());
297
+ }
298
+ };
299
+ //#endregion
300
+ //#region src/commands/setup.ts
301
+ const debug = logger.subdebug("setup");
302
+ async function checkInternalTools() {
303
+ const { $ } = quietShell;
304
+ const caddyVersion = await $`caddy --version`.nothrow();
305
+ if (caddyVersion.exitCode) {
306
+ logger.error("Caddy is not installed. Please install Caddy first:");
307
+ logger.info("macOS: brew install caddy");
308
+ logger.info("Linux: https://caddyserver.com/docs/install");
309
+ process.exit(1);
310
+ }
311
+ debug("caddy version: %s", caddyVersion.stdout.trim());
312
+ const hostsVersion = await $`hosts --version`.nothrow();
313
+ if (!hostsVersion) {
314
+ logger.error("hosts CLI tool is not installed. Please install hosts first:");
315
+ logger.info("macOS: brew tap xwmx/taps && brew install hosts");
316
+ logger.info("Linux: Check https://github.com/xwmx/hosts for installation");
317
+ process.exit(1);
318
+ }
319
+ debug("hosts version: %s", hostsVersion.stdout.trim());
320
+ }
321
+ function createSetupCommand({ binDir, installDir, caddyfilePath }) {
322
+ return createCommand("setup").description("setup config files").option("--verbose", "verbose mode, show background output", false).action(async function setupAction(options) {
323
+ const { verbose } = options;
324
+ await checkInternalTools();
325
+ if (!await fs$1.exists(installDir)) await fs$1.mkdir(installDir);
326
+ const exampleCaddyFilePath = path.join(binDir, "config", "Caddyfile.example");
327
+ const fileContent = await editor({
328
+ message: "Caddyfile",
329
+ default: await fs$1.exists(caddyfilePath) ? await fs$1.readFile(caddyfilePath, "utf-8") : await fs$1.readFile(exampleCaddyFilePath, "utf-8")
330
+ });
331
+ await fs$1.writeFile(caddyfilePath, fileContent);
332
+ await new FileService(caddyfilePath).print();
333
+ await new CaddyService(caddyfilePath).reboot({ verbose });
334
+ const hostnames = (await new CaddyfileService(caddyfilePath).getLocalDomains()).map((d) => d.hostname);
335
+ await new HostsService().setup({
336
+ verbose,
337
+ hostnames
338
+ });
339
+ logger.success("Setup completed!");
340
+ });
341
+ }
342
+ //#endregion
343
+ //#region src/commands/start.ts
344
+ function createStartCommand({ caddyfilePath }) {
345
+ return createCommand("start").description("start caddy server").option("--verbose", "verbose mode, show background output", false).action(async function startAction(options) {
346
+ await new CaddyService(caddyfilePath).start(options);
347
+ logger.success("Start completed!");
348
+ });
349
+ }
350
+ //#endregion
351
+ //#region src/commands/status.ts
352
+ function createStatusCommand({ caddyfilePath }) {
353
+ return createCommand("status").description("show configured localhosts").action(async function statusAction() {
354
+ if (await new CaddyService(caddyfilePath).isRunning()) logger.info("Caddy is running");
355
+ else logger.warn("Caddy is not running. Use `localp start` to start it.");
356
+ await new FileService(caddyfilePath).print();
357
+ const localDomains = await new CaddyfileService(caddyfilePath).getLocalDomains();
358
+ const enabledHosts = await new HostsService().getEnabledHosts();
359
+ localDomains.forEach(({ hostname, ports }) => {
360
+ const enabled = enabledHosts.some((h) => h.hostname === hostname);
361
+ const formattedPorts = ports.map((p) => `:${p}`).join(", ");
362
+ if (enabled) logger.success("`%s` is configured -> %s", hostname, formattedPorts);
363
+ else logger.warn("`%s` is not configured -> %s", hostname, formattedPorts);
364
+ });
365
+ });
366
+ }
367
+ //#endregion
368
+ //#region src/commands/stop.ts
369
+ function createStopCommand({ caddyfilePath }) {
370
+ return createCommand("stop").description("stop caddy server").option("--verbose", "verbose mode, show background output", false).action(async function stopAction(options) {
371
+ await new CaddyService(caddyfilePath).stop(options);
372
+ logger.success("Stop completed!");
373
+ });
374
+ }
375
+ const BANNER_TEXT = `${`🛠️ ${palette.bold("localproxy")}`}: Simple local development proxy automation\n`;
376
+ const CREDITS_TEXT = `\nAcknowledgment:
377
+ - Caddy: for being a powerful proxy server
378
+ ${palette.link("https://caddyserver.com")}
379
+
380
+ - hosts: making it easier to manage host file
381
+ ${palette.link("https://github.com/xwmx/hosts")}`;
382
+ //#endregion
383
+ //#region src/main.ts
384
+ async function createContext({ binDir, installDir }) {
385
+ const binPkg = await createPkgService(binDir);
386
+ if (!binPkg) throw new Error("Could not find bin package.json");
387
+ return {
388
+ installDir,
389
+ caddyfilePath: path.join(installDir, "Caddyfile"),
390
+ binDir,
391
+ binPkg
392
+ };
393
+ }
394
+ async function createProgram(options) {
395
+ const ctx = await createContext(options);
396
+ return createCommand("localproxy").alias("localp").version(getVersion(ctx.binPkg), "-v, --version").addHelpText("before", BANNER_TEXT).addHelpText("after", CREDITS_TEXT).addCommand(createSetupCommand(ctx)).addCommand(createStatusCommand(ctx)).addCommand(createStartCommand(ctx)).addCommand(createStopCommand(ctx)).addCommand(createCleanCommand(ctx));
397
+ }
398
+ async function main(options) {
399
+ try {
400
+ await (await createProgram(options)).parseAsync();
401
+ } catch (error) {
402
+ if (error instanceof Error && error.name === "ExitPromptError") logger.success("👋 cancelled, until next time!");
403
+ else {
404
+ logger.error(error);
405
+ process.exit(1);
406
+ }
407
+ }
408
+ }
409
+ //#endregion
410
+ //#region bin.ts
411
+ main({
412
+ binDir: path.dirname(fileURLToPath(import.meta.url)),
413
+ installDir: path.join(homedir(), ".localproxy")
414
+ });
415
+ //#endregion
416
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vlandoss/localproxy",
3
- "version": "0.0.10",
3
+ "version": "0.0.11-git-06df4ba.0",
4
4
  "description": "Simple local development proxy automation",
5
5
  "homepage": "https://github.com/variableland/dx/tree/main/packages/localproxy#readme",
6
6
  "bugs": {
@@ -8,34 +8,43 @@
8
8
  },
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/variableland/dx.git"
11
+ "url": "git+https://github.com/variableland/dx.git",
12
+ "directory": "packages/localproxy"
12
13
  },
13
14
  "license": "MIT",
14
15
  "author": "rcrd <rcrd@variable.land>",
15
16
  "type": "module",
16
17
  "bin": {
17
- "localproxy": "./bin.ts",
18
- "localp": "./bin.ts"
18
+ "localproxy": "./dist/bin.mjs",
19
+ "localp": "./dist/bin.mjs"
19
20
  },
20
21
  "files": [
21
- "bin",
22
+ "bin.ts",
23
+ "dist",
22
24
  "src",
25
+ "!src/**/__tests__",
26
+ "!src/**/*.test.*",
23
27
  "config",
24
28
  "tsconfig.json"
25
29
  ],
26
30
  "dependencies": {
27
31
  "@inquirer/prompts": "8.3.0",
28
32
  "commander": "14.0.3",
29
- "@vlandoss/clibuddy": "0.0.10",
30
- "@vlandoss/loggy": "0.0.7"
33
+ "@vlandoss/loggy": "0.0.8-git-06df4ba.0",
34
+ "@vlandoss/clibuddy": "0.0.11-git-06df4ba.0"
31
35
  },
32
36
  "publishConfig": {
33
37
  "access": "public"
34
38
  },
35
39
  "engines": {
40
+ "node": ">=20.0.0",
36
41
  "bun": ">=1.0.0"
37
42
  },
43
+ "devDependencies": {
44
+ "@vlandoss/tsdown-config": "^0.0.2-git-06df4ba.0"
45
+ },
38
46
  "scripts": {
47
+ "build": "tsdown",
39
48
  "test": "bun test",
40
49
  "test:types": "rr tsc"
41
50
  }