note-connector 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.
package/dist/net.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** Ports commonly used by LocalAnt — note-connector never binds these. */
2
+ export declare const RESERVED_PORTS: number[];
3
+ /**
4
+ * Pick a free port for note-connector, never using RESERVED_PORTS.
5
+ * Scans upward from `preferred` (default 8797).
6
+ */
7
+ export declare function resolveGatewayPort(preferred: number, host?: string, attempts?: number): Promise<number>;
package/dist/net.js ADDED
@@ -0,0 +1,34 @@
1
+ import net from "node:net";
2
+ /** Ports commonly used by LocalAnt — note-connector never binds these. */
3
+ export const RESERVED_PORTS = [8787, 8788];
4
+ function isPortFree(port, host) {
5
+ return new Promise((resolve) => {
6
+ const server = net.createServer();
7
+ server.once("error", () => resolve(false));
8
+ server.once("listening", () => {
9
+ server.close(() => resolve(true));
10
+ });
11
+ server.listen(port, host);
12
+ });
13
+ }
14
+ /**
15
+ * Pick a free port for note-connector, never using RESERVED_PORTS.
16
+ * Scans upward from `preferred` (default 8797).
17
+ */
18
+ export async function resolveGatewayPort(preferred, host = "127.0.0.1", attempts = 50) {
19
+ const skip = new Set(RESERVED_PORTS);
20
+ let start = preferred;
21
+ if (skip.has(start)) {
22
+ start = Math.max(...RESERVED_PORTS) + 1;
23
+ }
24
+ for (let i = 0; i < attempts; i++) {
25
+ const port = start + i;
26
+ if (port > 65535)
27
+ break;
28
+ if (skip.has(port))
29
+ continue;
30
+ if (await isPortFree(port, host))
31
+ return port;
32
+ }
33
+ throw new Error(`No free port found from ${start} on ${host} (avoiding ${[...skip].join(", ")}).`);
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { RESERVED_PORTS, resolveGatewayPort } from "./net.js";
4
+ test("RESERVED_PORTS includes LocalAnt gateway", () => {
5
+ assert.ok(RESERVED_PORTS.includes(8787));
6
+ });
7
+ test("resolveGatewayPort returns a free port", async () => {
8
+ const port = await resolveGatewayPort(8797);
9
+ assert.ok(port >= 8797);
10
+ assert.ok(!RESERVED_PORTS.includes(port));
11
+ });
@@ -0,0 +1,3 @@
1
+ export declare function isNoteAuthenticated(): boolean;
2
+ /** Start Playwright note login in background (does not block). */
3
+ export declare function startNoteLoginInBackground(): void;
@@ -0,0 +1,28 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { repoRootFromPackage } from "./paths.js";
3
+ export function isNoteAuthenticated() {
4
+ const root = repoRootFromPackage();
5
+ try {
6
+ execFileSync("uv", [
7
+ "run",
8
+ "python",
9
+ "-c",
10
+ "from note_mcp.auth.session import SessionManager; import sys; sys.exit(0 if SessionManager().has_session() else 1)",
11
+ ], { cwd: root, stdio: "ignore", timeout: 60_000 });
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ /** Start Playwright note login in background (does not block). */
19
+ export function startNoteLoginInBackground() {
20
+ const root = repoRootFromPackage();
21
+ const child = spawn("uv", ["run", "python", "-m", "note_mcp.chatgpt.login_once"], {
22
+ cwd: root,
23
+ stdio: "inherit",
24
+ detached: true,
25
+ shell: false,
26
+ });
27
+ child.unref();
28
+ }
@@ -0,0 +1,9 @@
1
+ export interface OnboardingInput {
2
+ publicMcpUrl: string;
3
+ localMcpUrl: string;
4
+ noteAuthenticated: boolean;
5
+ tunnelOk: boolean;
6
+ noOpen?: boolean;
7
+ noClipboard?: boolean;
8
+ }
9
+ export declare function runOnboarding(input: OnboardingInput): Promise<void>;
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { configDir } from "./paths.js";
4
+ import { CHATGPT_ADVANCED_SETTINGS, CHATGPT_APPS_CONNECTORS, CONNECTOR_NAME, SETUP_STEPS_JA, formatSetupGuide, } from "./chatgpt-setup.js";
5
+ import { copyToClipboard, openBrowser } from "./util.js";
6
+ const NOTE_LOGIN = "https://note.com/login";
7
+ export async function runOnboarding(input) {
8
+ const { publicMcpUrl, localMcpUrl, noteAuthenticated, tunnelOk, noOpen, noClipboard } = input;
9
+ if (tunnelOk && !noClipboard) {
10
+ const copied = await copyToClipboard(publicMcpUrl);
11
+ if (copied) {
12
+ console.log("✓ Public MCP URL をクリップボードにコピーしました(「接続 URL」に貼り付け)");
13
+ }
14
+ }
15
+ const guidePath = path.join(configDir(), "CONNECTOR-SETUP.md");
16
+ fs.writeFileSync(guidePath, formatSetupGuide(publicMcpUrl), "utf8");
17
+ console.log(`✓ 詳細手順: ${guidePath}`);
18
+ console.log("");
19
+ console.log("══════════════════════════════════════════════════════════");
20
+ console.log(" ChatGPT に note-connector を登録する手順");
21
+ console.log("══════════════════════════════════════════════════════════");
22
+ console.log("");
23
+ if (!tunnelOk) {
24
+ console.log("⚠ トンネル未接続のため Public URL がありません。ChatGPT には使えません。");
25
+ console.log(` ローカル検証用: ${localMcpUrl}`);
26
+ console.log("");
27
+ return;
28
+ }
29
+ for (const line of SETUP_STEPS_JA) {
30
+ console.log(line);
31
+ }
32
+ console.log("");
33
+ console.log(" 接続 URL(貼り付け用):");
34
+ console.log(` ${publicMcpUrl}`);
35
+ console.log("");
36
+ if (!noteAuthenticated) {
37
+ console.log("【note.com】ログイン用ブラウザを開きます(MCP セッション保存まで待機)");
38
+ }
39
+ else {
40
+ console.log("【note.com】ログイン済み ✓");
41
+ }
42
+ console.log("══════════════════════════════════════════════════════════");
43
+ console.log("");
44
+ if (!noOpen) {
45
+ openBrowser(CHATGPT_ADVANCED_SETTINGS);
46
+ console.log("ブラウザ: 高度な設定 → 開発者モードを ON にしてください");
47
+ console.log(`次に: ${CHATGPT_APPS_CONNECTORS} で「アプリを作成」`);
48
+ console.log(` 名前: ${CONNECTOR_NAME} / 認証: なし / URL: クリップボードの内容`);
49
+ if (!noteAuthenticated) {
50
+ setTimeout(() => openBrowser(NOTE_LOGIN), 1500);
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,6 @@
1
+ export declare function configDir(): string;
2
+ /** Directory containing `src/note_mcp` (clone) or set via config / env. */
3
+ export declare function resolveNoteConnectorRepo(): string;
4
+ /** @deprecated use resolveNoteConnectorRepo */
5
+ export declare function repoRootFromPackage(): string;
6
+ export declare function ensureConfigDir(): string;
package/dist/paths.js ADDED
@@ -0,0 +1,52 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ export function configDir() {
6
+ return process.env.NOTE_CONNECTOR_CONFIG_DIR ?? path.join(os.homedir(), ".note-connector");
7
+ }
8
+ /** Directory containing `src/note_mcp` (clone) or set via config / env. */
9
+ export function resolveNoteConnectorRepo() {
10
+ const fromEnv = process.env.NOTE_CONNECTOR_REPO;
11
+ if (fromEnv) {
12
+ const root = path.resolve(fromEnv);
13
+ if (fs.existsSync(path.join(root, "src", "note_mcp")))
14
+ return root;
15
+ }
16
+ const cfgFile = path.join(configDir(), "config.json");
17
+ if (fs.existsSync(cfgFile)) {
18
+ try {
19
+ const cfg = JSON.parse(fs.readFileSync(cfgFile, "utf8"));
20
+ if (cfg.repoPath) {
21
+ const root = path.resolve(cfg.repoPath);
22
+ if (fs.existsSync(path.join(root, "src", "note_mcp")))
23
+ return root;
24
+ }
25
+ }
26
+ catch {
27
+ /* ignore */
28
+ }
29
+ }
30
+ const here = path.dirname(fileURLToPath(import.meta.url));
31
+ const fromDevLayout = path.resolve(here, "..", "..");
32
+ if (fs.existsSync(path.join(fromDevLayout, "src", "note_mcp"))) {
33
+ return fromDevLayout;
34
+ }
35
+ const fromGlobal = path.resolve(here, "..");
36
+ if (fs.existsSync(path.join(fromGlobal, "src", "note_mcp"))) {
37
+ return fromGlobal;
38
+ }
39
+ throw new Error("note-connector Python 本体が見つかりません。\n"
40
+ + " git clone https://github.com/drillan/note-mcp.git\n"
41
+ + " note-connector config set repoPath /path/to/note-connector\n"
42
+ + "または NOTE_CONNECTOR_REPO=/path/to/note-connector を設定してください。");
43
+ }
44
+ /** @deprecated use resolveNoteConnectorRepo */
45
+ export function repoRootFromPackage() {
46
+ return resolveNoteConnectorRepo();
47
+ }
48
+ export function ensureConfigDir() {
49
+ const dir = configDir();
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ return dir;
52
+ }
@@ -0,0 +1,14 @@
1
+ export interface StartOptions {
2
+ noTunnel?: boolean;
3
+ port?: number;
4
+ noOpen?: boolean;
5
+ noClipboard?: boolean;
6
+ skipNoteLogin?: boolean;
7
+ }
8
+ /** Create config + token + optional Tailscale domain (runs on every start, idempotent). */
9
+ export declare function ensureInitialized(): Promise<void>;
10
+ /** @deprecated Use `note-connector` or `note-connector start` */
11
+ export declare function runSetup(): Promise<void>;
12
+ export declare function runStart(opts: StartOptions): Promise<void>;
13
+ /** Foreground mode (debug): keep terminal attached. */
14
+ export declare function runStartForeground(opts: StartOptions): Promise<void>;
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { buildMcpEndpoint, loadConfig, loadOrCreateToken, saveConfig } from "./config.js";
4
+ import { ensureConfigDir, configDir } from "./paths.js";
5
+ import { startPythonServer } from "./spawn-python.js";
6
+ import { resolveGatewayPort } from "./net.js";
7
+ import { discoverTailscaleFqdn } from "./tunnel/tailscale.js";
8
+ import { runOnboarding } from "./onboarding.js";
9
+ import { isNoteAuthenticated, startNoteLoginInBackground } from "./note-auth.js";
10
+ import { spawnDaemon } from "./daemon.js";
11
+ import { TunnelManager } from "./tunnel/manager.js";
12
+ async function verifyHealth(port) {
13
+ try {
14
+ const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(5000) });
15
+ return res.ok;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ /** Create config + token + optional Tailscale domain (runs on every start, idempotent). */
22
+ export async function ensureInitialized() {
23
+ const firstRun = !fs.existsSync(path.join(configDir(), "config.json"));
24
+ ensureConfigDir();
25
+ let config = loadConfig();
26
+ const fqdn = discoverTailscaleFqdn();
27
+ if (fqdn && !config.tunnel.domain) {
28
+ config = { ...config, tunnel: { ...config.tunnel, domain: fqdn } };
29
+ saveConfig(config);
30
+ if (firstRun) {
31
+ console.log(`Tailscale FQDN saved: ${fqdn}`);
32
+ }
33
+ }
34
+ else {
35
+ saveConfig(config);
36
+ }
37
+ loadOrCreateToken();
38
+ if (firstRun) {
39
+ console.log(`Ready (~/.note-connector). Starting…`);
40
+ }
41
+ }
42
+ /** @deprecated Use `note-connector` or `note-connector start` */
43
+ export async function runSetup() {
44
+ await ensureInitialized();
45
+ console.log("Setup is included in start. Just run: note-connector");
46
+ }
47
+ export async function runStart(opts) {
48
+ await ensureInitialized();
49
+ await spawnDaemon(opts);
50
+ return;
51
+ }
52
+ /** Foreground mode (debug): keep terminal attached. */
53
+ export async function runStartForeground(opts) {
54
+ await ensureInitialized();
55
+ const config = loadConfig();
56
+ const preferred = opts.port ?? config.gatewayPort;
57
+ let port;
58
+ try {
59
+ port = await resolveGatewayPort(preferred, "127.0.0.1");
60
+ }
61
+ catch (e) {
62
+ console.error(e.message);
63
+ process.exit(1);
64
+ }
65
+ if (port !== config.gatewayPort && opts.port === undefined) {
66
+ saveConfig({ ...config, gatewayPort: port });
67
+ console.log(`Using free port ${port} (saved to config; avoids LocalAnt 8787/8788).`);
68
+ }
69
+ else if (port !== preferred) {
70
+ console.log(`Using free port ${port} (preferred ${preferred} was busy).`);
71
+ }
72
+ const tokenFile = path.join(configDir(), "token");
73
+ loadOrCreateToken();
74
+ const tunnel = new TunnelManager();
75
+ let tunnelHost;
76
+ const tunnelHostHint = config.tunnel.domain ?? (config.tunnel.publicUrl ? new URL(config.tunnel.publicUrl).host : undefined);
77
+ const pidFile = path.join(configDir(), "note-connector.pid");
78
+ fs.writeFileSync(pidFile, String(process.pid));
79
+ const child = startPythonServer({
80
+ host: "127.0.0.1",
81
+ port,
82
+ tokenFile,
83
+ tunnelHost: tunnelHostHint,
84
+ });
85
+ for (let i = 0; i < 30; i++) {
86
+ if (await verifyHealth(port))
87
+ break;
88
+ await new Promise((r) => setTimeout(r, 500));
89
+ }
90
+ if (!opts.noTunnel) {
91
+ const info = await tunnel.start(port, config.tunnel);
92
+ if (info.url) {
93
+ try {
94
+ tunnelHost = new URL(info.url).host;
95
+ }
96
+ catch {
97
+ tunnelHost = undefined;
98
+ }
99
+ if (tunnelHost) {
100
+ process.env.NOTE_CONNECTOR_TUNNEL_HOST = tunnelHost;
101
+ }
102
+ }
103
+ }
104
+ const token = fs.readFileSync(tokenFile, "utf8").trim();
105
+ const localUrl = buildMcpEndpoint(`http://127.0.0.1:${port}`, token);
106
+ const t = tunnel.current();
107
+ const publicUrl = t.url ? buildMcpEndpoint(t.url, token) : localUrl;
108
+ console.log("");
109
+ console.log("note-connector is running");
110
+ console.log(`Local MCP: ${localUrl}`);
111
+ if (t.url) {
112
+ console.log(`Public MCP: ${publicUrl}`);
113
+ }
114
+ else if (t.error) {
115
+ console.log(`Tunnel: ${t.error}`);
116
+ console.log("Public URL なし → 下記はローカル用です。ChatGPT にはトンネル成功後の URL が必要です。");
117
+ const fqdn = discoverTailscaleFqdn();
118
+ if (fqdn) {
119
+ console.log(`Fix: note-connector config set tunnel.domain ${fqdn}`);
120
+ console.log(` tailscale funnel ${port}`);
121
+ }
122
+ }
123
+ const noteOk = isNoteAuthenticated();
124
+ if (!noteOk && !opts.skipNoteLogin) {
125
+ startNoteLoginInBackground();
126
+ }
127
+ await runOnboarding({
128
+ publicMcpUrl: publicUrl,
129
+ localMcpUrl: localUrl,
130
+ noteAuthenticated: noteOk,
131
+ tunnelOk: Boolean(t.url),
132
+ noOpen: opts.noOpen,
133
+ noClipboard: opts.noClipboard,
134
+ });
135
+ child.on("exit", (code, signal) => {
136
+ if (code !== 0 && code !== null) {
137
+ console.error(`Python MCP server exited (code=${code}${signal ? `, signal=${signal}` : ""}).\n` +
138
+ `If you saw 'address already in use', another app owns the port — stop LocalAnt or change gatewayPort.`);
139
+ process.exit(code ?? 1);
140
+ }
141
+ });
142
+ const shutdown = () => {
143
+ tunnel.stop();
144
+ child.kill("SIGTERM");
145
+ try {
146
+ fs.rmSync(pidFile, { force: true });
147
+ }
148
+ catch {
149
+ /* ignore */
150
+ }
151
+ process.exit(0);
152
+ };
153
+ process.on("SIGINT", shutdown);
154
+ process.on("SIGTERM", shutdown);
155
+ await new Promise(() => { });
156
+ }
@@ -0,0 +1,7 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ export declare function startPythonServer(options: {
3
+ host: string;
4
+ port: number;
5
+ tokenFile: string;
6
+ tunnelHost?: string;
7
+ }): ChildProcess;
@@ -0,0 +1,31 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+ import { repoRootFromPackage } from "./paths.js";
4
+ export function startPythonServer(options) {
5
+ const root = repoRootFromPackage();
6
+ const env = {
7
+ ...process.env,
8
+ HOME: process.env.HOME ?? process.env.USERPROFILE ?? "",
9
+ NOTE_CONNECTOR_CONFIG_DIR: path.dirname(options.tokenFile),
10
+ };
11
+ if (options.tunnelHost) {
12
+ env.NOTE_CONNECTOR_TUNNEL_HOST = options.tunnelHost;
13
+ }
14
+ return spawn("uv", [
15
+ "run",
16
+ "python",
17
+ "-m",
18
+ "note_mcp.chatgpt",
19
+ "--host",
20
+ options.host,
21
+ "--port",
22
+ String(options.port),
23
+ "--token-file",
24
+ options.tokenFile,
25
+ ], {
26
+ cwd: root,
27
+ env,
28
+ stdio: "inherit",
29
+ shell: false,
30
+ });
31
+ }
@@ -0,0 +1,18 @@
1
+ import type { TunnelConfig } from "../config.js";
2
+ export interface TunnelInfo {
3
+ provider: string;
4
+ url?: string;
5
+ status: "starting" | "running" | "stopped" | "error";
6
+ error?: string;
7
+ }
8
+ export declare class TunnelManager {
9
+ private child?;
10
+ private info;
11
+ private timeoutId?;
12
+ current(): TunnelInfo;
13
+ stop(): void;
14
+ start(port: number, tunnel: TunnelConfig): Promise<TunnelInfo>;
15
+ private stableTailscaleUrl;
16
+ private startTailscale;
17
+ private startNgrok;
18
+ }
@@ -0,0 +1,163 @@
1
+ import { spawn } from "node:child_process";
2
+ import { discoverTailscaleFqdn, funnelReset, parseTailscaleFunnelUrl, resolveTailscaleBin, tailscaleEnv, } from "./tailscale.js";
3
+ export class TunnelManager {
4
+ child;
5
+ info = { provider: "none", status: "stopped" };
6
+ timeoutId;
7
+ current() {
8
+ return this.info;
9
+ }
10
+ stop() {
11
+ if (this.timeoutId)
12
+ clearTimeout(this.timeoutId);
13
+ if (this.child) {
14
+ this.child.kill();
15
+ this.child = undefined;
16
+ }
17
+ this.info = { provider: "none", status: "stopped" };
18
+ }
19
+ async start(port, tunnel) {
20
+ if (tunnel.publicUrl) {
21
+ this.info = { provider: "custom", url: tunnel.publicUrl.replace(/\/$/, ""), status: "running" };
22
+ return this.info;
23
+ }
24
+ if (tunnel.provider === "none") {
25
+ this.info = { provider: "none", status: "stopped" };
26
+ return this.info;
27
+ }
28
+ if (tunnel.provider === "tailscale") {
29
+ return this.startTailscale(port, tunnel);
30
+ }
31
+ if (tunnel.provider === "ngrok") {
32
+ return this.startNgrok(port, tunnel);
33
+ }
34
+ this.info = {
35
+ provider: tunnel.provider,
36
+ status: "error",
37
+ error: `Unknown tunnel provider '${tunnel.provider}'.`,
38
+ };
39
+ return this.info;
40
+ }
41
+ stableTailscaleUrl(tunnel) {
42
+ if (tunnel.domain)
43
+ return `https://${tunnel.domain.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
44
+ const discovered = discoverTailscaleFqdn();
45
+ if (discovered)
46
+ return `https://${discovered}`;
47
+ return undefined;
48
+ }
49
+ startTailscale(port, tunnel) {
50
+ return new Promise((resolve) => {
51
+ this.info = { provider: "tailscale", status: "starting" };
52
+ const bin = resolveTailscaleBin();
53
+ if (!bin) {
54
+ this.info = {
55
+ provider: "tailscale",
56
+ status: "error",
57
+ error: "Tailscale not found. Install from https://tailscale.com/download or set tunnel.publicUrl.",
58
+ };
59
+ resolve(this.info);
60
+ return;
61
+ }
62
+ const stableUrl = this.stableTailscaleUrl(tunnel);
63
+ funnelReset(bin);
64
+ const env = tailscaleEnv();
65
+ this.child = spawn(bin, ["funnel", String(port)], { env, shell: false });
66
+ let acc = "";
67
+ const finishRunning = (url) => {
68
+ if (this.info.status === "running")
69
+ return;
70
+ this.info = { provider: "tailscale", url: url.replace(/\/$/, ""), status: "running" };
71
+ if (this.timeoutId)
72
+ clearTimeout(this.timeoutId);
73
+ resolve(this.info);
74
+ };
75
+ const onData = (chunk) => {
76
+ acc += chunk.toString("utf8");
77
+ const parsed = parseTailscaleFunnelUrl(acc);
78
+ if (parsed)
79
+ finishRunning(parsed);
80
+ };
81
+ this.child.stdout?.on("data", onData);
82
+ this.child.stderr?.on("data", onData);
83
+ this.child.on("error", (e) => {
84
+ this.info = { provider: "tailscale", status: "error", error: e.message };
85
+ resolve(this.info);
86
+ });
87
+ this.child.on("close", (code) => {
88
+ if (this.info.status === "running")
89
+ return;
90
+ if (stableUrl) {
91
+ finishRunning(stableUrl);
92
+ return;
93
+ }
94
+ this.info = {
95
+ provider: "tailscale",
96
+ status: "error",
97
+ error: `Tailscale Funnel exited (code ${code ?? "?"}). ` +
98
+ "Enable Funnel in the admin console, or run: tailscale funnel " +
99
+ port,
100
+ };
101
+ resolve(this.info);
102
+ });
103
+ this.timeoutId = setTimeout(() => {
104
+ if (this.info.status === "running")
105
+ return;
106
+ if (stableUrl) {
107
+ finishRunning(stableUrl);
108
+ return;
109
+ }
110
+ this.info = {
111
+ provider: "tailscale",
112
+ status: "error",
113
+ error: "Timed out waiting for Tailscale Funnel URL. Run once: tailscale funnel " +
114
+ port +
115
+ " (approve in admin), then: note-connector config set tunnel.domain <your-machine.ts.net>",
116
+ };
117
+ resolve(this.info);
118
+ }, 45_000);
119
+ });
120
+ }
121
+ startNgrok(port, tunnel) {
122
+ return new Promise((resolve) => {
123
+ this.info = { provider: "ngrok", status: "starting" };
124
+ const args = ["http", String(port), "--log", "stdout"];
125
+ if (tunnel.domain)
126
+ args.push("--domain", tunnel.domain);
127
+ if (tunnel.token)
128
+ args.push("--authtoken", tunnel.token);
129
+ this.child = spawn("ngrok", args, { shell: false });
130
+ let buf = "";
131
+ const onData = (chunk) => {
132
+ buf += chunk.toString("utf8");
133
+ const m = buf.match(/https:\/\/[a-z0-9-]+\.ngrok[a-z0-9.-]*\.(app|io)/i);
134
+ if (m && this.info.status !== "running") {
135
+ this.info = { provider: "ngrok", url: m[0], status: "running" };
136
+ resolve(this.info);
137
+ }
138
+ };
139
+ this.child.stdout?.on("data", onData);
140
+ this.child.stderr?.on("data", onData);
141
+ this.child.on("error", (e) => {
142
+ this.info = { provider: "ngrok", status: "error", error: e.message };
143
+ resolve(this.info);
144
+ });
145
+ setTimeout(() => {
146
+ if (this.info.status === "running")
147
+ return;
148
+ if (tunnel.domain) {
149
+ this.info = {
150
+ provider: "ngrok",
151
+ url: `https://${tunnel.domain}`,
152
+ status: "running",
153
+ };
154
+ resolve(this.info);
155
+ }
156
+ else {
157
+ this.info = { provider: "ngrok", status: "error", error: "Timed out waiting for ngrok URL" };
158
+ resolve(this.info);
159
+ }
160
+ }, 20_000);
161
+ });
162
+ }
163
+ }
@@ -0,0 +1,6 @@
1
+ export declare function resolveTailscaleBin(): string | null;
2
+ export declare function tailscaleEnv(): NodeJS.ProcessEnv;
3
+ /** Public Funnel FQDN from `tailscale status --json` (Self.DNSName). */
4
+ export declare function discoverTailscaleFqdn(): string | undefined;
5
+ export declare function funnelReset(bin: string): void;
6
+ export declare function parseTailscaleFunnelUrl(text: string): string | undefined;
@@ -0,0 +1,50 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ export function resolveTailscaleBin() {
4
+ if (fs.existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale")) {
5
+ return "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
6
+ }
7
+ return "tailscale";
8
+ }
9
+ export function tailscaleEnv() {
10
+ if (process.env.SHLVL && process.env.SHLVL.length > 0)
11
+ return process.env;
12
+ return { ...process.env, SHLVL: "1" };
13
+ }
14
+ /** Public Funnel FQDN from `tailscale status --json` (Self.DNSName). */
15
+ export function discoverTailscaleFqdn() {
16
+ const bin = resolveTailscaleBin();
17
+ if (!bin)
18
+ return undefined;
19
+ try {
20
+ const out = execFileSync(bin, ["status", "--json"], {
21
+ encoding: "utf8",
22
+ timeout: 8000,
23
+ env: tailscaleEnv(),
24
+ });
25
+ const dns = JSON.parse(out).Self?.DNSName;
26
+ if (!dns)
27
+ return undefined;
28
+ return dns.replace(/\.$/, "");
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ export function funnelReset(bin) {
35
+ try {
36
+ execFileSync(bin, ["funnel", "reset"], {
37
+ timeout: 10_000,
38
+ stdio: "ignore",
39
+ env: tailscaleEnv(),
40
+ });
41
+ }
42
+ catch {
43
+ /* best-effort */
44
+ }
45
+ }
46
+ const TS_NET_URL = /https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.ts\.net/i;
47
+ export function parseTailscaleFunnelUrl(text) {
48
+ const m = text.match(TS_NET_URL);
49
+ return m?.[0];
50
+ }
package/dist/util.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function copyToClipboard(text: string): Promise<boolean>;
2
+ export declare function openBrowser(url: string): void;