cloudflare-tunnel-kit 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cloudflare-tunnel-kit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # cloudflare-tunnel-kit
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.
4
+
5
+ ## Current version
6
+
7
+ `0.1.0` là bản MVP hiện có:
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.
16
+
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
+
19
+ ## Ý tưởng và nguyên tắc
20
+
21
+ Flow luôn là: `input -> detect -> validate -> preview plan -> confirm -> execute -> summary`.
22
+
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
+
25
+ ## Yêu cầu
26
+
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
+
31
+ ## Cài đặt
32
+
33
+ Khi package được phát hành:
34
+
35
+ ```bash
36
+ npm install --save-dev cloudflare-tunnel-kit
37
+ ```
38
+
39
+ Trong source checkout:
40
+
41
+ ```bash
42
+ npm install
43
+ npm run build
44
+ node dist/cli/main.js --help
45
+ ```
46
+
47
+ ## Makefile shortcuts
48
+
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:
65
+
66
+ ```bash
67
+ cf-tunnel doctor
68
+ ```
69
+
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.
71
+
72
+ Quick tunnel, chỉ validate/preview:
73
+
74
+ ```bash
75
+ cf-tunnel quick --url http://127.0.0.1:8000 --dry-run
76
+ ```
77
+
78
+ Named tunnel sau khi review plan:
79
+
80
+ ```bash
81
+ cf-tunnel create --url http://127.0.0.1:8000 --name my-project --hostname tunnel.example.com
82
+ ```
83
+
84
+ Lifecycle commands:
85
+
86
+ ```text
87
+ cf-tunnel start --name my-project
88
+ cf-tunnel stop --name my-project
89
+ cf-tunnel status --name my-project
90
+ cf-tunnel init --profile custom --url http://127.0.0.1:8000 --dry-run
91
+ ```
92
+
93
+ `--yes` không bỏ qua validation và không bypass confirmation của Laravel `.env`.
94
+
95
+ ## Live UI
96
+
97
+ ```bash
98
+ cf-tunnel ui
99
+ ```
100
+
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.
102
+
103
+ ## Custom profile
104
+
105
+ Custom profile không đoán framework:
106
+
107
+ ```bash
108
+ cf-tunnel quick --profile custom --url http://127.0.0.1:3000 --dry-run
109
+ cf-tunnel create --profile custom --url http://127.0.0.1:8000 --name billing --dry-run
110
+ ```
111
+
112
+ ## Laravel profile
113
+
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.
115
+
116
+ ```bash
117
+ cf-tunnel create --profile laravel --url http://127.0.0.1:8000 --name law-firm --dry-run
118
+ ```
119
+
120
+ ## API
121
+
122
+ ```ts
123
+ import { validateTunnelConfig, createTunnelPlan, executeTunnelPlan } from 'cloudflare-tunnel-kit';
124
+
125
+ const config = { profile: 'custom', operation: 'quick', localUrl: 'http://127.0.0.1:8000' };
126
+ const validation = validateTunnelConfig(config);
127
+ if (!validation.ok) for (const error of validation.issues) console.error(error.code, error.reason, error.fix);
128
+ const plan = createTunnelPlan(config);
129
+ const result = await executeTunnelPlan(plan, { dryRun: true });
130
+ console.log(result);
131
+ ```
132
+
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.
134
+
135
+ ## Error model
136
+
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`.
138
+
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.
140
+
141
+ ## Security model
142
+
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.
150
+
151
+ Tool không thay thế việc review Cloudflare account permissions, DNS, access policy hoặc secret management của tổ chức.
152
+
153
+ ## Phát triển
154
+
155
+ ```bash
156
+ npm test
157
+ npm run build
158
+ git diff --check
159
+ ```
160
+
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.
164
+
165
+ ## License
166
+
167
+ MIT. Xem [LICENSE](LICENSE).
@@ -0,0 +1,3 @@
1
+ import type { TunnelConfig, TunnelPlan } from '../core/types.js';
2
+ export declare function detectLaravel(projectRoot?: string): boolean;
3
+ export declare function createLaravelPlan(input: TunnelConfig): TunnelPlan;
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createTunnelPlan } from '../core/plan.js';
4
+ export function detectLaravel(projectRoot = process.cwd()) {
5
+ if (!existsSync(path.join(projectRoot, 'artisan')) || !existsSync(path.join(projectRoot, 'composer.json')))
6
+ return false;
7
+ try {
8
+ return /laravel|illuminate/i.test(readFileSync(path.join(projectRoot, 'composer.json'), 'utf8'));
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export function createLaravelPlan(input) {
15
+ const plan = createTunnelPlan({ ...input, profile: 'laravel' });
16
+ if (!detectLaravel(plan.config.projectRoot)) {
17
+ plan.valid = false;
18
+ plan.issues = [{ code: 'LARAVEL_NOT_DETECTED', reason: 'artisan and Laravel composer evidence were not found.', fix: 'Run this from the Laravel project root or use profile custom.' }];
19
+ }
20
+ else {
21
+ plan.confirmations = [...new Set([...plan.confirmations, 'laravel-env'])];
22
+ const mappings = input.laravel ?? {};
23
+ const envPath = path.join(plan.config.projectRoot ?? process.cwd(), '.env');
24
+ if (existsSync(envPath) && mappings.mapAppUrl) {
25
+ const current = readFileSync(envPath, 'utf8');
26
+ const next = setEnv(current, 'APP_URL', plan.config.hostname ? `https://${plan.config.hostname}` : plan.config.localUrl);
27
+ plan.fileOperations.push({ path: '.env', action: 'update', content: next, requiresConfirmation: true });
28
+ plan.summary += ' Proposed APP_URL update is shown in the plan and needs confirmation.';
29
+ }
30
+ else if (mappings.mapAppUrl) {
31
+ plan.valid = false;
32
+ plan.issues = [{ code: 'LARAVEL_ENV_NOT_FOUND', reason: '.env was requested for mapping but does not exist.', fix: 'Create .env from .env.example, or disable APP_URL mapping.', field: '.env' }];
33
+ }
34
+ plan.summary += ' Laravel .env mappings require explicit confirmation.';
35
+ }
36
+ return plan;
37
+ }
38
+ function setEnv(content, key, value) {
39
+ const line = `${key}=${value}`;
40
+ const expression = new RegExp(`^${key}=.*$`, 'm');
41
+ return expression.test(content) ? content.replace(expression, line) : `${content.replace(/\s*$/, '')}\n${line}\n`;
42
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from '../ui/server.js';
3
+ import { createTunnelPlan, executeTunnelPlan } from '../index.js';
4
+ import { createLaravelPlan } from '../index.js';
5
+ import { runWizard } from './wizard.js';
6
+ const args = process.argv.slice(2);
7
+ const command = args[0] ?? 'init';
8
+ const value = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
9
+ function help() { console.log(`cloudflare-tunnel-kit 0.1.0\n\nUsage: cf-tunnel <command> [options]\n\nCommands: init create quick start stop status doctor ui\nOptions: --url URL --name NAME --hostname HOST --profile custom|laravel --config PATH --dry-run --yes --no-open`); }
10
+ async function main() {
11
+ if (command === 'init') {
12
+ if (args.length === 1)
13
+ return runWizard();
14
+ }
15
+ if (command === 'help' || command === '--help')
16
+ return help();
17
+ if (command === 'ui') {
18
+ const server = createServer();
19
+ server.listen(0, '127.0.0.1', () => { const a = server.address(); console.log(`UI ready at http://127.0.0.1:${a.port}`); });
20
+ return;
21
+ }
22
+ if (command === 'doctor') {
23
+ console.log('Doctor: use `cf-tunnel quick --url http://127.0.0.1:8000 --dry-run` to validate a project without starting cloudflared.');
24
+ return;
25
+ }
26
+ const operation = (command === 'init' ? 'create' : command);
27
+ const config = { profile: value('--profile') ?? 'custom', operation, localUrl: value('--url') ?? 'http://127.0.0.1:8000', tunnelName: value('--name'), hostname: value('--hostname'), configPath: value('--config') };
28
+ const plan = config.profile === 'laravel' ? createLaravelPlan(config) : createTunnelPlan(config);
29
+ console.log(JSON.stringify({ summary: plan.summary, issues: plan.issues, argv: plan.argv, confirmations: plan.confirmations }, null, 2));
30
+ if (!plan.valid || args.includes('--dry-run'))
31
+ return;
32
+ const result = await executeTunnelPlan(plan, { confirmed: args.includes('--yes') ? plan.confirmations : [] });
33
+ console.log(JSON.stringify(result, null, 2));
34
+ if (!result.ok)
35
+ process.exitCode = 1;
36
+ }
37
+ main().catch(e => { console.error(e instanceof Error ? e.message : String(e)); process.exitCode = 1; });
@@ -0,0 +1,3 @@
1
+ import type { TunnelPlan } from '../core/types.js';
2
+ export declare function formatWizardSummary(plan: Pick<TunnelPlan, 'valid' | 'summary' | 'issues' | 'argv' | 'confirmations'>): string;
3
+ export declare function runWizard(): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { stdin, stdout } from 'node:process';
3
+ import { createTunnelPlan } from '../core/plan.js';
4
+ import { createLaravelPlan } from '../adapters/laravel.js';
5
+ import { executeTunnelPlan } from '../core/execution.js';
6
+ export function formatWizardSummary(plan) {
7
+ const lines = [plan.valid ? 'Validation passed.' : 'Validation failed.', plan.summary];
8
+ for (const i of plan.issues)
9
+ lines.push(`\n[${i.code}]${i.field ? ` ${i.field}` : ''}\nReason: ${i.reason}\nFix: ${i.fix}`);
10
+ if (plan.valid)
11
+ lines.push(`\nCommand preview: cloudflared ${plan.argv.join(' ')}`);
12
+ if (plan.confirmations.length)
13
+ lines.push(`\nConfirmation required: ${plan.confirmations.join(', ')}`);
14
+ return lines.join('\n');
15
+ }
16
+ export async function runWizard() {
17
+ const rl = createInterface({ input: stdin, output: stdout });
18
+ try {
19
+ const profileInput = (await rl.question('Profile [custom/laravel] (custom): ')).trim() || 'custom';
20
+ const operationInput = (await rl.question('Operation [quick/create] (quick): ')).trim() || 'quick';
21
+ const localUrl = (await rl.question('Local URL (http://127.0.0.1:8000): ')).trim() || 'http://127.0.0.1:8000';
22
+ const tunnelName = (await rl.question('Tunnel name (optional): ')).trim() || undefined;
23
+ const hostname = (await rl.question('Hostname (optional): ')).trim() || undefined;
24
+ const config = { profile: profileInput, operation: operationInput, localUrl, tunnelName, hostname };
25
+ const plan = config.profile === 'laravel' ? createLaravelPlan(config) : createTunnelPlan(config);
26
+ console.log(`\n${formatWizardSummary(plan)}`);
27
+ if (!plan.valid)
28
+ return;
29
+ const answer = (await rl.question('\nExecute this plan? [y/N]: ')).trim().toLowerCase();
30
+ if (answer === 'y' || answer === 'yes')
31
+ console.log(JSON.stringify(await executeTunnelPlan(plan, { confirmed: plan.confirmations }), null, 2));
32
+ else
33
+ console.log('Cancelled. No changes were made.');
34
+ }
35
+ finally {
36
+ rl.close();
37
+ }
38
+ }
@@ -0,0 +1,5 @@
1
+ import type { ValidationIssue } from './types.js';
2
+ export declare class TunnelKitError extends Error {
3
+ readonly issue: ValidationIssue;
4
+ constructor(issue: ValidationIssue);
5
+ }
@@ -0,0 +1,8 @@
1
+ export class TunnelKitError extends Error {
2
+ issue;
3
+ constructor(issue) {
4
+ super(`${issue.code}: ${issue.reason}`);
5
+ this.issue = issue;
6
+ this.name = 'TunnelKitError';
7
+ }
8
+ }
@@ -0,0 +1,5 @@
1
+ import type { ExecutionSummary, TunnelPlan } from './types.js';
2
+ export declare function executeTunnelPlan(plan: TunnelPlan, options?: {
3
+ confirmed?: string[];
4
+ dryRun?: boolean;
5
+ }): Promise<ExecutionSummary>;
@@ -0,0 +1,13 @@
1
+ import { runCloudflared } from '../providers/cloudflared.js';
2
+ export async function executeTunnelPlan(plan, options = {}) {
3
+ if (!plan.valid)
4
+ return { ok: false, operation: plan.config.operation, passed: [], skipped: [], issues: plan.issues };
5
+ const confirmed = new Set(options.confirmed ?? []);
6
+ const missing = plan.confirmations.filter(x => !confirmed.has(x));
7
+ if (missing.length)
8
+ return { ok: false, operation: plan.config.operation, passed: [], skipped: missing, issues: [{ code: 'CONFIRMATION_REQUIRED', reason: `Confirmation required for: ${missing.join(', ')}`, fix: 'Review the plan and confirm the listed operation(s).' }] };
9
+ if (options.dryRun)
10
+ return { ok: true, operation: plan.config.operation, passed: ['validation', 'plan'], skipped: ['execution (dry-run)'], issues: [] };
11
+ const result = await runCloudflared(plan.argv);
12
+ return { ok: result.code === 0, operation: plan.config.operation, passed: result.code === 0 ? ['validation', 'execution'] : ['validation'], skipped: [], issues: result.code === 0 ? [] : [{ code: 'PROCESS_FAILED', reason: result.stderr || 'cloudflared exited with a non-zero status.', fix: 'Run cf-tunnel doctor and check cloudflared authentication.' }], output: result.stdout };
13
+ }
@@ -0,0 +1,2 @@
1
+ import type { TunnelConfig, TunnelPlan } from './types.js';
2
+ export declare function createTunnelPlan(input: TunnelConfig): TunnelPlan;
@@ -0,0 +1,9 @@
1
+ import crypto from 'node:crypto';
2
+ import { validateTunnelConfig } from './validation.js';
3
+ export function createTunnelPlan(input) {
4
+ const validation = validateTunnelConfig(input);
5
+ const c = validation.normalized ?? input;
6
+ const argv = c.operation === 'quick' ? ['tunnel', '--url', c.localUrl] : c.operation === 'create' ? ['tunnel', 'create', c.tunnelName ?? ''] : ['tunnel', c.operation, c.tunnelName ?? ''];
7
+ const fileOperations = c.operation === 'create' && c.configPath ? [{ path: c.configPath, action: 'create', content: `# Generated by cloudflare-tunnel-kit\n`, requiresConfirmation: true }] : [];
8
+ return { id: crypto.randomUUID(), createdAt: new Date().toISOString(), config: c, valid: validation.ok, argv, fileOperations, confirmations: fileOperations.length ? ['config-file'] : [], issues: validation.issues, summary: validation.ok ? `Ready to run: cloudflared ${argv.join(' ')}` : `${validation.issues.length} validation issue(s) must be fixed before execution.` };
9
+ }
@@ -0,0 +1,3 @@
1
+ import type { ValidationIssue } from './types.js';
2
+ export declare function redact(value: string, key?: string): string;
3
+ export declare function aiPrompt(issue: ValidationIssue): string;
@@ -0,0 +1,9 @@
1
+ const secretKey = /(token|secret|password|private|credential|key|cert)/i;
2
+ export function redact(value, key) {
3
+ if (key && secretKey.test(key))
4
+ return '[REDACTED]';
5
+ return value.replace(/(Bearer\s+)[^\s]+/gi, '$1[REDACTED]').replace(/([A-Za-z0-9_-]{24,})/g, '[REDACTED]');
6
+ }
7
+ export function aiPrompt(issue) {
8
+ 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
+ }
@@ -0,0 +1,52 @@
1
+ export type Profile = 'custom' | 'laravel';
2
+ export type Operation = 'quick' | 'create' | 'start' | 'stop' | 'status';
3
+ export interface TunnelConfig {
4
+ profile: Profile;
5
+ operation: Operation;
6
+ localUrl: string;
7
+ projectRoot?: string;
8
+ tunnelName?: string;
9
+ hostname?: string;
10
+ configPath?: string;
11
+ laravel?: {
12
+ mapAppUrl?: boolean;
13
+ mapAssetUrl?: boolean;
14
+ mapReverbUrl?: boolean;
15
+ };
16
+ }
17
+ export interface ValidationIssue {
18
+ code: string;
19
+ field?: string;
20
+ reason: string;
21
+ fix: string;
22
+ }
23
+ export interface ValidationResult {
24
+ ok: boolean;
25
+ issues: ValidationIssue[];
26
+ normalized?: TunnelConfig;
27
+ }
28
+ export interface FileOperation {
29
+ path: string;
30
+ action: 'create' | 'update';
31
+ content?: string;
32
+ requiresConfirmation: boolean;
33
+ }
34
+ export interface TunnelPlan {
35
+ id: string;
36
+ createdAt: string;
37
+ config: TunnelConfig;
38
+ valid: boolean;
39
+ argv: string[];
40
+ fileOperations: FileOperation[];
41
+ confirmations: string[];
42
+ issues: ValidationIssue[];
43
+ summary: string;
44
+ }
45
+ export interface ExecutionSummary {
46
+ ok: boolean;
47
+ operation: string;
48
+ passed: string[];
49
+ skipped: string[];
50
+ issues: ValidationIssue[];
51
+ output?: string;
52
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { TunnelConfig, ValidationResult } from './types.js';
2
+ export declare function validateTunnelConfig(input: TunnelConfig): ValidationResult;
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ import { realpathSync } from 'node:fs';
3
+ function issue(code, reason, fix, field) { return { code, reason, fix, field }; }
4
+ function inside(root, candidate) {
5
+ try {
6
+ const r = realpathSync(root);
7
+ const c = realpathSync(path.dirname(candidate));
8
+ return c === r || c.startsWith(r + path.sep);
9
+ }
10
+ catch {
11
+ return path.resolve(candidate).startsWith(path.resolve(root) + path.sep);
12
+ }
13
+ }
14
+ export function validateTunnelConfig(input) {
15
+ const issues = [];
16
+ let url;
17
+ try {
18
+ url = new URL(input.localUrl);
19
+ if (!['http:', 'https:'].includes(url.protocol))
20
+ throw new Error();
21
+ }
22
+ catch {
23
+ issues.push(issue('INPUT_INVALID_URL', 'localUrl must be an http or https URL.', 'Use a value such as http://127.0.0.1:8000.', 'localUrl'));
24
+ }
25
+ if (!['custom', 'laravel'].includes(input.profile))
26
+ issues.push(issue('INPUT_INVALID_PROFILE', 'Unknown project profile.', 'Choose custom or laravel.', 'profile'));
27
+ if (input.operation === 'create' && !input.tunnelName)
28
+ issues.push(issue('INPUT_TUNNEL_NAME_REQUIRED', 'Named tunnels require a tunnel name.', 'Use lowercase letters, numbers, and hyphens.', 'tunnelName'));
29
+ if (input.tunnelName && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(input.tunnelName))
30
+ 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'));
33
+ const root = path.resolve(input.projectRoot ?? process.cwd());
34
+ if (input.configPath && !inside(root, path.resolve(root, input.configPath)))
35
+ 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 };
37
+ return { ok: issues.length === 0, issues, normalized: issues.length === 0 ? normalized : undefined };
38
+ }
@@ -0,0 +1,6 @@
1
+ export * from './core/types.js';
2
+ export * from './core/validation.js';
3
+ export * from './core/plan.js';
4
+ export * from './core/execution.js';
5
+ export * from './core/redact.js';
6
+ export * from './adapters/laravel.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from './core/types.js';
2
+ export * from './core/validation.js';
3
+ export * from './core/plan.js';
4
+ export * from './core/execution.js';
5
+ export * from './core/redact.js';
6
+ export * from './adapters/laravel.js';
@@ -0,0 +1,6 @@
1
+ export interface ProcessResult {
2
+ code: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+ export declare function runCloudflared(args: string[], timeoutMs?: number): Promise<ProcessResult>;
@@ -0,0 +1,5 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { redact } from '../core/redact.js';
3
+ 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) }); }); });
5
+ }
@@ -0,0 +1,2 @@
1
+ import { type Server } from 'node:http';
2
+ export declare function createServer(): Server;
@@ -0,0 +1,38 @@
1
+ import { createServer as httpServer } from 'node:http';
2
+ import crypto from 'node:crypto';
3
+ import { createTunnelPlan } from '../core/plan.js';
4
+ import { createLaravelPlan } from '../adapters/laravel.js';
5
+ import { executeTunnelPlan } from '../core/execution.js';
6
+ export function createServer() {
7
+ const token = crypto.randomUUID();
8
+ return httpServer(async (req, res) => {
9
+ res.setHeader('X-Content-Type-Options', 'nosniff');
10
+ if (req.method === 'GET' && req.url === '/') {
11
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
12
+ res.end(html);
13
+ return;
14
+ }
15
+ if (req.method === 'GET' && req.url === '/api/session')
16
+ return json(res, { confirmationToken: token });
17
+ if (req.method === 'POST' && ['/api/plan', '/api/validate', '/api/execute'].includes(req.url ?? '')) {
18
+ if (req.url === '/api/execute' && req.headers['x-confirmation-token'] !== token)
19
+ return json(res, { ok: false, issues: [{ code: 'UI_CONFIRMATION_TOKEN_INVALID', reason: 'The UI confirmation token is missing or invalid.', fix: 'Reload the local UI and confirm from the same browser session.' }] }, 403);
20
+ try {
21
+ const body = JSON.parse(await readBody(req));
22
+ const config = body.config;
23
+ const plan = config.profile === 'laravel' ? createLaravelPlan(config) : createTunnelPlan(config);
24
+ if (req.url === '/api/execute')
25
+ return json(res, await executeTunnelPlan(plan, { confirmed: body.confirmed ?? [], dryRun: body.dryRun ?? false }));
26
+ return json(res, { valid: plan.valid, issues: plan.issues, plan });
27
+ }
28
+ catch {
29
+ return json(res, { ok: false, issues: [{ code: 'UI_INVALID_JSON', reason: 'Request body is not valid JSON.', fix: 'Submit the wizard form again.' }] }, 400);
30
+ }
31
+ }
32
+ res.statusCode = 404;
33
+ res.end('Not found');
34
+ });
35
+ }
36
+ function readBody(req) { return new Promise(resolve => { let data = ''; req.on('data', (chunk) => data += chunk); req.on('end', () => resolve(data)); }); }
37
+ function json(res, value, status = 200) { res.statusCode = status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(value)); }
38
+ const html = `<!doctype html><html lang="en"><meta name="viewport" content="width=device-width"><title>Cloudflare Tunnel Kit</title><style>body{font:16px system-ui;max-width:720px;margin:40px auto;padding:0 20px;background:#f6f7fb;color:#18202a}main{background:white;padding:28px;border-radius:16px;box-shadow:0 8px 30px #0001}label{display:block;margin:14px 0 6px}input,select,button{font:inherit;padding:10px;border:1px solid #ccd3df;border-radius:8px;width:100%;box-sizing:border-box}button{margin-top:20px;background:#2457d6;color:white;cursor:pointer}pre{white-space:pre-wrap;background:#f1f3f7;padding:12px;border-radius:8px}.ok{color:#087443}.error{color:#a21b1b}</style><main><h1>Cloudflare Tunnel Kit</h1><p>Validate first. Review the plan. Confirm before running.</p><label>Profile</label><select id="profile"><option>custom</option><option>laravel</option></select><label>Local URL</label><input id="url" value="http://127.0.0.1:8000"><label>Tunnel name (named tunnel only)</label><input id="name" placeholder="my-project"><button id="check">Validate plan</button><button id="run" hidden>Confirm and execute</button><button id="copy" hidden>Copy AI help prompt</button><pre id="result">No plan yet.</pre></main><script>let plan,token;const $=id=>document.getElementById(id);fetch('/api/session').then(r=>r.json()).then(x=>token=x.confirmationToken);$('check').onclick=async()=>{const config={profile:$('profile').value,operation:$('name').value?'create':'quick',localUrl:$('url').value,tunnelName:$('name').value||undefined};const r=await fetch('/api/plan',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({config})});const x=await r.json();plan=x.plan;$('result').textContent=JSON.stringify({summary:plan?.summary,issues:x.issues,argv:plan?.argv,confirmations:plan?.confirmations},null,2);$('run').hidden=!x.valid;$('copy').hidden=x.valid;};$('run').onclick=async()=>{const r=await fetch('/api/execute',{method:'POST',headers:{'content-type':'application/json','x-confirmation-token':token},body:JSON.stringify({config:plan.config,confirmed:plan.confirmations})});$('result').textContent=JSON.stringify(await r.json(),null,2);};$('copy').onclick=()=>navigator.clipboard.writeText('I am configuring cloudflare-tunnel-kit. Here is the redacted diagnostic:\n'+$('result').textContent);</script>`;
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "cloudflare-tunnel-kit",
3
+ "version": "0.1.0",
4
+ "description": "Safe, reusable Cloudflare Tunnel setup for custom and Laravel projects.",
5
+ "type": "module",
6
+ "bin": { "cf-tunnel": "dist/cli/main.js" },
7
+ "main": "dist/index.js",
8
+ "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"
15
+ }