copilot-proxy-web 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.arch.md +87 -0
- package/README.md +295 -0
- package/bin/run-web.js +548 -0
- package/bin/wss-client.js +227 -0
- package/copilot-proxy.js +1114 -0
- package/lib/api.js +564 -0
- package/lib/auth-rate-limit.js +59 -0
- package/lib/cli.js +273 -0
- package/lib/cloudflare-service.js +326 -0
- package/lib/cloudflare-setup-deps.js +136 -0
- package/lib/cloudflare-setup-service.js +277 -0
- package/lib/cloudflare-state.js +100 -0
- package/lib/cloudflare-utils.js +69 -0
- package/lib/conversation-profiles.js +80 -0
- package/lib/daemon-service.js +210 -0
- package/lib/daemon-state.js +55 -0
- package/lib/format.js +29 -0
- package/lib/hooks.js +29 -0
- package/lib/log-rotate.js +109 -0
- package/lib/markdown.js +40 -0
- package/lib/pty.js +13 -0
- package/lib/telegram.js +124 -0
- package/lib/terminal-buffer.js +302 -0
- package/lib/ws.js +256 -0
- package/package.json +51 -0
- package/public/index.html +2850 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
function createCloudflareSetupDeps({
|
|
2
|
+
fs,
|
|
3
|
+
path,
|
|
4
|
+
os,
|
|
5
|
+
dns,
|
|
6
|
+
spawnSync,
|
|
7
|
+
cfRoot,
|
|
8
|
+
cfConfigFile,
|
|
9
|
+
}) {
|
|
10
|
+
function readLatestCredentialsFile() {
|
|
11
|
+
const dir = path.join(os.homedir(), ".cloudflared");
|
|
12
|
+
let entries;
|
|
13
|
+
try {
|
|
14
|
+
entries = fs.readdirSync(dir);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
let latest = null;
|
|
19
|
+
let latestMtime = 0;
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (!entry.endsWith(".json")) continue;
|
|
22
|
+
const full = path.join(dir, entry);
|
|
23
|
+
let stat;
|
|
24
|
+
try {
|
|
25
|
+
stat = fs.statSync(full);
|
|
26
|
+
} catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!stat.isFile()) continue;
|
|
30
|
+
if (stat.mtimeMs > latestMtime) {
|
|
31
|
+
latestMtime = stat.mtimeMs;
|
|
32
|
+
latest = full;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return latest;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function lookupDnsAddresses(hostname) {
|
|
39
|
+
if (!hostname) return { status: "missing" };
|
|
40
|
+
try {
|
|
41
|
+
const [v4, v6] = await Promise.allSettled([
|
|
42
|
+
dns.promises.resolve4(hostname),
|
|
43
|
+
dns.promises.resolve6(hostname),
|
|
44
|
+
]);
|
|
45
|
+
const addrs = [];
|
|
46
|
+
if (v4.status === "fulfilled") addrs.push(...(v4.value || []));
|
|
47
|
+
if (v6.status === "fulfilled") addrs.push(...(v6.value || []));
|
|
48
|
+
if (addrs.length === 0) return { status: "missing" };
|
|
49
|
+
return { status: "found", addrs };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return { status: "error", error: err };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasOriginCert() {
|
|
56
|
+
try {
|
|
57
|
+
fs.accessSync(path.join(os.homedir(), ".cloudflared", "cert.pem"), fs.constants.R_OK);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveTunnelId(bin, name) {
|
|
65
|
+
const res = spawnSync(bin, ["tunnel", "list"], { encoding: "utf8" });
|
|
66
|
+
if (res.error) return null;
|
|
67
|
+
const lines = (res.stdout || "").split("\n").slice(1);
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const trimmed = line.trim();
|
|
70
|
+
if (!trimmed) continue;
|
|
71
|
+
const parts = trimmed.split(/\s+/);
|
|
72
|
+
if (parts.length >= 2) {
|
|
73
|
+
const [id, tunnelName] = parts;
|
|
74
|
+
if (tunnelName === name) return id;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findDnsRouteLine(bin, hostname) {
|
|
81
|
+
const res = spawnSync(bin, ["tunnel", "route", "dns", "list"], { encoding: "utf8" });
|
|
82
|
+
if (res.error || res.status !== 0) return null;
|
|
83
|
+
const needle = hostname.toLowerCase();
|
|
84
|
+
const line = (res.stdout || "")
|
|
85
|
+
.split("\n")
|
|
86
|
+
.find((line) => line.toLowerCase().includes(needle));
|
|
87
|
+
return line || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findCredentialsForTunnel(tunnelId) {
|
|
91
|
+
if (!tunnelId) return null;
|
|
92
|
+
const file = path.join(os.homedir(), ".cloudflared", `${tunnelId}.json`);
|
|
93
|
+
try {
|
|
94
|
+
fs.accessSync(file, fs.constants.R_OK);
|
|
95
|
+
return file;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function writeCloudflareConfigWithService({
|
|
102
|
+
tunnel,
|
|
103
|
+
credentialsFile,
|
|
104
|
+
hostname,
|
|
105
|
+
service,
|
|
106
|
+
pathMatch,
|
|
107
|
+
}) {
|
|
108
|
+
fs.mkdirSync(cfRoot, { recursive: true });
|
|
109
|
+
const pathLine = pathMatch ? ` path: ${pathMatch}` : null;
|
|
110
|
+
const content = [
|
|
111
|
+
`tunnel: ${tunnel}`,
|
|
112
|
+
`credentials-file: ${credentialsFile}`,
|
|
113
|
+
"ingress:",
|
|
114
|
+
` - hostname: ${hostname}`,
|
|
115
|
+
...(pathLine ? [pathLine] : []),
|
|
116
|
+
` service: ${service}`,
|
|
117
|
+
" - service: http_status:404",
|
|
118
|
+
"",
|
|
119
|
+
].join("\n");
|
|
120
|
+
fs.writeFileSync(cfConfigFile, content);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
readLatestCredentialsFile,
|
|
125
|
+
lookupDnsAddresses,
|
|
126
|
+
hasOriginCert,
|
|
127
|
+
resolveTunnelId,
|
|
128
|
+
findDnsRouteLine,
|
|
129
|
+
findCredentialsForTunnel,
|
|
130
|
+
writeCloudflareConfigWithService,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
createCloudflareSetupDeps,
|
|
136
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
function createCloudflareSetupService({
|
|
2
|
+
extractHostname,
|
|
3
|
+
inferDomainFromHostname,
|
|
4
|
+
resolveHostnameWithDomain,
|
|
5
|
+
ensureCloudflaredOrExit,
|
|
6
|
+
port,
|
|
7
|
+
cfStateStore,
|
|
8
|
+
resolveTunnelId,
|
|
9
|
+
findCredentialsForTunnel,
|
|
10
|
+
readLatestCredentialsFile,
|
|
11
|
+
hasOriginCert,
|
|
12
|
+
lookupDnsAddresses,
|
|
13
|
+
findDnsRouteLine,
|
|
14
|
+
writeCloudflareConfigWithService,
|
|
15
|
+
parseRouteCreatedRecord,
|
|
16
|
+
spawnFn,
|
|
17
|
+
consoleLike,
|
|
18
|
+
processLike,
|
|
19
|
+
formatCheckLine,
|
|
20
|
+
cfConfigFile,
|
|
21
|
+
}) {
|
|
22
|
+
function cloudflareSetup(cfArgs) {
|
|
23
|
+
function getArg(flag) {
|
|
24
|
+
const idx = cfArgs.indexOf(flag);
|
|
25
|
+
if (idx >= 0 && idx + 1 < cfArgs.length) return cfArgs[idx + 1];
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const tunnelName = getArg("--tunnel-name") || "copilot-proxy-web";
|
|
29
|
+
const rawHostname = extractHostname(getArg("--hostname"));
|
|
30
|
+
let domain = extractHostname(getArg("--domain"));
|
|
31
|
+
let inferredDomain = "";
|
|
32
|
+
if (!domain && rawHostname) {
|
|
33
|
+
inferredDomain = inferDomainFromHostname(rawHostname);
|
|
34
|
+
if (inferredDomain) domain = inferredDomain;
|
|
35
|
+
}
|
|
36
|
+
let hostname = rawHostname;
|
|
37
|
+
let inferredHostname = false;
|
|
38
|
+
try {
|
|
39
|
+
const resolved = resolveHostnameWithDomain(rawHostname, domain);
|
|
40
|
+
hostname = resolved.hostname;
|
|
41
|
+
inferredHostname = Boolean(rawHostname) && Boolean(domain) && !rawHostname.includes(".");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
consoleLike.error(err?.message || "Invalid --domain/--hostname combination");
|
|
44
|
+
processLike.exit(1);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const localPort = getArg("--port") || port;
|
|
48
|
+
const serviceMode = getArg("--service");
|
|
49
|
+
const serviceUrl = getArg("--service-url");
|
|
50
|
+
const pathMatch = getArg("--path");
|
|
51
|
+
let serviceTarget = `http://127.0.0.1:${localPort}`;
|
|
52
|
+
let inferredService = true;
|
|
53
|
+
if (serviceUrl) {
|
|
54
|
+
serviceTarget = serviceUrl.trim();
|
|
55
|
+
inferredService = false;
|
|
56
|
+
} else if (serviceMode) {
|
|
57
|
+
const mode = serviceMode.trim().toLowerCase();
|
|
58
|
+
if (mode !== "http" && mode !== "https") {
|
|
59
|
+
consoleLike.error("Invalid --service. Use http or https.");
|
|
60
|
+
processLike.exit(1);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
serviceTarget = `${mode}://127.0.0.1:${localPort}`;
|
|
64
|
+
inferredService = false;
|
|
65
|
+
}
|
|
66
|
+
if (!hostname) {
|
|
67
|
+
consoleLike.error("Missing --hostname (e.g. --hostname proxy.example.com)");
|
|
68
|
+
consoleLike.error("Examples:");
|
|
69
|
+
consoleLike.error(" npx copilot-proxy-web cloudflare setup --hostname proxy.example.com");
|
|
70
|
+
consoleLike.error(" npx copilot-proxy-web cloudflare setup --hostname proxy.example.com --tunnel-name copilot-proxy-web");
|
|
71
|
+
consoleLike.error(" npx copilot-proxy-web cloudflare setup --hostname proxy.example.com --tunnel-name copilot-proxy-web --port 3000");
|
|
72
|
+
consoleLike.error(" npx copilot-proxy-web cloudflare setup --hostname proxy.example.com --tunnel-name copilot-proxy-web --port 3000 --service http");
|
|
73
|
+
consoleLike.error(" npx copilot-proxy-web cloudflare setup --hostname proxy.example.com --tunnel-name copilot-proxy-web --service http --service-url http://127.0.0.1:3000");
|
|
74
|
+
consoleLike.error(" npx copilot-proxy-web cloudflare setup --hostname proxy.example.com --tunnel-name copilot-proxy-web --service https --service-url https://127.0.0.1:3000");
|
|
75
|
+
consoleLike.error("This will create a Cloudflare Tunnel and route DNS for that hostname.");
|
|
76
|
+
consoleLike.error(`Service target: http://127.0.0.1:${port} (HTTP only by default)`);
|
|
77
|
+
consoleLike.error("You can override service with --service http|https or --service-url <url>.");
|
|
78
|
+
consoleLike.error("Use --path <path> to match a public URL path (requires --service-url if you need a different target path).");
|
|
79
|
+
consoleLike.error("Use --domain <zone> to pin a DNS zone or allow short hostnames (auto-infers from hostname if omitted).");
|
|
80
|
+
consoleLike.error("Auth options:");
|
|
81
|
+
consoleLike.error(" 1) cloudflared tunnel login (creates ~/.cloudflared/cert.pem for management)");
|
|
82
|
+
consoleLike.error(" 2) tunnel credentials JSON (~/.cloudflared/<TUNNEL-UUID>.json for locally-managed tunnel)");
|
|
83
|
+
consoleLike.error(" 3) tunnel token (Zero Trust Dashboard → Networks → Connectors → (tunnel) → token)");
|
|
84
|
+
consoleLike.error("Docs: https://developers.cloudflare.com/cloudflare-one/");
|
|
85
|
+
processLike.exit(1);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!domain && !hostname.includes(".")) {
|
|
89
|
+
consoleLike.error("Missing --domain for short hostname (e.g. --hostname office --domain example.com)");
|
|
90
|
+
processLike.exit(1);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (domain && !(hostname.toLowerCase() === domain.toLowerCase() || hostname.toLowerCase().endsWith(`.${domain.toLowerCase()}`))) {
|
|
94
|
+
consoleLike.error(`Hostname '${hostname}' is outside --domain '${domain}'.`);
|
|
95
|
+
processLike.exit(1);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const bin = ensureCloudflaredOrExit();
|
|
99
|
+
consoleLike.log("Cloudflare setup:");
|
|
100
|
+
consoleLike.log(` hostname: ${hostname}${inferredHostname ? " (inferred)" : ""}`);
|
|
101
|
+
if (domain) consoleLike.log(` domain: ${domain}${inferredDomain ? " (inferred)" : ""}`);
|
|
102
|
+
consoleLike.log(` tunnel-name: ${tunnelName}`);
|
|
103
|
+
consoleLike.log(` port: ${localPort}`);
|
|
104
|
+
consoleLike.log(` service: ${serviceTarget}${inferredService ? " (default)" : ""}`);
|
|
105
|
+
if (pathMatch) consoleLike.log(` path: ${pathMatch}`);
|
|
106
|
+
|
|
107
|
+
const hasCert = hasOriginCert();
|
|
108
|
+
const preflightTunnelId = hasCert ? resolveTunnelId(bin, tunnelName) : null;
|
|
109
|
+
const preflightToken = cfStateStore.hasToken();
|
|
110
|
+
const preflightCredentials =
|
|
111
|
+
(preflightTunnelId && findCredentialsForTunnel(preflightTunnelId)) ||
|
|
112
|
+
(hasCert ? readLatestCredentialsFile() : null);
|
|
113
|
+
|
|
114
|
+
const runLogin = () =>
|
|
115
|
+
new Promise((resolve, reject) => {
|
|
116
|
+
const proc = spawnFn(bin, ["tunnel", "login"], { stdio: "inherit" });
|
|
117
|
+
proc.on("exit", (code) => {
|
|
118
|
+
if (code === 0) resolve();
|
|
119
|
+
else reject(new Error("login failed"));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const runCreate = () =>
|
|
124
|
+
new Promise((resolve, reject) => {
|
|
125
|
+
const proc = spawnFn(bin, ["tunnel", "create", tunnelName], { stdio: "inherit" });
|
|
126
|
+
proc.on("exit", (code) => {
|
|
127
|
+
if (code === 0) resolve();
|
|
128
|
+
else reject(new Error("create failed"));
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const runRoute = () =>
|
|
133
|
+
new Promise((resolve, reject) => {
|
|
134
|
+
const proc = spawnFn(bin, ["tunnel", "route", "dns", tunnelName, hostname], {
|
|
135
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
136
|
+
});
|
|
137
|
+
let stderr = "";
|
|
138
|
+
proc.stdout.on("data", (chunk) => {
|
|
139
|
+
processLike.stdout.write(String(chunk));
|
|
140
|
+
});
|
|
141
|
+
proc.stderr.on("data", (chunk) => {
|
|
142
|
+
const text = String(chunk);
|
|
143
|
+
stderr += text;
|
|
144
|
+
processLike.stderr.write(text);
|
|
145
|
+
});
|
|
146
|
+
proc.on("exit", (code) => {
|
|
147
|
+
if (code === 0) {
|
|
148
|
+
resolve();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (/code:\s*1003/i.test(stderr) || /already exists/i.test(stderr)) {
|
|
152
|
+
consoleLike.error("Hint: overwrite the existing record with:");
|
|
153
|
+
consoleLike.error(` cloudflared tunnel route dns -f ${tunnelName} ${hostname}`);
|
|
154
|
+
}
|
|
155
|
+
const createdRecord = parseRouteCreatedRecord(stderr);
|
|
156
|
+
if (createdRecord && domain) {
|
|
157
|
+
const lowerDomain = domain.toLowerCase();
|
|
158
|
+
const lowerCreated = createdRecord.toLowerCase();
|
|
159
|
+
if (!(lowerCreated === lowerDomain || lowerCreated.endsWith(`.${lowerDomain}`))) {
|
|
160
|
+
reject(
|
|
161
|
+
new Error(
|
|
162
|
+
`route failed: cloudflared is targeting a different zone (${createdRecord}). Re-run 'cloudflared tunnel login' and choose zone '${domain}'.`
|
|
163
|
+
)
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
reject(new Error("route failed"));
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
(async () => {
|
|
173
|
+
const dnsLookup = await lookupDnsAddresses(hostname);
|
|
174
|
+
consoleLike.log("Preflight checks:");
|
|
175
|
+
consoleLike.log(" [1] Hostname / Application");
|
|
176
|
+
consoleLike.log(formatCheckLine(" hostname", hostname));
|
|
177
|
+
consoleLike.log(formatCheckLine(" domain", domain || "missing"));
|
|
178
|
+
consoleLike.log(formatCheckLine(" zero trust app", "needs manual check in Zero Trust dashboard"));
|
|
179
|
+
consoleLike.log(" [2] Tunnel");
|
|
180
|
+
consoleLike.log(formatCheckLine(" tunnel-name", tunnelName));
|
|
181
|
+
consoleLike.log(
|
|
182
|
+
formatCheckLine(
|
|
183
|
+
" tunnel",
|
|
184
|
+
preflightTunnelId ? `exists (${preflightTunnelId})` : hasCert ? "missing" : "unknown (login required)"
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
consoleLike.log(" [3] Tunnel ↔ DNS Route");
|
|
188
|
+
if (dnsLookup.status === "found") {
|
|
189
|
+
consoleLike.log(formatCheckLine(" dns lookup", "found (record exists; proxy may hide CNAME)"));
|
|
190
|
+
} else if (dnsLookup.status === "missing") {
|
|
191
|
+
consoleLike.log(formatCheckLine(" dns lookup", "missing"));
|
|
192
|
+
} else {
|
|
193
|
+
consoleLike.log(formatCheckLine(" dns lookup", "unknown (lookup failed)"));
|
|
194
|
+
}
|
|
195
|
+
consoleLike.log(formatCheckLine(" dns route", "needs manual check (cloudflared or Zero Trust dashboard)"));
|
|
196
|
+
consoleLike.log(formatCheckLine(" hint", `cloudflared tunnel route dns ${tunnelName} ${hostname}`));
|
|
197
|
+
consoleLike.log(formatCheckLine(" hint", `nslookup ${hostname}`));
|
|
198
|
+
consoleLike.log(" [4] Local credentials");
|
|
199
|
+
consoleLike.log(formatCheckLine(" login", hasCert ? "OK" : "required"));
|
|
200
|
+
if (preflightCredentials) {
|
|
201
|
+
consoleLike.log(formatCheckLine(" credentials", preflightCredentials));
|
|
202
|
+
} else {
|
|
203
|
+
consoleLike.log(formatCheckLine(" credentials", "missing (download JSON or use token)"));
|
|
204
|
+
}
|
|
205
|
+
consoleLike.log(formatCheckLine(" token", preflightToken ? "present" : "missing"));
|
|
206
|
+
if (hasCert && preflightTunnelId && !preflightCredentials && !preflightToken) {
|
|
207
|
+
consoleLike.log("Next steps:");
|
|
208
|
+
consoleLike.log(" - Download tunnel credentials JSON from Zero Trust → Networks → Connectors → (tunnel) and place it in ~/.cloudflared/");
|
|
209
|
+
consoleLike.log(" - Or set a tunnel token and skip credentials: npx copilot-proxy-web cloudflare token set --token <TOKEN>");
|
|
210
|
+
}
|
|
211
|
+
if (!hasCert) {
|
|
212
|
+
await runLogin();
|
|
213
|
+
}
|
|
214
|
+
const tunnelId = resolveTunnelId(bin, tunnelName);
|
|
215
|
+
const existingRouteLine = findDnsRouteLine(bin, hostname);
|
|
216
|
+
if (existingRouteLine) {
|
|
217
|
+
if (tunnelId && existingRouteLine.includes(tunnelId)) {
|
|
218
|
+
consoleLike.log(`Cloudflare DNS route already exists for ${hostname}.`);
|
|
219
|
+
} else {
|
|
220
|
+
consoleLike.error(`Hostname already has a DNS route: ${hostname}`);
|
|
221
|
+
consoleLike.error(existingRouteLine.trim());
|
|
222
|
+
consoleLike.error("Please remove or update the DNS route manually, then retry.");
|
|
223
|
+
processLike.exit(1);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!tunnelId) {
|
|
228
|
+
await runCreate();
|
|
229
|
+
}
|
|
230
|
+
const resolvedId = resolveTunnelId(bin, tunnelName);
|
|
231
|
+
const credentialsFile =
|
|
232
|
+
findCredentialsForTunnel(resolvedId) || readLatestCredentialsFile();
|
|
233
|
+
if (!credentialsFile) {
|
|
234
|
+
consoleLike.error("Failed to locate credentials file in ~/.cloudflared");
|
|
235
|
+
consoleLike.error("Download credentials from Zero Trust dashboard and place it there.");
|
|
236
|
+
processLike.exit(1);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
writeCloudflareConfigWithService({
|
|
240
|
+
tunnel: resolvedId || tunnelName,
|
|
241
|
+
credentialsFile,
|
|
242
|
+
hostname,
|
|
243
|
+
service: serviceTarget,
|
|
244
|
+
pathMatch: pathMatch || null,
|
|
245
|
+
});
|
|
246
|
+
if (!existingRouteLine) {
|
|
247
|
+
const routeTarget = resolvedId ? `${tunnelName} (${resolvedId})` : tunnelName;
|
|
248
|
+
consoleLike.log(`Routing DNS: ${hostname} -> ${routeTarget}`);
|
|
249
|
+
await runRoute();
|
|
250
|
+
}
|
|
251
|
+
cfStateStore.writeCfState({
|
|
252
|
+
tunnelName,
|
|
253
|
+
tunnelId: resolvedId || tunnelName,
|
|
254
|
+
hostname,
|
|
255
|
+
domain: domain || null,
|
|
256
|
+
localPort,
|
|
257
|
+
path: pathMatch || null,
|
|
258
|
+
service: serviceTarget,
|
|
259
|
+
credentialsFile,
|
|
260
|
+
configFile: cfConfigFile,
|
|
261
|
+
createdAt: new Date().toISOString(),
|
|
262
|
+
});
|
|
263
|
+
consoleLike.log("Cloudflare tunnel setup complete.");
|
|
264
|
+
})().catch((err) => {
|
|
265
|
+
consoleLike.error(err?.message || "cloudflare setup failed");
|
|
266
|
+
processLike.exit(1);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
cloudflareSetup,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = {
|
|
276
|
+
createCloudflareSetupService,
|
|
277
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
function createCloudflareStateStore({ fs, paths }) {
|
|
2
|
+
function readCfState() {
|
|
3
|
+
try {
|
|
4
|
+
const raw = fs.readFileSync(paths.cfStateFile, "utf8");
|
|
5
|
+
return JSON.parse(raw);
|
|
6
|
+
} catch {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function writeCfState(state) {
|
|
12
|
+
fs.mkdirSync(paths.cfRoot, { recursive: true });
|
|
13
|
+
fs.writeFileSync(paths.cfStateFile, JSON.stringify(state, null, 2));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeCfPid(pid) {
|
|
17
|
+
fs.mkdirSync(paths.cfRoot, { recursive: true });
|
|
18
|
+
fs.writeFileSync(paths.cfPidFile, String(pid));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readCfPid() {
|
|
22
|
+
try {
|
|
23
|
+
const raw = fs.readFileSync(paths.cfPidFile, "utf8");
|
|
24
|
+
const pid = Number(raw.trim());
|
|
25
|
+
return Number.isFinite(pid) ? pid : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function clearCfPid() {
|
|
32
|
+
try {
|
|
33
|
+
fs.unlinkSync(paths.cfPidFile);
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readToken() {
|
|
40
|
+
try {
|
|
41
|
+
return fs.readFileSync(paths.cfTokenFile, "utf8").trim();
|
|
42
|
+
} catch {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasToken() {
|
|
48
|
+
try {
|
|
49
|
+
fs.accessSync(paths.cfTokenFile, fs.constants.R_OK);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setToken(tokenValue) {
|
|
57
|
+
fs.mkdirSync(paths.cfRoot, { recursive: true });
|
|
58
|
+
fs.writeFileSync(paths.cfTokenFile, tokenValue, { mode: 0o600 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function clearToken() {
|
|
62
|
+
try {
|
|
63
|
+
if (fs.existsSync(paths.cfTokenFile)) fs.unlinkSync(paths.cfTokenFile);
|
|
64
|
+
} catch {
|
|
65
|
+
// ignore
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function patchCfState(patch) {
|
|
70
|
+
const current = readCfState() || {};
|
|
71
|
+
const next = { ...current, ...patch };
|
|
72
|
+
writeCfState(next);
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clean() {
|
|
77
|
+
if (fs.existsSync(paths.cfConfigFile)) fs.unlinkSync(paths.cfConfigFile);
|
|
78
|
+
if (fs.existsSync(paths.cfStateFile)) fs.unlinkSync(paths.cfStateFile);
|
|
79
|
+
if (fs.existsSync(paths.cfPidFile)) fs.unlinkSync(paths.cfPidFile);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
paths,
|
|
84
|
+
readCfState,
|
|
85
|
+
writeCfState,
|
|
86
|
+
writeCfPid,
|
|
87
|
+
readCfPid,
|
|
88
|
+
clearCfPid,
|
|
89
|
+
readToken,
|
|
90
|
+
hasToken,
|
|
91
|
+
setToken,
|
|
92
|
+
clearToken,
|
|
93
|
+
patchCfState,
|
|
94
|
+
clean,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
createCloudflareStateStore,
|
|
100
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function normalizePath(value) {
|
|
2
|
+
if (!value) return "/";
|
|
3
|
+
const trimmed = value.trim();
|
|
4
|
+
if (!trimmed) return "/";
|
|
5
|
+
if (trimmed.startsWith("/")) return trimmed;
|
|
6
|
+
return `/${trimmed}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function extractHostname(value) {
|
|
10
|
+
if (!value) return "";
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (!trimmed) return "";
|
|
13
|
+
if (trimmed.includes("://")) {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(trimmed);
|
|
16
|
+
return url.hostname;
|
|
17
|
+
} catch {
|
|
18
|
+
return trimmed.replace(/^\s+|\s+$/g, "");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return trimmed.replace(/^\s+|\s+$/g, "").replace(/\/+$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveHostnameWithDomain(rawHostname, domain) {
|
|
25
|
+
const hostname = extractHostname(rawHostname);
|
|
26
|
+
const zone = extractHostname(domain);
|
|
27
|
+
if (!zone) return { hostname, zone: "" };
|
|
28
|
+
const lowerHost = hostname.toLowerCase();
|
|
29
|
+
const lowerZone = zone.toLowerCase();
|
|
30
|
+
if (!hostname) return { hostname: "", zone: lowerZone };
|
|
31
|
+
if (lowerHost === lowerZone || lowerHost.endsWith(`.${lowerZone}`)) {
|
|
32
|
+
return { hostname, zone: lowerZone };
|
|
33
|
+
}
|
|
34
|
+
if (hostname.includes(".")) {
|
|
35
|
+
throw new Error(`Hostname '${hostname}' is outside --domain '${zone}'.`);
|
|
36
|
+
}
|
|
37
|
+
return { hostname: `${hostname}.${zone}`, zone: lowerZone };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inferDomainFromHostname(rawHostname) {
|
|
41
|
+
const hostname = extractHostname(rawHostname);
|
|
42
|
+
if (!hostname) return "";
|
|
43
|
+
if (hostname.includes(":")) return "";
|
|
44
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return "";
|
|
45
|
+
const parts = hostname.split(".").filter(Boolean);
|
|
46
|
+
if (parts.length < 2) return "";
|
|
47
|
+
return parts.slice(-2).join(".");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatCheckLine(label, value) {
|
|
51
|
+
const text = value ? String(value) : "missing";
|
|
52
|
+
return ` ${label}: ${text}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseRouteCreatedRecord(stderrText) {
|
|
56
|
+
if (!stderrText) return null;
|
|
57
|
+
const match = stderrText.match(/Failed to create record\s+([^\s]+)\s+with err/i);
|
|
58
|
+
if (!match) return null;
|
|
59
|
+
return extractHostname(match[1]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
normalizePath,
|
|
64
|
+
extractHostname,
|
|
65
|
+
resolveHostnameWithDomain,
|
|
66
|
+
inferDomainFromHostname,
|
|
67
|
+
formatCheckLine,
|
|
68
|
+
parseRouteCreatedRecord,
|
|
69
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
function detectProfileFromChunk(chunk = "", state = null) {
|
|
2
|
+
const text = String(chunk);
|
|
3
|
+
const hasCopilotVersion = /GitHub Copilot v/i.test(text);
|
|
4
|
+
const hasPromptMarker = text.includes("Type @ to mention files");
|
|
5
|
+
|
|
6
|
+
if (state && typeof state === "object") {
|
|
7
|
+
if (hasCopilotVersion) state.seenVersion = true;
|
|
8
|
+
if (hasPromptMarker) state.seenPrompt = true;
|
|
9
|
+
if (state.seenVersion && state.seenPrompt) return "copilot";
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (hasCopilotVersion && hasPromptMarker) {
|
|
14
|
+
return "copilot";
|
|
15
|
+
}
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function filterConversationLines(lines, profile = "none") {
|
|
20
|
+
if (profile !== "copilot") return lines;
|
|
21
|
+
|
|
22
|
+
const isSeparatorLine = (value) => /^[─━\-]+$/.test(value.replace(/\s+/g, ""));
|
|
23
|
+
const isThinkingLine = (value) =>
|
|
24
|
+
/^[◐◑◒◓◎◉∙•●]\s+Thinking \(Esc to cancel/.test(value.trim());
|
|
25
|
+
const isPromptOrFooterLine = (value) => {
|
|
26
|
+
const t = value.trim();
|
|
27
|
+
if (!t) return true;
|
|
28
|
+
if (isSeparatorLine(t)) return true;
|
|
29
|
+
if (t.startsWith("❯")) return true;
|
|
30
|
+
if (t.includes("Type @ to mention files")) return true;
|
|
31
|
+
if (t.includes("shift+tab cycle mode")) return true;
|
|
32
|
+
if (t.includes("Remaining requests")) return true;
|
|
33
|
+
if (/^[/~].*\[[^\]]+\]/.test(t)) return true;
|
|
34
|
+
return false;
|
|
35
|
+
};
|
|
36
|
+
const stripPromptBlockFromBottom = (inputLines) => {
|
|
37
|
+
if (!Array.isArray(inputLines) || inputLines.length < 4) return inputLines;
|
|
38
|
+
const out = inputLines.slice();
|
|
39
|
+
let n = -1;
|
|
40
|
+
for (let i = out.length - 1; i >= 0; i -= 1) {
|
|
41
|
+
const t = String(out[i] || "").trim();
|
|
42
|
+
if (t.includes("shift+tab cycle mode")) {
|
|
43
|
+
n = i;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (n < 2) return out;
|
|
48
|
+
const sepBelowPrompt = String(out[n - 1] || "").trim();
|
|
49
|
+
const promptLine = String(out[n - 2] || "").trim();
|
|
50
|
+
if (!isSeparatorLine(sepBelowPrompt)) return out;
|
|
51
|
+
if (!(promptLine.startsWith("❯") || promptLine.includes("Type @ to mention files"))) return out;
|
|
52
|
+
let m = -1;
|
|
53
|
+
for (let i = n - 3; i >= 0; i -= 1) {
|
|
54
|
+
if (isSeparatorLine(String(out[i] || "").trim())) {
|
|
55
|
+
m = i;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (m < 0 || m >= n - 1) return out;
|
|
60
|
+
out.splice(m, (n - 1) - m + 1);
|
|
61
|
+
return out;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const out = stripPromptBlockFromBottom(lines);
|
|
65
|
+
const trimmed = out.map((line) => String(line || "").trim()).filter(Boolean);
|
|
66
|
+
if (!trimmed.length) return out;
|
|
67
|
+
const thinking = trimmed.filter((line) => isThinkingLine(line));
|
|
68
|
+
const informative = trimmed.filter(
|
|
69
|
+
(line) => !isThinkingLine(line) && !isPromptOrFooterLine(line)
|
|
70
|
+
);
|
|
71
|
+
if (thinking.length && informative.length === 0) {
|
|
72
|
+
return [thinking[thinking.length - 1]];
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
detectProfileFromChunk,
|
|
79
|
+
filterConversationLines,
|
|
80
|
+
};
|