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 +21 -0
- package/README.md +167 -0
- package/dist/adapters/laravel.d.ts +3 -0
- package/dist/adapters/laravel.js +42 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +37 -0
- package/dist/cli/wizard.d.ts +3 -0
- package/dist/cli/wizard.js +38 -0
- package/dist/core/errors.d.ts +5 -0
- package/dist/core/errors.js +8 -0
- package/dist/core/execution.d.ts +5 -0
- package/dist/core/execution.js +13 -0
- package/dist/core/plan.d.ts +2 -0
- package/dist/core/plan.js +9 -0
- package/dist/core/redact.d.ts +3 -0
- package/dist/core/redact.js +9 -0
- package/dist/core/types.d.ts +52 -0
- package/dist/core/types.js +1 -0
- package/dist/core/validation.d.ts +2 -0
- package/dist/core/validation.js +38 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/providers/cloudflared.d.ts +6 -0
- package/dist/providers/cloudflared.js +5 -0
- package/dist/ui/server.d.ts +2 -0
- package/dist/ui/server.js +38 -0
- package/package.json +15 -0
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,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
|
+
}
|
package/dist/cli/main.js
ADDED
|
@@ -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,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,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,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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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,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
|
+
}
|