lizardtail 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +390 -0
  3. package/dist/index.js +786 -0
  4. package/package.json +46 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lizardtail contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,390 @@
1
+ # lizardtail
2
+
3
+ `lizardtail` runs a command, watches its output for a localhost server port, exposes that port with [Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve), and prints the private tailnet URL. Pass `--public` to use Tailscale Funnel intentionally.
4
+
5
+ ```bash
6
+ lizardtail pnpm dev
7
+ ```
8
+
9
+ ```text
10
+ Local: http://localhost:5173
11
+
12
+ lizardtail: detected local server on http://127.0.0.1:5173
13
+ lizardtail: serving via Tailscale: https://my-host.tailabc.ts.net:8443
14
+ ```
15
+
16
+ Use it when your dev server is running on a remote machine and you want to open it from another device on your tailnet without manually copying ports or reconfiguring Tailscale Serve.
17
+
18
+ ## Features
19
+
20
+ - Runs any command you pass it, such as `pnpm dev`, `npm run dev`, `bun run dev`, or `python -m http.server`.
21
+ - Streams the child command's stdout/stderr normally.
22
+ - Detects common dev-server output formats, including `http://localhost:5173`, `http://127.0.0.1:3000`, `started server on 0.0.0.0:8080`, and `PORT=4321`.
23
+ - Ignores timing output like `ready in 500 ms` so it does not accidentally expose port `500`.
24
+ - Waits briefly after the first candidate port so multi-process commands, such as Laravel plus Vite, can print the better app-server URL.
25
+ - Waits for the detected port to accept local connections before exposing it.
26
+ - Runs `tailscale serve --bg --https <tailscale-port> http://127.0.0.1:<port>`.
27
+ - Prints the HTTPS MagicDNS URL for the current Tailscale device.
28
+ - Supports an explicit `--port` when automatic detection is not possible.
29
+ - Supports `--tailscale-port` when you want the MagicDNS URL to include a specific HTTPS port.
30
+ - Detects Laravel + Vite dev output, exposes both servers, rewrites Laravel's `public/hot` file to the Tailscale Vite URL, and proxies Vite assets with CORS headers so module scripts can load cross-origin.
31
+ - Uses a stable alternate Tailscale HTTPS port by default: first free port from `8443` upward.
32
+ - Stays private to your tailnet by default.
33
+ - Supports `--public` / `--funnel` for intentional public internet sharing through Tailscale Funnel.
34
+ - Cleans up the Tailscale Serve/Funnel mappings it created when the child command exits or you press `Ctrl+C`.
35
+ - Has editable blocked-port guardrails with default entries for common HTTP/HTTPS ingress ports.
36
+
37
+ ## Requirements
38
+
39
+ - Node.js 20 or newer.
40
+ - Tailscale installed and available as `tailscale` on `PATH`.
41
+ - The device must be logged into Tailscale.
42
+ - Tailscale Serve must be available for the device/tailnet.
43
+ - For `--public`, Tailscale Funnel must be enabled for the device/tailnet.
44
+ - Your user must be allowed to update Tailscale Serve/Funnel config. If `tailscale serve` or `tailscale funnel` says access is denied, run this once:
45
+
46
+ ```bash
47
+ sudo tailscale set --operator=$USER
48
+ ```
49
+
50
+ Check Tailscale before using `lizardtail`:
51
+
52
+ ```bash
53
+ tailscale status
54
+ tailscale serve --help
55
+ # Optional, only for --public:
56
+ tailscale funnel --help
57
+ ```
58
+
59
+ `lizardtail` exposes services to your private tailnet via Tailscale Serve by default. It only uses Tailscale Funnel, which publishes to the public internet, when you explicitly pass `--public` or `--funnel`.
60
+
61
+ ## Installation
62
+
63
+ ### From source
64
+
65
+ ```bash
66
+ git clone https://github.com/dasomji/lizardtail.git
67
+ cd lizardtail
68
+ npm install
69
+ npm run build
70
+ npm link
71
+ ```
72
+
73
+ Then run:
74
+
75
+ ```bash
76
+ lizardtail --help
77
+ ```
78
+
79
+ ### During development
80
+
81
+ You can run the TypeScript source directly:
82
+
83
+ ```bash
84
+ npm run dev -- pnpm dev
85
+ ```
86
+
87
+ Or build and run the compiled CLI:
88
+
89
+ ```bash
90
+ npm run build
91
+ node dist/index.js pnpm dev
92
+ ```
93
+
94
+ ## Usage
95
+
96
+ ```bash
97
+ lizardtail [options] -- <command> [args...]
98
+ lizardtail [options] <command> [args...]
99
+ lizardtail help [topic]
100
+ lizardtail config init
101
+ ```
102
+
103
+ Use `--` when the command itself has flags that could be confused for `lizardtail` options:
104
+
105
+ ```bash
106
+ lizardtail -- npm run dev -- --host 0.0.0.0
107
+ ```
108
+
109
+ ### Help
110
+
111
+ ```bash
112
+ lizardtail help
113
+ lizardtail help config
114
+ ```
115
+
116
+ These commands are meant for both humans and coding agents: they describe usage, safety behavior, public/private exposure, and config file shape without needing to open the README.
117
+
118
+ ### Options
119
+
120
+ | Option | Default | Description |
121
+ | --- | --- | --- |
122
+ | `--port <port>` | auto-detect | Expose this port instead of reading one from command output. |
123
+ | `--host <host>` | `127.0.0.1` | Local host to pass to Tailscale Serve/Funnel. |
124
+ | `--timeout <ms>` | `30000` | How long to wait for a port to appear in command output. |
125
+ | `--tailscale-port <port>` | first free `8443+` | Expose the main app on this Tailscale HTTPS port and print it in the MagicDNS URL. Alias: `--https-port`. |
126
+ | `--vite-tailscale-port <port>` | first free `8443+` | Expose a detected Laravel Vite asset server on this Tailscale HTTPS port. Alias: `--vite-https-port`. |
127
+ | `--public`, `--funnel` | disabled | Use Tailscale Funnel for public internet access instead of private tailnet-only Serve. |
128
+ | `--no-open-check` | enabled | Skip waiting for the local port to accept connections before calling Tailscale. |
129
+ | `-h`, `--help` | | Show help. |
130
+
131
+ ## Examples
132
+
133
+ ### Vite / frontend dev server
134
+
135
+ ```bash
136
+ lizardtail pnpm dev
137
+ ```
138
+
139
+ If Vite is configured to bind to another host:
140
+
141
+ ```bash
142
+ lizardtail --host localhost pnpm dev
143
+ ```
144
+
145
+ ### npm script with extra flags
146
+
147
+ ```bash
148
+ lizardtail -- npm run dev -- --host 0.0.0.0
149
+ ```
150
+
151
+ ### Known port
152
+
153
+ ```bash
154
+ lizardtail --port 3000 npm run dev
155
+ ```
156
+
157
+ ### MagicDNS URL with an explicit port
158
+
159
+ By default, `lizardtail` uses the first free Tailscale HTTPS port from `8443` upward, so multiple projects can be served at the same time:
160
+
161
+ ```text
162
+ https://my-host.tailabc.ts.net:8443
163
+ ```
164
+
165
+ You can also choose the Tailscale HTTPS port explicitly:
166
+
167
+ ```bash
168
+ lizardtail --tailscale-port 8450 pnpm dev
169
+ ```
170
+
171
+ That prints a URL like:
172
+
173
+ ```text
174
+ https://my-host.tailabc.ts.net:8450
175
+ ```
176
+
177
+ ### Laravel / `composer run dev`
178
+
179
+ Laravel development commands often start both the PHP app server and the Vite asset server. When `lizardtail` sees both, it:
180
+
181
+ 1. exposes the Laravel app server;
182
+ 2. starts a small local proxy in front of Vite that adds CORS headers;
183
+ 3. exposes that Vite proxy on a separate Tailscale HTTPS port;
184
+ 4. writes `public/hot` to the Tailscale Vite URL so Laravel renders assets from the reachable Vite server.
185
+
186
+ ```bash
187
+ lizardtail composer run dev
188
+ ```
189
+
190
+ You can choose the Vite Tailscale port explicitly:
191
+
192
+ ```bash
193
+ lizardtail --vite-tailscale-port 8453 composer run dev
194
+ ```
195
+
196
+ `lizardtail` also sets `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` for the child command when it can read your Tailscale MagicDNS name. The local proxy handles CORS for module scripts loaded from the Vite Tailscale URL.
197
+
198
+ If your app server lands on a known port and you only want to expose that server, you can force it:
199
+
200
+ ```bash
201
+ lizardtail --port 8001 composer run dev
202
+ ```
203
+
204
+ ### Public internet sharing
205
+
206
+ By default, URLs are only reachable from devices in your tailnet. To intentionally publish through Tailscale Funnel:
207
+
208
+ ```bash
209
+ lizardtail --public pnpm dev
210
+ ```
211
+
212
+ or:
213
+
214
+ ```bash
215
+ lizardtail --funnel pnpm dev
216
+ ```
217
+
218
+ This prints a public HTTPS URL such as:
219
+
220
+ ```text
221
+ https://my-host.tailabc.ts.net:8443
222
+ ```
223
+
224
+ Use this only for apps you are comfortable exposing publicly. Stop `lizardtail` with `Ctrl+C` to remove the Funnel mapping it created.
225
+
226
+ ### Editable blocked ports
227
+
228
+ Lizard Tail ships with a small default blocked-port list for common HTTP/HTTPS ingress ports:
229
+
230
+ ```json
231
+ {
232
+ "blockedPorts": [
233
+ {
234
+ "port": 80,
235
+ "scope": "both",
236
+ "reason": "Common HTTP ingress/proxy port. Blocking prevents dev exposure from replacing a production web route."
237
+ },
238
+ {
239
+ "port": 443,
240
+ "scope": "both",
241
+ "reason": "Common HTTPS ingress/proxy port. Lizard Tail defaults to high explicit Tailscale HTTPS ports instead."
242
+ }
243
+ ]
244
+ }
245
+ ```
246
+
247
+ Create an editable config file:
248
+
249
+ ```bash
250
+ lizardtail config init
251
+ ```
252
+
253
+ Lizard Tail searches the current working directory for:
254
+
255
+ 1. `lizardtail.config.json`
256
+ 2. `.lizardtail.json`
257
+
258
+ You can also set `LIZARDTAIL_CONFIG=/path/to/config.json`.
259
+
260
+ Each blocked-port entry has:
261
+
262
+ - `port`: number from `1` to `65535`
263
+ - `scope`: `"local"`, `"tailscale"`, or `"both"` — defaults to `"both"`
264
+ - `reason`: explanation shown when the rule blocks an action
265
+
266
+ If a config file exists, its `blockedPorts` list replaces the built-in default list. Keep, edit, or remove entries based on your own host.
267
+
268
+ ### Longer startup timeout
269
+
270
+ ```bash
271
+ lizardtail --timeout 60000 pnpm dev
272
+ ```
273
+
274
+ ## How it works
275
+
276
+ 1. `lizardtail` starts the command you provide.
277
+ 2. It streams the command output to your terminal.
278
+ 3. It scans recent output for a local port.
279
+ 4. Once it finds a port, it waits for `127.0.0.1:<port>` or the configured `--host` to accept connections.
280
+ 5. It chooses the first free Tailscale HTTPS port from `8443` upward, unless `--tailscale-port` was provided. Ports in the configured blocked-port list are refused or skipped.
281
+ 6. It runs Tailscale Serve for private tailnet-only access:
282
+
283
+ ```bash
284
+ tailscale serve --bg --https <tailscale-port> http://<host>:<port>
285
+ ```
286
+
287
+ With `--public` / `--funnel`, it runs Tailscale Funnel for public internet access:
288
+
289
+ ```bash
290
+ tailscale funnel --bg --https <tailscale-port> http://<host>:<port>
291
+ ```
292
+
293
+ On older Tailscale versions, if that form fails for `127.0.0.1`/`localhost`, it falls back to the same command with just `<port>` as the target.
294
+
295
+ 7. It reads `tailscale status --json`, extracts the current device's MagicDNS name, and prints:
296
+
297
+ ```text
298
+ https://<device-name>.<tailnet>.ts.net:<tailscale-port>
299
+ ```
300
+
301
+ ## Shutdown behavior
302
+
303
+ When the child command exits, or when you press `Ctrl+C`, `lizardtail` removes the Tailscale mappings it created for that run:
304
+
305
+ ```bash
306
+ tailscale serve --https=<port> off
307
+ # or, with --public:
308
+ tailscale funnel --https=<port> off
309
+ ```
310
+
311
+ It only tracks ports created by the current `lizardtail` process.
312
+
313
+ ## Troubleshooting
314
+
315
+ ### No port detected
316
+
317
+ If the server does not print a recognizable port, pass it explicitly:
318
+
319
+ ```bash
320
+ lizardtail --port 5173 pnpm dev
321
+ ```
322
+
323
+ ### Tailscale command fails
324
+
325
+ Verify Tailscale is running and logged in:
326
+
327
+ ```bash
328
+ tailscale status
329
+ ```
330
+
331
+ Then check Serve support and current mappings:
332
+
333
+ ```bash
334
+ tailscale serve --help
335
+ tailscale serve status
336
+ ```
337
+
338
+ ### `Access denied: serve config denied`
339
+
340
+ Some Tailscale installs only allow root, or the configured Tailscale operator, to change Serve config. If you see:
341
+
342
+ ```text
343
+ Access denied: serve config denied
344
+ Use 'sudo tailscale serve ...'
345
+ To not require root, use 'sudo tailscale set --operator=$USER' once.
346
+ ```
347
+
348
+ run:
349
+
350
+ ```bash
351
+ sudo tailscale set --operator=$USER
352
+ ```
353
+
354
+ Then rerun `lizardtail`. This is a one-time local machine setup step.
355
+
356
+ ### Browser cannot load assets
357
+
358
+ Some frameworks, especially full-stack apps with separate backend and Vite dev servers, need more than one port exposed. `lizardtail` detects Laravel + Vite and exposes both automatically. For other multi-port setups, run a second `lizardtail --port <port> ...` command or configure Tailscale Serve manually with explicit high ports.
359
+
360
+ ### Host checks or CORS failures
361
+
362
+ Some dev servers reject requests from the Tailscale hostname. Configure your dev server to allow the Tailscale MagicDNS host or to bind with the right host/CORS options. For example, Vite may need `--host 0.0.0.0` and framework-specific allowed-host settings.
363
+
364
+ ## Development
365
+
366
+ ```bash
367
+ npm install
368
+ npm run typecheck
369
+ npm test
370
+ npm run build
371
+ ```
372
+
373
+ The test suite uses Node's built-in test runner through `tsx` and includes:
374
+
375
+ - unit tests for argument parsing and port detection;
376
+ - an integration-style CLI test with a fake `tailscale` executable and a real temporary HTTP server.
377
+
378
+ ## Contributing
379
+
380
+ Issues and pull requests are welcome. Please include tests for behavior changes and run:
381
+
382
+ ```bash
383
+ npm test
384
+ ```
385
+
386
+ before opening a pull request.
387
+
388
+ ## License
389
+
390
+ MIT. See [LICENSE](./LICENSE).
package/dist/index.js ADDED
@@ -0,0 +1,786 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { existsSync, realpathSync } from "node:fs";
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import { createServer, request as httpRequest } from "node:http";
7
+ import net from "node:net";
8
+ import path from "node:path";
9
+ import process from "node:process";
10
+ import { fileURLToPath } from "node:url";
11
+ export const DEFAULT_TIMEOUT_MS = 30_000;
12
+ export const DEFAULT_TAILSCALE_HTTPS_PORT = 8443;
13
+ const MAX_AUTO_TAILSCALE_HTTPS_PORT = 8999;
14
+ const CONFIG_FILENAMES = ["lizardtail.config.json", ".lizardtail.json"];
15
+ const DETECTION_SETTLE_MS = 1_500;
16
+ const DEFAULT_CONFIG = {
17
+ blockedPorts: [
18
+ {
19
+ port: 80,
20
+ scope: "both",
21
+ reason: "Common HTTP ingress/proxy port. Blocking prevents dev exposure from replacing a production web route.",
22
+ },
23
+ {
24
+ port: 443,
25
+ scope: "both",
26
+ reason: "Common HTTPS ingress/proxy port. Lizard Tail defaults to high explicit Tailscale HTTPS ports instead.",
27
+ },
28
+ ],
29
+ };
30
+ export function printUsage() {
31
+ console.error(`Usage: lizardtail [options] -- <command> [args...]
32
+ lizardtail [options] <command> [args...]
33
+ lizardtail help [topic]
34
+ lizardtail config init
35
+
36
+ Options:
37
+ --port <port> Expose this port instead of detecting one from output.
38
+ --host <host> Local host to expose. Default: 127.0.0.1
39
+ --timeout <ms> Port-detection timeout. Default: ${DEFAULT_TIMEOUT_MS}
40
+ --tailscale-port <port>
41
+ Expose the main app on this Tailscale HTTPS port. Default: first free ${DEFAULT_TAILSCALE_HTTPS_PORT}+ port.
42
+ --vite-tailscale-port <port>
43
+ Expose a detected Laravel Vite server on this Tailscale HTTPS port.
44
+ --public, --funnel Expose publicly on the internet with Tailscale Funnel instead of private tailnet-only Serve.
45
+ --no-open-check Skip waiting for the local port to accept connections.
46
+ -h, --help Show this help.
47
+
48
+ Examples:
49
+ lizardtail pnpm dev
50
+ lizardtail --port 3000 npm run dev
51
+ lizardtail --tailscale-port 8450 pnpm dev
52
+ lizardtail --public pnpm dev
53
+ lizardtail help config
54
+ `);
55
+ }
56
+ function printDetailedHelp(topic) {
57
+ if (topic === "config") {
58
+ console.log(`Lizard Tail configuration
59
+
60
+ Config files, searched from the current working directory:
61
+ 1. lizardtail.config.json
62
+ 2. .lizardtail.json
63
+
64
+ You can also set LIZARDTAIL_CONFIG=/path/to/config.json.
65
+
66
+ Create a starter config:
67
+ lizardtail config init
68
+
69
+ Config shape:
70
+ ${JSON.stringify(DEFAULT_CONFIG, null, 2)}
71
+
72
+ blockedPorts entries:
73
+ port Number from 1 to 65535.
74
+ scope "local", "tailscale", or "both". Defaults to "both".
75
+ reason Human-readable explanation shown when the rule blocks an action.
76
+
77
+ If a config file exists, its blockedPorts list replaces the built-in default list. Keep the default entries if they matter for your host, or edit/remove them if your environment is different.`);
78
+ return;
79
+ }
80
+ console.log(`Lizard Tail
81
+
82
+ Run a dev server command, detect its local port, and expose it through Tailscale.
83
+
84
+ Common usage:
85
+ lizardtail pnpm dev
86
+ lizardtail --port 3000 npm run dev
87
+ lizardtail --tailscale-port 8450 pnpm dev
88
+ lizardtail --public pnpm dev
89
+
90
+ Private vs public:
91
+ Default: private tailnet-only Tailscale Serve.
92
+ --public / --funnel: public internet exposure through Tailscale Funnel.
93
+
94
+ Safety:
95
+ Lizard Tail always uses explicit high Tailscale HTTPS ports by default (${DEFAULT_TAILSCALE_HTTPS_PORT}+).
96
+ It never runs bare tailscale serve/funnel target commands.
97
+ It only removes mappings it created in the current process.
98
+ Ports can be blocked with lizardtail.config.json or .lizardtail.json.
99
+
100
+ Help topics:
101
+ lizardtail help config
102
+
103
+ Other commands:
104
+ lizardtail config init Write a starter config file.
105
+ `);
106
+ }
107
+ function usage() {
108
+ printUsage();
109
+ process.exit(2);
110
+ }
111
+ async function handleMetaCommand(argv) {
112
+ if (argv[0] === "help") {
113
+ printDetailedHelp(argv[1]);
114
+ return true;
115
+ }
116
+ if (argv[0] === "config" && argv[1] === "init") {
117
+ await writeDefaultConfigFile();
118
+ return true;
119
+ }
120
+ if (argv[0] === "init-config") {
121
+ await writeDefaultConfigFile();
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+ async function writeDefaultConfigFile() {
127
+ const configPath = path.join(process.cwd(), "lizardtail.config.json");
128
+ if (existsSync(configPath))
129
+ throw new Error(`${configPath} already exists`);
130
+ await writeFile(configPath, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
131
+ console.log(`wrote ${configPath}`);
132
+ }
133
+ export function parseArgs(argv) {
134
+ const options = {
135
+ command: [],
136
+ host: "127.0.0.1",
137
+ timeoutMs: DEFAULT_TIMEOUT_MS,
138
+ openCheck: true,
139
+ public: false,
140
+ };
141
+ for (let i = 0; i < argv.length; i += 1) {
142
+ const arg = argv[i];
143
+ if (arg === "--") {
144
+ options.command = argv.slice(i + 1);
145
+ break;
146
+ }
147
+ if (arg === "-h" || arg === "--help") {
148
+ printUsage();
149
+ process.exit(0);
150
+ }
151
+ if (arg === "--no-open-check") {
152
+ options.openCheck = false;
153
+ continue;
154
+ }
155
+ if (arg === "--public" || arg === "--funnel") {
156
+ options.public = true;
157
+ continue;
158
+ }
159
+ if (arg === "--port") {
160
+ const value = argv[++i];
161
+ if (!value)
162
+ usage();
163
+ options.port = parsePort(value);
164
+ continue;
165
+ }
166
+ if (arg.startsWith("--port=")) {
167
+ options.port = parsePort(arg.slice("--port=".length));
168
+ continue;
169
+ }
170
+ if (arg === "--host") {
171
+ const value = argv[++i];
172
+ if (!value)
173
+ usage();
174
+ options.host = value;
175
+ continue;
176
+ }
177
+ if (arg.startsWith("--host=")) {
178
+ options.host = arg.slice("--host=".length);
179
+ continue;
180
+ }
181
+ if (arg === "--tailscale-port" || arg === "--https-port") {
182
+ const value = argv[++i];
183
+ if (!value)
184
+ usage();
185
+ options.tailscalePort = parsePort(value);
186
+ continue;
187
+ }
188
+ if (arg.startsWith("--tailscale-port=")) {
189
+ options.tailscalePort = parsePort(arg.slice("--tailscale-port=".length));
190
+ continue;
191
+ }
192
+ if (arg.startsWith("--https-port=")) {
193
+ options.tailscalePort = parsePort(arg.slice("--https-port=".length));
194
+ continue;
195
+ }
196
+ if (arg === "--vite-tailscale-port" || arg === "--vite-https-port") {
197
+ const value = argv[++i];
198
+ if (!value)
199
+ usage();
200
+ options.viteTailscalePort = parsePort(value);
201
+ continue;
202
+ }
203
+ if (arg.startsWith("--vite-tailscale-port=")) {
204
+ options.viteTailscalePort = parsePort(arg.slice("--vite-tailscale-port=".length));
205
+ continue;
206
+ }
207
+ if (arg.startsWith("--vite-https-port=")) {
208
+ options.viteTailscalePort = parsePort(arg.slice("--vite-https-port=".length));
209
+ continue;
210
+ }
211
+ if (arg === "--timeout") {
212
+ const value = argv[++i];
213
+ if (!value)
214
+ usage();
215
+ options.timeoutMs = parseTimeout(value);
216
+ continue;
217
+ }
218
+ if (arg.startsWith("--timeout=")) {
219
+ options.timeoutMs = parseTimeout(arg.slice("--timeout=".length));
220
+ continue;
221
+ }
222
+ if (arg.startsWith("-")) {
223
+ console.error(`lizardtail: unknown option ${arg}`);
224
+ usage();
225
+ }
226
+ options.command = argv.slice(i);
227
+ break;
228
+ }
229
+ if (options.command.length === 0)
230
+ usage();
231
+ return options;
232
+ }
233
+ function parsePort(value) {
234
+ const port = Number(value);
235
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
236
+ console.error(`lizardtail: invalid port: ${value}`);
237
+ process.exit(2);
238
+ }
239
+ return port;
240
+ }
241
+ function parseTimeout(value) {
242
+ const timeout = Number(value);
243
+ if (!Number.isInteger(timeout) || timeout < 1) {
244
+ console.error(`lizardtail: invalid timeout: ${value}`);
245
+ process.exit(2);
246
+ }
247
+ return timeout;
248
+ }
249
+ async function loadConfig() {
250
+ const configPath = findConfigPath();
251
+ if (!configPath)
252
+ return DEFAULT_CONFIG;
253
+ try {
254
+ const raw = await readFile(configPath, "utf8");
255
+ const parsed = JSON.parse(raw);
256
+ return normalizeConfig(parsed, configPath);
257
+ }
258
+ catch (error) {
259
+ if (error instanceof SyntaxError)
260
+ throw new Error(`failed to parse ${configPath}: ${error.message}`);
261
+ throw error;
262
+ }
263
+ }
264
+ function findConfigPath() {
265
+ if (process.env.LIZARDTAIL_CONFIG)
266
+ return process.env.LIZARDTAIL_CONFIG;
267
+ for (const filename of CONFIG_FILENAMES) {
268
+ const candidate = path.join(process.cwd(), filename);
269
+ if (existsSync(candidate))
270
+ return candidate;
271
+ }
272
+ return undefined;
273
+ }
274
+ function normalizeConfig(config, source) {
275
+ if (!Array.isArray(config.blockedPorts)) {
276
+ throw new Error(`${source} must contain a blockedPorts array`);
277
+ }
278
+ return {
279
+ blockedPorts: config.blockedPorts.map((rule, index) => normalizeBlockedPortRule(rule, `${source}:blockedPorts[${index}]`)),
280
+ };
281
+ }
282
+ function normalizeBlockedPortRule(rule, label) {
283
+ const port = Number(rule.port);
284
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
285
+ throw new Error(`${label}.port must be an integer from 1 to 65535`);
286
+ }
287
+ const scope = rule.scope ?? "both";
288
+ if (scope !== "local" && scope !== "tailscale" && scope !== "both") {
289
+ throw new Error(`${label}.scope must be "local", "tailscale", or "both"`);
290
+ }
291
+ const reason = typeof rule.reason === "string" && rule.reason.trim() ? rule.reason.trim() : "Blocked by lizardtail configuration.";
292
+ return { port, scope, reason };
293
+ }
294
+ function findBlockedPortRule(config, port, scope) {
295
+ return config.blockedPorts.find((rule) => rule.port === port && (rule.scope === "both" || rule.scope === scope || rule.scope === undefined));
296
+ }
297
+ function assertPortAllowed(config, port, scope) {
298
+ const rule = findBlockedPortRule(config, port, scope);
299
+ if (!rule)
300
+ return;
301
+ const label = scope === "tailscale" ? "Tailscale HTTPS" : "local";
302
+ throw new Error(`refusing to use ${label} port ${port}: ${rule.reason}`);
303
+ }
304
+ export function stripAnsi(input) {
305
+ return input.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
306
+ }
307
+ export function detectPortFromText(text) {
308
+ const candidates = detectPortCandidates(text);
309
+ candidates.sort((a, b) => b.score - a.score);
310
+ return candidates[0]?.port;
311
+ }
312
+ function detectPortCandidates(text) {
313
+ const clean = stripAnsi(text);
314
+ const candidates = [];
315
+ for (const line of clean.split(/\r?\n/)) {
316
+ candidates.push(...detectPortCandidatesFromLine(line));
317
+ }
318
+ return candidates;
319
+ }
320
+ function detectPortCandidatesFromLine(line) {
321
+ const candidates = [];
322
+ const lowerLine = line.toLowerCase();
323
+ const lineLooksLikeDuration = /\b\d{1,5}\s*ms\b/i.test(line);
324
+ const lineLooksLikeServer = /\b(server|listening|started|running)\b/i.test(line) || /\[server\]/i.test(line);
325
+ const lineLooksLikeVite = /\[vite\]|\bvite\b/i.test(line);
326
+ const localUrlPattern = /https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{1,5})/gi;
327
+ for (const match of line.matchAll(localUrlPattern)) {
328
+ const port = validDetectedPort(match[2]);
329
+ if (!port)
330
+ continue;
331
+ let score = 70;
332
+ if (lineLooksLikeServer)
333
+ score += 30;
334
+ if (lineLooksLikeVite)
335
+ score -= 15;
336
+ if (match[1] === "0.0.0.0")
337
+ score -= 10;
338
+ candidates.push({ port, score });
339
+ }
340
+ if (lineLooksLikeDuration)
341
+ return candidates;
342
+ const portPhrase = line.match(/\b(?:port|PORT)\s*(?:=|:|is|on)?\s*(\d{2,5})\b/);
343
+ if (portPhrase?.[1]) {
344
+ const port = validDetectedPort(portPhrase[1]);
345
+ if (port)
346
+ candidates.push({ port, score: lowerLine.includes("in use") ? 20 : 45 });
347
+ }
348
+ const serverPort = line.match(/\b(?:listening|started|running|server)\b[^\n\r]*:(\d{2,5})\b/i);
349
+ if (serverPort?.[1]) {
350
+ const port = validDetectedPort(serverPort[1]);
351
+ if (port)
352
+ candidates.push({ port, score: lineLooksLikeServer ? 60 : 40 });
353
+ }
354
+ return candidates;
355
+ }
356
+ function validDetectedPort(value) {
357
+ const port = Number(value);
358
+ return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : undefined;
359
+ }
360
+ export function detectLaravelViteServers(text) {
361
+ const clean = stripAnsi(text);
362
+ const detection = {};
363
+ const appMatch = clean.match(/\[server\][^\n\r]*Server running on \[http:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\]|::1):(\d{1,5})\]/i);
364
+ const appPort = appMatch?.[1] ? validDetectedPort(appMatch[1]) : undefined;
365
+ if (appPort)
366
+ detection.appPort = appPort;
367
+ const viteMatch = [...clean.matchAll(/\[vite\][^\n\r]*(?:Local:)\s*http:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{1,5})\/?/gi)].at(-1);
368
+ const vitePort = viteMatch?.[2] ? validDetectedPort(viteMatch[2]) : undefined;
369
+ if (vitePort) {
370
+ detection.vitePort = vitePort;
371
+ detection.viteHost = normalizeLocalHost(viteMatch?.[1] ?? "localhost");
372
+ }
373
+ return detection;
374
+ }
375
+ function normalizeLocalHost(host) {
376
+ return host === "[::1]" || host === "::1" ? "localhost" : host;
377
+ }
378
+ function errorMessage(error) {
379
+ return error instanceof Error ? error.message : String(error);
380
+ }
381
+ function isTailscaleServePermissionError(error) {
382
+ const message = errorMessage(error).toLowerCase();
383
+ return message.includes("access denied") && (message.includes("sudo tailscale serve") || message.includes("sudo tailscale funnel") || message.includes("operator"));
384
+ }
385
+ function tailscaleExposeCommand(target, tailscalePort, mode) {
386
+ return [mode, "--bg", "--https", String(tailscalePort), target];
387
+ }
388
+ function tailscaleUrl(dnsName, tailscalePort) {
389
+ return tailscalePort === undefined ? `https://${dnsName}` : `https://${dnsName}:${tailscalePort}`;
390
+ }
391
+ function tailscaleServePermissionHelp(target, tailscalePort, mode) {
392
+ const label = mode === "funnel" ? "Funnel" : "Serve";
393
+ return `Tailscale refused to update ${label} config without elevated privileges.
394
+
395
+ Run this once to let your user manage Tailscale ${label}:
396
+
397
+ sudo tailscale set --operator=$USER
398
+
399
+ Then rerun lizardtail.
400
+
401
+ Or expose this server manually with:
402
+
403
+ sudo tailscale ${tailscaleExposeCommand(target, tailscalePort, mode).join(" ")}`;
404
+ }
405
+ async function exec(command, args, opts = {}) {
406
+ const childEnv = opts.env ?? process.env;
407
+ const child = spawn(command, args, {
408
+ stdio: [opts.input ? "pipe" : "ignore", "pipe", "pipe"],
409
+ env: childEnv,
410
+ });
411
+ let stdout = "";
412
+ let stderr = "";
413
+ if (!child.stdout || !child.stderr) {
414
+ throw new Error(`failed to capture output for ${command}`);
415
+ }
416
+ child.stdout.setEncoding("utf8");
417
+ child.stderr.setEncoding("utf8");
418
+ child.stdout.on("data", (chunk) => {
419
+ stdout += chunk;
420
+ });
421
+ child.stderr.on("data", (chunk) => {
422
+ stderr += chunk;
423
+ });
424
+ if (opts.input) {
425
+ if (!child.stdin)
426
+ throw new Error(`failed to write input for ${command}`);
427
+ child.stdin.end(opts.input);
428
+ }
429
+ const [code, signal] = (await Promise.race([
430
+ once(child, "exit"),
431
+ once(child, "error").then(([error]) => {
432
+ throw error;
433
+ }),
434
+ ]));
435
+ if (code !== 0) {
436
+ const reason = signal ? `signal ${signal}` : `exit code ${code}`;
437
+ throw new Error(`${command} ${args.join(" ")} failed with ${reason}\n${stderr || stdout}`.trim());
438
+ }
439
+ return { stdout, stderr };
440
+ }
441
+ export async function waitForOpenPort(host, port, timeoutMs) {
442
+ const deadline = Date.now() + timeoutMs;
443
+ while (Date.now() < deadline) {
444
+ const isOpen = await new Promise((resolve) => {
445
+ const socket = net.connect({ host, port });
446
+ const done = (result) => {
447
+ socket.destroy();
448
+ resolve(result);
449
+ };
450
+ socket.setTimeout(500);
451
+ socket.once("connect", () => done(true));
452
+ socket.once("timeout", () => done(false));
453
+ socket.once("error", () => done(false));
454
+ });
455
+ if (isOpen)
456
+ return;
457
+ await new Promise((resolve) => setTimeout(resolve, 250));
458
+ }
459
+ throw new Error(`timed out waiting for ${host}:${port} to accept connections`);
460
+ }
461
+ async function getTailscaleDnsName() {
462
+ const { stdout } = await exec("tailscale", ["status", "--json"]);
463
+ const status = JSON.parse(stdout);
464
+ return status.Self?.DNSName?.replace(/\.$/, "");
465
+ }
466
+ async function getTailscaleExposureStatus(mode) {
467
+ try {
468
+ const { stdout } = await exec("tailscale", [mode, "status", "--json"]);
469
+ return JSON.parse(stdout);
470
+ }
471
+ catch {
472
+ return undefined;
473
+ }
474
+ }
475
+ function collectTailscaleStatusPorts(status, usedPorts) {
476
+ if (!status?.Web)
477
+ return;
478
+ for (const key of Object.keys(status.Web)) {
479
+ const match = key.match(/:(\d{2,5})$/);
480
+ const port = match?.[1] ? validDetectedPort(match[1]) : undefined;
481
+ if (port)
482
+ usedPorts.add(port);
483
+ }
484
+ }
485
+ async function resolveTailscaleHttpsPort(_target, requestedPort, config = DEFAULT_CONFIG) {
486
+ if (requestedPort !== undefined)
487
+ return requestedPort;
488
+ return chooseTailscaleHttpsPort(undefined, config);
489
+ }
490
+ async function chooseTailscaleHttpsPort(excludedPort, config = DEFAULT_CONFIG) {
491
+ const usedPorts = new Set();
492
+ collectTailscaleStatusPorts(await getTailscaleExposureStatus("serve"), usedPorts);
493
+ collectTailscaleStatusPorts(await getTailscaleExposureStatus("funnel"), usedPorts);
494
+ for (let port = DEFAULT_TAILSCALE_HTTPS_PORT; port <= MAX_AUTO_TAILSCALE_HTTPS_PORT; port += 1) {
495
+ if (port === excludedPort || usedPorts.has(port) || findBlockedPortRule(config, port, "tailscale"))
496
+ continue;
497
+ return port;
498
+ }
499
+ throw new Error("could not find an available Tailscale HTTPS port");
500
+ }
501
+ async function writeLaravelHotFile(viteUrl) {
502
+ const publicDir = path.join(process.cwd(), "public");
503
+ if (!existsSync(publicDir))
504
+ return undefined;
505
+ const hotPath = path.join(publicDir, "hot");
506
+ await writeFile(hotPath, viteUrl);
507
+ return hotPath;
508
+ }
509
+ async function startCorsProxy(targetHost, targetPort) {
510
+ const server = createServer((incoming, response) => {
511
+ if (incoming.method === "OPTIONS") {
512
+ response.writeHead(204, corsHeaders());
513
+ response.end();
514
+ return;
515
+ }
516
+ const upstream = httpRequest({
517
+ host: targetHost,
518
+ port: targetPort,
519
+ method: incoming.method,
520
+ path: incoming.url,
521
+ headers: { ...incoming.headers, host: `${targetHost}:${targetPort}` },
522
+ }, (upstreamResponse) => {
523
+ response.writeHead(upstreamResponse.statusCode ?? 502, {
524
+ ...upstreamResponse.headers,
525
+ ...corsHeaders(),
526
+ });
527
+ upstreamResponse.pipe(response);
528
+ });
529
+ upstream.on("error", (error) => {
530
+ response.writeHead(502, corsHeaders());
531
+ response.end(`lizardtail Vite proxy error: ${error.message}`);
532
+ });
533
+ incoming.pipe(upstream);
534
+ });
535
+ server.on("upgrade", (request, socket, head) => {
536
+ const upstream = net.connect(targetPort, targetHost, () => {
537
+ upstream.write(`${request.method ?? "GET"} ${request.url ?? "/"} HTTP/${request.httpVersion}\r\n`);
538
+ for (const [name, value] of Object.entries(request.headers)) {
539
+ if (value === undefined)
540
+ continue;
541
+ upstream.write(`${name}: ${Array.isArray(value) ? value.join(",") : value}\r\n`);
542
+ }
543
+ upstream.write(`\r\n`);
544
+ if (head.length > 0)
545
+ upstream.write(head);
546
+ socket.pipe(upstream).pipe(socket);
547
+ });
548
+ upstream.on("error", () => socket.destroy());
549
+ });
550
+ await new Promise((resolve, reject) => {
551
+ server.once("error", reject);
552
+ server.listen(0, "127.0.0.1", () => {
553
+ server.off("error", reject);
554
+ resolve();
555
+ });
556
+ });
557
+ const address = server.address();
558
+ if (!address || typeof address === "string")
559
+ throw new Error("failed to start Vite CORS proxy");
560
+ return { host: "127.0.0.1", port: address.port };
561
+ }
562
+ function corsHeaders() {
563
+ return {
564
+ "Access-Control-Allow-Origin": "*",
565
+ "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
566
+ "Access-Control-Allow-Headers": "*",
567
+ };
568
+ }
569
+ async function exposeWithTailscaleDetailed(host, port, tailscalePort, publicExposure = false, config) {
570
+ const resolvedConfig = config ?? (await loadConfig());
571
+ assertPortAllowed(resolvedConfig, port, "local");
572
+ await exec("tailscale", ["status"]);
573
+ const target = `http://${host}:${port}`;
574
+ const resolvedTailscalePort = await resolveTailscaleHttpsPort(target, tailscalePort, resolvedConfig);
575
+ assertPortAllowed(resolvedConfig, resolvedTailscalePort, "tailscale");
576
+ const mode = publicExposure ? "funnel" : "serve";
577
+ try {
578
+ await exec("tailscale", tailscaleExposeCommand(target, resolvedTailscalePort, mode));
579
+ }
580
+ catch (firstError) {
581
+ if (isTailscaleServePermissionError(firstError)) {
582
+ throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort, mode));
583
+ }
584
+ if (host !== "127.0.0.1" && host !== "localhost")
585
+ throw firstError;
586
+ try {
587
+ await exec("tailscale", tailscaleExposeCommand(String(port), resolvedTailscalePort, mode));
588
+ }
589
+ catch (fallbackError) {
590
+ if (isTailscaleServePermissionError(fallbackError)) {
591
+ throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort, mode));
592
+ }
593
+ throw fallbackError;
594
+ }
595
+ }
596
+ const { stdout } = await exec("tailscale", ["status", "--json"]);
597
+ const status = JSON.parse(stdout);
598
+ const dnsName = status.Self?.DNSName?.replace(/\.$/, "");
599
+ if (dnsName)
600
+ return { url: tailscaleUrl(dnsName, resolvedTailscalePort), httpsPort: resolvedTailscalePort, mode };
601
+ const ip = status.Self?.TailscaleIPs?.find((value) => /^\d+\.\d+\.\d+\.\d+$/.test(value));
602
+ if (ip)
603
+ return { url: tailscaleUrl(ip, resolvedTailscalePort), httpsPort: resolvedTailscalePort, mode };
604
+ throw new Error("could not determine this device's Tailscale DNS name or IP");
605
+ }
606
+ export async function exposeWithTailscale(host, port, tailscalePort, publicExposure = false) {
607
+ return (await exposeWithTailscaleDetailed(host, port, tailscalePort, publicExposure)).url;
608
+ }
609
+ async function removeTailscaleExposurePort(port, mode) {
610
+ await exec("tailscale", [mode, `--https=${port}`, "off"]);
611
+ }
612
+ export async function main() {
613
+ const argv = process.argv.slice(2);
614
+ if (await handleMetaCommand(argv))
615
+ return;
616
+ const options = parseArgs(argv);
617
+ const config = await loadConfig();
618
+ const [command, ...args] = options.command;
619
+ const tailscaleDnsName = await getTailscaleDnsName().catch(() => undefined);
620
+ const childEnv = { ...process.env, FORCE_COLOR: process.env.FORCE_COLOR ?? "1" };
621
+ if (tailscaleDnsName) {
622
+ childEnv.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS = childEnv.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS
623
+ ? `${childEnv.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS},${tailscaleDnsName}`
624
+ : tailscaleDnsName;
625
+ }
626
+ const child = spawn(command, args, {
627
+ stdio: ["inherit", "pipe", "pipe"],
628
+ env: childEnv,
629
+ });
630
+ let exposed = false;
631
+ let exposing;
632
+ let recentOutput = "";
633
+ let detectionTimer;
634
+ const createdTailscalePorts = new Map();
635
+ const cleanupTailscaleServe = async () => {
636
+ for (const [port, mode] of createdTailscalePorts) {
637
+ try {
638
+ await removeTailscaleExposurePort(port, mode);
639
+ console.error(`lizardtail: removed Tailscale ${mode === "funnel" ? "Funnel" : "Serve"} mapping on HTTPS port ${port}`);
640
+ }
641
+ catch (error) {
642
+ console.error(`lizardtail: failed to remove Tailscale ${mode === "funnel" ? "Funnel" : "Serve"} mapping on HTTPS port ${port}: ${errorMessage(error)}`);
643
+ }
644
+ }
645
+ createdTailscalePorts.clear();
646
+ };
647
+ const stopChild = () => {
648
+ if (!child.killed)
649
+ child.kill("SIGTERM");
650
+ };
651
+ const expose = (port) => {
652
+ if (exposed || exposing)
653
+ return;
654
+ exposed = true;
655
+ exposing = (async () => {
656
+ const localUrl = `http://${options.host}:${port}`;
657
+ console.error(`\nlizardtail: detected local server on ${localUrl}`);
658
+ assertPortAllowed(config, port, "local");
659
+ if (options.openCheck)
660
+ await waitForOpenPort(options.host, port, 10_000);
661
+ const exposure = await exposeWithTailscaleDetailed(options.host, port, options.tailscalePort, options.public, config);
662
+ createdTailscalePorts.set(exposure.httpsPort, exposure.mode);
663
+ console.error(`lizardtail: serving via Tailscale: ${exposure.url}`);
664
+ console.error(`lizardtail: cleanup command: tailscale ${exposure.mode} --https=${exposure.httpsPort} off`);
665
+ console.error("");
666
+ })().catch((error) => {
667
+ console.error(`lizardtail: failed to expose server: ${errorMessage(error)}`);
668
+ stopChild();
669
+ process.exitCode = 1;
670
+ });
671
+ };
672
+ const exposeLaravelVite = (appPort, vitePort, viteHost) => {
673
+ if (exposed || exposing)
674
+ return;
675
+ exposed = true;
676
+ exposing = (async () => {
677
+ const appLocalUrl = `http://${options.host}:${appPort}`;
678
+ const viteLocalUrl = `http://${viteHost}:${vitePort}`;
679
+ console.error(`\nlizardtail: detected Laravel app server on ${appLocalUrl}`);
680
+ console.error(`lizardtail: detected Vite asset server on ${viteLocalUrl}`);
681
+ assertPortAllowed(config, appPort, "local");
682
+ assertPortAllowed(config, vitePort, "local");
683
+ if (options.openCheck) {
684
+ await waitForOpenPort(options.host, appPort, 10_000);
685
+ await waitForOpenPort(viteHost, vitePort, 10_000);
686
+ }
687
+ const appExposure = await exposeWithTailscaleDetailed(options.host, appPort, options.tailscalePort, options.public, config);
688
+ createdTailscalePorts.set(appExposure.httpsPort, appExposure.mode);
689
+ const viteProxy = await startCorsProxy(viteHost, vitePort);
690
+ const viteTailscalePort = options.viteTailscalePort ?? (await chooseTailscaleHttpsPort(appExposure.httpsPort, config));
691
+ const viteExposure = await exposeWithTailscaleDetailed(viteProxy.host, viteProxy.port, viteTailscalePort, options.public, config);
692
+ createdTailscalePorts.set(viteExposure.httpsPort, viteExposure.mode);
693
+ const hotPath = await writeLaravelHotFile(viteExposure.url);
694
+ console.error(`lizardtail: serving Laravel via Tailscale: ${appExposure.url}`);
695
+ console.error(`lizardtail: serving Vite assets via Tailscale: ${viteExposure.url}`);
696
+ console.error(`lizardtail: proxying Vite through local CORS proxy: http://${viteProxy.host}:${viteProxy.port} -> ${viteLocalUrl}`);
697
+ if (hotPath)
698
+ console.error(`lizardtail: wrote Laravel Vite hot file: ${hotPath}`);
699
+ console.error(`lizardtail: cleanup command: tailscale ${appExposure.mode} --https=${appExposure.httpsPort} off`);
700
+ console.error(`lizardtail: cleanup command: tailscale ${viteExposure.mode} --https=${viteExposure.httpsPort} off`);
701
+ console.error("");
702
+ })().catch((error) => {
703
+ console.error(`lizardtail: failed to expose Laravel/Vite servers: ${errorMessage(error)}`);
704
+ stopChild();
705
+ process.exitCode = 1;
706
+ });
707
+ };
708
+ child.stdout.setEncoding("utf8");
709
+ child.stderr.setEncoding("utf8");
710
+ const inspectChunk = (chunk) => {
711
+ recentOutput = (recentOutput + chunk).slice(-8_000);
712
+ const detectedPort = detectPortFromText(recentOutput);
713
+ if (!detectedPort || exposed || exposing)
714
+ return;
715
+ if (detectionTimer)
716
+ clearTimeout(detectionTimer);
717
+ detectionTimer = setTimeout(() => {
718
+ detectionTimer = undefined;
719
+ const laravelVite = detectLaravelViteServers(recentOutput);
720
+ if (laravelVite.appPort && laravelVite.vitePort) {
721
+ exposeLaravelVite(laravelVite.appPort, laravelVite.vitePort, laravelVite.viteHost ?? "localhost");
722
+ return;
723
+ }
724
+ const settledPort = detectPortFromText(recentOutput);
725
+ if (settledPort)
726
+ expose(settledPort);
727
+ }, DETECTION_SETTLE_MS);
728
+ };
729
+ child.stdout.on("data", (chunk) => {
730
+ process.stdout.write(chunk);
731
+ if (!options.port)
732
+ inspectChunk(chunk);
733
+ });
734
+ child.stderr.on("data", (chunk) => {
735
+ process.stderr.write(chunk);
736
+ if (!options.port)
737
+ inspectChunk(chunk);
738
+ });
739
+ child.on("error", (error) => {
740
+ console.error(`lizardtail: failed to start ${command}: ${error.message}`);
741
+ process.exit(1);
742
+ });
743
+ if (options.port)
744
+ expose(options.port);
745
+ const timeout = options.port
746
+ ? undefined
747
+ : setTimeout(() => {
748
+ if (!exposed) {
749
+ console.error(`lizardtail: no server port detected within ${options.timeoutMs}ms`);
750
+ stopChild();
751
+ process.exitCode = 1;
752
+ }
753
+ }, options.timeoutMs);
754
+ for (const signal of ["SIGINT", "SIGTERM"]) {
755
+ process.once(signal, () => {
756
+ child.kill(signal);
757
+ });
758
+ }
759
+ const [code, signal] = (await once(child, "exit"));
760
+ if (timeout)
761
+ clearTimeout(timeout);
762
+ if (detectionTimer)
763
+ clearTimeout(detectionTimer);
764
+ if (exposing)
765
+ await exposing;
766
+ await cleanupTailscaleServe();
767
+ if (signal)
768
+ process.kill(process.pid, signal);
769
+ process.exit(code ?? process.exitCode ?? 0);
770
+ }
771
+ function isCliEntrypoint() {
772
+ if (!process.argv[1])
773
+ return false;
774
+ try {
775
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
776
+ }
777
+ catch {
778
+ return false;
779
+ }
780
+ }
781
+ if (isCliEntrypoint()) {
782
+ main().catch((error) => {
783
+ console.error(`lizardtail: ${errorMessage(error)}`);
784
+ process.exit(1);
785
+ });
786
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "lizardtail",
3
+ "version": "0.1.0",
4
+ "description": "Run a dev server command, detect its port, expose it with Tailscale Serve or Funnel, and print the URL.",
5
+ "type": "module",
6
+ "bin": {
7
+ "lizardtail": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "dev": "tsx src/index.ts",
17
+ "test": "npm run build && tsx --test tests/**/*.test.ts",
18
+ "typecheck": "tsc -p tsconfig.json --noEmit",
19
+ "prepare": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "tailscale",
23
+ "serve",
24
+ "dev-server",
25
+ "cli",
26
+ "tailnet",
27
+ "localhost"
28
+ ],
29
+ "license": "MIT",
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.10.2",
35
+ "tsx": "^4.19.2",
36
+ "typescript": "^5.7.2"
37
+ },
38
+ "homepage": "https://github.com/dasomji/lizardtail#readme",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/dasomji/lizardtail.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/dasomji/lizardtail/issues"
45
+ }
46
+ }