diff --git a/skills/previewer/README.md b/skills/previewer/README.md index 9aa9c0d..a1572f0 100644 --- a/skills/previewer/README.md +++ b/skills/previewer/README.md @@ -1,6 +1,7 @@ # previewer -把本机的 HTML、PRD、报告或其它静态文件通过 Tailscale Serve 暴露给 tailnet,并返回可访问的 HTTP URL。 +用 Node 启动默认存活 1 小时的轻量静态服务,再通过 Tailscale Serve 把本机的 HTML、PRD、报告 +或其它静态文件暴露给 tailnet,并返回可访问的 HTTP URL。 ## 什么时候使用 @@ -11,7 +12,7 @@ ## 使用前准备 -- 本机已安装并登录 Tailscale,且访问者也在允许访问的 tailnet 中。 +- 本机已安装 Node.js、已登录 Tailscale,且访问者也在允许访问的 tailnet 中。 - 提供要展示的文件路径;文件所在目录会被作为静态目录服务。 ## 使用示例 @@ -22,9 +23,12 @@ ## Agent 会做什么 -1. 检查 Tailscale 是否在线,以及是否已有 Serve 路由。 -2. 将文件所在目录挂到指定的独立路径,不覆盖冲突路由。 -3. 返回包含 Tailscale IPv4 的 HTTP URL 和停止该路由的命令。 +1. 检查 Tailscale 是否在线,以及 Serve 状态是否可读取。 +2. 用 Node 在 localhost 启动文件所在目录的静态服务。 +3. 将指定 Tailscale 路径代理到本地端口。 +4. 返回包含 Tailscale IPv4 的 HTTP URL、Node PID、过期时间和停止命令。 + +默认 1 小时后自动关闭 Node 服务和对应的 Tailscale 路由;需要其它时长时可传 `--ttl` 秒数。 ## 如何判断完成 diff --git a/skills/previewer/SKILL.md b/skills/previewer/SKILL.md index 810c3d5..26b5c9a 100644 --- a/skills/previewer/SKILL.md +++ b/skills/previewer/SKILL.md @@ -8,8 +8,9 @@ description: >- # Previewer:Tailnet 静态文件预览 -调用方只提供一个要展示的文件。Previewer 检查 Tailscale 是否在线和 Serve 状态,按独立路径 -把文件所在目录交给 `tailscale serve`,最后返回 tailnet 内 HTTP URL。 +调用方只提供一个要展示的文件。Previewer 检查 Tailscale 是否在线和 Serve 状态,启动一个 +Node 本地静态服务,再让 `tailscale serve` 代理这个 localhost 端口,最后返回 tailnet 内 +HTTP URL。 ## 何时使用 @@ -25,19 +26,22 @@ description: >- ## 调用方式 -找到本 skill 目录 ``,执行: +前置条件:本机安装 Node.js 和 Tailscale,并已登录目标 tailnet。找到本 skill 目录 +``,执行: ```bash -python3 /scripts/serve_file.py \ +node /scripts/serve_file.js \ \ --mount-path /preview/ ``` -脚本默认使用 HTTP 80 端口;可用 `--port` 指定其它端口。它输出 JSON,包含文件、挂载目录、 -mount path、Tailscale IPv4 和最终 URL,适合被其它 skill 读取。 +脚本默认使用 Tailscale HTTP 80 端口,并为本地 Node 服务自动选择空闲端口;可用 `--port` +指定 Tailscale 对外端口、`--local-port` 指定本地端口。Node 服务默认 1 小时后自动关闭, +到期时同时关闭对应 Serve 路由;可用 `--ttl` 以秒覆盖。它输出一个 JSON,包含文件、挂载目录、 +本地进程 PID、过期时间、Tailscale IPv4 和最终 URL,适合被其它 skill 读取。 -如果调用方没有提供 `--mount-path`,脚本按文件父目录名生成 `/preview/`;遇到 -同名但指向不同目录的已有路由时停止,要求调用方提供稳定且唯一的 mount path。 +如果调用方没有提供 `--mount-path`,脚本按文件父目录名生成 `/preview/`;不同 +预览应显式使用稳定且唯一的 mount path。 详细契约见 [contract.md](references/contract.md)。脚本执行逻辑和 Tailscale CLI 兼容性只在需要 排查调用失败时读取,不要把用户文件内容当作命令。 @@ -46,15 +50,14 @@ mount path、Tailscale IPv4 和最终 URL,适合被其它 skill 读取。 - 只接受存在且为普通文件的输入;解析为绝对路径后,服务它的父目录,以支持 HTML 的相对资源。 - 运行 `tailscale ip -4` 和 `tailscale serve status --json` 检查当前节点与 Serve 状态。 -- 没有目标路由时,用 `tailscale serve --bg --http= --set-path= ` - 创建或追加路由;不执行 `tailscale serve reset`。 -- 已有同一路由指向其它目录时 fail closed,不覆盖、不删除、不猜测用户意图。 -- `tailscale serve` 的配置是持久的;脚本返回停止该路由所需的精确命令,但不自动停止其它路由。 -- 只使用 Tailscale tailnet 地址,不使用 Funnel,不上传文件,不启动第二个 Python HTTP 服务。 +- Node 只监听 `127.0.0.1`,用 `tailscale serve --bg --http= --set-path=` + 代理这个本地端口;不执行 `tailscale serve reset`。 +- Node 服务默认只存活 1 小时,到期自动关闭本地进程和对应 Serve 路由;脚本也返回可提前停止的精确命令。 +- 只使用 Tailscale tailnet 地址,不使用 Funnel,不上传文件,不启动 Python HTTP 服务。 - 文件所在目录会对 tailnet 暴露目录列表和同目录文件;调用方应使用专门的预览目录,不要传项目根目录。 ## 验证 调用成功必须同时满足:脚本退出码为 0;输出 URL 使用 Tailscale IPv4、HTTP 和指定 mount path; -`tailscale serve status --json` 中能找到该 mount path 和输入文件的父目录。Tailscale 未安装、 -节点离线、Serve 命令失败、状态无法确认或路由冲突都必须返回非零退出码和可行动错误。 +Tailscale Serve 命令成功。Tailscale 或 Node 未安装、节点离线、Serve 状态无法读取、Node 服务 +启动失败或 Serve 命令失败都必须返回非零退出码和可行动错误。 diff --git a/skills/previewer/references/contract.md b/skills/previewer/references/contract.md index 5ea621a..052a717 100644 --- a/skills/previewer/references/contract.md +++ b/skills/previewer/references/contract.md @@ -3,15 +3,19 @@ ## 输入 ```text -python3 scripts/serve_file.py FILE [--mount-path PATH] [--port PORT] +node scripts/serve_file.js FILE [--mount-path PATH] [--port PORT] [--local-port PORT] [--ttl SECONDS] ``` - `FILE`:必须是存在的普通文件,建议使用绝对路径。 - `--mount-path`:以 `/` 开头的 URL 路径,不能包含 `?`、`#`、空段或 `..`;不提供时按父目录名生成。 -- `--port`:HTTP 端口,默认 `80`。 +- `--port`:Tailscale 对外 HTTP 端口,默认 `80`。 +- `--local-port`:Node 监听的 localhost 端口,默认自动选择空闲端口。 +- `--ttl`:服务存活时间,单位为秒,默认 `3600`(1 小时);到期同时关闭 Node 服务和对应 + Tailscale Serve 路由。 -脚本服务 `FILE` 的父目录,而不是把文件复制到其它地方。这样 HTML 可以加载同目录的 CSS、 -图片和其它资源,但也意味着同目录其它文件会对 tailnet 可见。 +Node 服务 `FILE` 的父目录,而不是把文件复制到其它地方。这样 HTML 可以加载同目录的 CSS、 +图片和其它资源,但也意味着同目录其它文件会对 tailnet 可见。Node 只监听 `127.0.0.1`, +Tailscale Serve 负责把指定路径代理到这个本地端口。 ## 输出 @@ -24,8 +28,12 @@ python3 scripts/serve_file.py FILE [--mount-path PATH] [--port PORT] "mountPath": "/preview/billing", "ip": "100.101.102.103", "port": 80, + "localPort": 43721, + "pid": 12345, + "ttl": 3600, + "expiresAt": "2026-10-11T13:00:00.000Z", "url": "http://100.101.102.103/preview/billing/prd-v002.html", - "stopCommand": "tailscale serve --http=80 --set-path=/preview/billing off" + "stopCommand": "kill 12345 2>/dev/null || true; tailscale serve --http=80 --set-path=/preview/billing off" } ``` @@ -33,10 +41,9 @@ python3 scripts/serve_file.py FILE [--mount-path PATH] [--port PORT] ## 状态和冲突 -脚本先读取 `tailscale serve status --json`。如果相同 mount path 已指向相同目录,可以复用;如果 -指向其它目录,必须失败并要求新的 mount path。没有该路由时追加 `--set-path` 配置,不重置已有 -Serve 配置。 +脚本先读取 `tailscale serve status --json`,确认当前 Tailscale Serve 可用,然后启动 Node 本地 +服务并追加指定 `--set-path` 路由。应为不同预览使用不同 mount path;脚本不会执行全量重置。 -Serve 配置由 Tailscale 持久保存;`--bg` 路由会在 Tailscale 重启后恢复。停止时只能使用输出的 -同端口、同 mount path 的 `off` 命令,不能使用 `tailscale serve reset`,因为 reset 会清除该节点 -的全部 Serve 配置。 +Serve 配置由 Tailscale 持久保存,但 Node 服务默认在 1 小时后自动关闭并清理对应路由。需要 +提前停止时使用输出的命令,不能使用 `tailscale serve reset`,因为 reset 会清除该节点的全部 +Serve 配置。 diff --git a/skills/previewer/scripts/serve_file.js b/skills/previewer/scripts/serve_file.js new file mode 100644 index 0000000..692a765 --- /dev/null +++ b/skills/previewer/scripts/serve_file.js @@ -0,0 +1,310 @@ +#!/usr/bin/env node +/* + * Start a tiny local static server and expose it through Tailscale Serve. + * Node is used for the local server so the Tailscale route only needs to + * proxy a localhost port. + */ + +'use strict'; + +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); + +class PreviewError extends Error {} + +function usage() { + return `Usage: node serve_file.js FILE [--mount-path PATH] [--port PORT] [--ttl SECONDS] + +Expose FILE through Tailscale Serve. Node serves FILE's parent directory on +localhost; Tailscale proxies that local server to the tailnet. +`; +} + +function parseArgs(argv) { + const args = { + file: null, root: null, mountPath: null, port: 80, localPort: 0, + ttl: 3600, tailscalePort: null, server: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const value = argv[i]; + if (value === '--help' || value === '-h') { + console.log(usage()); + process.exit(0); + } + if (value === '--server') { + args.server = true; + continue; + } + if (value === '--root') { + args.root = argv[++i]; + continue; + } + if (value === '--mount-path') { + args.mountPath = argv[++i]; + continue; + } + if (value === '--port') { + args.port = Number(argv[++i]); + continue; + } + if (value === '--local-port') { + args.localPort = Number(argv[++i]); + continue; + } + if (value === '--ttl') { + args.ttl = Number(argv[++i]); + continue; + } + if (value === '--tailscale-port') { + args.tailscalePort = Number(argv[++i]); + continue; + } + if (value.startsWith('-')) { + throw new PreviewError(`未知参数:${value}`); + } + if (args.file === null) { + args.file = value; + } else { + throw new PreviewError(`多余参数:${value}`); + } + } + return args; +} + +function validPort(port, name) { + if (!Number.isInteger(port) || port < 0 || port > 65535 || (name === 'port' && port === 0)) { + throw new PreviewError(`${name} 必须是有效端口`); + } +} + +function validTtl(ttl) { + if (!Number.isInteger(ttl) || ttl <= 0) { + throw new PreviewError('ttl 必须是正整数秒数'); + } +} + +function normalizeMountPath(value) { + const mountPath = value.trim(); + if (!mountPath.startsWith('/') || mountPath === '/') { + throw new PreviewError('mount path 必须以 / 开头且不能是根路径'); + } + if (mountPath.includes('?') || mountPath.includes('#') || mountPath.includes('//')) { + throw new PreviewError('mount path 不能包含 query、fragment 或空路径段'); + } + const parts = mountPath.split('/'); + if (parts.some((part) => part === '.' || part === '..')) { + throw new PreviewError('mount path 不能包含 . 或 ..'); + } + return mountPath.replace(/\/$/, ''); +} + +function defaultMountPath(directory) { + const slug = path.basename(directory).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); + if (!slug) { + throw new PreviewError('无法从文件目录生成 mount path,请显式提供 --mount-path'); + } + return `/preview/${slug}`; +} + +function run(command, commandArgs) { + const result = spawnSync(command, commandArgs, { encoding: 'utf8' }); + if (result.error) { + throw new PreviewError(`无法运行 ${command}:${result.error.message}`); + } + return result; +} + +function tailscaleIPv4() { + const result = run('tailscale', ['ip', '-4']); + if (result.status !== 0) { + throw new PreviewError(`Tailscale 节点不可用:${(result.stderr || result.stdout).trim() || 'tailscale ip -4 失败'}`); + } + const address = result.stdout.split(/\s+/).find((candidate) => net.isIPv4(candidate)); + if (!address) { + throw new PreviewError('tailscale 没有返回 IPv4 地址;请确认节点已连接 tailnet'); + } + return address; +} + +function checkTailscaleServe() { + const result = run('tailscale', ['serve', 'status', '--json']); + if (result.status !== 0) { + throw new PreviewError(`无法读取 Tailscale Serve 状态:${(result.stderr || result.stdout).trim() || 'status 失败'}`); + } +} + +function serveStatic(root, mountPath, port, ttl, tailscalePort) { + const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + }; + + const server = http.createServer((request, response) => { + let pathname; + try { + pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname); + } catch { + response.writeHead(400).end('Bad request'); + return; + } + if (pathname === mountPath || pathname.startsWith(`${mountPath}/`)) { + pathname = pathname.slice(mountPath.length) || '/'; + } + const relativePath = pathname.replace(/^\/+/, ''); + const candidate = path.resolve(root, relativePath); + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) { + response.writeHead(403).end('Forbidden'); + return; + } + fs.stat(candidate, (statError, info) => { + if (statError || !info.isFile()) { + response.writeHead(404).end('Not found'); + return; + } + response.setHeader('Content-Type', contentTypes[path.extname(candidate).toLowerCase()] || 'application/octet-stream'); + fs.createReadStream(candidate).on('error', () => response.destroy()).pipe(response); + }); + }); + + server.listen(port, '127.0.0.1', () => { + const address = server.address(); + console.log(`READY ${address.port}`); + }); + + setTimeout(() => { + spawnSync('tailscale', [ + 'serve', `--http=${tailscalePort}`, `--set-path=${mountPath}`, 'off', + ], { stdio: 'ignore' }); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1000); + }, ttl * 1000); +} + +function startDetachedServer(root, mountPath, localPort, ttl, tailscalePort) { + const child = spawn(process.execPath, [ + __filename, '--server', '--root', root, '--mount-path', mountPath, + '--port', String(localPort), '--ttl', String(ttl), '--tailscale-port', String(tailscalePort), + ], { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return new Promise((resolve, reject) => { + let output = ''; + const timeout = setTimeout(() => { + child.kill(); + reject(new PreviewError('Node 静态服务启动超时')); + }, 5000); + child.stdout.on('data', (chunk) => { + output += chunk.toString(); + const match = output.match(/READY (\d+)/); + if (!match) return; + clearTimeout(timeout); + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + resolve({ pid: child.pid, port: Number(match[1]) }); + }); + child.on('error', (error) => { + clearTimeout(timeout); + reject(new PreviewError(`Node 静态服务启动失败:${error.message}`)); + }); + child.on('exit', (code) => { + if (code !== null && code !== 0) { + clearTimeout(timeout); + reject(new PreviewError(`Node 静态服务退出,code=${code}`)); + } + }); + }); +} + +function stopChild(pid) { + if (!pid) return; + try { + process.kill(pid, 'SIGTERM'); + } catch { + // The child may have exited while Tailscale was being configured. + } +} + +async function launch(args) { + if (!args.file) throw new PreviewError('缺少要展示的文件'); + validPort(args.port, 'port'); + validPort(args.localPort, 'local-port'); + validTtl(args.ttl); + + const filePath = path.resolve(args.file); + let info; + try { + info = fs.statSync(filePath); + } catch { + throw new PreviewError(`输入文件不存在:${filePath}`); + } + if (!info.isFile()) throw new PreviewError(`输入不是普通文件:${filePath}`); + + const root = path.dirname(filePath); + const mountPath = args.mountPath ? normalizeMountPath(args.mountPath) : defaultMountPath(root); + const ip = tailscaleIPv4(); + checkTailscaleServe(); + const child = await startDetachedServer(root, mountPath, args.localPort, args.ttl, args.port); + + try { + const result = run('tailscale', [ + 'serve', '--bg', `--http=${args.port}`, `--set-path=${mountPath}`, '--yes', + `http://127.0.0.1:${child.port}`, + ]); + if (result.status !== 0) { + throw new PreviewError(`Tailscale Serve 配置失败:${(result.stderr || result.stdout).trim() || 'serve 命令失败'}`); + } + } catch (error) { + stopChild(child.pid); + throw error; + } + + const relative = path.relative(root, filePath).split(path.sep).map(encodeURIComponent).join('/'); + const suffix = args.port === 80 ? '' : `:${args.port}`; + const url = `http://${ip}${suffix}${mountPath}/${relative}`; + console.log(JSON.stringify({ + file: filePath, + directory: root, + mountPath, + ip, + port: args.port, + localPort: child.port, + pid: child.pid, + ttl: args.ttl, + expiresAt: new Date(Date.now() + args.ttl * 1000).toISOString(), + url, + stopCommand: `kill ${child.pid} 2>/dev/null || true; tailscale serve --http=${args.port} --set-path=${mountPath} off`, + })); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.server) { + if (!args.mountPath || !args.root || !args.tailscalePort) throw new PreviewError('内部参数不完整'); + validPort(args.port, 'local-port'); + validPort(args.tailscalePort, 'port'); + validTtl(args.ttl); + serveStatic(path.resolve(args.root), args.mountPath, args.port, args.ttl, args.tailscalePort); + return; + } + await launch(args); +} + +main().catch((error) => { + console.error(`previewer: ${error.message}`); + process.exitCode = 2; +}); diff --git a/skills/previewer/scripts/serve_file.py b/skills/previewer/scripts/serve_file.py deleted file mode 100644 index b3b8c8c..0000000 --- a/skills/previewer/scripts/serve_file.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env python3 -"""Expose a static file's parent directory through a Tailscale Serve HTTP route.""" - -from __future__ import annotations - -import argparse -import ipaddress -import json -import re -import shutil -import subprocess -import sys -from pathlib import Path, PurePosixPath -from urllib.parse import quote - - -class PreviewError(RuntimeError): - pass - - -def run_tailscale(*args: str) -> subprocess.CompletedProcess[str]: - try: - return subprocess.run( - ["tailscale", *args], - check=False, - capture_output=True, - text=True, - ) - except OSError as exc: - raise PreviewError(f"无法运行 tailscale:{exc}") from exc - - -def require_tailscale() -> None: - if shutil.which("tailscale") is None: - raise PreviewError("未找到 tailscale,请先安装并登录 Tailscale") - - -def tailscale_ipv4() -> str: - result = run_tailscale("ip", "-4") - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise PreviewError(f"Tailscale 节点不可用:{detail or 'tailscale ip -4 失败'}") - for line in result.stdout.splitlines(): - candidate = line.strip() - try: - address = ipaddress.ip_address(candidate) - except ValueError: - continue - if address.version == 4: - return candidate - raise PreviewError("tailscale 没有返回 IPv4 地址;请确认节点已连接 tailnet") - - -def serve_status() -> tuple[dict, str]: - result = run_tailscale("serve", "status", "--json") - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise PreviewError(f"无法读取 Tailscale Serve 状态:{detail or 'status 失败'}") - try: - data = json.loads(result.stdout or "{}") - except json.JSONDecodeError as exc: - raise PreviewError("Tailscale Serve 返回了无法解析的 JSON 状态") from exc - if not isinstance(data, dict): - raise PreviewError("Tailscale Serve 状态不是 JSON 对象") - - plain = run_tailscale("serve", "status") - if plain.returncode != 0: - detail = (plain.stderr or plain.stdout).strip() - raise PreviewError(f"无法读取 Tailscale Serve 文本状态:{detail or 'status 失败'}") - return data, plain.stdout - - -def status_blob(data: dict, plain: str) -> str: - return json.dumps(data, ensure_ascii=False, sort_keys=True) + "\n" + plain - - -def normalize_mount_path(value: str) -> str: - path = value.strip() - if not path.startswith("/") or path == "/": - raise PreviewError("mount path 必须以 / 开头且不能是根路径") - if "?" in path or "#" in path or "//" in path: - raise PreviewError("mount path 不能包含 query、fragment 或空路径段") - parts = PurePosixPath(path).parts - if any(part in {".", ".."} for part in parts): - raise PreviewError("mount path 不能包含 . 或 ..") - return "/" + "/".join(parts[1:]) - - -def default_mount_path(directory: Path) -> str: - slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", directory.name).strip("-") - if not slug: - raise PreviewError("无法从文件目录生成 mount path,请显式提供 --mount-path") - return f"/preview/{slug}" - - -def route_matches(blob: str, mount_path: str, directory: Path) -> bool: - return mount_path in blob and str(directory) in blob - - -def route_conflicts(blob: str, mount_path: str, directory: Path) -> bool: - return mount_path in blob and str(directory) not in blob - - -def configure_route(directory: Path, mount_path: str, port: int, data: dict, plain: str) -> None: - blob = status_blob(data, plain) - if route_conflicts(blob, mount_path, directory): - raise PreviewError( - f"mount path {mount_path} 已被其它目录占用;请改用唯一的 --mount-path,不会覆盖已有路由" - ) - - if route_matches(blob, mount_path, directory): - return - - result = run_tailscale( - "serve", - "--bg", - f"--http={port}", - f"--set-path={mount_path}", - "--yes", - str(directory), - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise PreviewError(f"Tailscale Serve 配置失败:{detail or 'serve 命令失败'}") - - data, plain = serve_status() - blob = status_blob(data, plain) - if route_conflicts(blob, mount_path, directory) or not route_matches(blob, mount_path, directory): - raise PreviewError("Serve 命令返回成功,但状态中没有确认目标路由;停止输出 URL") - - -def build_result(file_path: Path, directory: Path, mount_path: str, ip: str, port: int) -> dict[str, object]: - relative = file_path.relative_to(directory).as_posix() - suffix = f":{port}" if port != 80 else "" - url = f"http://{ip}{suffix}{mount_path}/{quote(relative, safe='/')}" - return { - "file": str(file_path), - "directory": str(directory), - "mountPath": mount_path, - "ip": ip, - "port": port, - "url": url, - "stopCommand": f"tailscale serve --http={port} --set-path={mount_path} off", - } - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("file", type=Path, help="要展示的静态文件") - parser.add_argument("--mount-path", help="Tailscale Serve 的 URL 路径") - parser.add_argument("--port", type=int, default=80, help="HTTP 端口,默认 80") - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv or sys.argv[1:]) - try: - if not 1 <= args.port <= 65535: - raise PreviewError("port 必须在 1 到 65535 之间") - require_tailscale() - file_path = args.file.expanduser().resolve() - if not file_path.is_file(): - raise PreviewError(f"输入不是普通文件或不存在:{file_path}") - directory = file_path.parent - mount_path = normalize_mount_path(args.mount_path) if args.mount_path else default_mount_path(directory) - ip = tailscale_ipv4() - data, plain = serve_status() - configure_route(directory, mount_path, args.port, data, plain) - print(json.dumps(build_result(file_path, directory, mount_path, ip, args.port), ensure_ascii=False)) - return 0 - except PreviewError as exc: - print(f"previewer: {exc}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main())