cloudflare-tunnel-kit 0.1.0 → 0.1.2

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
@@ -1,84 +1,78 @@
1
1
  # cloudflare-tunnel-kit
2
2
 
3
- Thư viện mã nguồn mở giúp dự án tạo và tích hợp Cloudflare Tunnel bằng hai cách: command line hoặc live UI chạy cục bộ. Mục tiêu là thay thế các shell script/Makefile rời rạc bằng flow wizard có validation, preview và confirmation rõ ràng.
3
+ An open-source toolkit for creating and integrating Cloudflare Tunnels through two simple interfaces: a command-line wizard and a local live UI. It replaces scattered shell scripts and Makefile targets with a validated, reviewable, and confirmation-based workflow.
4
4
 
5
5
  ## Current version
6
6
 
7
- `0.1.0` là bản MVP hiện có:
7
+ `0.1.0` is the current MVP and includes:
8
8
 
9
- - Core TypeScript API cho validation, tạo plan, redaction và execution.
10
- - CLI `cf-tunnel` với `init`, `create`, `quick`, `start`, `stop`, `status`, `doctor`, `ui`.
11
- - Profile `custom` và Laravel detection với proposal mapping `APP_URL`.
12
- - Quick tunnel và named-tunnel argv generation.
13
- - Validation URL, hostname, tunnel name và path dưới project root.
14
- - Dry-run, structured errors và prompt an toàn để copy hỏi AI.
15
- - UI HTML/CSS/JS nhẹ, bind loopback, không cần frontend framework.
9
+ - A reusable TypeScript API for validation, plan generation, execution, and redaction.
10
+ - The `cf-tunnel` CLI with `init`, `create`, `quick`, `start`, `stop`, `status`, `doctor`, and `ui` commands.
11
+ - `custom` and `laravel` project profiles.
12
+ - Quick Tunnel and named-tunnel command generation.
13
+ - URL, hostname, tunnel-name, and project-path validation.
14
+ - Dry-run mode, structured errors, remediation guidance, and copyable AI help prompts.
15
+ - A lightweight localhost UI with live plan preview and confirmation-token protection.
16
+ - Laravel detection and `APP_URL` proposal/diff with explicit confirmation.
16
17
 
17
- Laravel `.env` mapping cho `APP_URL` đã có ở dạng proposal/diff; thao tác vẫn cần confirmation và hiện chưa tự động ghi file trong CLI/UI.
18
+ Laravel `.env` changes are never written silently. The current MVP presents the proposed diff and requires confirmation; automatic file mutation is intentionally not enabled yet.
18
19
 
19
- ## Ý tưởng và nguyên tắc
20
+ ## Design principles
20
21
 
21
- Flow luôn là: `input -> detect -> validate -> preview plan -> confirm -> execute -> summary`.
22
+ The workflow is always:
22
23
 
23
- Không nối input thành shell command, không in secret ra log, không ghi đè config hoặc `.env` âm thầm. Người dùng luôn nhìn thấy command/file operation trước khi chạy.
24
+ ```text
25
+ input -> detect -> validate -> preview plan -> confirm -> execute -> summary
26
+ ```
24
27
 
25
- ## Yêu cầu
28
+ The toolkit does not concatenate user input into shell commands, print secrets to logs, overwrite configuration silently, or send diagnostics to an external service.
26
29
 
27
- - Node.js 20 trở lên.
28
- - `cloudflared` trong `PATH` nếu muốn chạy tunnel thật.
29
- - Quyền Cloudflare phù hợp với loại named tunnel.
30
+ ## Requirements
30
31
 
31
- ## Cài đặt
32
+ - Node.js 20 or newer.
33
+ - `cloudflared` available in `PATH` when starting a real tunnel.
34
+ - Appropriate Cloudflare permissions for named tunnels.
32
35
 
33
- Khi package được phát hành:
36
+ ## Installation
37
+
38
+ Install the package after it is published:
34
39
 
35
40
  ```bash
36
41
  npm install --save-dev cloudflare-tunnel-kit
37
42
  ```
38
43
 
39
- Trong source checkout:
44
+ For a source checkout:
40
45
 
41
46
  ```bash
42
47
  npm install
43
48
  npm run build
44
- node dist/cli/main.js --help
49
+ node ./dist/cli/main.js --help
45
50
  ```
46
51
 
47
- ## Makefile shortcuts
52
+ ## CLI usage
48
53
 
49
- Nếu thích command ngắn, package có Makefile:
50
-
51
- ```bash
52
- make setup
53
- make help
54
- make init
55
- make ui
56
- make quick URL=http://127.0.0.1:8000
57
- make create NAME=law-firm URL=http://127.0.0.1:8000
58
- ```
59
-
60
- `make quick` và `make create` mặc định chỉ preview (`--dry-run`). Sau khi review, dùng CLI trực tiếp để execute và xác nhận rõ ràng.
61
-
62
- ## CLI text-only
63
-
64
- Kiểm tra môi trường:
54
+ Check the local environment:
65
55
 
66
56
  ```bash
67
57
  cf-tunnel doctor
68
58
  ```
69
59
 
70
- Chạy `cf-tunnel` hoặc `cf-tunnel init` không kèm options để mở interactive wizard text-only. Wizard hỏi từng bước, in lỗi kèm cách sửa, hiển thị command preview và hỏi xác nhận trước khi execute.
60
+ Run `cf-tunnel` or `cf-tunnel init` without options to start the interactive text-only wizard. It asks for each value, validates before execution, prints a command preview, and asks for confirmation.
71
61
 
72
- Quick tunnel, chỉ validate/preview:
62
+ Preview a Quick Tunnel without starting `cloudflared`:
73
63
 
74
64
  ```bash
75
65
  cf-tunnel quick --url http://127.0.0.1:8000 --dry-run
76
66
  ```
77
67
 
78
- Named tunnel sau khi review plan:
68
+ Preview a named tunnel:
79
69
 
80
70
  ```bash
81
- cf-tunnel create --url http://127.0.0.1:8000 --name my-project --hostname tunnel.example.com
71
+ cf-tunnel create \
72
+ --url http://127.0.0.1:8000 \
73
+ --name my-project \
74
+ --hostname tunnel.example.com \
75
+ --dry-run
82
76
  ```
83
77
 
84
78
  Lifecycle commands:
@@ -87,22 +81,42 @@ Lifecycle commands:
87
81
  cf-tunnel start --name my-project
88
82
  cf-tunnel stop --name my-project
89
83
  cf-tunnel status --name my-project
90
- cf-tunnel init --profile custom --url http://127.0.0.1:8000 --dry-run
91
84
  ```
92
85
 
93
- `--yes` không bỏ qua validation và không bypass confirmation của Laravel `.env`.
86
+ `--yes` does not bypass validation or Laravel `.env` confirmation.
87
+
88
+ ## Makefile shortcuts
89
+
90
+ The repository includes a small Makefile for discoverable commands:
91
+
92
+ ```bash
93
+ make setup
94
+ make help
95
+ make init
96
+ make ui
97
+ make quick URL=http://127.0.0.1:8000
98
+ make create NAME=law-firm URL=http://127.0.0.1:8000
99
+ make doctor
100
+ make test
101
+ ```
102
+
103
+ `make quick` and `make create` use `--dry-run` by default. Review the plan, then use the CLI to execute and confirm the operation explicitly.
94
104
 
95
105
  ## Live UI
96
106
 
107
+ Start the local UI:
108
+
97
109
  ```bash
98
110
  cf-tunnel ui
99
111
  ```
100
112
 
101
- Mở URL được in ra, thường là `http://127.0.0.1:<port>`. Wizard gồm profile, local URL, tunnel name, validation và plan preview. UI chỉ lắng nghe loopback. Nút copy tạo prompt AI đã loại bỏ secret; package không tự gửi prompt đó đi đâu.
113
+ Open the URL printed in the terminal, usually `http://127.0.0.1:<port>`. The wizard includes profile selection, local URL input, tunnel name, validation, plan preview, confirmation, execution, and a button to copy a redacted AI-help prompt.
114
+
115
+ The UI binds to loopback by default and does not send the copied prompt anywhere.
102
116
 
103
117
  ## Custom profile
104
118
 
105
- Custom profile không đoán framework:
119
+ The custom profile makes no framework assumptions:
106
120
 
107
121
  ```bash
108
122
  cf-tunnel quick --profile custom --url http://127.0.0.1:3000 --dry-run
@@ -111,57 +125,104 @@ cf-tunnel create --profile custom --url http://127.0.0.1:8000 --name billing --d
111
125
 
112
126
  ## Laravel profile
113
127
 
114
- Laravel adapter kiểm tra `artisan` và `composer.json`, sau đó đề xuất mapping như `APP_URL`, `ASSET_URL` hoặc Reverb URL. Mỗi mapping phải hiện thành diff và cần confirmation riêng. Nếu `.env` thiếu hoặc không rõ, tool dừng với hướng dẫn; không tự đoán và không tự ghi ngầm.
128
+ The Laravel adapter checks for `artisan` and Laravel evidence in `composer.json`. It can propose mappings such as `APP_URL`, `ASSET_URL`, and optional Reverb URLs.
129
+
130
+ Every mapping is shown as a diff and requires explicit confirmation. If `.env` is missing or ambiguous, the adapter stops with a remediation message instead of guessing.
115
131
 
116
132
  ```bash
117
- cf-tunnel create --profile laravel --url http://127.0.0.1:8000 --name law-firm --dry-run
133
+ cf-tunnel create \
134
+ --profile laravel \
135
+ --url http://127.0.0.1:8000 \
136
+ --name law-firm \
137
+ --dry-run
118
138
  ```
119
139
 
120
140
  ## API
121
141
 
122
142
  ```ts
123
- import { validateTunnelConfig, createTunnelPlan, executeTunnelPlan } from 'cloudflare-tunnel-kit';
143
+ import {
144
+ validateTunnelConfig,
145
+ createTunnelPlan,
146
+ executeTunnelPlan,
147
+ } from 'cloudflare-tunnel-kit';
148
+
149
+ const config = {
150
+ profile: 'custom',
151
+ operation: 'quick',
152
+ localUrl: 'http://127.0.0.1:8000',
153
+ };
124
154
 
125
- const config = { profile: 'custom', operation: 'quick', localUrl: 'http://127.0.0.1:8000' };
126
155
  const validation = validateTunnelConfig(config);
127
- if (!validation.ok) for (const error of validation.issues) console.error(error.code, error.reason, error.fix);
156
+ if (!validation.ok) {
157
+ for (const error of validation.issues) {
158
+ console.error(error.code, error.reason, error.fix);
159
+ }
160
+ }
161
+
128
162
  const plan = createTunnelPlan(config);
129
163
  const result = await executeTunnelPlan(plan, { dryRun: true });
130
164
  console.log(result);
131
165
  ```
132
166
 
133
- Plan có thể serialize để hiển thị trong hệ thống riêng. Chỉ execute plan đã validated và sau khi người dùng approve confirmation group.
167
+ Plans are serializable and can be displayed inside another system. Execute only validated plans and provide the required confirmation groups.
134
168
 
135
169
  ## Error model
136
170
 
137
- Mỗi lỗi có `code`, `field` (nếu có), `reason` và `fix`. Mã thường gặp: `INPUT_INVALID_URL`, `INPUT_INVALID_HOSTNAME`, `INPUT_INVALID_TUNNEL_NAME`, `PATH_OUTSIDE_PROJECT`, `CONFIRMATION_REQUIRED`, `PROCESS_FAILED`.
171
+ Every error includes a stable `code`, optional `field`, `reason`, and `fix`. Common codes include:
172
+
173
+ - `INPUT_INVALID_URL`: the local URL is not HTTP/HTTPS.
174
+ - `INPUT_INVALID_HOSTNAME`: the hostname is not valid.
175
+ - `INPUT_INVALID_TUNNEL_NAME`: the tunnel name is unsafe.
176
+ - `PATH_OUTSIDE_PROJECT`: the config path escapes the project root.
177
+ - `CONFIRMATION_REQUIRED`: a mutation has not been confirmed.
178
+ - `PROCESS_FAILED`: `cloudflared` failed or could not be started.
138
179
 
139
- Khi copy lỗi để hỏi AI, kiểm tra lại prompt đã redact trước khi dán vào dịch vụ bên ngoài.
180
+ Review the redacted prompt before pasting it into an external AI service.
140
181
 
141
182
  ## Security model
142
183
 
143
- - UI bind `127.0.0.1` mặc định.
144
- - Process chạy argv array với shell disabled.
145
- - Secret-looking key/value, bearer token và credential path được redact.
146
- - File path được kiểm tra dưới project root.
147
- - Dry-run không gọi cloudflared.
148
- - Config overwrite và Laravel `.env` write phải được preview và confirm.
149
- - Không gửi telemetry hoặc diagnostic ra ngoài.
184
+ - The UI binds to `127.0.0.1` by default.
185
+ - Child processes use argv arrays with shell execution disabled.
186
+ - Secret-looking keys/values, bearer tokens, and credential paths are redacted.
187
+ - File paths are checked against the project root.
188
+ - Dry-run does not start `cloudflared`.
189
+ - Configuration overwrite and Laravel `.env` changes require a visible plan and confirmation.
190
+ - No telemetry or diagnostics are sent externally.
191
+
192
+ This toolkit does not replace review of Cloudflare account permissions, DNS, access policies, or organizational secret management.
193
+
194
+ ## Publishing to npm
195
+
196
+ After logging in to npm and completing any required 2FA verification:
150
197
 
151
- Tool không thay thế việc review Cloudflare account permissions, DNS, access policy hoặc secret management của tổ chức.
198
+ ```bash
199
+ npm install
200
+ npm run build
201
+ npm test
202
+ npm pack --dry-run
203
+ npm publish
204
+ ```
205
+
206
+ Increase the version before publishing a new release:
152
207
 
153
- ## Phát triển
208
+ ```bash
209
+ npm version patch
210
+ npm publish
211
+ ```
212
+
213
+ An already-published `name@version` cannot be published again. See the [npm publish documentation](https://docs.npmjs.com/cli/commands/npm-publish/).
214
+
215
+ ## Development
154
216
 
155
217
  ```bash
218
+ npm install
156
219
  npm test
157
220
  npm run build
158
221
  git diff --check
159
222
  ```
160
223
 
161
- GitHub Actions hiện chỉ chạy CI build/test với Node.js 24. Project không dùng `actions/deploy-pages` vì live UI là local Node server, không phải static site chạy trên GitHub Pages.
162
-
163
- Test dùng Node built-ins và temporary fixtures; không cần Cloudflare account. Khi đóng góp, thêm test trước cho behavior mới và không đưa secret thật vào fixture.
224
+ Tests use Node built-ins and temporary fixtures; no Cloudflare account is required. Add tests before introducing new behavior, do not place real secrets in fixtures, and keep remediation messages actionable.
164
225
 
165
226
  ## License
166
227
 
167
- MIT. Xem [LICENSE](LICENSE).
228
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ export interface AppPaths {
2
+ dataDir: string;
3
+ database: string;
4
+ projectsDir: string;
5
+ backupsDir: string;
6
+ }
7
+ export declare function resolveAppPaths(input?: {
8
+ platform?: string;
9
+ home?: string;
10
+ env?: Record<string, string | undefined>;
11
+ }): AppPaths;
@@ -0,0 +1,23 @@
1
+ import path from 'node:path';
2
+ export function resolveAppPaths(input = {}) {
3
+ const platform = input.platform ?? process.platform;
4
+ const env = input.env ?? process.env;
5
+ const home = input.home ?? env.HOME ?? env.USERPROFILE ?? '';
6
+ if (!home && platform !== 'win32')
7
+ throw new Error('Unable to determine the user home directory.');
8
+ const paths = platform === 'win32' ? path.win32 : path;
9
+ const root = platform === 'darwin'
10
+ ? paths.join(home, 'Library', 'Application Support')
11
+ : platform === 'win32'
12
+ ? (env.LOCALAPPDATA ?? paths.join(home, 'AppData', 'Local'))
13
+ : (env.XDG_DATA_HOME ?? paths.join(home, '.local', 'share'));
14
+ if (!root)
15
+ throw new Error('Unable to determine the local application-data directory.');
16
+ const dataDir = paths.join(root, 'cloudflare-tunnel-kit');
17
+ return {
18
+ dataDir,
19
+ database: paths.join(dataDir, 'state.db'),
20
+ projectsDir: paths.join(dataDir, 'projects'),
21
+ backupsDir: paths.join(dataDir, 'backups'),
22
+ };
23
+ }
@@ -1,5 +1,13 @@
1
- import type { ValidationIssue } from './types.js';
1
+ import type { TunnelError, ValidationIssue } from './types.js';
2
2
  export declare class TunnelKitError extends Error {
3
3
  readonly issue: ValidationIssue;
4
4
  constructor(issue: ValidationIssue);
5
5
  }
6
+ interface ErrorContext {
7
+ hostname?: string;
8
+ completedEffects?: string[];
9
+ exitCode?: number;
10
+ stderr?: string;
11
+ }
12
+ export declare function tunnelError(code: string, context?: ErrorContext): TunnelError;
13
+ export {};
@@ -1,3 +1,4 @@
1
+ import { redact } from './redact.js';
1
2
  export class TunnelKitError extends Error {
2
3
  issue;
3
4
  constructor(issue) {
@@ -6,3 +7,56 @@ export class TunnelKitError extends Error {
6
7
  this.name = 'TunnelKitError';
7
8
  }
8
9
  }
10
+ const definitions = {
11
+ AUTH_STALE: {
12
+ title: 'Cloudflare login has expired',
13
+ summary: () => 'The saved Cloudflare account certificate is no longer accepted.',
14
+ likelyCause: 'The account permissions changed, the login was revoked, or the certificate is stale.',
15
+ remediationSteps: ['Sign in to Cloudflare again.', 'Retry the interrupted workflow step.'],
16
+ availableActions: ['sign-in-again', 'retry', 'copy-diagnostics'],
17
+ retryFromStep: 'authentication',
18
+ },
19
+ CLOUDFLARED_OUTPUT_UNRECOGNIZED: {
20
+ title: 'Cloudflare output was not recognized',
21
+ summary: () => 'cloudflared completed, but its output did not contain expected resource information.',
22
+ likelyCause: 'The installed cloudflared version may use an unsupported output format.',
23
+ remediationSteps: ['Check the installed cloudflared version.', 'Copy the redacted diagnostics when reporting this compatibility issue.'],
24
+ availableActions: ['retry', 'copy-diagnostics'],
25
+ },
26
+ DNS_PERMISSION_DENIED: {
27
+ title: 'DNS permission denied',
28
+ summary: context => `Cloudflare did not allow this account to create a DNS record for ${context.hostname ?? 'the requested hostname'}.`,
29
+ likelyCause: 'The selected account or zone does not have permission to create DNS records for this hostname.',
30
+ remediationSteps: [
31
+ 'Sign in again with an account that manages the requested domain.',
32
+ 'Select the correct zone during Cloudflare login.',
33
+ 'Retry the DNS step after access is corrected.',
34
+ ],
35
+ availableActions: ['sign-in-again', 'retry', 'copy-diagnostics'],
36
+ retryFromStep: 'dns-route',
37
+ },
38
+ CLOUDFLARED_COMMAND_FAILED: {
39
+ title: 'Cloudflare command failed',
40
+ summary: () => 'cloudflared exited before the requested operation completed.',
41
+ likelyCause: 'The command returned an error that Cloudflare Tunnel Kit does not recognize yet.',
42
+ remediationSteps: ['Review the redacted diagnostics.', 'Retry after correcting the reported Cloudflare or local environment issue.'],
43
+ availableActions: ['retry', 'copy-diagnostics'],
44
+ },
45
+ };
46
+ export function tunnelError(code, context = {}) {
47
+ const definition = definitions[code] ?? definitions.CLOUDFLARED_COMMAND_FAILED;
48
+ const safeDiagnostics = context.exitCode === undefined && context.stderr === undefined
49
+ ? undefined
50
+ : { exitCode: context.exitCode, stderr: redact(context.stderr ?? '') };
51
+ return {
52
+ code: definitions[code] ? code : 'CLOUDFLARED_COMMAND_FAILED',
53
+ title: definition.title,
54
+ summary: definition.summary(context),
55
+ likelyCause: definition.likelyCause,
56
+ completedEffects: context.completedEffects ?? [],
57
+ remediationSteps: definition.remediationSteps,
58
+ availableActions: definition.availableActions,
59
+ safeDiagnostics,
60
+ retryFromStep: definition.retryFromStep,
61
+ };
62
+ }
@@ -0,0 +1,18 @@
1
+ export interface OriginIssue {
2
+ code: string;
3
+ reason: string;
4
+ fix: string;
5
+ }
6
+ export type OriginCheckResult = {
7
+ reachable: true;
8
+ status: number;
9
+ warning?: OriginIssue;
10
+ } | {
11
+ reachable: false;
12
+ error: OriginIssue;
13
+ };
14
+ export declare function checkOrigin(localUrl: string, options?: {
15
+ timeoutMs?: number;
16
+ maxRedirects?: number;
17
+ fetchImpl?: typeof fetch;
18
+ }): Promise<OriginCheckResult>;
@@ -0,0 +1,34 @@
1
+ function issue(code, reason, fix) { return { code, reason, fix }; }
2
+ export async function checkOrigin(localUrl, options = {}) {
3
+ const timeoutMs = options.timeoutMs ?? 3_000;
4
+ const maxRedirects = options.maxRedirects ?? 5;
5
+ const fetchImpl = options.fetchImpl ?? fetch;
6
+ let current = localUrl;
7
+ try {
8
+ for (let redirects = 0; redirects <= maxRedirects; redirects += 1) {
9
+ const response = await fetchImpl(current, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
10
+ if (response.status >= 300 && response.status < 400 && response.headers.get('location')) {
11
+ if (redirects === maxRedirects)
12
+ return { reachable: false, error: issue('ORIGIN_REDIRECT_LOOP', 'The local origin exceeded the redirect limit.', 'Fix the local redirect configuration before creating the tunnel.') };
13
+ current = new URL(response.headers.get('location'), current).toString();
14
+ continue;
15
+ }
16
+ if (response.status >= 400 && response.status < 500)
17
+ return { reachable: true, status: response.status, warning: issue('ORIGIN_HTTP_CLIENT_ERROR', `The local origin responded with HTTP ${response.status}.`, 'The service is reachable; verify that the requested path is expected to return this status.') };
18
+ if (response.status >= 500)
19
+ return { reachable: true, status: response.status, warning: issue('ORIGIN_HTTP_SERVER_ERROR', `The local origin responded with HTTP ${response.status}.`, 'Fix the local application error or continue only if this response is expected.') };
20
+ return { reachable: true, status: response.status };
21
+ }
22
+ return { reachable: false, error: issue('ORIGIN_REDIRECT_LOOP', 'The local origin exceeded the redirect limit.', 'Fix the local redirect configuration before creating the tunnel.') };
23
+ }
24
+ catch (error) {
25
+ const message = error instanceof Error ? `${error.message} ${String(error.cause?.code ?? '')}` : String(error);
26
+ if (/ECONNREFUSED|fetch failed/i.test(message))
27
+ return { reachable: false, error: issue('ORIGIN_CONNECTION_REFUSED', 'The local service refused the connection.', 'Start the local application and verify its host and port.') };
28
+ if (/TimeoutError|timed out|AbortError/i.test(message))
29
+ return { reachable: false, error: issue('ORIGIN_TIMEOUT', 'The local service did not respond before the timeout.', 'Verify the local URL and that the application is responsive.') };
30
+ if (/certificate|TLS|SSL/i.test(message))
31
+ return { reachable: false, error: issue('ORIGIN_TLS_INVALID', 'The local HTTPS certificate could not be verified.', 'Use a trusted local certificate or explicitly choose the advanced insecure-origin option.') };
32
+ return { reachable: false, error: issue('ORIGIN_UNREACHABLE', 'The local service could not be reached.', 'Verify the local URL and inspect the application logs.') };
33
+ }
34
+ }
@@ -1,3 +1,4 @@
1
1
  import type { ValidationIssue } from './types.js';
2
2
  export declare function redact(value: string, key?: string): string;
3
+ export declare function redactValue(value: unknown, key?: string): unknown;
3
4
  export declare function aiPrompt(issue: ValidationIssue): string;
@@ -4,6 +4,18 @@ export function redact(value, key) {
4
4
  return '[REDACTED]';
5
5
  return value.replace(/(Bearer\s+)[^\s]+/gi, '$1[REDACTED]').replace(/([A-Za-z0-9_-]{24,})/g, '[REDACTED]');
6
6
  }
7
+ export function redactValue(value, key) {
8
+ if (key && secretKey.test(key))
9
+ return '[REDACTED]';
10
+ if (typeof value === 'string')
11
+ return redact(value, key);
12
+ if (Array.isArray(value))
13
+ return value.map(item => redactValue(item));
14
+ if (value && typeof value === 'object') {
15
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactValue(entryValue, entryKey)]));
16
+ }
17
+ return value;
18
+ }
7
19
  export function aiPrompt(issue) {
8
20
  return `I am configuring cloudflare-tunnel-kit.\nError: ${issue.code}\nField: ${issue.field ?? 'environment'}\nReason: ${issue.reason}\nHow can I fix it? Do not expose secrets.`;
9
21
  }
@@ -1,5 +1,30 @@
1
1
  export type Profile = 'custom' | 'laravel';
2
2
  export type Operation = 'quick' | 'create' | 'start' | 'stop' | 'status';
3
+ export type TunnelKind = 'quick' | 'named';
4
+ export type WorkflowStepState = 'pending' | 'running' | 'succeeded' | 'warning' | 'failed' | 'skipped' | 'cancelled';
5
+ export type NamedStep = 'environment' | 'input' | 'origin' | 'filesystem' | 'git-safety' | 'authentication' | 'account-access' | 'tunnel' | 'configuration' | 'ingress-validation' | 'dns-route' | 'connector' | 'cloudflare-health' | 'public-health';
6
+ export type RecoveryAction = 'retry' | 'sign-in-again' | 'change-input' | 'open-dashboard' | 'copy-diagnostics' | 'stop' | 'force-stop';
7
+ export interface TunnelError {
8
+ code: string;
9
+ title: string;
10
+ summary: string;
11
+ likelyCause: string;
12
+ completedEffects: string[];
13
+ remediationSteps: string[];
14
+ availableActions: RecoveryAction[];
15
+ safeDiagnostics?: Record<string, unknown>;
16
+ retryFromStep?: NamedStep;
17
+ }
18
+ export interface WorkflowStepResult {
19
+ name: string;
20
+ state: WorkflowStepState;
21
+ attempts: number;
22
+ startedAt?: string;
23
+ finishedAt?: string;
24
+ effects: string[];
25
+ error?: TunnelError;
26
+ }
27
+ export declare function stepState(value: string): WorkflowStepState;
3
28
  export interface TunnelConfig {
4
29
  profile: Profile;
5
30
  operation: Operation;
@@ -1 +1,6 @@
1
- export {};
1
+ export function stepState(value) {
2
+ const values = ['pending', 'running', 'succeeded', 'warning', 'failed', 'skipped', 'cancelled'];
3
+ if (!values.includes(value))
4
+ throw new Error(`Unknown workflow step state: ${value}`);
5
+ return value;
6
+ }
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { realpathSync } from 'node:fs';
3
+ import { domainToASCII } from 'node:url';
3
4
  function issue(code, reason, fix, field) { return { code, reason, fix, field }; }
4
5
  function inside(root, candidate) {
5
6
  try {
@@ -26,13 +27,17 @@ export function validateTunnelConfig(input) {
26
27
  issues.push(issue('INPUT_INVALID_PROFILE', 'Unknown project profile.', 'Choose custom or laravel.', 'profile'));
27
28
  if (input.operation === 'create' && !input.tunnelName)
28
29
  issues.push(issue('INPUT_TUNNEL_NAME_REQUIRED', 'Named tunnels require a tunnel name.', 'Use lowercase letters, numbers, and hyphens.', 'tunnelName'));
30
+ if (input.operation === 'create' && !input.hostname)
31
+ issues.push(issue('INPUT_HOSTNAME_REQUIRED', 'Named tunnels require a public hostname.', 'Use a hostname from a domain already managed by Cloudflare, such as dev.example.com.', 'hostname'));
29
32
  if (input.tunnelName && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(input.tunnelName))
30
33
  issues.push(issue('INPUT_INVALID_TUNNEL_NAME', 'Tunnel name is not DNS-safe.', 'Use 1-63 lowercase letters, numbers, or hyphens.', 'tunnelName'));
31
- if (input.hostname && !/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(input.hostname))
32
- issues.push(issue('INPUT_INVALID_HOSTNAME', 'Hostname is not a valid DNS hostname.', 'Use a fully qualified hostname such as tunnel.example.com.', 'hostname'));
34
+ const asciiHostname = input.hostname ? domainToASCII(input.hostname.trim().toLowerCase()) : '';
35
+ if (input.hostname && (!asciiHostname || asciiHostname.includes('*') || !/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])$/i.test(asciiHostname))) {
36
+ issues.push(issue('INPUT_INVALID_HOSTNAME', 'Hostname is not a valid public DNS hostname.', 'Use a fully qualified hostname such as tunnel.example.com, without a scheme, path, wildcard, or IP address.', 'hostname'));
37
+ }
33
38
  const root = path.resolve(input.projectRoot ?? process.cwd());
34
39
  if (input.configPath && !inside(root, path.resolve(root, input.configPath)))
35
40
  issues.push(issue('PATH_OUTSIDE_PROJECT', 'Config path escapes the project root.', 'Choose a path inside projectRoot.', 'configPath'));
36
- const normalized = { ...input, localUrl: url?.toString().replace(/\/$/, '') ?? input.localUrl, projectRoot: root };
41
+ const normalized = { ...input, localUrl: url?.toString().replace(/\/$/, '') ?? input.localUrl, hostname: asciiHostname || input.hostname, projectRoot: root };
37
42
  return { ok: issues.length === 0, issues, normalized: issues.length === 0 ? normalized : undefined };
38
43
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  export * from './core/types.js';
2
+ export * from './core/errors.js';
2
3
  export * from './core/validation.js';
4
+ export * from './core/origin-check.js';
3
5
  export * from './core/plan.js';
4
6
  export * from './core/execution.js';
5
7
  export * from './core/redact.js';
6
8
  export * from './adapters/laravel.js';
9
+ export * from './app/paths.js';
10
+ export * from './persistence/database.js';
11
+ export * from './persistence/store.js';
12
+ export * from './providers/command-runner.js';
13
+ export * from './providers/cloudflared.js';
14
+ export * from './providers/git-safety.js';
15
+ export * from './providers/process-supervisor.js';
package/dist/index.js CHANGED
@@ -1,6 +1,15 @@
1
1
  export * from './core/types.js';
2
+ export * from './core/errors.js';
2
3
  export * from './core/validation.js';
4
+ export * from './core/origin-check.js';
3
5
  export * from './core/plan.js';
4
6
  export * from './core/execution.js';
5
7
  export * from './core/redact.js';
6
8
  export * from './adapters/laravel.js';
9
+ export * from './app/paths.js';
10
+ export * from './persistence/database.js';
11
+ export * from './persistence/store.js';
12
+ export * from './providers/command-runner.js';
13
+ export * from './providers/cloudflared.js';
14
+ export * from './providers/git-safety.js';
15
+ export * from './providers/process-supervisor.js';
@@ -0,0 +1,2 @@
1
+ import Database from 'better-sqlite3';
2
+ export declare function openStateDatabase(filename: string): Database.Database;
@@ -0,0 +1,32 @@
1
+ import Database from 'better-sqlite3';
2
+ import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { migrations } from './migrations.js';
5
+ export function openStateDatabase(filename) {
6
+ mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
7
+ const existed = existsSync(filename);
8
+ const db = new Database(filename);
9
+ db.pragma('foreign_keys = ON');
10
+ db.pragma('journal_mode = WAL');
11
+ db.pragma('busy_timeout = 3000');
12
+ db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
13
+ const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map(row => row.version));
14
+ const pending = migrations.filter(migration => !applied.has(migration.version));
15
+ if (existed && pending.length > 0)
16
+ copyFileSync(filename, `${filename}.pre-migration-backup`);
17
+ for (const migration of pending) {
18
+ const apply = db.transaction(() => {
19
+ db.exec(migration.sql);
20
+ db.prepare('INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)')
21
+ .run(migration.version, new Date().toISOString());
22
+ });
23
+ try {
24
+ apply();
25
+ }
26
+ catch (error) {
27
+ db.close();
28
+ throw error;
29
+ }
30
+ }
31
+ return db;
32
+ }
@@ -0,0 +1,5 @@
1
+ export interface Migration {
2
+ version: number;
3
+ sql: string;
4
+ }
5
+ export declare const migrations: Migration[];
@@ -0,0 +1,78 @@
1
+ export const migrations = [{
2
+ version: 1,
3
+ sql: `
4
+ CREATE TABLE projects (
5
+ id TEXT PRIMARY KEY,
6
+ display_name TEXT NOT NULL,
7
+ path TEXT NOT NULL UNIQUE,
8
+ profile TEXT NOT NULL,
9
+ created_at TEXT NOT NULL,
10
+ updated_at TEXT NOT NULL
11
+ );
12
+ CREATE TABLE tunnels (
13
+ id TEXT PRIMARY KEY,
14
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
15
+ kind TEXT NOT NULL,
16
+ name TEXT,
17
+ uuid TEXT UNIQUE,
18
+ hostname TEXT,
19
+ local_url TEXT,
20
+ config_path TEXT,
21
+ credentials_path TEXT,
22
+ created_at TEXT NOT NULL,
23
+ updated_at TEXT NOT NULL
24
+ );
25
+ CREATE TABLE workflow_runs (
26
+ id TEXT PRIMARY KEY,
27
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
28
+ kind TEXT NOT NULL,
29
+ state TEXT NOT NULL,
30
+ current_step TEXT,
31
+ created_at TEXT NOT NULL,
32
+ updated_at TEXT NOT NULL,
33
+ finished_at TEXT
34
+ );
35
+ CREATE TABLE workflow_steps (
36
+ id TEXT PRIMARY KEY,
37
+ workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
38
+ name TEXT NOT NULL,
39
+ state TEXT NOT NULL,
40
+ attempts INTEGER NOT NULL,
41
+ effects_json TEXT NOT NULL,
42
+ safe_result_json TEXT NOT NULL,
43
+ error_json TEXT,
44
+ started_at TEXT,
45
+ finished_at TEXT,
46
+ UNIQUE(workflow_run_id, name)
47
+ );
48
+ CREATE TABLE process_sessions (
49
+ id TEXT PRIMARY KEY,
50
+ tunnel_id TEXT REFERENCES tunnels(id) ON DELETE CASCADE,
51
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
52
+ process_key TEXT NOT NULL,
53
+ pid INTEGER,
54
+ executable TEXT,
55
+ argv_fingerprint TEXT,
56
+ state TEXT NOT NULL,
57
+ ephemeral_url TEXT,
58
+ ephemeral_url_expired INTEGER NOT NULL DEFAULT 0,
59
+ started_at TEXT NOT NULL,
60
+ stopped_at TEXT
61
+ );
62
+ CREATE TABLE events (
63
+ id TEXT PRIMARY KEY,
64
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
65
+ workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE CASCADE,
66
+ severity TEXT NOT NULL,
67
+ message TEXT NOT NULL,
68
+ safe_data_json TEXT NOT NULL,
69
+ created_at TEXT NOT NULL
70
+ );
71
+ CREATE TABLE installations (
72
+ id TEXT PRIMARY KEY,
73
+ component TEXT NOT NULL UNIQUE,
74
+ version TEXT NOT NULL,
75
+ checked_at TEXT NOT NULL
76
+ );
77
+ `,
78
+ }];
@@ -0,0 +1,51 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { Profile, TunnelKind, WorkflowStepState } from '../core/types.js';
3
+ export interface SavedProject {
4
+ id: string;
5
+ displayName: string;
6
+ path: string;
7
+ profile: Profile;
8
+ createdAt: string;
9
+ updatedAt: string;
10
+ }
11
+ export interface SavedWorkflow {
12
+ id: string;
13
+ projectId: string;
14
+ kind: TunnelKind;
15
+ state: WorkflowStepState;
16
+ currentStep?: string;
17
+ steps: SavedWorkflowStep[];
18
+ }
19
+ export interface SavedWorkflowStep {
20
+ name: string;
21
+ state: WorkflowStepState;
22
+ attempts: number;
23
+ effects: string[];
24
+ safeResult: unknown;
25
+ error?: unknown;
26
+ }
27
+ export declare class StateStore {
28
+ private readonly db;
29
+ constructor(db: Database.Database);
30
+ saveProject(input: {
31
+ displayName: string;
32
+ path: string;
33
+ profile: Profile;
34
+ }): SavedProject;
35
+ listProjects(): SavedProject[];
36
+ createWorkflow(input: {
37
+ projectId: string;
38
+ kind: TunnelKind;
39
+ }): {
40
+ id: string;
41
+ };
42
+ recordStep(runId: string, input: {
43
+ name: string;
44
+ state: WorkflowStepState;
45
+ attempts: number;
46
+ effects: string[];
47
+ safeResult: unknown;
48
+ error?: unknown;
49
+ }): void;
50
+ getWorkflow(id: string): SavedWorkflow;
51
+ }
@@ -0,0 +1,59 @@
1
+ import crypto from 'node:crypto';
2
+ import { redactValue } from '../core/redact.js';
3
+ function safeJson(value) { return JSON.stringify(redactValue(value)); }
4
+ export class StateStore {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ saveProject(input) {
10
+ const existing = this.db.prepare('SELECT id, created_at FROM projects WHERE path = ?').get(input.path);
11
+ const now = new Date().toISOString();
12
+ const id = existing?.id ?? crypto.randomUUID();
13
+ const createdAt = existing?.created_at ?? now;
14
+ this.db.prepare(`
15
+ INSERT INTO projects(id, display_name, path, profile, created_at, updated_at)
16
+ VALUES (?, ?, ?, ?, ?, ?)
17
+ ON CONFLICT(path) DO UPDATE SET display_name=excluded.display_name, profile=excluded.profile, updated_at=excluded.updated_at
18
+ `).run(id, input.displayName, input.path, input.profile, createdAt, now);
19
+ return { id, ...input, createdAt, updatedAt: now };
20
+ }
21
+ listProjects() {
22
+ const rows = this.db.prepare('SELECT id, display_name, path, profile, created_at, updated_at FROM projects ORDER BY updated_at DESC').all();
23
+ return rows.map(row => ({ id: row.id, displayName: row.display_name, path: row.path, profile: row.profile, createdAt: row.created_at, updatedAt: row.updated_at }));
24
+ }
25
+ createWorkflow(input) {
26
+ const id = crypto.randomUUID();
27
+ const now = new Date().toISOString();
28
+ this.db.prepare('INSERT INTO workflow_runs(id, project_id, kind, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
29
+ .run(id, input.projectId, input.kind, 'pending', now, now);
30
+ return { id };
31
+ }
32
+ recordStep(runId, input) {
33
+ const now = new Date().toISOString();
34
+ this.db.prepare(`
35
+ INSERT INTO workflow_steps(id, workflow_run_id, name, state, attempts, effects_json, safe_result_json, error_json, started_at, finished_at)
36
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
37
+ ON CONFLICT(workflow_run_id, name) DO UPDATE SET
38
+ state=excluded.state, attempts=excluded.attempts, effects_json=excluded.effects_json,
39
+ safe_result_json=excluded.safe_result_json, error_json=excluded.error_json,
40
+ started_at=COALESCE(workflow_steps.started_at, excluded.started_at), finished_at=excluded.finished_at
41
+ `).run(crypto.randomUUID(), runId, input.name, input.state, input.attempts, safeJson(input.effects), safeJson(input.safeResult), input.error === undefined ? null : safeJson(input.error), now, ['succeeded', 'warning', 'failed', 'cancelled'].includes(input.state) ? now : null);
42
+ this.db.prepare('UPDATE workflow_runs SET state=?, current_step=?, updated_at=? WHERE id=?')
43
+ .run(input.state === 'failed' ? 'failed' : input.state === 'cancelled' ? 'cancelled' : 'running', input.name, now, runId);
44
+ }
45
+ getWorkflow(id) {
46
+ const run = this.db.prepare('SELECT id, project_id, kind, state, current_step FROM workflow_runs WHERE id = ?').get(id);
47
+ if (!run)
48
+ throw new Error(`Workflow not found: ${id}`);
49
+ const rows = this.db.prepare('SELECT name, state, attempts, effects_json, safe_result_json, error_json FROM workflow_steps WHERE workflow_run_id = ? ORDER BY rowid').all(id);
50
+ return {
51
+ id: run.id, projectId: run.project_id, kind: run.kind, state: run.state, currentStep: run.current_step ?? undefined,
52
+ steps: rows.map(row => ({
53
+ name: row.name, state: row.state, attempts: row.attempts,
54
+ effects: JSON.parse(row.effects_json), safeResult: JSON.parse(row.safe_result_json),
55
+ error: row.error_json ? JSON.parse(row.error_json) : undefined,
56
+ })),
57
+ };
58
+ }
59
+ }
@@ -1,6 +1,51 @@
1
- export interface ProcessResult {
1
+ import type { TunnelError } from '../core/types.js';
2
+ export type Result<T> = {
3
+ ok: true;
4
+ value: T;
5
+ } | {
6
+ ok: false;
7
+ error: TunnelError;
8
+ };
9
+ export interface TunnelObservation {
10
+ uuid: string;
11
+ name: string;
12
+ createdAt?: string;
13
+ connections: number;
14
+ }
15
+ export declare class CloudflaredAdapter {
16
+ private readonly executable;
17
+ private readonly baseArgs;
18
+ private readonly env;
19
+ constructor(options?: {
20
+ executable?: string;
21
+ baseArgs?: string[];
22
+ env?: NodeJS.ProcessEnv;
23
+ });
24
+ private command;
25
+ private failure;
26
+ version(): Promise<Result<{
27
+ version: string;
28
+ }>>;
29
+ login(): Promise<Result<{
30
+ completed: true;
31
+ }>>;
32
+ listTunnels(): Promise<Result<TunnelObservation[]>>;
33
+ createTunnel(name: string): Promise<Result<{
34
+ uuid: string;
35
+ credentialsFile: string;
36
+ }>>;
37
+ validateIngress(configPath: string): Promise<Result<{
38
+ valid: true;
39
+ }>>;
40
+ routeDns(tunnel: string, hostname: string): Promise<Result<{
41
+ hostname: string;
42
+ }>>;
43
+ info(tunnel: string): Promise<Result<{
44
+ connectorState: 'healthy' | 'degraded' | 'disconnected' | 'unknown';
45
+ }>>;
46
+ }
47
+ export declare function runCloudflared(args: string[], timeoutMs?: number): Promise<{
2
48
  code: number;
3
49
  stdout: string;
4
50
  stderr: string;
5
- }
6
- export declare function runCloudflared(args: string[], timeoutMs?: number): Promise<ProcessResult>;
51
+ }>;
@@ -1,5 +1,79 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { redact } from '../core/redact.js';
2
+ import { tunnelError } from '../core/errors.js';
3
+ import { runCommand } from './command-runner.js';
4
+ export class CloudflaredAdapter {
5
+ executable;
6
+ baseArgs;
7
+ env;
8
+ constructor(options = {}) {
9
+ this.executable = options.executable ?? 'cloudflared';
10
+ this.baseArgs = options.baseArgs ?? [];
11
+ this.env = options.env ?? process.env;
12
+ }
13
+ command(args, timeoutMs = 30_000) {
14
+ return runCommand({ executable: this.executable, args: [...this.baseArgs, ...args], env: this.env, timeoutMs });
15
+ }
16
+ failure(result) {
17
+ const stderr = redact(result.stderr);
18
+ if (/authenticate|origin certificate/i.test(result.stderr) && /invalid|revoked|expired/i.test(result.stderr)) {
19
+ return { ok: false, error: tunnelError('AUTH_STALE', { exitCode: result.exitCode, stderr }) };
20
+ }
21
+ return { ok: false, error: tunnelError('CLOUDFLARED_COMMAND_FAILED', { exitCode: result.exitCode, stderr }) };
22
+ }
23
+ async version() {
24
+ const result = await this.command(['--version']);
25
+ if (result.exitCode !== 0)
26
+ return this.failure(result);
27
+ const match = result.stdout.match(/cloudflared version\s+([^\s]+)/i);
28
+ return match ? { ok: true, value: { version: match[1] } } : { ok: false, error: tunnelError('CLOUDFLARED_OUTPUT_UNRECOGNIZED', { stderr: result.stdout }) };
29
+ }
30
+ async login() {
31
+ const result = await this.command(['tunnel', 'login'], 180_000);
32
+ return result.exitCode === 0 ? { ok: true, value: { completed: true } } : this.failure(result);
33
+ }
34
+ async listTunnels() {
35
+ const result = await this.command(['tunnel', 'list', '--output', 'json']);
36
+ if (result.exitCode !== 0)
37
+ return this.failure(result);
38
+ try {
39
+ const rows = JSON.parse(result.stdout);
40
+ return { ok: true, value: rows.map(row => ({ uuid: row.id ?? row.uuid ?? '', name: row.name, createdAt: row.createdAt, connections: row.connections?.length ?? 0 })).filter(row => row.uuid) };
41
+ }
42
+ catch {
43
+ return { ok: false, error: tunnelError('CLOUDFLARED_OUTPUT_UNRECOGNIZED', { stderr: result.stdout }) };
44
+ }
45
+ }
46
+ async createTunnel(name) {
47
+ const result = await this.command(['tunnel', 'create', name]);
48
+ if (result.exitCode !== 0)
49
+ return this.failure(result);
50
+ const uuid = result.stdout.match(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i)?.[0];
51
+ const credentialsFile = result.stdout.match(/(?:written to|credentials[^\n]*?)\s+([^\s]+\.json)/i)?.[1];
52
+ if (!uuid || !credentialsFile)
53
+ return { ok: false, error: tunnelError('CLOUDFLARED_OUTPUT_UNRECOGNIZED', { stderr: result.stdout }) };
54
+ return { ok: true, value: { uuid, credentialsFile } };
55
+ }
56
+ async validateIngress(configPath) {
57
+ const result = await this.command(['tunnel', 'ingress', 'validate', '--config', configPath]);
58
+ return result.exitCode === 0 ? { ok: true, value: { valid: true } } : this.failure(result);
59
+ }
60
+ async routeDns(tunnel, hostname) {
61
+ const result = await this.command(['tunnel', 'route', 'dns', tunnel, hostname]);
62
+ return result.exitCode === 0 ? { ok: true, value: { hostname } } : this.failure(result);
63
+ }
64
+ async info(tunnel) {
65
+ const result = await this.command(['tunnel', 'info', tunnel, '--output', 'json']);
66
+ if (result.exitCode !== 0)
67
+ return this.failure(result);
68
+ try {
69
+ const value = JSON.parse(result.stdout);
70
+ return { ok: true, value: { connectorState: value.connections?.length ? 'healthy' : 'disconnected' } };
71
+ }
72
+ catch {
73
+ return { ok: false, error: tunnelError('CLOUDFLARED_OUTPUT_UNRECOGNIZED', { stderr: result.stdout }) };
74
+ }
75
+ }
76
+ }
3
77
  export function runCloudflared(args, timeoutMs = 120_000) {
4
- return new Promise(resolve => { const child = spawn('cloudflared', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = '', stderr = ''; const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs); child.stdout.on('data', (d) => stdout += d); child.stderr.on('data', (d) => stderr += d); child.on('error', (e) => { clearTimeout(timer); resolve({ code: 127, stdout: '', stderr: redact(e.message) }); }); child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? 1, stdout: redact(stdout), stderr: redact(stderr) }); }); });
78
+ return runCommand({ executable: 'cloudflared', args, timeoutMs }).then(result => ({ code: result.exitCode, stdout: redact(result.stdout), stderr: redact(result.stderr) }));
5
79
  }
@@ -0,0 +1,16 @@
1
+ export interface CommandResult {
2
+ exitCode: number;
3
+ signal: string | null;
4
+ stdout: string;
5
+ stderr: string;
6
+ timedOut: boolean;
7
+ }
8
+ export declare function runCommand(options: {
9
+ executable: string;
10
+ args: string[];
11
+ env?: NodeJS.ProcessEnv;
12
+ cwd?: string;
13
+ timeoutMs?: number;
14
+ signal?: AbortSignal;
15
+ maxOutputBytes?: number;
16
+ }): Promise<CommandResult>;
@@ -0,0 +1,26 @@
1
+ import { spawn } from 'node:child_process';
2
+ export function runCommand(options) {
3
+ const timeoutMs = options.timeoutMs ?? 30_000;
4
+ const maxOutputBytes = options.maxOutputBytes ?? 256 * 1024;
5
+ return new Promise(resolve => {
6
+ let settled = false;
7
+ let stdout = '', stderr = '', timedOut = false;
8
+ const child = spawn(options.executable, options.args, { cwd: options.cwd, env: options.env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
9
+ const append = (current, chunk) => Buffer.from(current + chunk.toString('utf8')).subarray(-maxOutputBytes).toString('utf8');
10
+ child.stdout.on('data', (chunk) => { stdout = append(stdout, chunk); });
11
+ child.stderr.on('data', (chunk) => { stderr = append(stderr, chunk); });
12
+ const finish = (exitCode, signal) => {
13
+ if (settled)
14
+ return;
15
+ settled = true;
16
+ clearTimeout(timer);
17
+ options.signal?.removeEventListener('abort', abort);
18
+ resolve({ exitCode, signal, stdout, stderr, timedOut });
19
+ };
20
+ const abort = () => child.kill('SIGTERM');
21
+ options.signal?.addEventListener('abort', abort, { once: true });
22
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, timeoutMs);
23
+ child.on('error', error => { stderr = append(stderr, Buffer.from(error.message)); finish(127, null); });
24
+ child.on('close', (code, signal) => finish(code ?? 1, signal));
25
+ });
26
+ }
@@ -0,0 +1,13 @@
1
+ export interface GitSafetyIssue {
2
+ code: 'GIT_SENSITIVE_FILE_TRACKED';
3
+ reason: string;
4
+ fix: string;
5
+ paths: string[];
6
+ }
7
+ export interface GitSafetyResult {
8
+ repository: boolean;
9
+ root?: string;
10
+ issues: GitSafetyIssue[];
11
+ }
12
+ export declare function inspectGitSafety(candidate: string): Promise<GitSafetyResult>;
13
+ export declare function applyLocalExclude(repository: string, rule: string): Promise<void>;
@@ -0,0 +1,58 @@
1
+ import { readFile, rename, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runCommand } from './command-runner.js';
4
+ async function git(root, args) {
5
+ return runCommand({ executable: 'git', args: ['-C', root, ...args], timeoutMs: 10_000 });
6
+ }
7
+ function sensitive(file) {
8
+ const name = path.basename(file).toLowerCase();
9
+ return name === 'cert.pem'
10
+ || /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.json$/i.test(name)
11
+ || /\.(key|p12|pfx)$/i.test(name)
12
+ || file.split(/[\\/]/).includes('.cloudflare-tunnel-kit');
13
+ }
14
+ export async function inspectGitSafety(candidate) {
15
+ const rootResult = await git(candidate, ['rev-parse', '--show-toplevel']);
16
+ if (rootResult.exitCode !== 0)
17
+ return { repository: false, issues: [] };
18
+ const root = rootResult.stdout.trim();
19
+ const [trackedResult, stagedResult] = await Promise.all([
20
+ git(root, ['ls-files']),
21
+ git(root, ['diff', '--cached', '--name-only']),
22
+ ]);
23
+ const files = new Set([...trackedResult.stdout.split('\n'), ...stagedResult.stdout.split('\n')].map(value => value.trim()).filter(Boolean));
24
+ const paths = [...files].filter(sensitive).sort();
25
+ return {
26
+ repository: true,
27
+ root,
28
+ issues: paths.length ? [{
29
+ code: 'GIT_SENSITIVE_FILE_TRACKED',
30
+ reason: 'Git is tracking or staging a Cloudflare credential or generated tunnel file.',
31
+ fix: 'Remove the file from Git tracking and rotate any credential that may already have been shared.',
32
+ paths,
33
+ }] : [],
34
+ };
35
+ }
36
+ export async function applyLocalExclude(repository, rule) {
37
+ if (!rule.trim() || /[\r\n]/.test(rule))
38
+ throw new Error('A local Git exclude rule must be one non-empty line.');
39
+ const locationResult = await git(repository, ['rev-parse', '--git-path', 'info/exclude']);
40
+ if (locationResult.exitCode !== 0)
41
+ throw new Error('The selected project is not a Git repository.');
42
+ const location = path.resolve(repository, locationResult.stdout.trim());
43
+ let content = '';
44
+ try {
45
+ content = await readFile(location, 'utf8');
46
+ }
47
+ catch (error) {
48
+ if (error.code !== 'ENOENT')
49
+ throw error;
50
+ }
51
+ const lines = content.split(/\r?\n/);
52
+ if (lines.includes(rule))
53
+ return;
54
+ const next = `${content}${content && !content.endsWith('\n') ? '\n' : ''}${rule}\n`;
55
+ const temporary = `${location}.cloudflare-tunnel-kit.tmp`;
56
+ await writeFile(temporary, next, { encoding: 'utf8', mode: 0o600 });
57
+ await rename(temporary, location);
58
+ }
@@ -0,0 +1,39 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export type ManagedProcessState = 'starting' | 'running' | 'stopping' | 'stopped' | 'failed';
3
+ export declare class ManagedSession {
4
+ readonly key: string;
5
+ readonly child: ChildProcess;
6
+ private readonly maxLogBytes;
7
+ state: ManagedProcessState;
8
+ readonly startedAt: string;
9
+ readonly pid?: number;
10
+ private logText;
11
+ private readonly events;
12
+ constructor(key: string, child: ChildProcess, maxLogBytes: number);
13
+ append(value: string): void;
14
+ logs(): string;
15
+ waitForOutput(pattern: RegExp, timeoutMs: number): Promise<string>;
16
+ }
17
+ export declare class ProcessSupervisor {
18
+ private readonly sessions;
19
+ private readonly maxLogBytes;
20
+ constructor(options?: {
21
+ maxLogBytes?: number;
22
+ });
23
+ start(options: {
24
+ key: string;
25
+ executable: string;
26
+ args: string[];
27
+ env?: NodeJS.ProcessEnv;
28
+ cwd?: string;
29
+ }): Promise<ManagedSession>;
30
+ status(key: string): {
31
+ state: ManagedProcessState;
32
+ pid?: number;
33
+ logs: string;
34
+ };
35
+ stop(key: string, graceMs?: number): Promise<{
36
+ stopped: boolean;
37
+ forceRequired: boolean;
38
+ }>;
39
+ }
@@ -0,0 +1,77 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { redact } from '../core/redact.js';
4
+ export class ManagedSession {
5
+ key;
6
+ child;
7
+ maxLogBytes;
8
+ state = 'starting';
9
+ startedAt = new Date().toISOString();
10
+ pid;
11
+ logText = '';
12
+ events = new EventEmitter();
13
+ constructor(key, child, maxLogBytes) {
14
+ this.key = key;
15
+ this.child = child;
16
+ this.maxLogBytes = maxLogBytes;
17
+ this.pid = child.pid;
18
+ }
19
+ append(value) {
20
+ this.logText = Buffer.from(this.logText + redact(value)).subarray(-this.maxLogBytes).toString('utf8');
21
+ this.events.emit('output', this.logText);
22
+ }
23
+ logs() { return this.logText; }
24
+ waitForOutput(pattern, timeoutMs) {
25
+ if (pattern.test(this.logText))
26
+ return Promise.resolve(this.logText);
27
+ return new Promise((resolve, reject) => {
28
+ const output = (logs) => {
29
+ if (!pattern.test(logs))
30
+ return;
31
+ cleanup();
32
+ resolve(logs);
33
+ };
34
+ const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for process output: ${pattern}`)); }, timeoutMs);
35
+ const cleanup = () => { clearTimeout(timeout); this.events.off('output', output); };
36
+ this.events.on('output', output);
37
+ });
38
+ }
39
+ }
40
+ export class ProcessSupervisor {
41
+ sessions = new Map();
42
+ maxLogBytes;
43
+ constructor(options = {}) { this.maxLogBytes = options.maxLogBytes ?? 256 * 1024; }
44
+ async start(options) {
45
+ const existing = this.sessions.get(options.key);
46
+ if (existing && ['starting', 'running', 'stopping'].includes(existing.state))
47
+ throw new Error(`A connector is already running for ${options.key}.`);
48
+ const child = spawn(options.executable, options.args, { cwd: options.cwd, env: options.env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
49
+ const session = new ManagedSession(options.key, child, this.maxLogBytes);
50
+ this.sessions.set(options.key, session);
51
+ child.stdout?.on('data', chunk => session.append(chunk.toString('utf8')));
52
+ child.stderr?.on('data', chunk => session.append(chunk.toString('utf8')));
53
+ child.on('close', code => { session.state = code === 0 || session.state === 'stopping' ? 'stopped' : 'failed'; });
54
+ return new Promise((resolve, reject) => {
55
+ child.once('spawn', () => { session.state = 'running'; resolve(session); });
56
+ child.once('error', error => { session.state = 'failed'; session.append(error.message); reject(error); });
57
+ });
58
+ }
59
+ status(key) {
60
+ const session = this.sessions.get(key);
61
+ return session ? { state: session.state, pid: session.pid, logs: session.logs() } : { state: 'stopped', logs: '' };
62
+ }
63
+ async stop(key, graceMs = 2_000) {
64
+ const session = this.sessions.get(key);
65
+ if (!session || ['stopped', 'failed'].includes(session.state))
66
+ return { stopped: true, forceRequired: false };
67
+ session.state = 'stopping';
68
+ session.child.kill('SIGTERM');
69
+ const stopped = await new Promise(resolve => {
70
+ const timer = setTimeout(() => resolve(false), graceMs);
71
+ session.child.once('close', () => { clearTimeout(timer); resolve(true); });
72
+ });
73
+ if (stopped)
74
+ session.state = 'stopped';
75
+ return { stopped, forceRequired: !stopped };
76
+ }
77
+ }
package/package.json CHANGED
@@ -1,15 +1,39 @@
1
1
  {
2
2
  "name": "cloudflare-tunnel-kit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Safe, reusable Cloudflare Tunnel setup for custom and Laravel projects.",
5
5
  "type": "module",
6
- "bin": { "cf-tunnel": "dist/cli/main.js" },
6
+ "bin": {
7
+ "cf-tunnel": "dist/cli/main.js"
8
+ },
7
9
  "main": "dist/index.js",
8
10
  "types": "dist/index.d.ts",
9
- "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
10
- "files": ["dist", "README.md", "LICENSE"],
11
- "scripts": { "build": "tsc", "test": "npm run build && node --test tests/**/*.test.js", "dev": "tsc --watch" },
12
- "devDependencies": { "typescript": "^5.7.2" },
13
- "engines": { "node": ">=20" },
14
- "license": "MIT"
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "test": "npm run build && node --test tests/**/*.test.js",
25
+ "dev": "tsc --watch"
26
+ },
27
+ "devDependencies": {
28
+ "@types/better-sqlite3": "^9.6.0",
29
+ "@types/node": "^26.4.0",
30
+ "typescript": "^5.7.2"
31
+ },
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "license": "MIT",
36
+ "dependencies": {
37
+ "better-sqlite3": "^13.0.3"
38
+ }
15
39
  }