@iamken/cloudtunnel 0.3.0 → 0.5.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 CHANGED
@@ -16,21 +16,21 @@ Instant, self-owned tunnel sharing — the tunnel and DNS live in **your** Cloud
16
16
  ```bash
17
17
  npm i -g @iamken/cloudtunnel
18
18
 
19
- cloudtunnel login # once — paste a Cloudflare token; account + domain auto-resolved
20
- cloudtunnel 3000 # → https://brave-otter-1a2b.example.com is live ✨
19
+ cloudtunnel login # once — paste a Cloudflare token; account + domain auto-resolved
20
+ cloudtunnel 8080 # → https://brave-otter-1a2b.example.com is live ✨
21
21
  ```
22
22
 
23
- > 💨 Prefer less typing? **`ctun`** is a built-in short alias — `ctun 3000`, `ctun ls`, `ctun down 1`.
23
+ > 💨 Prefer less typing? **`ctun`** is a built-in short alias — `ctun 8080`, `ctun ls`, `ctun delete 1`.
24
24
 
25
25
  ---
26
26
 
27
27
  ## ✨ Why cloudtunnel
28
28
 
29
29
  - 🔗 **Your domains, real subdomains** — routes through native Cloudflare Tunnel to `*.your-domain.com`, not a shared third-party host.
30
- - ⚡ **One command** — `cloudtunnel 3000` creates the tunnel, DNS, and connector, then prints a live HTTPS URL. It even asks you which domain + subdomain to use.
31
- - 🧭 **Just two states** — `up` brings a subdomain online; `down` (or Ctrl-C) releases it (deletes the tunnel + DNS). Re-running `up` always starts clean no leftovers, no conflicts.
32
- - 🗂️ **Profiles** — save a whole project's services and bring them all up with `cloudtunnel run mb`.
33
- - 🌙 **Background mode** — `--detach` keeps connectors running after you close the terminal.
30
+ - ⚡ **One command, one or many** — `cloudtunnel api:8080 web:5173` brings up several tunnels at once, each a live HTTPS URL.
31
+ - 🎯 **Spec-driven** — a tunnel is just `[subdomain:]port[@host]`. No profiles to define, nothing to save.
32
+ - 🧭 **Two states** — `up` brings subdomains online; `delete` (or Ctrl-C) releases them (removes the tunnel + DNS). Re-running `up` always starts clean.
33
+ - 🌙 **Background & boot** — `--detach` keeps connectors running after you close the terminal; `--service` registers a native OS service (systemd · launchd · Task Scheduler) that survives restarts.
34
34
  - 🔒 **Secure by default** — token passed via env (never argv), stored `0600`, destructive ops are ownership-gated and re-verified.
35
35
 
36
36
  ---
@@ -38,32 +38,25 @@ cloudtunnel 3000 # → https://brave-otter-1a2b.example.com is live ✨
38
38
  ## 🚀 Quickstart
39
39
 
40
40
  ```bash
41
- cloudtunnel login # authenticate once
42
- cloudtunnel 3000 # asks for domain + subdomain, then goes live
43
- cloudtunnel 3000 -s api # skip the prompts: api.<your-domain>
44
- cloudtunnel 3000 -s api -d foo.io # subdomain + domain
45
- cloudtunnel 3000 -s @ # the root domain itself (example.com)
46
- cloudtunnel 3000 --source 192.168.1.5 # forward to another host/IP (IPv4/IPv6), not localhost
47
- cloudtunnel 3000 --detach # run in the background
41
+ cloudtunnel login # authenticate once
42
+ cloudtunnel 8080 # asks for a subdomain (+ domain), then goes live
43
+ cloudtunnel api:8080 # api.<your-domain> → localhost:8080
44
+ cloudtunnel api:8080@192.168.1.20 # forward to another host/IP (IPv4/IPv6)
45
+ cloudtunnel api:8080 web:5173 -d foo.io # several tunnels at once, under foo.io
46
+ cloudtunnel api:8080 --detach # run in the background
47
+ cloudtunnel api:8080 --service # register a boot service (Linux/macOS/Windows)
48
48
  ```
49
49
 
50
- > Replacing an existing DNS record asks for confirmation first — pass `-y` to skip, or `-f` to also replace a non-tunnel record.
50
+ A **spec** is `[subdomain:]port[@host]`:
51
51
 
52
- Run `cloudtunnel 3000` with no flags and it guides you:
52
+ | Spec | Means |
53
+ | --- | --- |
54
+ | `8080` | random subdomain → `localhost:8080` |
55
+ | `api:8080` | `api.<domain>` → `localhost:8080` |
56
+ | `api:8080@192.168.1.20` | `api.<domain>` → `192.168.1.20:8080` (a LAN device, container, another server) |
57
+ | `@:8080` | the root/apex domain itself |
53
58
 
54
- ```
55
- ┌ cloudtunnel
56
- ◇ Choose a domain
57
- │ ● example.com ○ foo.io
58
- ◇ Subdomain
59
- │ api (leave blank for a random name)
60
- ◇ Connected
61
-
62
- ◇ Live ─────────────────────────────────────────╮
63
- │ https://api.example.com → http://localhost:3000
64
- │ Ctrl-C stops and releases this subdomain
65
- ╰─────────────────────────────────────────────────╯
66
- ```
59
+ Run `cloudtunnel` with no arguments and it guides you (port → subdomain → domain). A missing subdomain becomes a friendly random name; a missing host is `localhost`. The local-service protocol is `--proto http|https` (default `http`); replacing an existing DNS record asks first — pass `-y` to skip, `-f` to also replace a non-tunnel record.
67
60
 
68
61
  ---
69
62
 
@@ -72,43 +65,24 @@ Run `cloudtunnel 3000` with no flags and it guides you:
72
65
  | Command | What it does |
73
66
  | --- | --- |
74
67
  | `cloudtunnel login` | Authenticate; resolve account + list your domains. `--status` to inspect. |
75
- | `cloudtunnel <port>` · `up` | Bring a subdomain online. `-s/--subdomain`, `-d/--domain`, `--source`, `--detach`, `-f/--force`, `--proto`, `--protocol`. |
76
- | `cloudtunnel ls` · `ps` | List subdomains — `# · SUBDOMAIN · TARGET · STATE · PID`. `--all` scans the whole account. |
77
- | `cloudtunnel down <target>` · `rm` · `stop` | Release a subdomain — stop connector + delete tunnel + DNS. `--all`, `--dry-run`, `-f`. |
68
+ | `cloudtunnel <spec…>` · `up` | Bring one or more tunnels online. `-d/--domain`, `--proto`, `--protocol`, `--detach`, `--service`, `-f/--force`, `-y/--yes`. |
69
+ | `cloudtunnel ls` · `ps` | List tunnels — `# · URL · TARGET · STATE · SERVICE · PID`. `--all` scans the whole account. |
70
+ | `cloudtunnel delete <target…>` | Release tunnel(s) — stop connector + delete tunnel + DNS + any boot service. `--all`, `--dry-run`, `-f`. |
78
71
  | `cloudtunnel logs <target>` | Show a connector's log. `-f` to follow, `-n` for line count. |
79
- | `cloudtunnel zones` | List the domains in your account. |
80
- | `cloudtunnel save <profile> <svc…>` | Save a group of services. `svc` = `name:port[:proto][@host]`, `-d/--domain`, `--protocol`, or `--from-running`. |
81
- | `cloudtunnel run <profile> [--detach]` | Bring up every service in a profile at once. `--protocol` overrides the saved transport. |
82
- | `cloudtunnel profiles [--rm <name>]` | List saved profiles — `PROFILE · SERVICES · DOMAIN · PROTOCOL · SERVICE`. |
83
- | `cloudtunnel service enable\|disable\|status <profile>` | Register a profile as a systemd boot service (Linux, needs sudo). |
84
72
 
85
- > A **`<target>`** is a `#` number, a subdomain name, a full hostname, or a tunnel-id prefix — all shown in `ls`. `down` also accepts `rm` / `remove` / `delete` / `stop`.
73
+ > A **`<target>`** is a `#` number, a subdomain name, a full hostname/URL, or a tunnel-id prefix — all shown in `ls`.
86
74
 
87
75
  ```
88
76
  $ cloudtunnel ls
89
- ┌───┬─────────────────────┬────────────────────────┬───────┬───────┐
90
- │ # │ SUBDOMAIN │ TARGET │ STATE │ PID │
91
- ├───┼─────────────────────┼────────────────────────┼───────┼───────┤
92
- │ 1 │ api.example.com │ http://localhost:3000 │ up │ 48213 │
93
- │ 2 │ web.example.com │ https://localhost:5173 │ down │ - │
94
- └───┴─────────────────────┴────────────────────────┴───────┴───────┘
95
-
96
- $ cloudtunnel down 1 # release by number
97
- $ cloudtunnel down --all # release everything
98
- ```
99
-
100
- ---
101
-
102
- ## 🗂️ Profiles
103
-
104
- Expose a whole project's services with one command:
105
-
106
- ```bash
107
- cloudtunnel save mb api:3000 web:5173:https # define the group (or: save mb --from-running)
108
- cloudtunnel save mb api:3000@192.168.1.5 # forward a service to another host/IP
109
- cloudtunnel run mb --detach # backend + frontend live in the background
110
- cloudtunnel logs api -f # follow one service's log
111
- cloudtunnel down --all # release them all
77
+ ┌───┬────────────────────────────┬────────────────────────┬───────┬─────────┬───────┐
78
+ │ # │ URL │ TARGET │ STATE │ SERVICE │ PID │
79
+ ├───┼────────────────────────────┼────────────────────────┼───────┼─────────┼───────┤
80
+ │ 1 │ https://api.example.com │ http://localhost:8080 │ up │ active │ 48213 │
81
+ │ 2 │ https://web.example.com │ https://localhost:5173 │ down │ - │ -
82
+ └───┴────────────────────────────┴────────────────────────┴───────┴─────────┴───────┘
83
+
84
+ $ cloudtunnel delete 1 # release by number
85
+ $ cloudtunnel delete --all # release everything
112
86
  ```
113
87
 
114
88
  ---
@@ -121,34 +95,47 @@ errors. Force **`http2`** (TCP) there:
121
95
 
122
96
  ```bash
123
97
  cloudtunnel up 8080 --protocol http2
124
- cloudtunnel save mb api:3000 web:5173 --protocol http2 # persist per profile
125
- cloudtunnel run mb --protocol http2 # or override per run
126
98
  ```
127
99
 
128
100
  Values: `auto` (default) · `http2` · `quic`.
129
101
 
130
102
  ---
131
103
 
132
- ## 🔁 Run on boot (systemd service)
104
+ ## 🔁 Run on boot (`--service`)
105
+
106
+ Add **`--service`** to register each subdomain as a native OS service so it comes
107
+ back automatically — cross-platform:
133
108
 
134
- Register a profile as a **system service** so it comes up automatically after a
135
- reboot (Linux + systemd; installs to `/etc/systemd/system`, so it needs sudo):
109
+ | OS | Backend | Autostart | Privilege |
110
+ | --- | --- | --- | --- |
111
+ | Linux | systemd unit (`/etc/systemd/system`) | boot | sudo |
112
+ | macOS | launchd LaunchAgent (`~/Library/LaunchAgents`) | login | none |
113
+ | Windows | Task Scheduler (`cloudtunnel\<name>`) | logon | none |
136
114
 
137
115
  ```bash
138
- cloudtunnel service enable mb --protocol http2 # install + enable + start now
139
- cloudtunnel service status mb # active | enabled | disabled | none
140
- cloudtunnel service disable mb # stop + disable + remove
116
+ cloudtunnel api:8080 --service --protocol http2 # install + enable + start now
117
+ cloudtunnel api:8080 web:5173 --service # one service per subdomain
118
+ cloudtunnel ls # the SERVICE column shows each one's state
119
+ cloudtunnel delete api # stops + removes the tunnel and its service
141
120
  ```
142
121
 
143
- The `SERVICE` column in `cloudtunnel profiles` shows each profile's current state.
122
+ Each subdomain gets its own service, so deleting one never touches the others.
123
+ The concrete subdomain is baked in, so the URL stays stable across restarts. All
124
+ backends restart the connector on failure.
144
125
 
145
126
  ---
146
127
 
147
- ## 🧭 Two states: up & down
128
+ ## 🧭 Two states: up & delete
148
129
 
149
- There are only two states. **`up`** brings a subdomain online (creating the tunnel + DNS). **`down`** — or pressing <kbd>Ctrl-C</kbd> in a foreground `up` — **releases** it: it stops the connector and deletes the tunnel + DNS on Cloudflare. Running `up` again recreates it cleanly (any leftover tunnel record for that name is cleaned up first, so you never hit conflicts).
130
+ There are only two states. **`up`** brings subdomains online (creating the tunnel
131
+ + DNS). **`delete`** — or pressing <kbd>Ctrl-C</kbd> in a foreground `up` —
132
+ **releases** them: it stops the connector and deletes the tunnel + DNS on
133
+ Cloudflare (and any `--service` unit). Running `up` again recreates cleanly (any
134
+ leftover tunnel record for that name is cleaned up first, so you never hit conflicts).
150
135
 
151
- Add **`--detach`** to keep a connector running in the **background** after the CLI exits (and after you close the terminal). Release it later with `cloudtunnel down <target>` (or `--all`) and tail its output with `cloudtunnel logs <target> -f`.
136
+ Add **`--detach`** to keep connectors running in the **background** after the CLI
137
+ exits. Release them later with `cloudtunnel delete <target>` (or `--all`) and tail
138
+ their output with `cloudtunnel logs <target> -f`.
152
139
 
153
140
  ---
154
141
 
@@ -173,7 +160,25 @@ Provide it via (highest precedence first): `CLOUDFLARE_API_TOKEN` env → `cloud
173
160
 
174
161
  - **Node.js ≥ 20**
175
162
  - **`cloudflared`** on your `PATH` — install via `brew install cloudflared`, your package manager, or the [releases page](https://github.com/cloudflare/cloudflared/releases).
176
- <br/>_(Auto-download is built in and fetches a pinned, SHA256-verified binary, but stays disabled until release checksums are pinned — treat `cloudflared` as a prerequisite for now.)_
163
+
164
+ ---
165
+
166
+ ## 🧳 Upgrading from 0.3.x (profiles removed)
167
+
168
+ `save`, `run`, `profiles`, `zones`, and the `service …` sub-commands are gone —
169
+ everything is now inline specs on `up`, and reads live in `ls`:
170
+
171
+ | Old | New |
172
+ | --- | --- |
173
+ | `run mb` | `cloudtunnel api:8080 web:5173` (pass the specs directly) |
174
+ | `service enable mb --protocol http2` | `cloudtunnel <spec…> --service --protocol http2` |
175
+ | `service disable mb` / `profiles --rm mb` | `cloudtunnel delete <target>` |
176
+ | `service status` / `profiles` | `cloudtunnel ls` (SERVICE column) |
177
+ | `zones` | `cloudtunnel login --status` (also a ZONE in each `ls` URL) |
178
+ | `down <t>` / `rm <t>` | `cloudtunnel delete <t>` |
179
+
180
+ Profiles that were registered as boot services are **migrated automatically** to
181
+ the new per-subdomain units the first time you run cloudtunnel in a terminal.
177
182
 
178
183
  ---
179
184
 
@@ -181,10 +186,10 @@ Provide it via (highest precedence first): `CLOUDFLARE_API_TOKEN` env → `cloud
181
186
 
182
187
  | Symptom | Cause & fix |
183
188
  | --- | --- |
184
- | **HTTP 1016** / subdomain won't load | The connector isn't running (`STATE = down` in `ls`). Bring it back up: `cloudtunnel <port> -s <name>`. |
185
- | **`down` says "active connections"** | Handled automatically — cloudtunnel cleans up the connections and retries the delete. |
189
+ | **HTTP 1016** / subdomain won't load | The connector isn't running (`STATE = down` in `ls`). Bring it back up: `cloudtunnel <spec>`. |
190
+ | **`delete` says "active connections"** | Handled automatically — cloudtunnel cleans up the connections and retries the delete. |
186
191
  | **"grey-clouded" error** | The zone couldn't proxy the record; cfargotunnel routing needs an orange-cloud (proxied) CNAME. |
187
- | **A DNS record already occupies the name** | Pick another `-s`/`-d`, or pass `-f/--force` to replace a non-tunnel record. |
192
+ | **A DNS record already occupies the name** | Pick another subdomain/domain, or pass `-f/--force` to replace a non-tunnel record. |
188
193
 
189
194
  ---
190
195
 
@@ -3,7 +3,7 @@ import {
3
3
  CliError,
4
4
  cfPaginate,
5
5
  cfRequest
6
- } from "./chunk-UPBVRXLF.js";
6
+ } from "./chunk-RWR6VXNB.js";
7
7
 
8
8
  // src/cloudflare/zones.ts
9
9
  function listZones(token) {
@@ -24,4 +24,4 @@ export {
24
24
  listZones,
25
25
  resolveZone
26
26
  };
27
- //# sourceMappingURL=chunk-2TCFCMJS.js.map
27
+ //# sourceMappingURL=chunk-CWJA4L7J.js.map
@@ -3,7 +3,7 @@ import {
3
3
  CliError,
4
4
  cfPaginate,
5
5
  cfRequest
6
- } from "./chunk-UPBVRXLF.js";
6
+ } from "./chunk-RWR6VXNB.js";
7
7
 
8
8
  // src/cloudflare/dns.ts
9
9
  var MANAGED_DNS_COMMENT = "managed-by:cloudtunnel";
@@ -54,4 +54,4 @@ export {
54
54
  listCargoCnames,
55
55
  deleteDnsRecord
56
56
  };
57
- //# sourceMappingURL=chunk-YLTQB4F7.js.map
57
+ //# sourceMappingURL=chunk-NZKGIDIW.js.map
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, { get: all[name], enumerable: true });
6
+ };
2
7
 
3
8
  // src/ui/errors.ts
4
9
  import pc from "picocolors";
@@ -128,6 +133,7 @@ async function cfPaginate(token, basePath) {
128
133
  }
129
134
 
130
135
  export {
136
+ __export,
131
137
  CliError,
132
138
  reportError,
133
139
  configFile,
@@ -143,4 +149,4 @@ export {
143
149
  cfRequest,
144
150
  cfPaginate
145
151
  };
146
- //# sourceMappingURL=chunk-UPBVRXLF.js.map
152
+ //# sourceMappingURL=chunk-RWR6VXNB.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/ui/errors.ts","../src/config/store.ts","../src/config/paths.ts","../src/cloudflare/client.ts"],"sourcesContent":["import pc from \"picocolors\";\n\n/**\n * A user-facing CLI error. The message is printed as-is (no stack trace) and\n * `exitCode` drives the process exit code. Use `hint` to tell the user exactly\n * which command to run next — every error should be actionable.\n */\nexport class CliError extends Error {\n readonly exitCode: number;\n readonly hint?: string;\n /** HTTP status when this wraps a Cloudflare API error (lets callers tell a\n * genuine 404 \"already gone\" from a transient failure). */\n readonly status?: number;\n\n constructor(message: string, opts: { exitCode?: number; hint?: string; status?: number } = {}) {\n super(message);\n this.name = \"CliError\";\n this.exitCode = opts.exitCode ?? 1;\n this.hint = opts.hint;\n this.status = opts.status;\n }\n}\n\n/** Print an error and return the exit code. Known CliErrors print cleanly; the\n * rest print their message plus a note that it was unexpected. */\nexport function reportError(err: unknown): number {\n if (err instanceof CliError) {\n console.error(pc.red(`✗ ${err.message}`));\n if (err.hint) console.error(pc.dim(` → ${err.hint}`));\n return err.exitCode;\n }\n const message = err instanceof Error ? err.message : String(err);\n console.error(pc.red(`✗ ${message}`));\n console.error(pc.dim(\" (unexpected error — please report if this persists)\"));\n return 1;\n}\n","import { chmodSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { CliError } from \"../ui/errors.js\";\nimport { configFile, ensureDirs } from \"./paths.js\";\n\nexport interface CloudtunnelConfig {\n apiToken?: string;\n accountId?: string;\n defaultZone?: string;\n}\n\nexport interface Credentials {\n apiToken: string;\n accountId?: string;\n defaultZone?: string;\n}\n\n/** Read config.json (missing/invalid → empty config, never throws). */\nexport function loadConfig(): CloudtunnelConfig {\n try {\n return JSON.parse(readFileSync(configFile, \"utf8\")) as CloudtunnelConfig;\n } catch {\n return {};\n }\n}\n\n/** Persist config.json with owner-only perms (0600). */\nexport function saveConfig(config: CloudtunnelConfig): void {\n ensureDirs();\n writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 });\n chmodSync(configFile, 0o600); // enforce even if the file pre-existed\n}\n\n/**\n * Resolve credentials: env vars override the config file. Throws an actionable\n * CliError if no API token is available anywhere.\n */\nexport function getCredentials(): Credentials {\n const config = loadConfig();\n const apiToken = process.env.CLOUDFLARE_API_TOKEN ?? config.apiToken;\n const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? config.accountId;\n if (!apiToken) {\n throw new CliError(\"Not authenticated with Cloudflare.\", {\n hint: \"run `cloudtunnel login` (or set CLOUDFLARE_API_TOKEN)\",\n });\n }\n return { apiToken, accountId, defaultZone: config.defaultZone };\n}\n","import envPaths from \"env-paths\";\nimport { mkdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n// `~/.config/cloudtunnel/` (XDG). suffix:'' avoids env-paths' default \"-nodejs\".\nconst paths = envPaths(\"cloudtunnel\", { suffix: \"\" });\n\nexport const configDir = paths.config;\nexport const configFile = join(configDir, \"config.json\");\nexport const registryFile = join(configDir, \"tunnels.json\");\nexport const profilesFile = join(configDir, \"profiles.json\");\nexport const binDir = join(configDir, \"bin\");\nexport const logDir = join(configDir, \"logs\");\n\n/** Create the app dirs with owner-only perms (secrets live here). Idempotent. */\nexport function ensureDirs(): void {\n for (const dir of [configDir, binDir, logDir]) {\n mkdirSync(dir, { recursive: true, mode: 0o700 });\n }\n}\n","import { CliError } from \"../ui/errors.js\";\nimport { getCredentials } from \"../config/store.js\";\n\nconst API_BASE = \"https://api.cloudflare.com/client/v4\";\nconst RETRYABLE = new Set([429, 500, 502, 503, 504]);\nconst MAX_ATTEMPTS = 4;\n\n/** Resolved Cloudflare context for account-scoped calls. */\nexport interface Cf {\n token: string;\n accountId: string;\n}\n\ninterface CfEnvelope<T> {\n success: boolean;\n result: T;\n result_info?: { page: number; total_pages?: number; per_page: number; count: number };\n errors?: { message: string }[];\n}\n\n/** Token + account id required for tunnel ops. Throws actionably if unresolved. */\nexport function resolveCf(): Cf {\n const creds = getCredentials();\n if (!creds.accountId) {\n throw new CliError(\"No Cloudflare account id resolved.\", {\n hint: \"run `cloudtunnel login` (or set CLOUDFLARE_ACCOUNT_ID)\",\n });\n }\n return { token: creds.apiToken, accountId: creds.accountId };\n}\n\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\n/**\n * Single Cloudflare API call with backoff on 429/5xx (honors Retry-After).\n * Errors are sanitized — the bearer token is only ever sent in the header and\n * never appears in a thrown message.\n */\nexport async function cfRequest<T>(\n token: string,\n method: string,\n path: string,\n body?: unknown,\n): Promise<CfEnvelope<T>> {\n for (let attempt = 0; ; attempt++) {\n let res: Response;\n try {\n res = await fetch(`${API_BASE}${path}`, {\n method,\n headers: { Authorization: `Bearer ${token}`, \"Content-Type\": \"application/json\" },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n } catch {\n throw new CliError(\"Could not reach the Cloudflare API (network error).\");\n }\n if (RETRYABLE.has(res.status) && attempt < MAX_ATTEMPTS) {\n const retryAfter = Number(res.headers.get(\"retry-after\")) || 0;\n await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500);\n continue;\n }\n const env = (await res.json().catch(() => ({}))) as CfEnvelope<T>;\n if (!res.ok || !env.success) {\n const msg = env.errors?.[0]?.message ?? `HTTP ${res.status}`;\n throw new CliError(`Cloudflare API error: ${msg}`, { status: res.status });\n }\n return env;\n }\n}\n\n/**\n * Fetch every page of a list endpoint. Robust when `total_pages` is absent:\n * stops when a page returns fewer than per_page items.\n */\nexport async function cfPaginate<T>(token: string, basePath: string): Promise<T[]> {\n const sep = basePath.includes(\"?\") ? \"&\" : \"?\";\n const out: T[] = [];\n for (let page = 1; ; page++) {\n const env = await cfRequest<T[]>(token, \"GET\", `${basePath}${sep}per_page=50&page=${page}`);\n const items = env.result ?? [];\n out.push(...items);\n const perPage = env.result_info?.per_page ?? 50;\n const totalPages = env.result_info?.total_pages;\n const more = totalPages ? page < totalPages : items.length === perPage;\n if (items.length === 0 || !more) break;\n }\n return out;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/ui/errors.ts","../src/config/store.ts","../src/config/paths.ts","../src/cloudflare/client.ts"],"sourcesContent":["import pc from \"picocolors\";\n\n/**\n * A user-facing CLI error. The message is printed as-is (no stack trace) and\n * `exitCode` drives the process exit code. Use `hint` to tell the user exactly\n * which command to run next — every error should be actionable.\n */\nexport class CliError extends Error {\n readonly exitCode: number;\n readonly hint?: string;\n /** HTTP status when this wraps a Cloudflare API error (lets callers tell a\n * genuine 404 \"already gone\" from a transient failure). */\n readonly status?: number;\n\n constructor(message: string, opts: { exitCode?: number; hint?: string; status?: number } = {}) {\n super(message);\n this.name = \"CliError\";\n this.exitCode = opts.exitCode ?? 1;\n this.hint = opts.hint;\n this.status = opts.status;\n }\n}\n\n/** Print an error and return the exit code. Known CliErrors print cleanly; the\n * rest print their message plus a note that it was unexpected. */\nexport function reportError(err: unknown): number {\n if (err instanceof CliError) {\n console.error(pc.red(`✗ ${err.message}`));\n if (err.hint) console.error(pc.dim(` → ${err.hint}`));\n return err.exitCode;\n }\n const message = err instanceof Error ? err.message : String(err);\n console.error(pc.red(`✗ ${message}`));\n console.error(pc.dim(\" (unexpected error — please report if this persists)\"));\n return 1;\n}\n","import { chmodSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { CliError } from \"../ui/errors.js\";\nimport { configFile, ensureDirs } from \"./paths.js\";\n\nexport interface CloudtunnelConfig {\n apiToken?: string;\n accountId?: string;\n defaultZone?: string;\n}\n\nexport interface Credentials {\n apiToken: string;\n accountId?: string;\n defaultZone?: string;\n}\n\n/** Read config.json (missing/invalid → empty config, never throws). */\nexport function loadConfig(): CloudtunnelConfig {\n try {\n return JSON.parse(readFileSync(configFile, \"utf8\")) as CloudtunnelConfig;\n } catch {\n return {};\n }\n}\n\n/** Persist config.json with owner-only perms (0600). */\nexport function saveConfig(config: CloudtunnelConfig): void {\n ensureDirs();\n writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 });\n chmodSync(configFile, 0o600); // enforce even if the file pre-existed\n}\n\n/**\n * Resolve credentials: env vars override the config file. Throws an actionable\n * CliError if no API token is available anywhere.\n */\nexport function getCredentials(): Credentials {\n const config = loadConfig();\n const apiToken = process.env.CLOUDFLARE_API_TOKEN ?? config.apiToken;\n const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? config.accountId;\n if (!apiToken) {\n throw new CliError(\"Not authenticated with Cloudflare.\", {\n hint: \"run `cloudtunnel login` (or set CLOUDFLARE_API_TOKEN)\",\n });\n }\n return { apiToken, accountId, defaultZone: config.defaultZone };\n}\n","import envPaths from \"env-paths\";\nimport { mkdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n// `~/.config/cloudtunnel/` (XDG). suffix:'' avoids env-paths' default \"-nodejs\".\nconst paths = envPaths(\"cloudtunnel\", { suffix: \"\" });\n\nexport const configDir = paths.config;\nexport const configFile = join(configDir, \"config.json\");\nexport const registryFile = join(configDir, \"tunnels.json\");\nexport const profilesFile = join(configDir, \"profiles.json\");\nexport const binDir = join(configDir, \"bin\");\nexport const logDir = join(configDir, \"logs\");\n\n/** Create the app dirs with owner-only perms (secrets live here). Idempotent. */\nexport function ensureDirs(): void {\n for (const dir of [configDir, binDir, logDir]) {\n mkdirSync(dir, { recursive: true, mode: 0o700 });\n }\n}\n","import { CliError } from \"../ui/errors.js\";\nimport { getCredentials } from \"../config/store.js\";\n\nconst API_BASE = \"https://api.cloudflare.com/client/v4\";\nconst RETRYABLE = new Set([429, 500, 502, 503, 504]);\nconst MAX_ATTEMPTS = 4;\n\n/** Resolved Cloudflare context for account-scoped calls. */\nexport interface Cf {\n token: string;\n accountId: string;\n}\n\ninterface CfEnvelope<T> {\n success: boolean;\n result: T;\n result_info?: { page: number; total_pages?: number; per_page: number; count: number };\n errors?: { message: string }[];\n}\n\n/** Token + account id required for tunnel ops. Throws actionably if unresolved. */\nexport function resolveCf(): Cf {\n const creds = getCredentials();\n if (!creds.accountId) {\n throw new CliError(\"No Cloudflare account id resolved.\", {\n hint: \"run `cloudtunnel login` (or set CLOUDFLARE_ACCOUNT_ID)\",\n });\n }\n return { token: creds.apiToken, accountId: creds.accountId };\n}\n\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\n/**\n * Single Cloudflare API call with backoff on 429/5xx (honors Retry-After).\n * Errors are sanitized — the bearer token is only ever sent in the header and\n * never appears in a thrown message.\n */\nexport async function cfRequest<T>(\n token: string,\n method: string,\n path: string,\n body?: unknown,\n): Promise<CfEnvelope<T>> {\n for (let attempt = 0; ; attempt++) {\n let res: Response;\n try {\n res = await fetch(`${API_BASE}${path}`, {\n method,\n headers: { Authorization: `Bearer ${token}`, \"Content-Type\": \"application/json\" },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n } catch {\n throw new CliError(\"Could not reach the Cloudflare API (network error).\");\n }\n if (RETRYABLE.has(res.status) && attempt < MAX_ATTEMPTS) {\n const retryAfter = Number(res.headers.get(\"retry-after\")) || 0;\n await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500);\n continue;\n }\n const env = (await res.json().catch(() => ({}))) as CfEnvelope<T>;\n if (!res.ok || !env.success) {\n const msg = env.errors?.[0]?.message ?? `HTTP ${res.status}`;\n throw new CliError(`Cloudflare API error: ${msg}`, { status: res.status });\n }\n return env;\n }\n}\n\n/**\n * Fetch every page of a list endpoint. Robust when `total_pages` is absent:\n * stops when a page returns fewer than per_page items.\n */\nexport async function cfPaginate<T>(token: string, basePath: string): Promise<T[]> {\n const sep = basePath.includes(\"?\") ? \"&\" : \"?\";\n const out: T[] = [];\n for (let page = 1; ; page++) {\n const env = await cfRequest<T[]>(token, \"GET\", `${basePath}${sep}per_page=50&page=${page}`);\n const items = env.result ?? [];\n out.push(...items);\n const perPage = env.result_info?.per_page ?? 50;\n const totalPages = env.result_info?.total_pages;\n const more = totalPages ? page < totalPages : items.length === perPage;\n if (items.length === 0 || !more) break;\n }\n return out;\n}\n"],"mappings":"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","names":[]}
@@ -7,8 +7,8 @@ import {
7
7
  findCname,
8
8
  isManagedDns,
9
9
  listCargoCnames
10
- } from "./chunk-YLTQB4F7.js";
11
- import "./chunk-UPBVRXLF.js";
10
+ } from "./chunk-NZKGIDIW.js";
11
+ import "./chunk-RWR6VXNB.js";
12
12
  export {
13
13
  MANAGED_DNS_COMMENT,
14
14
  cargoTarget,
@@ -18,4 +18,4 @@ export {
18
18
  isManagedDns,
19
19
  listCargoCnames
20
20
  };
21
- //# sourceMappingURL=dns-PAPFSYFP.js.map
21
+ //# sourceMappingURL=dns-5OXFAQ4D.js.map