@iamken/cloudtunnel 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/README.md +57 -0
- package/dist/chunk-2TCFCMJS.js +27 -0
- package/dist/chunk-2TCFCMJS.js.map +1 -0
- package/dist/chunk-UPBVRXLF.js +146 -0
- package/dist/chunk-UPBVRXLF.js.map +1 -0
- package/dist/chunk-YLTQB4F7.js +57 -0
- package/dist/chunk-YLTQB4F7.js.map +1 -0
- package/dist/dns-PAPFSYFP.js +21 -0
- package/dist/dns-PAPFSYFP.js.map +1 -0
- package/dist/index.js +1245 -0
- package/dist/index.js.map +1 -0
- package/dist/zones-YNGQYXAF.js +11 -0
- package/dist/zones-YNGQYXAF.js.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
listZones,
|
|
4
|
+
resolveZone
|
|
5
|
+
} from "./chunk-2TCFCMJS.js";
|
|
6
|
+
import {
|
|
7
|
+
createCname,
|
|
8
|
+
deleteDnsRecord,
|
|
9
|
+
findCname,
|
|
10
|
+
isManagedDns
|
|
11
|
+
} from "./chunk-YLTQB4F7.js";
|
|
12
|
+
import {
|
|
13
|
+
CliError,
|
|
14
|
+
binDir,
|
|
15
|
+
cfPaginate,
|
|
16
|
+
cfRequest,
|
|
17
|
+
configFile,
|
|
18
|
+
ensureDirs,
|
|
19
|
+
getCredentials,
|
|
20
|
+
loadConfig,
|
|
21
|
+
logDir,
|
|
22
|
+
profilesFile,
|
|
23
|
+
registryFile,
|
|
24
|
+
reportError,
|
|
25
|
+
resolveCf,
|
|
26
|
+
saveConfig
|
|
27
|
+
} from "./chunk-UPBVRXLF.js";
|
|
28
|
+
|
|
29
|
+
// src/index.ts
|
|
30
|
+
import { Command } from "commander";
|
|
31
|
+
import { createRequire } from "module";
|
|
32
|
+
import pc2 from "picocolors";
|
|
33
|
+
|
|
34
|
+
// src/commands/login.ts
|
|
35
|
+
import * as clack from "@clack/prompts";
|
|
36
|
+
|
|
37
|
+
// src/ui/output.ts
|
|
38
|
+
import pc from "picocolors";
|
|
39
|
+
import Table from "cli-table3";
|
|
40
|
+
import { cancel, intro, isCancel, note, outro, select, spinner } from "@clack/prompts";
|
|
41
|
+
function redactToken(token) {
|
|
42
|
+
if (!token) return "";
|
|
43
|
+
const last4 = token.length > 4 ? token.slice(-4) : token;
|
|
44
|
+
return `\u2022\u2022\u2022\u2022${last4}`;
|
|
45
|
+
}
|
|
46
|
+
var say = {
|
|
47
|
+
info: (msg) => console.log(msg),
|
|
48
|
+
ok: (msg) => console.log(pc.green(`\u2713 ${msg}`)),
|
|
49
|
+
warn: (msg) => console.warn(pc.yellow(`! ${msg}`)),
|
|
50
|
+
dim: (msg) => console.log(pc.dim(msg)),
|
|
51
|
+
step: (msg) => console.log(pc.cyan(`\u2192 ${msg}`))
|
|
52
|
+
};
|
|
53
|
+
var dim = (s) => pc.dim(s);
|
|
54
|
+
function formatRoute(host, target) {
|
|
55
|
+
return `${pc.green(pc.bold(`https://${host}`))} ${pc.dim("\u2192")} ${pc.cyan(target)}`;
|
|
56
|
+
}
|
|
57
|
+
function printTable(head, rows) {
|
|
58
|
+
const table = new Table({
|
|
59
|
+
head: head.map((h) => pc.bold(h)),
|
|
60
|
+
style: { head: [], border: [] }
|
|
61
|
+
});
|
|
62
|
+
for (const row of rows) table.push(row);
|
|
63
|
+
console.log(table.toString());
|
|
64
|
+
}
|
|
65
|
+
async function selectOne(message, items, label) {
|
|
66
|
+
const value = await select({
|
|
67
|
+
message,
|
|
68
|
+
options: items.map((item, i) => ({ value: String(i), label: label(item) }))
|
|
69
|
+
});
|
|
70
|
+
if (isCancel(value)) {
|
|
71
|
+
cancel("Cancelled.");
|
|
72
|
+
throw new CliError("Cancelled.", { exitCode: 130 });
|
|
73
|
+
}
|
|
74
|
+
return items[Number(value)];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/config/token-url.ts
|
|
78
|
+
import { spawn } from "child_process";
|
|
79
|
+
var REQUIRED_SCOPES = [
|
|
80
|
+
"Account \xB7 Cloudflare Tunnel \xB7 Edit",
|
|
81
|
+
"Account \xB7 Account Settings \xB7 Read",
|
|
82
|
+
"Zone \xB7 DNS \xB7 Edit",
|
|
83
|
+
"Zone \xB7 Zone \xB7 Read"
|
|
84
|
+
];
|
|
85
|
+
function tokenCreateUrl() {
|
|
86
|
+
return "https://dash.cloudflare.com/profile/api-tokens?name=cloudtunnel";
|
|
87
|
+
}
|
|
88
|
+
function openBrowser(url) {
|
|
89
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
90
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
91
|
+
try {
|
|
92
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
93
|
+
child.on("error", () => {
|
|
94
|
+
});
|
|
95
|
+
child.unref();
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/config/resolve-identity.ts
|
|
101
|
+
var API_BASE = "https://api.cloudflare.com/client/v4";
|
|
102
|
+
async function cfGet(path, token) {
|
|
103
|
+
let res;
|
|
104
|
+
try {
|
|
105
|
+
res = await fetch(`${API_BASE}${path}`, {
|
|
106
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
throw new CliError("Could not reach the Cloudflare API (network error).");
|
|
110
|
+
}
|
|
111
|
+
if (res.status === 401) {
|
|
112
|
+
throw new CliError("Cloudflare rejected the token (invalid or expired).", {
|
|
113
|
+
hint: `mint a new token: ${tokenCreateUrl()}`
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (res.status === 403) {
|
|
117
|
+
throw new CliError(`Token is missing a required scope for ${path}.`, {
|
|
118
|
+
hint: `token needs: ${REQUIRED_SCOPES.join(", ")}`
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const body = await res.json().catch(() => ({}));
|
|
122
|
+
if (!res.ok || !body.success) {
|
|
123
|
+
throw new CliError(`Cloudflare API error (${res.status}) on ${path}.`);
|
|
124
|
+
}
|
|
125
|
+
return body.result ?? [];
|
|
126
|
+
}
|
|
127
|
+
function listAccounts(token) {
|
|
128
|
+
return cfGet("/accounts?per_page=50", token);
|
|
129
|
+
}
|
|
130
|
+
function listZones2(token) {
|
|
131
|
+
return cfGet("/zones?per_page=50", token);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/commands/login.ts
|
|
135
|
+
async function readStdin() {
|
|
136
|
+
const chunks = [];
|
|
137
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
138
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
139
|
+
}
|
|
140
|
+
async function acquireToken(opts) {
|
|
141
|
+
const envToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
142
|
+
if (envToken) {
|
|
143
|
+
say.dim("Using token from CLOUDFLARE_API_TOKEN.");
|
|
144
|
+
return { token: envToken, fromEnv: true };
|
|
145
|
+
}
|
|
146
|
+
if (opts.tokenStdin) return { token: await readStdin(), fromEnv: false };
|
|
147
|
+
if (opts.token) {
|
|
148
|
+
say.warn("--token puts the token in your shell history \u2014 prefer --token-stdin or the prompt. Rotate it if this is a shared host.");
|
|
149
|
+
return { token: opts.token, fromEnv: false };
|
|
150
|
+
}
|
|
151
|
+
if (!process.stdin.isTTY) {
|
|
152
|
+
throw new CliError("No token provided and no interactive terminal.", {
|
|
153
|
+
hint: "pipe it: `printf %s $TOKEN | cloudtunnel login --token-stdin`"
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
clack.note(REQUIRED_SCOPES.map((s) => `\u2022 ${s}`).join("\n"), "Create a token with these scopes");
|
|
157
|
+
openBrowser(tokenCreateUrl());
|
|
158
|
+
say.dim(`(opened ${tokenCreateUrl()})`);
|
|
159
|
+
const token = await clack.password({ message: "Paste your Cloudflare API token", mask: "\u2022" });
|
|
160
|
+
if (clack.isCancel(token) || !token) {
|
|
161
|
+
clack.cancel("Cancelled.");
|
|
162
|
+
throw new CliError("Cancelled.", { exitCode: 130 });
|
|
163
|
+
}
|
|
164
|
+
return { token, fromEnv: false };
|
|
165
|
+
}
|
|
166
|
+
async function runLoginFlow(opts = {}) {
|
|
167
|
+
if (process.stdout.isTTY) clack.intro("cloudtunnel \xB7 connect to Cloudflare");
|
|
168
|
+
const { token, fromEnv } = await acquireToken(opts);
|
|
169
|
+
const spin = clack.spinner();
|
|
170
|
+
spin.start("Verifying token\u2026");
|
|
171
|
+
const [accounts, zones] = await Promise.all([listAccounts(token), listZones2(token)]).catch((err) => {
|
|
172
|
+
spin.stop("Token check failed");
|
|
173
|
+
throw err;
|
|
174
|
+
});
|
|
175
|
+
spin.stop("Token verified");
|
|
176
|
+
if (accounts.length === 0) throw new CliError("Token can't see any Cloudflare account.");
|
|
177
|
+
let account = opts.account ? accounts.find((a) => a.id === opts.account) : void 0;
|
|
178
|
+
if (opts.account && !account) throw new CliError(`Account ${opts.account} not visible to this token.`);
|
|
179
|
+
if (!account) {
|
|
180
|
+
account = accounts.length === 1 || !process.stdin.isTTY ? accounts[0] : await selectOne("Select an account", accounts, (a) => `${a.name} (${a.id})`);
|
|
181
|
+
}
|
|
182
|
+
let defaultZone = opts.zone;
|
|
183
|
+
if (!defaultZone) {
|
|
184
|
+
if (zones.length === 1) defaultZone = zones[0].name;
|
|
185
|
+
else if (zones.length > 1 && process.stdin.isTTY) {
|
|
186
|
+
defaultZone = (await selectOne("Select a default domain", zones, (z) => z.name)).name;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
saveConfig({ apiToken: fromEnv ? void 0 : token, accountId: account.id, defaultZone });
|
|
190
|
+
const summary = `Logged in as ${account.name}${defaultZone ? ` \xB7 default domain ${defaultZone}` : ""}`;
|
|
191
|
+
if (process.stdout.isTTY) clack.outro(summary);
|
|
192
|
+
else say.ok(summary);
|
|
193
|
+
if (!defaultZone) say.dim("No default domain set \u2014 pass -d <domain> on `up`, or re-run `login --zone <domain>`.");
|
|
194
|
+
}
|
|
195
|
+
function showStatus() {
|
|
196
|
+
const config = loadConfig();
|
|
197
|
+
const token = process.env.CLOUDFLARE_API_TOKEN ?? config.apiToken;
|
|
198
|
+
if (!token) {
|
|
199
|
+
say.warn("Not logged in. Run `cloudtunnel login`.");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const source = process.env.CLOUDFLARE_API_TOKEN ? "env" : "config";
|
|
203
|
+
say.info(`Token: ${redactToken(token)} (${source})`);
|
|
204
|
+
say.info(`Account: ${config.accountId ?? "(from env / unresolved)"}`);
|
|
205
|
+
say.info(`Domain: ${config.defaultZone ?? "(none)"}`);
|
|
206
|
+
say.dim(`Config: ${configFile}`);
|
|
207
|
+
}
|
|
208
|
+
function registerLogin(program) {
|
|
209
|
+
program.command("login").description("Authenticate with Cloudflare (paste a token once; account + domain auto-resolved)").option("--token-stdin", "read the API token from stdin (scriptable, avoids shell history)").option("--token <token>", "[discouraged] token as an argument (leaks into shell history)").option("--account <id>", "Cloudflare account id (auto-resolved when you have one account)").option("--zone <domain>", "default domain for new tunnels (auto-resolved when you have one)").option("--status", "show current identity (redacted) and exit").action(async (opts) => {
|
|
210
|
+
if (opts.status) return showStatus();
|
|
211
|
+
await runLoginFlow(opts);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/commands/up.ts
|
|
216
|
+
import { join as join2 } from "path";
|
|
217
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
218
|
+
import * as clack2 from "@clack/prompts";
|
|
219
|
+
|
|
220
|
+
// src/config/ensure-auth.ts
|
|
221
|
+
async function ensureAuth() {
|
|
222
|
+
try {
|
|
223
|
+
return getCredentials();
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err instanceof CliError && process.stdin.isTTY) {
|
|
226
|
+
say.info("Welcome to cloudtunnel \u2014 let's get you connected to Cloudflare first.");
|
|
227
|
+
await runLoginFlow();
|
|
228
|
+
return getCredentials();
|
|
229
|
+
}
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/connector/binary.ts
|
|
235
|
+
import { execFileSync } from "child_process";
|
|
236
|
+
import { createHash } from "crypto";
|
|
237
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "fs";
|
|
238
|
+
import { join } from "path";
|
|
239
|
+
var PINNED_VERSION = "2025.1.0";
|
|
240
|
+
var RELEASE_BASE = `https://github.com/cloudflare/cloudflared/releases/download/${PINNED_VERSION}`;
|
|
241
|
+
var ASSETS = {
|
|
242
|
+
"linux-x64": { file: "cloudflared-linux-amd64", archive: false, sha256: "" },
|
|
243
|
+
"linux-arm64": { file: "cloudflared-linux-arm64", archive: false, sha256: "" },
|
|
244
|
+
"darwin-x64": { file: "cloudflared-darwin-amd64.tgz", archive: true, sha256: "" },
|
|
245
|
+
"darwin-arm64": { file: "cloudflared-darwin-arm64.tgz", archive: true, sha256: "" },
|
|
246
|
+
"win32-x64": { file: "cloudflared-windows-amd64.exe", archive: false, sha256: "" }
|
|
247
|
+
};
|
|
248
|
+
function binaryWorks(bin) {
|
|
249
|
+
try {
|
|
250
|
+
execFileSync(bin, ["--version"], { stdio: "ignore" });
|
|
251
|
+
return true;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function cachedPath() {
|
|
257
|
+
return join(binDir, process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
|
|
258
|
+
}
|
|
259
|
+
function isMusl() {
|
|
260
|
+
try {
|
|
261
|
+
return process.platform === "linux" && readFileSync("/usr/bin/ldd", "utf8").includes("musl");
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function ensureCloudflared() {
|
|
267
|
+
if (binaryWorks("cloudflared")) return "cloudflared";
|
|
268
|
+
const cached = cachedPath();
|
|
269
|
+
if (existsSync(cached) && binaryWorks(cached)) return cached;
|
|
270
|
+
return downloadCloudflared(cached);
|
|
271
|
+
}
|
|
272
|
+
async function downloadCloudflared(dest) {
|
|
273
|
+
if (isMusl()) {
|
|
274
|
+
throw new CliError("cloudflared has no musl (Alpine) build.", {
|
|
275
|
+
hint: "install it manually: https://github.com/cloudflare/cloudflared/releases"
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const key = `${process.platform}-${process.arch}`;
|
|
279
|
+
const asset = ASSETS[key];
|
|
280
|
+
if (!asset || !asset.sha256) {
|
|
281
|
+
throw new CliError(`Auto-install unavailable for ${key} (no pinned checksum).`, {
|
|
282
|
+
hint: "install cloudflared manually: https://github.com/cloudflare/cloudflared/releases"
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
say.step(`cloudflared not found \u2014 downloading v${PINNED_VERSION} (checksum-verified)\u2026`);
|
|
286
|
+
const res = await fetch(`${RELEASE_BASE}/${asset.file}`);
|
|
287
|
+
if (!res.ok) throw new CliError(`Download failed (HTTP ${res.status}).`);
|
|
288
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
289
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
290
|
+
if (digest !== asset.sha256) {
|
|
291
|
+
throw new CliError("cloudflared checksum mismatch \u2014 refusing to run the download.", {
|
|
292
|
+
hint: "network tampering or an outdated pin; install manually instead"
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
ensureDirs();
|
|
296
|
+
const binary = asset.archive ? extractTgz(bytes) : bytes;
|
|
297
|
+
writeFileSync(dest, binary, { mode: 493 });
|
|
298
|
+
chmodSync(dest, 493);
|
|
299
|
+
if (!binaryWorks(dest)) throw new CliError("Downloaded cloudflared is not runnable.");
|
|
300
|
+
return dest;
|
|
301
|
+
}
|
|
302
|
+
function extractTgz(_bytes) {
|
|
303
|
+
throw new CliError("darwin .tgz extraction not yet wired.", {
|
|
304
|
+
hint: "install cloudflared via `brew install cloudflared`"
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/connector/process.ts
|
|
309
|
+
import { execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
|
|
310
|
+
import { openSync } from "fs";
|
|
311
|
+
|
|
312
|
+
// src/connector/registry.ts
|
|
313
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
|
|
314
|
+
import { readFile } from "fs/promises";
|
|
315
|
+
import os from "os";
|
|
316
|
+
import lockfile from "proper-lockfile";
|
|
317
|
+
function currentBootId() {
|
|
318
|
+
try {
|
|
319
|
+
return readFileSync2("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
320
|
+
} catch {
|
|
321
|
+
return `uptime-${Math.round(os.uptime())}-${os.hostname()}`;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function readRegistry() {
|
|
325
|
+
try {
|
|
326
|
+
return JSON.parse(readFileSync2(registryFile, "utf8"));
|
|
327
|
+
} catch {
|
|
328
|
+
return {};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function writeRegistry(reg) {
|
|
332
|
+
ensureDirs();
|
|
333
|
+
const tmp = `${registryFile}.tmp`;
|
|
334
|
+
writeFileSync2(tmp, JSON.stringify(reg, null, 2), { mode: 384 });
|
|
335
|
+
renameSync(tmp, registryFile);
|
|
336
|
+
}
|
|
337
|
+
async function mutateRegistry(fn) {
|
|
338
|
+
ensureDirs();
|
|
339
|
+
if (!existsSync2(registryFile)) writeFileSync2(registryFile, "{}", { mode: 384 });
|
|
340
|
+
const release = await lockfile.lock(registryFile, { retries: { retries: 10, minTimeout: 50 } });
|
|
341
|
+
try {
|
|
342
|
+
const reg = readRegistry();
|
|
343
|
+
const result = fn(reg);
|
|
344
|
+
writeRegistry(reg);
|
|
345
|
+
return result;
|
|
346
|
+
} finally {
|
|
347
|
+
await release();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function listEntries() {
|
|
351
|
+
return Object.values(readRegistry());
|
|
352
|
+
}
|
|
353
|
+
function getEntry(fqdn) {
|
|
354
|
+
return readRegistry()[fqdn];
|
|
355
|
+
}
|
|
356
|
+
function upsertEntry(fqdn, patch) {
|
|
357
|
+
return mutateRegistry((reg) => {
|
|
358
|
+
const prev = reg[fqdn];
|
|
359
|
+
reg[fqdn] = {
|
|
360
|
+
createdAt: prev?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
361
|
+
state: "provisioning",
|
|
362
|
+
...prev,
|
|
363
|
+
...patch
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
function patchEntry(fqdn, patch) {
|
|
368
|
+
return mutateRegistry((reg) => {
|
|
369
|
+
const prev = reg[fqdn];
|
|
370
|
+
if (prev) reg[fqdn] = { ...prev, ...patch };
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
function removeEntry(fqdn) {
|
|
374
|
+
return mutateRegistry((reg) => {
|
|
375
|
+
delete reg[fqdn];
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function pidAlive(pid) {
|
|
379
|
+
try {
|
|
380
|
+
process.kill(pid, 0);
|
|
381
|
+
return true;
|
|
382
|
+
} catch {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function isOurConnector(entry) {
|
|
387
|
+
if (!entry.pid || entry.bootId !== currentBootId()) return false;
|
|
388
|
+
if (!pidAlive(entry.pid)) return false;
|
|
389
|
+
if (process.platform === "linux") {
|
|
390
|
+
try {
|
|
391
|
+
const cmdline = await readFile(`/proc/${entry.pid}/cmdline`, "utf8");
|
|
392
|
+
return cmdline.includes("cloudflared");
|
|
393
|
+
} catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
async function reconcile() {
|
|
400
|
+
const entries = listEntries();
|
|
401
|
+
for (const entry of entries) {
|
|
402
|
+
if (entry.state === "running" && !await isOurConnector(entry)) {
|
|
403
|
+
const fqdn = `${entry.subdomain}.${entry.zone}`;
|
|
404
|
+
await mutateRegistry((reg) => {
|
|
405
|
+
const e = reg[fqdn];
|
|
406
|
+
if (e) {
|
|
407
|
+
e.state = "stopped";
|
|
408
|
+
delete e.pid;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return listEntries();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/connector/process.ts
|
|
417
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
418
|
+
function startConnector(opts) {
|
|
419
|
+
const args = ["tunnel", "run"];
|
|
420
|
+
const env = { ...process.env, TUNNEL_TOKEN: opts.token };
|
|
421
|
+
const fd = openSync(opts.logFile, "a", 384);
|
|
422
|
+
const child = spawn2(opts.bin, args, { env, detached: opts.detach, stdio: ["ignore", fd, fd] });
|
|
423
|
+
if (!child.pid) throw new CliError("Failed to start the cloudflared connector.");
|
|
424
|
+
if (opts.detach) {
|
|
425
|
+
child.unref();
|
|
426
|
+
return { pid: child.pid };
|
|
427
|
+
}
|
|
428
|
+
child.on("exit", (code) => opts.onExit?.(code));
|
|
429
|
+
child.on("error", () => opts.onExit?.(1));
|
|
430
|
+
return { pid: child.pid, child };
|
|
431
|
+
}
|
|
432
|
+
async function stopConnector(entry) {
|
|
433
|
+
if (!entry.pid || !await isOurConnector(entry)) return false;
|
|
434
|
+
const pid = entry.pid;
|
|
435
|
+
if (process.platform === "win32") {
|
|
436
|
+
try {
|
|
437
|
+
execFileSync2("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
process.kill(pid, "SIGTERM");
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
await sleep(3e3);
|
|
449
|
+
if (await isOurConnector(entry)) {
|
|
450
|
+
try {
|
|
451
|
+
process.kill(pid, "SIGKILL");
|
|
452
|
+
} catch {
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/cloudflare/tunnels.ts
|
|
459
|
+
var MANAGED_TUNNEL_PREFIX = "ct-";
|
|
460
|
+
function isManagedTunnel(tunnel) {
|
|
461
|
+
return tunnel.name.startsWith(MANAGED_TUNNEL_PREFIX);
|
|
462
|
+
}
|
|
463
|
+
async function createTunnel(cf, name) {
|
|
464
|
+
const env = await cfRequest(cf.token, "POST", `/accounts/${cf.accountId}/cfd_tunnel`, {
|
|
465
|
+
name,
|
|
466
|
+
config_src: "cloudflare"
|
|
467
|
+
});
|
|
468
|
+
return env.result;
|
|
469
|
+
}
|
|
470
|
+
function listTunnels(cf) {
|
|
471
|
+
return cfPaginate(cf.token, `/accounts/${cf.accountId}/cfd_tunnel?is_deleted=false`);
|
|
472
|
+
}
|
|
473
|
+
async function getTunnel(cf, id) {
|
|
474
|
+
return (await cfRequest(cf.token, "GET", `/accounts/${cf.accountId}/cfd_tunnel/${id}`)).result;
|
|
475
|
+
}
|
|
476
|
+
async function deleteTunnel(cf, id) {
|
|
477
|
+
await cfRequest(cf.token, "DELETE", `/accounts/${cf.accountId}/cfd_tunnel/${id}`);
|
|
478
|
+
}
|
|
479
|
+
async function getTunnelToken(cf, id) {
|
|
480
|
+
return (await cfRequest(cf.token, "GET", `/accounts/${cf.accountId}/cfd_tunnel/${id}/token`)).result;
|
|
481
|
+
}
|
|
482
|
+
async function putIngress(cf, id, ingress) {
|
|
483
|
+
await cfRequest(cf.token, "PUT", `/accounts/${cf.accountId}/cfd_tunnel/${id}/configurations`, {
|
|
484
|
+
config: { ingress }
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
async function getConnections(cf, id) {
|
|
488
|
+
const env = await cfRequest(
|
|
489
|
+
cf.token,
|
|
490
|
+
"GET",
|
|
491
|
+
`/accounts/${cf.accountId}/cfd_tunnel/${id}/connections`
|
|
492
|
+
);
|
|
493
|
+
return env.result ?? [];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/connector/health.ts
|
|
497
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
498
|
+
async function waitHealthy(cf, tunnelId, opts = {}) {
|
|
499
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 3e4);
|
|
500
|
+
while (Date.now() < deadline) {
|
|
501
|
+
if (opts.signal?.aborted) return "dead";
|
|
502
|
+
try {
|
|
503
|
+
const connections = await getConnections(cf, tunnelId);
|
|
504
|
+
if (connections.length > 0) return "healthy";
|
|
505
|
+
} catch {
|
|
506
|
+
}
|
|
507
|
+
await sleep2(2e3);
|
|
508
|
+
}
|
|
509
|
+
return opts.signal?.aborted ? "dead" : "provisioning";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/core/orchestrator-create.ts
|
|
513
|
+
import { randomInt as randomInt2 } from "crypto";
|
|
514
|
+
|
|
515
|
+
// src/core/ingress.ts
|
|
516
|
+
function buildIngress(opts) {
|
|
517
|
+
return [
|
|
518
|
+
{ hostname: opts.hostname, service: `${opts.proto}://localhost:${opts.port}` },
|
|
519
|
+
{ service: "http_status:404" }
|
|
520
|
+
];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/core/slug.ts
|
|
524
|
+
import { randomInt } from "crypto";
|
|
525
|
+
var ADJECTIVES = [
|
|
526
|
+
"brave",
|
|
527
|
+
"calm",
|
|
528
|
+
"clever",
|
|
529
|
+
"eager",
|
|
530
|
+
"gentle",
|
|
531
|
+
"happy",
|
|
532
|
+
"jolly",
|
|
533
|
+
"kind",
|
|
534
|
+
"lively",
|
|
535
|
+
"mighty",
|
|
536
|
+
"nimble",
|
|
537
|
+
"proud",
|
|
538
|
+
"quick",
|
|
539
|
+
"royal",
|
|
540
|
+
"swift",
|
|
541
|
+
"witty"
|
|
542
|
+
];
|
|
543
|
+
var NOUNS = [
|
|
544
|
+
"otter",
|
|
545
|
+
"falcon",
|
|
546
|
+
"maple",
|
|
547
|
+
"comet",
|
|
548
|
+
"harbor",
|
|
549
|
+
"lynx",
|
|
550
|
+
"willow",
|
|
551
|
+
"cedar",
|
|
552
|
+
"raven",
|
|
553
|
+
"meadow",
|
|
554
|
+
"pixel",
|
|
555
|
+
"quartz",
|
|
556
|
+
"river",
|
|
557
|
+
"sparrow",
|
|
558
|
+
"tiger",
|
|
559
|
+
"walnut"
|
|
560
|
+
];
|
|
561
|
+
var pick = (arr) => arr[randomInt(arr.length)];
|
|
562
|
+
function randomSlug() {
|
|
563
|
+
const suffix = randomInt(65536).toString(16).padStart(4, "0");
|
|
564
|
+
return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${suffix}`;
|
|
565
|
+
}
|
|
566
|
+
function resolveHostSpec(opts, defaultZone) {
|
|
567
|
+
if (opts.hostname) {
|
|
568
|
+
const dot = opts.hostname.indexOf(".");
|
|
569
|
+
if (dot <= 0) throw new CliError(`Invalid hostname: ${opts.hostname}`);
|
|
570
|
+
return {
|
|
571
|
+
subdomain: opts.hostname.slice(0, dot),
|
|
572
|
+
zone: opts.hostname.slice(dot + 1),
|
|
573
|
+
hostname: opts.hostname
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
const zone = opts.zone ?? defaultZone;
|
|
577
|
+
if (!zone) {
|
|
578
|
+
throw new CliError("No zone specified and no default zone set.", {
|
|
579
|
+
hint: "pass --zone <domain>, or run `cloudtunnel login --zone <domain>`"
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const subdomain = opts.name ?? randomSlug();
|
|
583
|
+
return { subdomain, zone, hostname: `${subdomain}.${zone}` };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/core/orchestrator-create.ts
|
|
587
|
+
var tunnelIdFromCname = (content) => content.replace(/\.cfargotunnel\.com\.?$/, "");
|
|
588
|
+
async function createTunnelSubdomain(cf, opts) {
|
|
589
|
+
const host = resolveHostSpec(opts, opts.defaultZone);
|
|
590
|
+
const zone = await resolveZone(cf.token, host.zone);
|
|
591
|
+
const existing = await findCname(cf.token, zone.id, host.hostname);
|
|
592
|
+
if (existing) {
|
|
593
|
+
if (isManagedDns(existing) && !opts.force) {
|
|
594
|
+
const tunnelId2 = tunnelIdFromCname(existing.content);
|
|
595
|
+
const token = await getTunnelToken(cf, tunnelId2);
|
|
596
|
+
await putIngress(cf, tunnelId2, buildIngress({ hostname: host.hostname, port: opts.port, proto: opts.proto }));
|
|
597
|
+
await recordRunning(host, zone.id, tunnelId2, existing.id, opts);
|
|
598
|
+
say.dim(`Re-attaching to existing tunnel for ${host.hostname}.`);
|
|
599
|
+
return { host, tunnelId: tunnelId2, token, adopted: true };
|
|
600
|
+
}
|
|
601
|
+
if (!opts.force) {
|
|
602
|
+
throw new CliError(`${host.hostname} is already taken by a record not managed by cloudtunnel.`, {
|
|
603
|
+
hint: "pick another --subdomain/--hostname, or pass -f/--force to take it over"
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
await releaseHostname(cf, zone.id, existing);
|
|
607
|
+
say.dim(`Released ${host.hostname} (--force) \u2014 recreating.`);
|
|
608
|
+
}
|
|
609
|
+
await upsertEntry(host.hostname, {
|
|
610
|
+
subdomain: host.subdomain,
|
|
611
|
+
zone: host.zone,
|
|
612
|
+
zoneId: zone.id,
|
|
613
|
+
port: opts.port,
|
|
614
|
+
proto: opts.proto,
|
|
615
|
+
state: "provisioning"
|
|
616
|
+
});
|
|
617
|
+
let tunnelId;
|
|
618
|
+
let dnsRecordId;
|
|
619
|
+
try {
|
|
620
|
+
const suffix = randomInt2(65536).toString(16).padStart(4, "0");
|
|
621
|
+
const tunnel = await createTunnel(cf, `${MANAGED_TUNNEL_PREFIX}${host.subdomain}-${suffix}`);
|
|
622
|
+
tunnelId = tunnel.id;
|
|
623
|
+
const token = await getTunnelToken(cf, tunnelId);
|
|
624
|
+
await putIngress(cf, tunnelId, buildIngress({ hostname: host.hostname, port: opts.port, proto: opts.proto }));
|
|
625
|
+
const record = await createCname(cf.token, zone.id, host.hostname, tunnelId);
|
|
626
|
+
dnsRecordId = record.id;
|
|
627
|
+
await recordRunning(host, zone.id, tunnelId, dnsRecordId, opts);
|
|
628
|
+
return { host, tunnelId, token, adopted: false };
|
|
629
|
+
} catch (err) {
|
|
630
|
+
const clean = await rollback(cf, zone.id, tunnelId, dnsRecordId, host.hostname);
|
|
631
|
+
if (clean) await removeEntry(host.hostname);
|
|
632
|
+
else await patchEntry(host.hostname, { state: "orphaned" });
|
|
633
|
+
throw err;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function recordRunning(host, zoneId, tunnelId, dnsRecordId, opts) {
|
|
637
|
+
await upsertEntry(host.hostname, {
|
|
638
|
+
subdomain: host.subdomain,
|
|
639
|
+
zone: host.zone,
|
|
640
|
+
zoneId,
|
|
641
|
+
tunnelId,
|
|
642
|
+
dnsRecordId,
|
|
643
|
+
port: opts.port,
|
|
644
|
+
proto: opts.proto,
|
|
645
|
+
bootId: currentBootId(),
|
|
646
|
+
state: "running"
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
async function releaseHostname(cf, zoneId, record) {
|
|
650
|
+
if (record.content.endsWith(".cfargotunnel.com")) {
|
|
651
|
+
const oldTunnelId = tunnelIdFromCname(record.content);
|
|
652
|
+
try {
|
|
653
|
+
const tunnel = await getTunnel(cf, oldTunnelId);
|
|
654
|
+
if (isManagedTunnel(tunnel)) await deleteTunnel(cf, oldTunnelId);
|
|
655
|
+
} catch {
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
await deleteDnsRecord(cf.token, zoneId, record.id);
|
|
659
|
+
}
|
|
660
|
+
async function rollback(cf, zoneId, tunnelId, dnsRecordId, hostname) {
|
|
661
|
+
let clean = true;
|
|
662
|
+
if (dnsRecordId) {
|
|
663
|
+
try {
|
|
664
|
+
await deleteDnsRecord(cf.token, zoneId, dnsRecordId);
|
|
665
|
+
} catch {
|
|
666
|
+
clean = false;
|
|
667
|
+
say.warn(`Left a DNS record behind for ${hostname} (${dnsRecordId}) \u2014 run \`cloudtunnel rm --force ${hostname}\`.`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (tunnelId) {
|
|
671
|
+
try {
|
|
672
|
+
await deleteTunnel(cf, tunnelId);
|
|
673
|
+
} catch {
|
|
674
|
+
clean = false;
|
|
675
|
+
say.warn(`Left tunnel ${tunnelId} behind \u2014 run \`cloudtunnel gc\`.`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return clean;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/core/orchestrator-manage.ts
|
|
682
|
+
var tunnelIdFromCname2 = (content) => content.replace(/\.cfargotunnel\.com\.?$/, "");
|
|
683
|
+
var isNotFound = (err) => err instanceof CliError && err.status === 404;
|
|
684
|
+
var zoneFromFqdn = (fqdn) => fqdn.slice(fqdn.indexOf(".") + 1);
|
|
685
|
+
function resolveTarget(target) {
|
|
686
|
+
if (target.includes(".")) return { fqdn: target, entry: getEntry(target) };
|
|
687
|
+
const matches = listEntries().filter((e) => e.subdomain === target);
|
|
688
|
+
if (matches.length > 1) {
|
|
689
|
+
throw new CliError(`"${target}" matches multiple zones.`, {
|
|
690
|
+
hint: `use the full hostname: ${matches.map((m) => `${m.subdomain}.${m.zone}`).join(", ")}`
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
const entry = matches[0];
|
|
694
|
+
if (!entry) throw new CliError(`No tracked subdomain named "${target}".`, { hint: "pass a full hostname" });
|
|
695
|
+
return { fqdn: `${entry.subdomain}.${entry.zone}`, entry };
|
|
696
|
+
}
|
|
697
|
+
async function removeTunnelSubdomain(cf, target, opts = {}) {
|
|
698
|
+
const { fqdn, entry } = resolveTarget(target);
|
|
699
|
+
if (!entry && !opts.force) {
|
|
700
|
+
throw new CliError(`${fqdn} is not managed by cloudtunnel.`, { hint: "pass --force to delete it anyway" });
|
|
701
|
+
}
|
|
702
|
+
const zoneId = entry?.zoneId ?? (await resolveZone(cf.token, zoneFromFqdn(fqdn))).id;
|
|
703
|
+
const record = await findCname(cf.token, zoneId, fqdn);
|
|
704
|
+
if (record && !isManagedDns(record) && !opts.force) {
|
|
705
|
+
throw new CliError(`${fqdn} points to a record not managed by cloudtunnel.`, { hint: "pass --force to delete it" });
|
|
706
|
+
}
|
|
707
|
+
const tunnelId = record ? tunnelIdFromCname2(record.content) : entry?.tunnelId;
|
|
708
|
+
if (opts.dryRun) {
|
|
709
|
+
say.info(`Would delete: tunnel ${tunnelId ?? "(none)"}${record && !opts.keepDns ? `, DNS ${record.id}` : ""}`);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (entry) await stopConnector(entry);
|
|
713
|
+
if (tunnelId) {
|
|
714
|
+
let tunnel;
|
|
715
|
+
try {
|
|
716
|
+
tunnel = await getTunnel(cf, tunnelId);
|
|
717
|
+
} catch (err) {
|
|
718
|
+
if (!isNotFound(err)) throw err;
|
|
719
|
+
}
|
|
720
|
+
if (tunnel && !isManagedTunnel(tunnel) && !opts.force) {
|
|
721
|
+
throw new CliError(`Tunnel ${tunnelId} is not managed by cloudtunnel.`, { hint: "pass --force" });
|
|
722
|
+
}
|
|
723
|
+
if (tunnel) {
|
|
724
|
+
try {
|
|
725
|
+
await deleteTunnel(cf, tunnelId);
|
|
726
|
+
} catch (err) {
|
|
727
|
+
if (!isNotFound(err)) throw err;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (record && !opts.keepDns) {
|
|
732
|
+
try {
|
|
733
|
+
await deleteDnsRecord(cf.token, zoneId, record.id);
|
|
734
|
+
} catch (err) {
|
|
735
|
+
if (!isNotFound(err)) throw err;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
await removeEntry(fqdn);
|
|
739
|
+
say.ok(`Removed ${fqdn}`);
|
|
740
|
+
}
|
|
741
|
+
async function updateIngress(cf, target, port, proto) {
|
|
742
|
+
const { fqdn, entry } = resolveTarget(target);
|
|
743
|
+
if (!entry?.tunnelId) throw new CliError(`No tracked tunnel for ${fqdn}.`);
|
|
744
|
+
const nextProto = proto ?? entry.proto;
|
|
745
|
+
await putIngress(cf, entry.tunnelId, buildIngress({ hostname: fqdn, port, proto: nextProto }));
|
|
746
|
+
await patchEntry(fqdn, { port, proto: nextProto });
|
|
747
|
+
say.ok(`${fqdn} now points to ${nextProto}://localhost:${port} (no restart needed)`);
|
|
748
|
+
}
|
|
749
|
+
async function listAll(cf, opts = {}) {
|
|
750
|
+
const entries = await reconcile();
|
|
751
|
+
const tunnels = new Map((await listTunnels(cf)).map((t) => [t.id, t]));
|
|
752
|
+
const rows = entries.map((e) => ({
|
|
753
|
+
hostname: `${e.subdomain}.${e.zone}`,
|
|
754
|
+
zone: e.zone,
|
|
755
|
+
port: `${e.proto}://localhost:${e.port}`,
|
|
756
|
+
state: e.tunnelId && !tunnels.has(e.tunnelId) ? "dangling" : e.state,
|
|
757
|
+
managed: true
|
|
758
|
+
}));
|
|
759
|
+
if (opts.all) {
|
|
760
|
+
const { listCargoCnames } = await import("./dns-PAPFSYFP.js");
|
|
761
|
+
const { listZones: listZones3 } = await import("./zones-YNGQYXAF.js");
|
|
762
|
+
const tracked = new Set(entries.map((e) => `${e.subdomain}.${e.zone}`));
|
|
763
|
+
for (const zone of await listZones3(cf.token)) {
|
|
764
|
+
for (const rec of await listCargoCnames(cf.token, zone.id)) {
|
|
765
|
+
if (!tracked.has(rec.name)) {
|
|
766
|
+
rows.push({ hostname: rec.name, zone: zone.name, port: "-", state: "unmanaged", managed: false });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return rows;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/commands/up.ts
|
|
775
|
+
function parsePort(port) {
|
|
776
|
+
const n = Number(port);
|
|
777
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
778
|
+
throw new CliError(`Invalid port: ${port}`, { hint: "use a number 1\u201365535, e.g. `cloudtunnel 3000`" });
|
|
779
|
+
}
|
|
780
|
+
return n;
|
|
781
|
+
}
|
|
782
|
+
async function resolveDomain(token, explicit, saved) {
|
|
783
|
+
if (explicit) return explicit;
|
|
784
|
+
if (saved) return saved;
|
|
785
|
+
const zones = await listZones(token);
|
|
786
|
+
if (zones.length === 0) throw new CliError("No domains found in this Cloudflare account.");
|
|
787
|
+
if (zones.length === 1) return zones[0].name;
|
|
788
|
+
if (!process.stdin.isTTY) {
|
|
789
|
+
throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>, e.g. -d example.com" });
|
|
790
|
+
}
|
|
791
|
+
const chosen = await selectOne("Choose a domain", zones, (z) => z.name);
|
|
792
|
+
saveConfig({ ...loadConfig(), defaultZone: chosen.name });
|
|
793
|
+
say.dim(`Saved ${chosen.name} as your default domain (change it with \`cloudtunnel login --zone <domain>\`).`);
|
|
794
|
+
return chosen.name;
|
|
795
|
+
}
|
|
796
|
+
function showLogTail(logFile) {
|
|
797
|
+
try {
|
|
798
|
+
const tail = readFileSync3(logFile, "utf8").trim().split("\n").slice(-8).join("\n");
|
|
799
|
+
if (tail) say.dim(tail);
|
|
800
|
+
} catch {
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async function runUp(portArg, opts) {
|
|
804
|
+
const port = parsePort(portArg);
|
|
805
|
+
const creds = await ensureAuth();
|
|
806
|
+
const cf = resolveCf();
|
|
807
|
+
const bin = await ensureCloudflared();
|
|
808
|
+
const subdomain = opts.subdomain ?? opts.name;
|
|
809
|
+
const domain = opts.hostname ? void 0 : await resolveDomain(cf.token, opts.domain ?? opts.zone, creds.defaultZone);
|
|
810
|
+
if (process.stdout.isTTY) clack2.intro("cloudtunnel");
|
|
811
|
+
const spin = clack2.spinner();
|
|
812
|
+
let spinnerActive = true;
|
|
813
|
+
const stopSpin = (msg) => {
|
|
814
|
+
if (spinnerActive) {
|
|
815
|
+
spinnerActive = false;
|
|
816
|
+
spin.stop(msg);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
spin.start("Creating tunnel\u2026");
|
|
820
|
+
const result = await createTunnelSubdomain(cf, {
|
|
821
|
+
port,
|
|
822
|
+
proto: opts.proto,
|
|
823
|
+
name: subdomain,
|
|
824
|
+
zone: domain,
|
|
825
|
+
hostname: opts.hostname,
|
|
826
|
+
defaultZone: creds.defaultZone,
|
|
827
|
+
force: opts.force
|
|
828
|
+
}).catch((err) => {
|
|
829
|
+
stopSpin("Failed to create the tunnel");
|
|
830
|
+
throw err;
|
|
831
|
+
});
|
|
832
|
+
const fqdn = result.host.hostname;
|
|
833
|
+
const logFile = join2(logDir, `${result.host.subdomain}.log`);
|
|
834
|
+
const target = `${opts.proto}://localhost:${port}`;
|
|
835
|
+
if (opts.detach) {
|
|
836
|
+
const started2 = startConnector({ bin, token: result.token, detach: true, logFile });
|
|
837
|
+
await patchEntry(fqdn, { pid: started2.pid, bootId: currentBootId(), logFile });
|
|
838
|
+
stopSpin("Started in the background");
|
|
839
|
+
clack2.note(formatRoute(fqdn, target), `pid ${started2.pid}`);
|
|
840
|
+
if (process.stdout.isTTY) clack2.outro(`Stop it with: cloudtunnel down ${result.host.subdomain}`);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
spin.message("Connecting to the Cloudflare edge\u2026");
|
|
844
|
+
const controller = new AbortController();
|
|
845
|
+
let tornDown = false;
|
|
846
|
+
const teardown = async (exitCode) => {
|
|
847
|
+
if (tornDown) return;
|
|
848
|
+
tornDown = true;
|
|
849
|
+
controller.abort();
|
|
850
|
+
stopSpin("Stopping\u2026");
|
|
851
|
+
try {
|
|
852
|
+
const entry = getEntry(fqdn);
|
|
853
|
+
if (entry) await stopConnector(entry);
|
|
854
|
+
if (opts.ephemeral) {
|
|
855
|
+
await removeTunnelSubdomain(cf, fqdn, { force: true });
|
|
856
|
+
clack2.outro(`Stopped \xB7 ${fqdn} deleted`);
|
|
857
|
+
} else {
|
|
858
|
+
clack2.outro(`Stopped \xB7 ${fqdn} kept \u2014 re-attach: cloudtunnel ${port} -s ${result.host.subdomain}`);
|
|
859
|
+
}
|
|
860
|
+
} catch (err) {
|
|
861
|
+
reportError(err);
|
|
862
|
+
} finally {
|
|
863
|
+
process.exit(exitCode);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
const started = startConnector({
|
|
867
|
+
bin,
|
|
868
|
+
token: result.token,
|
|
869
|
+
detach: false,
|
|
870
|
+
logFile,
|
|
871
|
+
onExit: (code) => {
|
|
872
|
+
if (!tornDown) {
|
|
873
|
+
stopSpin("cloudflared exited");
|
|
874
|
+
showLogTail(logFile);
|
|
875
|
+
void teardown(code ?? 1);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
await patchEntry(fqdn, { pid: started.pid, bootId: currentBootId(), logFile });
|
|
880
|
+
for (const sig of ["SIGINT", "SIGHUP", "SIGTERM"]) {
|
|
881
|
+
process.on(sig, () => void teardown(0));
|
|
882
|
+
}
|
|
883
|
+
const health = await waitHealthy(cf, result.tunnelId, { signal: controller.signal });
|
|
884
|
+
if (health === "healthy") {
|
|
885
|
+
stopSpin("Connected");
|
|
886
|
+
clack2.note(`${formatRoute(fqdn, target)}
|
|
887
|
+
${dim("Ctrl-C stops the connector \u2014 the subdomain is kept")}`, "Live");
|
|
888
|
+
} else if (health === "provisioning") {
|
|
889
|
+
stopSpin("Provisioning");
|
|
890
|
+
say.warn(`${fqdn} is not healthy yet \u2014 it should be live shortly.`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
function registerUp(program) {
|
|
894
|
+
program.command("up").argument("<port>", "local port to expose (e.g. 3000)").description("Expose a local port at an HTTPS subdomain (also: `cloudtunnel <port>`)").option("-s, --subdomain <name>", "subdomain label (default: a friendly random slug)").option("-d, --domain <domain>", "domain to create the subdomain under (default: your default; picks interactively if unset)").option("--name <name>", "alias of --subdomain").option("--zone <domain>", "alias of --domain").option("--hostname <fqdn>", "full hostname override (instead of --subdomain + --domain)").option("--detach", "run the connector in the background").option("--ephemeral", "delete the tunnel + DNS on exit (nport-style; default keeps them)").option("-f, --force", "take over a subdomain already occupied by another record").option("--proto <proto>", "local service protocol: http | https", "http").action((port, opts) => runUp(port, opts));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// src/commands/ls.ts
|
|
898
|
+
function registerLs(program) {
|
|
899
|
+
program.command("ls").description("List tunnel subdomains (managed by default; --all scans the whole account)").option("--all", "scan every zone in the account (slower; shows unmanaged tunnels too)").action(async (opts) => {
|
|
900
|
+
await ensureAuth();
|
|
901
|
+
const cf = resolveCf();
|
|
902
|
+
const rows = await listAll(cf, { all: opts.all });
|
|
903
|
+
if (rows.length === 0) {
|
|
904
|
+
say.info("No tunnel subdomains yet. Create one: `cloudtunnel 3000`");
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
printTable(
|
|
908
|
+
["SUBDOMAIN", "ZONE", "TARGET", "STATE"],
|
|
909
|
+
rows.map((r) => [r.hostname, r.zone, r.port, r.state])
|
|
910
|
+
);
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// src/commands/rm.ts
|
|
915
|
+
function registerRm(program) {
|
|
916
|
+
program.command("rm").argument("<target>", "subdomain name or full hostname to delete").description("Delete a tunnel subdomain (stops connector, removes tunnel + DNS)").option("--force", "allow deleting a resource not created by cloudtunnel").option("--dry-run", "show what would be deleted without deleting").option("--keep-dns", "delete the tunnel but leave the DNS record").action(async (target, opts) => {
|
|
917
|
+
await ensureAuth();
|
|
918
|
+
const cf = resolveCf();
|
|
919
|
+
await removeTunnelSubdomain(cf, target, opts);
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/commands/update.ts
|
|
924
|
+
function registerUpdate(program) {
|
|
925
|
+
program.command("update").argument("<name>", "subdomain name or full hostname to update").description("Change the local port/protocol a subdomain points to (zero-downtime)").option("--port <port>", "new local port").option("--proto <proto>", "new local protocol: http | https").action(async (name, opts) => {
|
|
926
|
+
if (!opts.port) throw new CliError("--port is required", { hint: "e.g. `cloudtunnel update myapp --port 8080`" });
|
|
927
|
+
const port = Number(opts.port);
|
|
928
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new CliError(`Invalid port: ${opts.port}`);
|
|
929
|
+
await ensureAuth();
|
|
930
|
+
const cf = resolveCf();
|
|
931
|
+
await updateIngress(cf, name, port, opts.proto);
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/commands/status.ts
|
|
936
|
+
function registerStatus(program) {
|
|
937
|
+
program.command("status").argument("<name>", "subdomain name or full hostname").description("Show tunnel health and connector state for a subdomain").action(async (name) => {
|
|
938
|
+
await ensureAuth();
|
|
939
|
+
const cf = resolveCf();
|
|
940
|
+
const { fqdn, entry } = resolveTarget(name);
|
|
941
|
+
if (!entry?.tunnelId) throw new CliError(`No tracked tunnel for ${fqdn}.`);
|
|
942
|
+
const connections = await getConnections(cf, entry.tunnelId);
|
|
943
|
+
const connectorAlive = await isOurConnector(entry);
|
|
944
|
+
say.info(`Host: https://${fqdn}`);
|
|
945
|
+
say.info(`Tunnel: ${entry.tunnelId} \u2014 ${connections.length} edge connection(s)`);
|
|
946
|
+
say.info(`Connector: ${connectorAlive ? `running (pid ${entry.pid})` : "stopped"}`);
|
|
947
|
+
say.info(`Target: ${entry.proto}://localhost:${entry.port}`);
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// src/commands/down.ts
|
|
952
|
+
async function stopEntry(entry) {
|
|
953
|
+
const stopped = await stopConnector(entry);
|
|
954
|
+
await mutateRegistry((reg) => {
|
|
955
|
+
const e = reg[`${entry.subdomain}.${entry.zone}`];
|
|
956
|
+
if (e) {
|
|
957
|
+
e.state = "stopped";
|
|
958
|
+
delete e.pid;
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
return stopped;
|
|
962
|
+
}
|
|
963
|
+
function registerDown(program) {
|
|
964
|
+
program.command("down").argument("[name]", "subdomain to stop (omit with --all to stop everything)").description("Stop a running connector, leaving the tunnel + DNS intact").option("--all", "stop all running connectors").action(async (name, opts) => {
|
|
965
|
+
if (opts.all) {
|
|
966
|
+
const running = listEntries().filter((e) => e.pid);
|
|
967
|
+
let stopped = 0;
|
|
968
|
+
for (const entry2 of running) if (await stopEntry(entry2)) stopped++;
|
|
969
|
+
say.ok(`Stopped ${stopped} connector(s).`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (!name) throw new CliError("Pass a subdomain name or --all.");
|
|
973
|
+
const { fqdn, entry } = resolveTarget(name);
|
|
974
|
+
if (!entry) throw new CliError(`No tracked subdomain for ${fqdn}.`);
|
|
975
|
+
await stopEntry(entry);
|
|
976
|
+
say.ok(`Stopped ${fqdn}.`);
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/commands/gc.ts
|
|
981
|
+
function registerGc(program) {
|
|
982
|
+
program.command("gc").description("Prune crash orphans (provisioning/orphaned entries) after confirmation").option("--yes", "skip the confirmation prompt").action(async (opts) => {
|
|
983
|
+
await ensureAuth();
|
|
984
|
+
const cf = resolveCf();
|
|
985
|
+
await reconcile();
|
|
986
|
+
const orphans = listEntries().filter((e) => e.state === "provisioning" || e.state === "orphaned");
|
|
987
|
+
if (orphans.length === 0) {
|
|
988
|
+
say.info("Nothing to clean up.");
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
say.info(`Found ${orphans.length} orphaned entr${orphans.length === 1 ? "y" : "ies"}:`);
|
|
992
|
+
for (const o of orphans) say.dim(` ${o.subdomain}.${o.zone} (${o.state})`);
|
|
993
|
+
if (!opts.yes) {
|
|
994
|
+
say.warn("Re-run with --yes to delete these tunnels/records.");
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
for (const o of orphans) {
|
|
998
|
+
try {
|
|
999
|
+
await removeTunnelSubdomain(cf, `${o.subdomain}.${o.zone}`, { force: true });
|
|
1000
|
+
} catch {
|
|
1001
|
+
say.warn(`Could not fully clean ${o.subdomain}.${o.zone} \u2014 check the dashboard.`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/commands/zones.ts
|
|
1008
|
+
function registerZones(program) {
|
|
1009
|
+
program.command("zones").description("List the zones (domains) available in your Cloudflare account").action(async () => {
|
|
1010
|
+
await ensureAuth();
|
|
1011
|
+
const cf = resolveCf();
|
|
1012
|
+
const zones = await listZones(cf.token);
|
|
1013
|
+
if (zones.length === 0) {
|
|
1014
|
+
say.info("No zones in this account.");
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
printTable(
|
|
1018
|
+
["ZONE", "STATUS", "ID"],
|
|
1019
|
+
zones.map((z) => [z.name, z.status ?? "-", z.id])
|
|
1020
|
+
);
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/core/profiles.ts
|
|
1025
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
1026
|
+
function readProfiles() {
|
|
1027
|
+
try {
|
|
1028
|
+
return JSON.parse(readFileSync4(profilesFile, "utf8"));
|
|
1029
|
+
} catch {
|
|
1030
|
+
return {};
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function writeProfiles(profiles) {
|
|
1034
|
+
ensureDirs();
|
|
1035
|
+
writeFileSync3(profilesFile, JSON.stringify(profiles, null, 2), { mode: 384 });
|
|
1036
|
+
}
|
|
1037
|
+
function listProfiles() {
|
|
1038
|
+
return Object.entries(readProfiles()).map(([name, profile]) => ({ name, profile }));
|
|
1039
|
+
}
|
|
1040
|
+
function getProfile(name) {
|
|
1041
|
+
const profile = readProfiles()[name];
|
|
1042
|
+
if (!profile) {
|
|
1043
|
+
throw new CliError(`No profile named "${name}".`, { hint: "list them with `cloudtunnel profiles`" });
|
|
1044
|
+
}
|
|
1045
|
+
return profile;
|
|
1046
|
+
}
|
|
1047
|
+
function saveProfile(name, profile) {
|
|
1048
|
+
const profiles = readProfiles();
|
|
1049
|
+
profiles[name] = profile;
|
|
1050
|
+
writeProfiles(profiles);
|
|
1051
|
+
}
|
|
1052
|
+
function removeProfile(name) {
|
|
1053
|
+
const profiles = readProfiles();
|
|
1054
|
+
if (!profiles[name]) throw new CliError(`No profile named "${name}".`);
|
|
1055
|
+
delete profiles[name];
|
|
1056
|
+
writeProfiles(profiles);
|
|
1057
|
+
}
|
|
1058
|
+
function parseServiceSpec(spec) {
|
|
1059
|
+
const [name, portStr, proto] = spec.split(":");
|
|
1060
|
+
const port = Number(portStr);
|
|
1061
|
+
if (!name || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1062
|
+
throw new CliError(`Invalid service "${spec}".`, { hint: "use name:port, e.g. api:3000 or web:5173:https" });
|
|
1063
|
+
}
|
|
1064
|
+
if (proto && proto !== "http" && proto !== "https") {
|
|
1065
|
+
throw new CliError(`Invalid protocol "${proto}" in "${spec}".`, { hint: "proto must be http or https" });
|
|
1066
|
+
}
|
|
1067
|
+
return { name, port, proto: proto ?? "http" };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// src/commands/save.ts
|
|
1071
|
+
function registerSave(program) {
|
|
1072
|
+
program.command("save").argument("<profile>", "profile name, e.g. mb").argument("[services...]", "services as name:port[:proto], e.g. api:3000 web:5173").description("Save a group of services as a profile you can `run` together").option("--from-running", "snapshot the currently tracked tunnels instead of listing services").option("-d, --domain <domain>", "default domain for this profile").action((profile, specs, opts) => {
|
|
1073
|
+
let services;
|
|
1074
|
+
if (opts.fromRunning) {
|
|
1075
|
+
const entries = listEntries().filter((e) => e.tunnelId);
|
|
1076
|
+
if (entries.length === 0) {
|
|
1077
|
+
throw new CliError("No tunnels to snapshot.", { hint: "start some with `cloudtunnel up`, or pass services like api:3000" });
|
|
1078
|
+
}
|
|
1079
|
+
services = entries.map((e) => ({ name: e.subdomain, port: e.port, proto: e.proto, domain: e.zone }));
|
|
1080
|
+
} else {
|
|
1081
|
+
if (specs.length === 0) {
|
|
1082
|
+
throw new CliError("No services given.", { hint: "e.g. `cloudtunnel save mb api:3000 web:5173`" });
|
|
1083
|
+
}
|
|
1084
|
+
services = specs.map(parseServiceSpec);
|
|
1085
|
+
}
|
|
1086
|
+
saveProfile(profile, { services, domain: opts.domain });
|
|
1087
|
+
say.ok(`Saved profile "${profile}" (${services.length} service${services.length === 1 ? "" : "s"}). Run it: cloudtunnel run ${profile}`);
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// src/commands/run.ts
|
|
1092
|
+
import { join as join3 } from "path";
|
|
1093
|
+
import * as clack3 from "@clack/prompts";
|
|
1094
|
+
async function runProfile(name, opts) {
|
|
1095
|
+
const creds = await ensureAuth();
|
|
1096
|
+
const cf = resolveCf();
|
|
1097
|
+
const bin = await ensureCloudflared();
|
|
1098
|
+
const profile = getProfile(name);
|
|
1099
|
+
if (process.stdout.isTTY) clack3.intro(`cloudtunnel \xB7 profile "${name}"`);
|
|
1100
|
+
const spin = clack3.spinner();
|
|
1101
|
+
spin.start("Creating tunnels\u2026");
|
|
1102
|
+
const started = [];
|
|
1103
|
+
for (const svc of profile.services) {
|
|
1104
|
+
spin.message(`Creating ${svc.name} (:${svc.port})\u2026`);
|
|
1105
|
+
const result = await createTunnelSubdomain(cf, {
|
|
1106
|
+
port: svc.port,
|
|
1107
|
+
proto: svc.proto,
|
|
1108
|
+
name: svc.name,
|
|
1109
|
+
zone: svc.domain ?? opts.domain ?? profile.domain,
|
|
1110
|
+
defaultZone: creds.defaultZone,
|
|
1111
|
+
force: opts.force
|
|
1112
|
+
});
|
|
1113
|
+
const fqdn = result.host.hostname;
|
|
1114
|
+
const logFile = join3(logDir, `${result.host.subdomain}.log`);
|
|
1115
|
+
const conn = startConnector({
|
|
1116
|
+
bin,
|
|
1117
|
+
token: result.token,
|
|
1118
|
+
detach: false,
|
|
1119
|
+
logFile,
|
|
1120
|
+
onExit: () => say.warn(`Connector for ${fqdn} exited \u2014 check \`cloudtunnel status ${result.host.subdomain}\`.`)
|
|
1121
|
+
});
|
|
1122
|
+
await patchEntry(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile });
|
|
1123
|
+
started.push({ fqdn, subdomain: result.host.subdomain, tunnelId: result.tunnelId, target: `${svc.proto}://localhost:${svc.port}` });
|
|
1124
|
+
}
|
|
1125
|
+
spin.message("Connecting to the Cloudflare edge\u2026");
|
|
1126
|
+
const healths = await Promise.all(started.map((s) => waitHealthy(cf, s.tunnelId, { timeoutMs: 3e4 })));
|
|
1127
|
+
const live = healths.filter((h) => h === "healthy").length;
|
|
1128
|
+
spin.stop(`${started.length} service(s) started`);
|
|
1129
|
+
const lines = started.map((s, i) => `${formatRoute(s.fqdn, s.target)}${healths[i] === "healthy" ? "" : dim(` (${healths[i]})`)}`);
|
|
1130
|
+
clack3.note(lines.join("\n"), `profile "${name}" \u2014 ${live}/${started.length} live`);
|
|
1131
|
+
say.dim("Ctrl-C stops all connectors (subdomains are kept).");
|
|
1132
|
+
let tornDown = false;
|
|
1133
|
+
const teardownAll = async (code) => {
|
|
1134
|
+
if (tornDown) return;
|
|
1135
|
+
tornDown = true;
|
|
1136
|
+
try {
|
|
1137
|
+
for (const s of started) {
|
|
1138
|
+
const entry = getEntry(s.fqdn);
|
|
1139
|
+
if (entry) await stopConnector(entry);
|
|
1140
|
+
}
|
|
1141
|
+
if (process.stdout.isTTY) clack3.outro(`Stopped ${started.length} connector(s) \xB7 subdomains kept`);
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
reportError(err);
|
|
1144
|
+
} finally {
|
|
1145
|
+
process.exit(code);
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
for (const sig of ["SIGINT", "SIGHUP", "SIGTERM"]) {
|
|
1149
|
+
process.on(sig, () => void teardownAll(0));
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
function registerRun(program) {
|
|
1153
|
+
program.command("run").argument("<profile>", "name of a saved profile (see `cloudtunnel profiles`)").description("Start every service in a saved profile at once").option("-f, --force", "take over subdomains already occupied by another record").option("-d, --domain <domain>", "override the profile's domain for this run").action((name, opts) => runProfile(name, opts));
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// src/commands/profiles.ts
|
|
1157
|
+
function registerProfiles(program) {
|
|
1158
|
+
program.command("profiles").description("List saved profiles (or delete one with --rm)").option("--rm <name>", "delete a profile").action((opts) => {
|
|
1159
|
+
if (opts.rm) {
|
|
1160
|
+
removeProfile(opts.rm);
|
|
1161
|
+
say.ok(`Deleted profile "${opts.rm}".`);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
const profiles = listProfiles();
|
|
1165
|
+
if (profiles.length === 0) {
|
|
1166
|
+
say.info("No profiles yet. Create one: `cloudtunnel save mb api:3000 web:5173`");
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
printTable(
|
|
1170
|
+
["PROFILE", "SERVICES", "DOMAIN"],
|
|
1171
|
+
profiles.map(({ name, profile }) => [
|
|
1172
|
+
name,
|
|
1173
|
+
profile.services.map((s) => `${s.name}:${s.port}`).join(", "),
|
|
1174
|
+
profile.domain ?? "(default)"
|
|
1175
|
+
])
|
|
1176
|
+
);
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// src/index.ts
|
|
1181
|
+
var require2 = createRequire(import.meta.url);
|
|
1182
|
+
var pkg = require2("../package.json");
|
|
1183
|
+
var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
|
|
1184
|
+
"login",
|
|
1185
|
+
"up",
|
|
1186
|
+
"ls",
|
|
1187
|
+
"rm",
|
|
1188
|
+
"update",
|
|
1189
|
+
"status",
|
|
1190
|
+
"down",
|
|
1191
|
+
"gc",
|
|
1192
|
+
"zones",
|
|
1193
|
+
"save",
|
|
1194
|
+
"run",
|
|
1195
|
+
"profiles",
|
|
1196
|
+
"help"
|
|
1197
|
+
]);
|
|
1198
|
+
function applyBarePortAlias(argv) {
|
|
1199
|
+
const args = argv.slice(2);
|
|
1200
|
+
const first = args[0];
|
|
1201
|
+
if (first && /^\d{1,5}$/.test(first) && !KNOWN_COMMANDS.has(first)) {
|
|
1202
|
+
args.unshift("up");
|
|
1203
|
+
}
|
|
1204
|
+
return [argv[0], argv[1], ...args];
|
|
1205
|
+
}
|
|
1206
|
+
function buildProgram() {
|
|
1207
|
+
const program = new Command();
|
|
1208
|
+
program.name("cloudtunnel").description("Manage Cloudflare Tunnels and subdomains account-wide, nport-style.").version(pkg.version, "-v, --version").showHelpAfterError();
|
|
1209
|
+
program.addHelpText(
|
|
1210
|
+
"before",
|
|
1211
|
+
[
|
|
1212
|
+
pc2.bold("Quickstart:"),
|
|
1213
|
+
` ${pc2.cyan("cloudtunnel login")} once \u2014 paste a token (or set CLOUDFLARE_API_TOKEN)`,
|
|
1214
|
+
` ${pc2.cyan("cloudtunnel 3000")} \u2192 your local :3000 goes live at an HTTPS URL`,
|
|
1215
|
+
""
|
|
1216
|
+
].join("\n")
|
|
1217
|
+
);
|
|
1218
|
+
for (const register of [
|
|
1219
|
+
registerLogin,
|
|
1220
|
+
registerUp,
|
|
1221
|
+
registerLs,
|
|
1222
|
+
registerRm,
|
|
1223
|
+
registerUpdate,
|
|
1224
|
+
registerStatus,
|
|
1225
|
+
registerDown,
|
|
1226
|
+
registerGc,
|
|
1227
|
+
registerZones,
|
|
1228
|
+
registerSave,
|
|
1229
|
+
registerRun,
|
|
1230
|
+
registerProfiles
|
|
1231
|
+
]) {
|
|
1232
|
+
register(program);
|
|
1233
|
+
}
|
|
1234
|
+
return program;
|
|
1235
|
+
}
|
|
1236
|
+
async function main() {
|
|
1237
|
+
const program = buildProgram();
|
|
1238
|
+
try {
|
|
1239
|
+
await program.parseAsync(applyBarePortAlias(process.argv));
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
process.exitCode = reportError(err);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
void main();
|
|
1245
|
+
//# sourceMappingURL=index.js.map
|