cloudflare-tunnel-kit 0.1.2 → 0.1.4
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 +32 -34
- package/dist/app/service.d.ts +46 -0
- package/dist/app/service.js +102 -0
- package/dist/cli/main.js +73 -24
- package/dist/cli/wizard.d.ts +2 -1
- package/dist/cli/wizard.js +64 -20
- package/dist/core/named-workflow.d.ts +47 -0
- package/dist/core/named-workflow.js +134 -0
- package/dist/core/quick-workflow.d.ts +28 -0
- package/dist/core/quick-workflow.js +61 -0
- package/dist/core/workflow.d.ts +12 -0
- package/dist/core/workflow.js +19 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/persistence/store.d.ts +38 -0
- package/dist/persistence/store.js +52 -0
- package/dist/providers/browser.d.ts +9 -0
- package/dist/providers/browser.js +17 -0
- package/dist/ui/page.d.ts +1 -0
- package/dist/ui/page.js +34 -0
- package/dist/ui/server.d.ts +5 -1
- package/dist/ui/server.js +71 -27
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -35,40 +35,52 @@ The toolkit does not concatenate user input into shell commands, print secrets t
|
|
|
35
35
|
|
|
36
36
|
## Installation
|
|
37
37
|
|
|
38
|
-
Install the package
|
|
38
|
+
Install the package in the project that needs a tunnel:
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
41
|
npm install --save-dev cloudflare-tunnel-kit
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
The package exposes the `cf-tunnel` binary locally. Run it through `npx` so no global installation is required:
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
npx cf-tunnel
|
|
48
|
+
npx cf-tunnel ui
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
You can also add project scripts:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"scripts": {
|
|
56
|
+
"tunnel": "cf-tunnel",
|
|
57
|
+
"tunnel:ui": "cf-tunnel ui"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
50
60
|
```
|
|
51
61
|
|
|
62
|
+
Then run `npm run tunnel` or `npm run tunnel:ui`.
|
|
63
|
+
|
|
52
64
|
## CLI usage
|
|
53
65
|
|
|
54
66
|
Check the local environment:
|
|
55
67
|
|
|
56
68
|
```bash
|
|
57
|
-
cf-tunnel doctor
|
|
69
|
+
npx cf-tunnel doctor
|
|
58
70
|
```
|
|
59
71
|
|
|
60
|
-
Run `
|
|
72
|
+
Run `npx cf-tunnel` 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.
|
|
61
73
|
|
|
62
74
|
Preview a Quick Tunnel without starting `cloudflared`:
|
|
63
75
|
|
|
64
76
|
```bash
|
|
65
|
-
cf-tunnel quick --url http://127.0.0.1:8000 --dry-run
|
|
77
|
+
npx cf-tunnel quick --url http://127.0.0.1:8000 --dry-run
|
|
66
78
|
```
|
|
67
79
|
|
|
68
80
|
Preview a named tunnel:
|
|
69
81
|
|
|
70
82
|
```bash
|
|
71
|
-
cf-tunnel create \
|
|
83
|
+
npx cf-tunnel create \
|
|
72
84
|
--url http://127.0.0.1:8000 \
|
|
73
85
|
--name my-project \
|
|
74
86
|
--hostname tunnel.example.com \
|
|
@@ -78,39 +90,22 @@ cf-tunnel create \
|
|
|
78
90
|
Lifecycle commands:
|
|
79
91
|
|
|
80
92
|
```text
|
|
81
|
-
cf-tunnel start --name my-project
|
|
82
|
-
cf-tunnel stop --name my-project
|
|
83
|
-
cf-tunnel status --name my-project
|
|
93
|
+
npx cf-tunnel start --name my-project
|
|
94
|
+
npx cf-tunnel stop --name my-project
|
|
95
|
+
npx cf-tunnel status --name my-project
|
|
84
96
|
```
|
|
85
97
|
|
|
86
98
|
`--yes` does not bypass validation or Laravel `.env` confirmation.
|
|
87
99
|
|
|
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.
|
|
104
|
-
|
|
105
100
|
## Live UI
|
|
106
101
|
|
|
107
102
|
Start the local UI:
|
|
108
103
|
|
|
109
104
|
```bash
|
|
110
|
-
cf-tunnel ui
|
|
105
|
+
npx cf-tunnel ui
|
|
111
106
|
```
|
|
112
107
|
|
|
113
|
-
|
|
108
|
+
The command prints startup progress, chooses an available loopback port, and opens the browser automatically. If the browser cannot be opened, copy the printed `http://127.0.0.1:<port>` URL. Use `npx cf-tunnel ui --no-open` when you only want the URL.
|
|
114
109
|
|
|
115
110
|
The UI binds to loopback by default and does not send the copied prompt anywhere.
|
|
116
111
|
|
|
@@ -119,8 +114,8 @@ The UI binds to loopback by default and does not send the copied prompt anywhere
|
|
|
119
114
|
The custom profile makes no framework assumptions:
|
|
120
115
|
|
|
121
116
|
```bash
|
|
122
|
-
cf-tunnel quick --profile custom --url http://127.0.0.1:3000 --dry-run
|
|
123
|
-
cf-tunnel create --profile custom --url http://127.0.0.1:8000 --name billing --dry-run
|
|
117
|
+
npx cf-tunnel quick --profile custom --url http://127.0.0.1:3000 --dry-run
|
|
118
|
+
npx cf-tunnel create --profile custom --url http://127.0.0.1:8000 --name billing --hostname billing.example.com --dry-run
|
|
124
119
|
```
|
|
125
120
|
|
|
126
121
|
## Laravel profile
|
|
@@ -130,10 +125,11 @@ The Laravel adapter checks for `artisan` and Laravel evidence in `composer.json`
|
|
|
130
125
|
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.
|
|
131
126
|
|
|
132
127
|
```bash
|
|
133
|
-
cf-tunnel create \
|
|
128
|
+
npx cf-tunnel create \
|
|
134
129
|
--profile laravel \
|
|
135
130
|
--url http://127.0.0.1:8000 \
|
|
136
131
|
--name law-firm \
|
|
132
|
+
--hostname law.example.com \
|
|
137
133
|
--dry-run
|
|
138
134
|
```
|
|
139
135
|
|
|
@@ -214,6 +210,8 @@ An already-published `name@version` cannot be published again. See the [npm publ
|
|
|
214
210
|
|
|
215
211
|
## Development
|
|
216
212
|
|
|
213
|
+
These commands are only for contributors working from a source checkout. Projects that install the npm package do not need this repository's Makefile.
|
|
214
|
+
|
|
217
215
|
```bash
|
|
218
216
|
npm install
|
|
219
217
|
npm test
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
interface PreparedPlan {
|
|
3
|
+
id: string;
|
|
4
|
+
kind: 'quick' | 'named';
|
|
5
|
+
input: any;
|
|
6
|
+
effects: string[];
|
|
7
|
+
confirmations: string[];
|
|
8
|
+
createdAt: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class TunnelKitService {
|
|
11
|
+
private readonly plans;
|
|
12
|
+
private readonly store;
|
|
13
|
+
private readonly supervisor;
|
|
14
|
+
private readonly quickWorkflow;
|
|
15
|
+
private readonly namedWorkflow;
|
|
16
|
+
private readonly cloudflare;
|
|
17
|
+
constructor(options: {
|
|
18
|
+
store: any;
|
|
19
|
+
supervisor: any;
|
|
20
|
+
quickWorkflow: any;
|
|
21
|
+
namedWorkflow: any;
|
|
22
|
+
cloudflare: any;
|
|
23
|
+
database?: Database.Database;
|
|
24
|
+
});
|
|
25
|
+
private readonly database?;
|
|
26
|
+
listProjects(): Promise<any[]>;
|
|
27
|
+
getProject(id: string): Promise<any>;
|
|
28
|
+
prepareQuick(input: any): Promise<PreparedPlan>;
|
|
29
|
+
prepareNamed(input: any): Promise<PreparedPlan>;
|
|
30
|
+
private savePlan;
|
|
31
|
+
execute(id: string, confirmations: string[]): Promise<any>;
|
|
32
|
+
retry(projectId: string): Promise<any>;
|
|
33
|
+
stop(projectId: string): Promise<void>;
|
|
34
|
+
start(projectId: string): Promise<any>;
|
|
35
|
+
restart(projectId: string): Promise<any>;
|
|
36
|
+
relinkProject(projectId: string, nextPath: string): Promise<void>;
|
|
37
|
+
removeLocal(projectId: string): Promise<void>;
|
|
38
|
+
prepareCloudflareCleanup(projectId: string): Promise<any>;
|
|
39
|
+
doctor(): Promise<any>;
|
|
40
|
+
close(): void;
|
|
41
|
+
}
|
|
42
|
+
export declare function createTunnelKitService(options?: {
|
|
43
|
+
dataDir?: string;
|
|
44
|
+
cloudflaredExecutable?: string;
|
|
45
|
+
}): TunnelKitService;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolveAppPaths } from './paths.js';
|
|
4
|
+
import { openStateDatabase } from '../persistence/database.js';
|
|
5
|
+
import { StateStore } from '../persistence/store.js';
|
|
6
|
+
import { ProcessSupervisor } from '../providers/process-supervisor.js';
|
|
7
|
+
import { CloudflaredAdapter } from '../providers/cloudflared.js';
|
|
8
|
+
import { QuickTunnelWorkflow } from '../core/quick-workflow.js';
|
|
9
|
+
import { NamedTunnelWorkflow } from '../core/named-workflow.js';
|
|
10
|
+
export class TunnelKitService {
|
|
11
|
+
plans = new Map();
|
|
12
|
+
store;
|
|
13
|
+
supervisor;
|
|
14
|
+
quickWorkflow;
|
|
15
|
+
namedWorkflow;
|
|
16
|
+
cloudflare;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
Object.assign(this, options);
|
|
19
|
+
this.database = options.database;
|
|
20
|
+
}
|
|
21
|
+
database;
|
|
22
|
+
async listProjects() {
|
|
23
|
+
return this.store.listProjects().map((project) => {
|
|
24
|
+
const tunnel = this.store.getTunnelForProject(project.id);
|
|
25
|
+
let session;
|
|
26
|
+
try {
|
|
27
|
+
session = this.store.getLatestSession(project.id);
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
const observed = session ? this.supervisor.status(session.processKey) : { state: 'stopped', logs: '' };
|
|
31
|
+
const status = !existsSync(project.path) ? 'Needs attention' : observed.state === 'running' ? 'Running' : 'Stopped';
|
|
32
|
+
return { ...project, status, kind: tunnel?.kind, hostname: tunnel?.hostname, localUrl: tunnel?.localUrl, publicUrl: session?.ephemeralUrlExpired ? undefined : session?.ephemeralUrl, processState: observed.state };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async getProject(id) {
|
|
36
|
+
const project = this.store.getProject(id);
|
|
37
|
+
const tunnel = this.store.getTunnelForProject(id);
|
|
38
|
+
let session;
|
|
39
|
+
try {
|
|
40
|
+
session = this.store.getLatestSession(id);
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
const observed = session ? this.supervisor.status(session.processKey) : { state: 'stopped', logs: '' };
|
|
44
|
+
return { ...project, tunnel, session, health: { localProcess: observed.state, cloudflareConnector: 'unknown', publicHostname: 'unchecked' }, logs: observed.logs };
|
|
45
|
+
}
|
|
46
|
+
async prepareQuick(input) { return this.savePlan('quick', input, [`Start a temporary Quick Tunnel to ${input.localUrl}.`], ['start-connector']); }
|
|
47
|
+
async prepareNamed(input) { return this.savePlan('named', input, [`Create or reuse tunnel ${input.tunnelName}.`, `Create DNS route ${input.hostname}.`, `Start a connector to ${input.localUrl}.`], ['cloudflare-resources', 'start-connector']); }
|
|
48
|
+
savePlan(kind, input, effects, confirmations) {
|
|
49
|
+
const plan = { id: crypto.randomUUID(), kind, input, effects, confirmations, createdAt: Date.now() };
|
|
50
|
+
this.plans.set(plan.id, plan);
|
|
51
|
+
return plan;
|
|
52
|
+
}
|
|
53
|
+
async execute(id, confirmations) {
|
|
54
|
+
const plan = this.plans.get(id);
|
|
55
|
+
if (!plan)
|
|
56
|
+
throw new Error('Plan not found or expired.');
|
|
57
|
+
if (Date.now() - plan.createdAt > 10 * 60_000) {
|
|
58
|
+
this.plans.delete(id);
|
|
59
|
+
throw new Error('Plan expired. Review the settings again.');
|
|
60
|
+
}
|
|
61
|
+
const missing = plan.confirmations.filter(item => !confirmations.includes(item));
|
|
62
|
+
if (missing.length)
|
|
63
|
+
throw new Error(`Confirmation required for: ${missing.join(', ')}`);
|
|
64
|
+
this.plans.delete(id);
|
|
65
|
+
return plan.kind === 'quick' ? this.quickWorkflow.run(plan.input) : this.namedWorkflow.run(plan.input);
|
|
66
|
+
}
|
|
67
|
+
async retry(projectId) { const tunnel = this.store.getTunnelForProject(projectId); return tunnel?.kind === 'named' ? this.namedWorkflow.retry(projectId) : this.quickWorkflow.restart(projectId); }
|
|
68
|
+
async stop(projectId) { const tunnel = this.store.getTunnelForProject(projectId); return tunnel?.kind === 'named' ? this.namedWorkflow.stop(projectId) : this.quickWorkflow.stop(projectId); }
|
|
69
|
+
async start(projectId) { return this.retry(projectId); }
|
|
70
|
+
async restart(projectId) { try {
|
|
71
|
+
await this.stop(projectId);
|
|
72
|
+
}
|
|
73
|
+
catch { } return this.start(projectId); }
|
|
74
|
+
async relinkProject(projectId, nextPath) { if (!nextPath?.trim())
|
|
75
|
+
throw new Error('A new project folder is required.'); this.store.relinkProject(projectId, nextPath); }
|
|
76
|
+
async removeLocal(projectId) {
|
|
77
|
+
let session;
|
|
78
|
+
try {
|
|
79
|
+
session = this.store.getLatestSession(projectId);
|
|
80
|
+
}
|
|
81
|
+
catch { }
|
|
82
|
+
if (session && this.supervisor.status(session.processKey).state === 'running')
|
|
83
|
+
throw new Error('Stop the connector before removing this project from the local dashboard.');
|
|
84
|
+
this.store.removeProject(projectId);
|
|
85
|
+
}
|
|
86
|
+
async prepareCloudflareCleanup(projectId) {
|
|
87
|
+
const tunnel = this.store.getTunnelForProject(projectId);
|
|
88
|
+
return { enabled: false, code: 'CLOUDFLARE_CLEANUP_NOT_ENABLED', resources: tunnel ? { tunnelUuid: tunnel.uuid, hostname: tunnel.hostname, configPath: tunnel.configPath } : {}, message: 'Cloudflare resource deletion is not enabled in this release. Remove resources explicitly in the Cloudflare dashboard.' };
|
|
89
|
+
}
|
|
90
|
+
async doctor() { const version = await this.cloudflare.version(); return { ok: version.ok, checks: [{ name: 'Node.js', state: 'passed', detail: process.version }, { name: 'cloudflared', state: version.ok ? 'passed' : 'failed', detail: version.ok ? version.value.version : version.error.summary }] }; }
|
|
91
|
+
close() { this.database?.close(); }
|
|
92
|
+
}
|
|
93
|
+
export function createTunnelKitService(options = {}) {
|
|
94
|
+
const paths = options.dataDir ? { dataDir: options.dataDir, database: `${options.dataDir}/state.db`, projectsDir: `${options.dataDir}/projects`, backupsDir: `${options.dataDir}/backups` } : resolveAppPaths();
|
|
95
|
+
const database = openStateDatabase(paths.database);
|
|
96
|
+
const store = new StateStore(database);
|
|
97
|
+
const supervisor = new ProcessSupervisor();
|
|
98
|
+
const cloudflare = new CloudflaredAdapter({ executable: options.cloudflaredExecutable });
|
|
99
|
+
const quickWorkflow = new QuickTunnelWorkflow({ store, supervisor, executable: options.cloudflaredExecutable });
|
|
100
|
+
const namedWorkflow = new NamedTunnelWorkflow({ store, supervisor, cloudflare, projectsDir: paths.projectsDir, executable: options.cloudflaredExecutable });
|
|
101
|
+
return new TunnelKitService({ store, supervisor, quickWorkflow, namedWorkflow, cloudflare, database });
|
|
102
|
+
}
|
package/dist/cli/main.js
CHANGED
|
@@ -1,37 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { stdin } from 'node:process';
|
|
2
4
|
import { createServer } from '../ui/server.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
+
import { launchBrowser } from '../providers/browser.js';
|
|
6
|
+
import { createTunnelKitService } from '../app/service.js';
|
|
5
7
|
import { runWizard } from './wizard.js';
|
|
6
8
|
const args = process.argv.slice(2);
|
|
7
9
|
const command = args[0] ?? 'init';
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
+
const packageVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
11
|
+
const value = (name) => { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; };
|
|
12
|
+
function help() { console.log(`cloudflare-tunnel-kit ${packageVersion}\n\nUsage from an installed project:\n npx cf-tunnel\n npx cf-tunnel ui\n\nCommands: init create quick start stop restart status doctor ui\nOptions: --url URL --name NAME --hostname HOST --profile custom|laravel --project ID --dry-run --yes --json --no-open`); }
|
|
13
|
+
function interactiveRequired() { console.error('[INTERACTIVE_INPUT_REQUIRED] This command needs wizard input, but the terminal is not interactive.\nRun `npx cf-tunnel ui` or provide all required flags with `--yes`.'); process.exit(2); }
|
|
14
|
+
async function startUi() {
|
|
15
|
+
console.log('Starting Cloudflare Tunnel Kit UI on this machine...');
|
|
16
|
+
const service = createTunnelKitService({ dataDir: process.env.CLOUDFLARE_TUNNEL_KIT_DATA_DIR });
|
|
17
|
+
const server = createServer({ service });
|
|
18
|
+
server.once('error', error => { console.error(`Unable to start the local UI: ${error.message}`); console.error('Run `npx cf-tunnel ui` again after checking local server permissions.'); process.exitCode = 1; service.close(); });
|
|
19
|
+
server.listen(0, '127.0.0.1', async () => { const address = server.address(); if (!address || typeof address === 'string')
|
|
20
|
+
return; const url = `http://127.0.0.1:${address.port}`; console.log(`UI ready at ${url}`); if (!args.includes('--no-open'))
|
|
21
|
+
console.log((await launchBrowser(url)).message); });
|
|
22
|
+
process.once('SIGINT', () => server.close(() => { service.close(); process.exit(130); }));
|
|
23
|
+
}
|
|
10
24
|
async function main() {
|
|
11
|
-
if (
|
|
12
|
-
if (args.length === 1)
|
|
13
|
-
return runWizard();
|
|
14
|
-
}
|
|
15
|
-
if (command === 'help' || command === '--help')
|
|
25
|
+
if (['help', '--help', '-h'].includes(command))
|
|
16
26
|
return help();
|
|
17
|
-
if (command === 'ui')
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
if (command === 'ui')
|
|
28
|
+
return startUi();
|
|
29
|
+
if (command === 'init' && !stdin.isTTY)
|
|
30
|
+
interactiveRequired();
|
|
31
|
+
const service = createTunnelKitService({ dataDir: process.env.CLOUDFLARE_TUNNEL_KIT_DATA_DIR });
|
|
32
|
+
if (command === 'init')
|
|
33
|
+
return runWizard(service);
|
|
34
|
+
if (command === 'doctor') {
|
|
35
|
+
const report = await service.doctor();
|
|
36
|
+
console.log(JSON.stringify(report, null, 2));
|
|
37
|
+
service.close();
|
|
20
38
|
return;
|
|
21
39
|
}
|
|
22
|
-
if (command === '
|
|
23
|
-
|
|
40
|
+
if (command === 'quick' || command === 'create') {
|
|
41
|
+
const mode = command === 'quick' ? 'quick' : 'named';
|
|
42
|
+
const localUrl = value('--url');
|
|
43
|
+
const tunnelName = value('--name');
|
|
44
|
+
const hostname = value('--hostname');
|
|
45
|
+
if (!localUrl || (mode === 'named' && (!tunnelName || !hostname))) {
|
|
46
|
+
if (!stdin.isTTY)
|
|
47
|
+
interactiveRequired();
|
|
48
|
+
return runWizard(service, mode);
|
|
49
|
+
}
|
|
50
|
+
const input = { projectPath: value('--path') ?? process.cwd(), displayName: value('--project-name'), profile: value('--profile') === 'laravel' ? 'laravel' : 'custom', localUrl, tunnelName, hostname };
|
|
51
|
+
const plan = mode === 'quick' ? await service.prepareQuick(input) : await service.prepareNamed(input);
|
|
52
|
+
if (args.includes('--dry-run')) {
|
|
53
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
54
|
+
service.close();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!args.includes('--yes')) {
|
|
58
|
+
if (!stdin.isTTY)
|
|
59
|
+
interactiveRequired();
|
|
60
|
+
return runWizard(service, mode);
|
|
61
|
+
}
|
|
62
|
+
console.log(JSON.stringify(await service.execute(plan.id, plan.confirmations), null, 2));
|
|
24
63
|
return;
|
|
25
64
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
65
|
+
if (['start', 'stop', 'restart', 'status', 'retry'].includes(command)) {
|
|
66
|
+
const projectId = value('--project');
|
|
67
|
+
if (!projectId) {
|
|
68
|
+
if (!stdin.isTTY)
|
|
69
|
+
interactiveRequired();
|
|
70
|
+
return runWizard(service);
|
|
71
|
+
}
|
|
72
|
+
const result = command === 'status' ? await service.getProject(projectId)
|
|
73
|
+
: command === 'start' ? await service.start(projectId)
|
|
74
|
+
: command === 'stop' ? await service.stop(projectId)
|
|
75
|
+
: command === 'restart' ? await service.restart(projectId)
|
|
76
|
+
: await service.retry(projectId);
|
|
77
|
+
console.log(JSON.stringify(result, null, 2));
|
|
78
|
+
if (command === 'status' || command === 'stop')
|
|
79
|
+
service.close();
|
|
31
80
|
return;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
81
|
+
}
|
|
82
|
+
help();
|
|
83
|
+
process.exitCode = 2;
|
|
84
|
+
service.close();
|
|
36
85
|
}
|
|
37
|
-
main().catch(
|
|
86
|
+
main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
package/dist/cli/wizard.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { TunnelPlan } from '../core/types.js';
|
|
2
2
|
export declare function formatWizardSummary(plan: Pick<TunnelPlan, 'valid' | 'summary' | 'issues' | 'argv' | 'confirmations'>): string;
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function mainMenu(): string;
|
|
4
|
+
export declare function runWizard(service: any, initialMode?: 'quick' | 'named'): Promise<void>;
|
package/dist/cli/wizard.js
CHANGED
|
@@ -1,38 +1,82 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
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
3
|
export function formatWizardSummary(plan) {
|
|
7
4
|
const lines = [plan.valid ? 'Validation passed.' : 'Validation failed.', plan.summary];
|
|
8
|
-
for (const
|
|
9
|
-
lines.push(`\n[${
|
|
5
|
+
for (const issue of plan.issues)
|
|
6
|
+
lines.push(`\n[${issue.code}]${issue.field ? ` ${issue.field}` : ''}\nReason: ${issue.reason}\nFix: ${issue.fix}`);
|
|
10
7
|
if (plan.valid)
|
|
11
8
|
lines.push(`\nCommand preview: cloudflared ${plan.argv.join(' ')}`);
|
|
12
9
|
if (plan.confirmations.length)
|
|
13
10
|
lines.push(`\nConfirmation required: ${plan.confirmations.join(', ')}`);
|
|
14
11
|
return lines.join('\n');
|
|
15
12
|
}
|
|
16
|
-
export
|
|
13
|
+
export function mainMenu() {
|
|
14
|
+
return `What would you like to do?\n\n 1. Create a Quick Tunnel\n 2. Set up a custom domain\n 3. Open a saved project\n 4. Check system requirements`;
|
|
15
|
+
}
|
|
16
|
+
export async function runWizard(service, initialMode) {
|
|
17
17
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
18
18
|
try {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
console.log('\nCloudflare Tunnel Kit\n');
|
|
20
|
+
let mode = initialMode;
|
|
21
|
+
if (!mode) {
|
|
22
|
+
console.log(mainMenu());
|
|
23
|
+
const choice = (await rl.question('\nChoose [1-4]: ')).trim();
|
|
24
|
+
if (choice === '3')
|
|
25
|
+
return openSaved(service, rl);
|
|
26
|
+
if (choice === '4')
|
|
27
|
+
return printDoctor(await service.doctor());
|
|
28
|
+
mode = choice === '2' ? 'named' : 'quick';
|
|
29
|
+
}
|
|
30
|
+
const displayName = (await rl.question('Project name: ')).trim() || 'Local project';
|
|
31
|
+
const projectPath = (await rl.question(`Project folder (${process.cwd()}): `)).trim() || process.cwd();
|
|
32
|
+
const localUrl = (await rl.question('Local application URL (http://127.0.0.1:8000): ')).trim() || 'http://127.0.0.1:8000';
|
|
33
|
+
const profileInput = (await rl.question('Project type [custom/laravel] (custom): ')).trim() || 'custom';
|
|
34
|
+
const input = { displayName, projectPath, localUrl, profile: profileInput === 'laravel' ? 'laravel' : 'custom' };
|
|
35
|
+
if (mode === 'named') {
|
|
36
|
+
input.tunnelName = (await rl.question('Tunnel name: ')).trim();
|
|
37
|
+
input.hostname = (await rl.question('Public hostname (for example dev.example.com): ')).trim();
|
|
38
|
+
}
|
|
39
|
+
const plan = mode === 'quick' ? await service.prepareQuick(input) : await service.prepareNamed(input);
|
|
40
|
+
console.log('\nReview changes:');
|
|
41
|
+
for (const effect of plan.effects)
|
|
42
|
+
console.log(` - ${effect}`);
|
|
43
|
+
if (mode === 'named')
|
|
44
|
+
console.log(' - Cloudflare login may open in your browser if authentication is missing or stale.');
|
|
45
|
+
const answer = (await rl.question('\nConfirm and run? [y/N]: ')).trim().toLowerCase();
|
|
46
|
+
if (!['y', 'yes'].includes(answer)) {
|
|
33
47
|
console.log('Cancelled. No changes were made.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
console.log('\nRunning validated workflow...');
|
|
51
|
+
console.log(JSON.stringify(await service.execute(plan.id, plan.confirmations), null, 2));
|
|
34
52
|
}
|
|
35
53
|
finally {
|
|
36
54
|
rl.close();
|
|
37
55
|
}
|
|
38
56
|
}
|
|
57
|
+
async function openSaved(service, rl) {
|
|
58
|
+
const projects = await service.listProjects();
|
|
59
|
+
if (!projects.length) {
|
|
60
|
+
console.log('No saved projects were found on this machine.');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
projects.forEach((project, index) => console.log(` ${index + 1}. ${project.displayName} — ${project.status}`));
|
|
64
|
+
const selected = Number((await rl.question('Choose a project: ')).trim()) - 1;
|
|
65
|
+
const project = projects[selected];
|
|
66
|
+
if (!project) {
|
|
67
|
+
console.log('Invalid project selection.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const action = (await rl.question('Action [start/stop/retry/status] (status): ')).trim() || 'status';
|
|
71
|
+
if (action === 'status')
|
|
72
|
+
console.log(JSON.stringify(await service.getProject(project.id), null, 2));
|
|
73
|
+
else if (['start', 'stop', 'retry'].includes(action))
|
|
74
|
+
console.log(JSON.stringify(await service[action](project.id), null, 2));
|
|
75
|
+
else
|
|
76
|
+
console.log('Unknown action.');
|
|
77
|
+
}
|
|
78
|
+
function printDoctor(report) {
|
|
79
|
+
console.log('\nSystem requirements:');
|
|
80
|
+
for (const check of report.checks)
|
|
81
|
+
console.log(` ${check.state === 'passed' ? '✓' : '✗'} ${check.name}: ${check.detail}`);
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Profile } from './types.js';
|
|
2
|
+
import { type OriginCheckResult } from './origin-check.js';
|
|
3
|
+
import type { StateStore } from '../persistence/store.js';
|
|
4
|
+
import type { ProcessSupervisor } from '../providers/process-supervisor.js';
|
|
5
|
+
interface CloudflarePort {
|
|
6
|
+
version(): Promise<any>;
|
|
7
|
+
login(): Promise<any>;
|
|
8
|
+
listTunnels(): Promise<any>;
|
|
9
|
+
createTunnel(name: string): Promise<any>;
|
|
10
|
+
validateIngress(configPath: string): Promise<any>;
|
|
11
|
+
routeDns(tunnel: string, hostname: string): Promise<any>;
|
|
12
|
+
info(tunnel: string): Promise<any>;
|
|
13
|
+
}
|
|
14
|
+
export declare class NamedTunnelWorkflow {
|
|
15
|
+
private readonly store;
|
|
16
|
+
private readonly cloudflare;
|
|
17
|
+
private readonly supervisor;
|
|
18
|
+
private readonly projectsDir;
|
|
19
|
+
private readonly executable;
|
|
20
|
+
private readonly baseArgs;
|
|
21
|
+
private readonly env?;
|
|
22
|
+
private readonly originCheck;
|
|
23
|
+
private readonly publicCheck;
|
|
24
|
+
constructor(options: {
|
|
25
|
+
store: StateStore;
|
|
26
|
+
cloudflare: CloudflarePort;
|
|
27
|
+
supervisor: ProcessSupervisor;
|
|
28
|
+
projectsDir: string;
|
|
29
|
+
executable?: string;
|
|
30
|
+
baseArgs?: string[];
|
|
31
|
+
env?: NodeJS.ProcessEnv;
|
|
32
|
+
originCheck?: (url: string) => Promise<OriginCheckResult>;
|
|
33
|
+
publicCheck?: (url: string) => Promise<OriginCheckResult>;
|
|
34
|
+
});
|
|
35
|
+
run(input: {
|
|
36
|
+
projectPath: string;
|
|
37
|
+
displayName?: string;
|
|
38
|
+
profile: Profile;
|
|
39
|
+
localUrl: string;
|
|
40
|
+
tunnelName: string;
|
|
41
|
+
hostname: string;
|
|
42
|
+
}): Promise<any>;
|
|
43
|
+
retry(projectId: string): Promise<any>;
|
|
44
|
+
private execute;
|
|
45
|
+
stop(projectId: string): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdir, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { checkOrigin } from './origin-check.js';
|
|
4
|
+
import { validateTunnelConfig } from './validation.js';
|
|
5
|
+
import { WorkflowRunner } from './workflow.js';
|
|
6
|
+
export class NamedTunnelWorkflow {
|
|
7
|
+
store;
|
|
8
|
+
cloudflare;
|
|
9
|
+
supervisor;
|
|
10
|
+
projectsDir;
|
|
11
|
+
executable;
|
|
12
|
+
baseArgs;
|
|
13
|
+
env;
|
|
14
|
+
originCheck;
|
|
15
|
+
publicCheck;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.store = options.store;
|
|
18
|
+
this.cloudflare = options.cloudflare;
|
|
19
|
+
this.supervisor = options.supervisor;
|
|
20
|
+
this.projectsDir = options.projectsDir;
|
|
21
|
+
this.executable = options.executable ?? 'cloudflared';
|
|
22
|
+
this.baseArgs = options.baseArgs ?? [];
|
|
23
|
+
this.env = options.env;
|
|
24
|
+
this.originCheck = options.originCheck ?? (url => checkOrigin(url));
|
|
25
|
+
this.publicCheck = options.publicCheck ?? (url => checkOrigin(url, { timeoutMs: 10_000 }));
|
|
26
|
+
}
|
|
27
|
+
async run(input) {
|
|
28
|
+
const project = this.store.saveProject({ displayName: input.displayName ?? path.basename(input.projectPath), path: path.resolve(input.projectPath), profile: input.profile });
|
|
29
|
+
return this.execute(project.id, input);
|
|
30
|
+
}
|
|
31
|
+
async retry(projectId) {
|
|
32
|
+
const project = this.store.getProject(projectId);
|
|
33
|
+
const tunnel = this.store.getTunnelForProject(projectId);
|
|
34
|
+
if (!tunnel?.name || !tunnel.hostname || !tunnel.localUrl)
|
|
35
|
+
throw new Error('Saved named tunnel settings are incomplete.');
|
|
36
|
+
return this.execute(projectId, { projectPath: project.path, displayName: project.displayName, profile: project.profile, localUrl: tunnel.localUrl, tunnelName: tunnel.name, hostname: tunnel.hostname });
|
|
37
|
+
}
|
|
38
|
+
async execute(projectId, input) {
|
|
39
|
+
const run = this.store.createWorkflow({ projectId, kind: 'named' });
|
|
40
|
+
const runner = new WorkflowRunner(this.store, run.id);
|
|
41
|
+
const validation = validateTunnelConfig({ profile: input.profile, operation: 'create', localUrl: input.localUrl, tunnelName: input.tunnelName, hostname: input.hostname, projectRoot: input.projectPath });
|
|
42
|
+
if (!validation.ok) {
|
|
43
|
+
runner.fail('input', validation.issues);
|
|
44
|
+
return { state: 'failed', projectId, runId: run.id, error: validation.issues[0] };
|
|
45
|
+
}
|
|
46
|
+
await runner.step('input', async () => ({ value: validation.normalized, effects: ['Validated tunnel settings.'] }));
|
|
47
|
+
const origin = await this.originCheck(input.localUrl);
|
|
48
|
+
if (!origin.reachable) {
|
|
49
|
+
runner.fail('origin', origin.error);
|
|
50
|
+
return { state: 'failed', projectId, runId: run.id, error: origin.error };
|
|
51
|
+
}
|
|
52
|
+
await runner.step('origin', async () => ({ state: origin.warning ? 'warning' : 'succeeded', value: origin, effects: ['Verified the local application.'] }));
|
|
53
|
+
const version = await this.cloudflare.version();
|
|
54
|
+
if (!version.ok) {
|
|
55
|
+
runner.fail('environment', version.error);
|
|
56
|
+
return { state: 'failed', projectId, runId: run.id, error: version.error };
|
|
57
|
+
}
|
|
58
|
+
await runner.step('environment', async () => ({ value: version.value, effects: ['Found cloudflared.'] }));
|
|
59
|
+
let listed = await this.cloudflare.listTunnels();
|
|
60
|
+
if (!listed.ok && ['AUTH_REQUIRED', 'AUTH_STALE'].includes(listed.error.code)) {
|
|
61
|
+
const login = await this.cloudflare.login();
|
|
62
|
+
if (!login.ok) {
|
|
63
|
+
runner.fail('authentication', login.error);
|
|
64
|
+
return { state: 'failed', projectId, runId: run.id, error: login.error };
|
|
65
|
+
}
|
|
66
|
+
await runner.step('authentication', async () => ({ value: login.value, effects: ['Signed in to Cloudflare.'] }));
|
|
67
|
+
listed = await this.cloudflare.listTunnels();
|
|
68
|
+
}
|
|
69
|
+
if (!listed.ok) {
|
|
70
|
+
runner.fail('account-access', listed.error);
|
|
71
|
+
return { state: 'failed', projectId, runId: run.id, error: listed.error };
|
|
72
|
+
}
|
|
73
|
+
await runner.step('account-access', async () => ({ value: { tunnelCount: listed.value.length }, effects: ['Verified Cloudflare account access.'] }));
|
|
74
|
+
let tunnel = this.store.getTunnelForProject(projectId);
|
|
75
|
+
if (!tunnel?.uuid) {
|
|
76
|
+
const conflict = listed.value.find((item) => item.name === input.tunnelName);
|
|
77
|
+
if (conflict) {
|
|
78
|
+
const error = { code: 'TUNNEL_NAME_CONFLICT', reason: `Tunnel ${input.tunnelName} already exists but is not managed by this local project.`, fix: 'Choose another name or explicitly adopt the existing tunnel.' };
|
|
79
|
+
runner.fail('tunnel', error);
|
|
80
|
+
return { state: 'failed', projectId, runId: run.id, error };
|
|
81
|
+
}
|
|
82
|
+
const created = await this.cloudflare.createTunnel(input.tunnelName);
|
|
83
|
+
if (!created.ok) {
|
|
84
|
+
runner.fail('tunnel', created.error);
|
|
85
|
+
return { state: 'failed', projectId, runId: run.id, error: created.error };
|
|
86
|
+
}
|
|
87
|
+
const configPath = path.join(this.projectsDir, projectId, 'config.yml');
|
|
88
|
+
tunnel = this.store.saveTunnel({ projectId, kind: 'named', name: input.tunnelName, uuid: created.value.uuid, hostname: input.hostname, localUrl: input.localUrl, configPath, credentialsPath: created.value.credentialsFile });
|
|
89
|
+
await runner.step('tunnel', async () => ({ value: { uuid: tunnel.uuid }, effects: [`Created tunnel ${input.tunnelName} (${tunnel.uuid}).`] }));
|
|
90
|
+
}
|
|
91
|
+
if (!tunnel.uuid || !tunnel.credentialsPath || !tunnel.configPath)
|
|
92
|
+
throw new Error('Saved tunnel identity is incomplete.');
|
|
93
|
+
try {
|
|
94
|
+
const credentialStat = await stat(tunnel.credentialsPath);
|
|
95
|
+
if (!credentialStat.isFile())
|
|
96
|
+
throw new Error();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
const error = { code: 'TUNNEL_CREDENTIALS_MISSING', reason: 'The tunnel credential file is missing.', fix: 'Restore the credential file or create a new tunnel.' };
|
|
100
|
+
runner.fail('configuration', error);
|
|
101
|
+
return { state: 'failed', projectId, runId: run.id, error };
|
|
102
|
+
}
|
|
103
|
+
await mkdir(path.dirname(tunnel.configPath), { recursive: true, mode: 0o700 });
|
|
104
|
+
const yaml = `tunnel: ${JSON.stringify(tunnel.uuid)}\ncredentials-file: ${JSON.stringify(tunnel.credentialsPath)}\ningress:\n - hostname: ${JSON.stringify(input.hostname)}\n service: ${JSON.stringify(input.localUrl)}\n - service: http_status:404\n`;
|
|
105
|
+
const temporary = `${tunnel.configPath}.tmp`;
|
|
106
|
+
await writeFile(temporary, yaml, { mode: 0o600 });
|
|
107
|
+
await rename(temporary, tunnel.configPath);
|
|
108
|
+
await runner.step('configuration', async () => ({ value: { configPath: tunnel.configPath }, effects: ['Wrote application-owned tunnel config outside the repository.'] }));
|
|
109
|
+
const ingress = await this.cloudflare.validateIngress(tunnel.configPath);
|
|
110
|
+
if (!ingress.ok) {
|
|
111
|
+
runner.fail('ingress-validation', ingress.error);
|
|
112
|
+
return { state: 'failed', projectId, runId: run.id, error: ingress.error };
|
|
113
|
+
}
|
|
114
|
+
await runner.step('ingress-validation', async () => ({ value: ingress.value, effects: ['Validated ingress rules.'] }));
|
|
115
|
+
const routed = await this.cloudflare.routeDns(tunnel.uuid, input.hostname);
|
|
116
|
+
if (!routed.ok) {
|
|
117
|
+
runner.fail('dns-route', routed.error);
|
|
118
|
+
return { state: 'failed', projectId, runId: run.id, error: routed.error, configPath: tunnel.configPath };
|
|
119
|
+
}
|
|
120
|
+
await runner.step('dns-route', async () => ({ value: routed.value, effects: [`Routed ${input.hostname} to the tunnel.`] }));
|
|
121
|
+
const sessionKey = `named:${tunnel.uuid}`;
|
|
122
|
+
const session = await this.supervisor.start({ key: sessionKey, executable: this.executable, args: [...this.baseArgs, 'tunnel', '--config', tunnel.configPath, 'run', tunnel.uuid], env: this.env });
|
|
123
|
+
await runner.step('connector', async () => ({ value: { pid: session.pid }, effects: ['Started the Cloudflare connector.'] }));
|
|
124
|
+
const info = await this.cloudflare.info(tunnel.uuid);
|
|
125
|
+
await runner.step('cloudflare-health', async () => ({ state: info.ok && info.value.connectorState === 'healthy' ? 'succeeded' : 'warning', value: info.ok ? info.value : info.error, effects: [] }));
|
|
126
|
+
const publicUrl = `https://${input.hostname}`;
|
|
127
|
+
const publicHealth = await this.publicCheck(publicUrl);
|
|
128
|
+
await runner.step('public-health', async () => ({ state: publicHealth.reachable ? 'succeeded' : 'warning', value: publicHealth, effects: [] }));
|
|
129
|
+
this.store.saveSession({ projectId, processKey: sessionKey, pid: session.pid, state: 'running', executable: this.executable });
|
|
130
|
+
this.store.completeWorkflow(run.id, 'succeeded');
|
|
131
|
+
return { state: 'succeeded', projectId, runId: run.id, publicUrl, configPath: tunnel.configPath, sessionKey, tunnelUuid: tunnel.uuid };
|
|
132
|
+
}
|
|
133
|
+
async stop(projectId) { const session = this.store.getLatestSession(projectId); await this.supervisor.stop(session.processKey); this.store.stopSession(session.id); }
|
|
134
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Profile } from './types.js';
|
|
2
|
+
import { type OriginCheckResult } from './origin-check.js';
|
|
3
|
+
import type { StateStore } from '../persistence/store.js';
|
|
4
|
+
import type { ProcessSupervisor } from '../providers/process-supervisor.js';
|
|
5
|
+
export declare class QuickTunnelWorkflow {
|
|
6
|
+
private readonly store;
|
|
7
|
+
private readonly supervisor;
|
|
8
|
+
private readonly executable;
|
|
9
|
+
private readonly baseArgs;
|
|
10
|
+
private readonly env?;
|
|
11
|
+
private readonly originCheck;
|
|
12
|
+
constructor(options: {
|
|
13
|
+
store: StateStore;
|
|
14
|
+
supervisor: ProcessSupervisor;
|
|
15
|
+
executable?: string;
|
|
16
|
+
baseArgs?: string[];
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
originCheck?: (url: string) => Promise<OriginCheckResult>;
|
|
19
|
+
});
|
|
20
|
+
run(input: {
|
|
21
|
+
projectPath: string;
|
|
22
|
+
displayName?: string;
|
|
23
|
+
profile: Profile;
|
|
24
|
+
localUrl: string;
|
|
25
|
+
}): Promise<any>;
|
|
26
|
+
stop(projectId: string): Promise<void>;
|
|
27
|
+
restart(projectId: string): Promise<any>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { checkOrigin } from './origin-check.js';
|
|
3
|
+
import { WorkflowRunner } from './workflow.js';
|
|
4
|
+
export class QuickTunnelWorkflow {
|
|
5
|
+
store;
|
|
6
|
+
supervisor;
|
|
7
|
+
executable;
|
|
8
|
+
baseArgs;
|
|
9
|
+
env;
|
|
10
|
+
originCheck;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.store = options.store;
|
|
13
|
+
this.supervisor = options.supervisor;
|
|
14
|
+
this.executable = options.executable ?? 'cloudflared';
|
|
15
|
+
this.baseArgs = options.baseArgs ?? [];
|
|
16
|
+
this.env = options.env;
|
|
17
|
+
this.originCheck = options.originCheck ?? (url => checkOrigin(url));
|
|
18
|
+
}
|
|
19
|
+
async run(input) {
|
|
20
|
+
const project = this.store.saveProject({ displayName: input.displayName ?? path.basename(input.projectPath), path: path.resolve(input.projectPath), profile: input.profile });
|
|
21
|
+
this.store.saveTunnel({ projectId: project.id, kind: 'quick', localUrl: input.localUrl });
|
|
22
|
+
const run = this.store.createWorkflow({ projectId: project.id, kind: 'quick' });
|
|
23
|
+
const runner = new WorkflowRunner(this.store, run.id);
|
|
24
|
+
const origin = await this.originCheck(input.localUrl);
|
|
25
|
+
if (!origin.reachable) {
|
|
26
|
+
runner.fail('origin', origin.error);
|
|
27
|
+
return { state: 'failed', projectId: project.id, runId: run.id, error: origin.error };
|
|
28
|
+
}
|
|
29
|
+
await runner.step('origin', async () => ({ state: origin.warning ? 'warning' : 'succeeded', value: origin, effects: ['Verified the local application is reachable.'] }));
|
|
30
|
+
const sessionKey = `quick:${project.id}`;
|
|
31
|
+
try {
|
|
32
|
+
const session = await this.supervisor.start({ key: sessionKey, executable: this.executable, args: [...this.baseArgs, 'tunnel', '--url', input.localUrl], env: this.env });
|
|
33
|
+
const logs = await session.waitForOutput(/https:\/\/[-a-z0-9]+\.trycloudflare\.com/i, 15_000);
|
|
34
|
+
const publicUrl = logs.match(/https:\/\/[-a-z0-9]+\.trycloudflare\.com/i)?.[0];
|
|
35
|
+
if (!publicUrl)
|
|
36
|
+
throw new Error('Quick Tunnel URL was not received.');
|
|
37
|
+
await runner.step('connector', async () => ({ value: { publicUrl }, effects: [`Started Quick Tunnel at ${publicUrl}.`] }));
|
|
38
|
+
this.store.saveSession({ projectId: project.id, processKey: sessionKey, pid: session.pid, state: 'running', ephemeralUrl: publicUrl, executable: this.executable });
|
|
39
|
+
this.store.completeWorkflow(run.id, 'succeeded');
|
|
40
|
+
return { state: 'succeeded', projectId: project.id, runId: run.id, sessionKey, publicUrl };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
await this.supervisor.stop(sessionKey);
|
|
44
|
+
const safe = { code: 'QUICK_URL_NOT_RECEIVED', reason: error instanceof Error ? error.message : String(error), fix: 'Check cloudflared connectivity and retry.' };
|
|
45
|
+
runner.fail('connector', safe);
|
|
46
|
+
return { state: 'failed', projectId: project.id, runId: run.id, error: safe };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async stop(projectId) {
|
|
50
|
+
const saved = this.store.getLatestSession(projectId);
|
|
51
|
+
await this.supervisor.stop(saved.processKey);
|
|
52
|
+
this.store.stopSession(saved.id);
|
|
53
|
+
}
|
|
54
|
+
async restart(projectId) {
|
|
55
|
+
const project = this.store.getProject(projectId);
|
|
56
|
+
const tunnel = this.store.getTunnelForProject(projectId);
|
|
57
|
+
if (!tunnel?.localUrl)
|
|
58
|
+
throw new Error('Saved Quick Tunnel settings are incomplete.');
|
|
59
|
+
return this.run({ projectPath: project.path, displayName: project.displayName, profile: project.profile, localUrl: tunnel.localUrl });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StateStore } from '../persistence/store.js';
|
|
2
|
+
export declare class WorkflowRunner {
|
|
3
|
+
private readonly store;
|
|
4
|
+
readonly runId: string;
|
|
5
|
+
constructor(store: StateStore, runId: string);
|
|
6
|
+
step<T>(name: string, operation: () => Promise<{
|
|
7
|
+
state?: 'succeeded' | 'warning';
|
|
8
|
+
value?: T;
|
|
9
|
+
effects?: string[];
|
|
10
|
+
}>): Promise<T | undefined>;
|
|
11
|
+
fail(name: string, error: unknown): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class WorkflowRunner {
|
|
2
|
+
store;
|
|
3
|
+
runId;
|
|
4
|
+
constructor(store, runId) {
|
|
5
|
+
this.store = store;
|
|
6
|
+
this.runId = runId;
|
|
7
|
+
}
|
|
8
|
+
async step(name, operation) {
|
|
9
|
+
this.store.recordStep(this.runId, { name, state: 'running', attempts: 1, effects: [], safeResult: {} });
|
|
10
|
+
const result = await operation();
|
|
11
|
+
const state = result.state ?? 'succeeded';
|
|
12
|
+
this.store.recordStep(this.runId, { name, state, attempts: 1, effects: result.effects ?? [], safeResult: result.value ?? {} });
|
|
13
|
+
return result.value;
|
|
14
|
+
}
|
|
15
|
+
fail(name, error) {
|
|
16
|
+
this.store.recordStep(this.runId, { name, state: 'failed', attempts: 1, effects: [], safeResult: {}, error });
|
|
17
|
+
this.store.completeWorkflow(this.runId, 'failed');
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,14 +2,19 @@ export * from './core/types.js';
|
|
|
2
2
|
export * from './core/errors.js';
|
|
3
3
|
export * from './core/validation.js';
|
|
4
4
|
export * from './core/origin-check.js';
|
|
5
|
+
export * from './core/workflow.js';
|
|
6
|
+
export * from './core/quick-workflow.js';
|
|
7
|
+
export * from './core/named-workflow.js';
|
|
5
8
|
export * from './core/plan.js';
|
|
6
9
|
export * from './core/execution.js';
|
|
7
10
|
export * from './core/redact.js';
|
|
8
11
|
export * from './adapters/laravel.js';
|
|
9
12
|
export * from './app/paths.js';
|
|
13
|
+
export * from './app/service.js';
|
|
10
14
|
export * from './persistence/database.js';
|
|
11
15
|
export * from './persistence/store.js';
|
|
12
16
|
export * from './providers/command-runner.js';
|
|
13
17
|
export * from './providers/cloudflared.js';
|
|
14
18
|
export * from './providers/git-safety.js';
|
|
15
19
|
export * from './providers/process-supervisor.js';
|
|
20
|
+
export * from './providers/browser.js';
|
package/dist/index.js
CHANGED
|
@@ -2,14 +2,19 @@ export * from './core/types.js';
|
|
|
2
2
|
export * from './core/errors.js';
|
|
3
3
|
export * from './core/validation.js';
|
|
4
4
|
export * from './core/origin-check.js';
|
|
5
|
+
export * from './core/workflow.js';
|
|
6
|
+
export * from './core/quick-workflow.js';
|
|
7
|
+
export * from './core/named-workflow.js';
|
|
5
8
|
export * from './core/plan.js';
|
|
6
9
|
export * from './core/execution.js';
|
|
7
10
|
export * from './core/redact.js';
|
|
8
11
|
export * from './adapters/laravel.js';
|
|
9
12
|
export * from './app/paths.js';
|
|
13
|
+
export * from './app/service.js';
|
|
10
14
|
export * from './persistence/database.js';
|
|
11
15
|
export * from './persistence/store.js';
|
|
12
16
|
export * from './providers/command-runner.js';
|
|
13
17
|
export * from './providers/cloudflared.js';
|
|
14
18
|
export * from './providers/git-safety.js';
|
|
15
19
|
export * from './providers/process-supervisor.js';
|
|
20
|
+
export * from './providers/browser.js';
|
|
@@ -24,6 +24,28 @@ export interface SavedWorkflowStep {
|
|
|
24
24
|
safeResult: unknown;
|
|
25
25
|
error?: unknown;
|
|
26
26
|
}
|
|
27
|
+
export interface SavedSession {
|
|
28
|
+
id: string;
|
|
29
|
+
projectId: string;
|
|
30
|
+
processKey: string;
|
|
31
|
+
pid?: number;
|
|
32
|
+
state: string;
|
|
33
|
+
ephemeralUrl?: string;
|
|
34
|
+
ephemeralUrlExpired: boolean;
|
|
35
|
+
startedAt: string;
|
|
36
|
+
stoppedAt?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface SavedTunnel {
|
|
39
|
+
id: string;
|
|
40
|
+
projectId: string;
|
|
41
|
+
kind: TunnelKind;
|
|
42
|
+
name?: string;
|
|
43
|
+
uuid?: string;
|
|
44
|
+
hostname?: string;
|
|
45
|
+
localUrl?: string;
|
|
46
|
+
configPath?: string;
|
|
47
|
+
credentialsPath?: string;
|
|
48
|
+
}
|
|
27
49
|
export declare class StateStore {
|
|
28
50
|
private readonly db;
|
|
29
51
|
constructor(db: Database.Database);
|
|
@@ -33,6 +55,11 @@ export declare class StateStore {
|
|
|
33
55
|
profile: Profile;
|
|
34
56
|
}): SavedProject;
|
|
35
57
|
listProjects(): SavedProject[];
|
|
58
|
+
getProject(id: string): SavedProject;
|
|
59
|
+
relinkProject(id: string, nextPath: string): void;
|
|
60
|
+
removeProject(id: string): void;
|
|
61
|
+
saveTunnel(input: Omit<SavedTunnel, 'id'>): SavedTunnel;
|
|
62
|
+
getTunnelForProject(projectId: string): SavedTunnel | undefined;
|
|
36
63
|
createWorkflow(input: {
|
|
37
64
|
projectId: string;
|
|
38
65
|
kind: TunnelKind;
|
|
@@ -48,4 +75,15 @@ export declare class StateStore {
|
|
|
48
75
|
error?: unknown;
|
|
49
76
|
}): void;
|
|
50
77
|
getWorkflow(id: string): SavedWorkflow;
|
|
78
|
+
completeWorkflow(id: string, state: 'succeeded' | 'failed' | 'cancelled'): void;
|
|
79
|
+
saveSession(input: {
|
|
80
|
+
projectId: string;
|
|
81
|
+
processKey: string;
|
|
82
|
+
pid?: number;
|
|
83
|
+
state: string;
|
|
84
|
+
ephemeralUrl?: string;
|
|
85
|
+
executable?: string;
|
|
86
|
+
}): SavedSession;
|
|
87
|
+
getLatestSession(projectId: string): SavedSession;
|
|
88
|
+
stopSession(id: string): void;
|
|
51
89
|
}
|
|
@@ -22,6 +22,37 @@ export class StateStore {
|
|
|
22
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
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
24
|
}
|
|
25
|
+
getProject(id) {
|
|
26
|
+
const row = this.db.prepare('SELECT id, display_name, path, profile, created_at, updated_at FROM projects WHERE id=?').get(id);
|
|
27
|
+
if (!row)
|
|
28
|
+
throw new Error(`Project not found: ${id}`);
|
|
29
|
+
return { id: row.id, displayName: row.display_name, path: row.path, profile: row.profile, createdAt: row.created_at, updatedAt: row.updated_at };
|
|
30
|
+
}
|
|
31
|
+
relinkProject(id, nextPath) {
|
|
32
|
+
const result = this.db.prepare('UPDATE projects SET path=?, updated_at=? WHERE id=?').run(nextPath, new Date().toISOString(), id);
|
|
33
|
+
if (!result.changes)
|
|
34
|
+
throw new Error(`Project not found: ${id}`);
|
|
35
|
+
}
|
|
36
|
+
removeProject(id) {
|
|
37
|
+
const result = this.db.prepare('DELETE FROM projects WHERE id=?').run(id);
|
|
38
|
+
if (!result.changes)
|
|
39
|
+
throw new Error(`Project not found: ${id}`);
|
|
40
|
+
}
|
|
41
|
+
saveTunnel(input) {
|
|
42
|
+
const existing = this.db.prepare('SELECT id FROM tunnels WHERE project_id=? ORDER BY updated_at DESC LIMIT 1').get(input.projectId);
|
|
43
|
+
const id = existing?.id ?? crypto.randomUUID();
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
this.db.prepare(`INSERT INTO tunnels(id, project_id, kind, name, uuid, hostname, local_url, config_path, credentials_path, created_at, updated_at)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
47
|
+
ON CONFLICT(id) DO UPDATE SET kind=excluded.kind, name=excluded.name, uuid=excluded.uuid, hostname=excluded.hostname,
|
|
48
|
+
local_url=excluded.local_url, config_path=excluded.config_path, credentials_path=excluded.credentials_path, updated_at=excluded.updated_at`)
|
|
49
|
+
.run(id, input.projectId, input.kind, input.name ?? null, input.uuid ?? null, input.hostname ?? null, input.localUrl ?? null, input.configPath ?? null, input.credentialsPath ?? null, now, now);
|
|
50
|
+
return { id, ...input };
|
|
51
|
+
}
|
|
52
|
+
getTunnelForProject(projectId) {
|
|
53
|
+
const row = this.db.prepare('SELECT * FROM tunnels WHERE project_id=? ORDER BY updated_at DESC LIMIT 1').get(projectId);
|
|
54
|
+
return row ? { id: row.id, projectId: row.project_id, kind: row.kind, name: row.name ?? undefined, uuid: row.uuid ?? undefined, hostname: row.hostname ?? undefined, localUrl: row.local_url ?? undefined, configPath: row.config_path ?? undefined, credentialsPath: row.credentials_path ?? undefined } : undefined;
|
|
55
|
+
}
|
|
25
56
|
createWorkflow(input) {
|
|
26
57
|
const id = crypto.randomUUID();
|
|
27
58
|
const now = new Date().toISOString();
|
|
@@ -56,4 +87,25 @@ export class StateStore {
|
|
|
56
87
|
})),
|
|
57
88
|
};
|
|
58
89
|
}
|
|
90
|
+
completeWorkflow(id, state) {
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
this.db.prepare('UPDATE workflow_runs SET state=?, updated_at=?, finished_at=? WHERE id=?').run(state, now, now, id);
|
|
93
|
+
}
|
|
94
|
+
saveSession(input) {
|
|
95
|
+
const id = crypto.randomUUID();
|
|
96
|
+
const startedAt = new Date().toISOString();
|
|
97
|
+
this.db.prepare(`INSERT INTO process_sessions(id, project_id, process_key, pid, executable, state, ephemeral_url, started_at)
|
|
98
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
99
|
+
.run(id, input.projectId, input.processKey, input.pid ?? null, input.executable ?? null, input.state, input.ephemeralUrl ?? null, startedAt);
|
|
100
|
+
return { id, projectId: input.projectId, processKey: input.processKey, pid: input.pid, state: input.state, ephemeralUrl: input.ephemeralUrl, ephemeralUrlExpired: false, startedAt };
|
|
101
|
+
}
|
|
102
|
+
getLatestSession(projectId) {
|
|
103
|
+
const row = this.db.prepare('SELECT * FROM process_sessions WHERE project_id=? ORDER BY started_at DESC LIMIT 1').get(projectId);
|
|
104
|
+
if (!row)
|
|
105
|
+
throw new Error(`Process session not found for project: ${projectId}`);
|
|
106
|
+
return { id: row.id, projectId: row.project_id, processKey: row.process_key, pid: row.pid ?? undefined, state: row.state, ephemeralUrl: row.ephemeral_url ?? undefined, ephemeralUrlExpired: Boolean(row.ephemeral_url_expired), startedAt: row.started_at, stoppedAt: row.stopped_at ?? undefined };
|
|
107
|
+
}
|
|
108
|
+
stopSession(id) {
|
|
109
|
+
this.db.prepare('UPDATE process_sessions SET state=?, ephemeral_url_expired=1, stopped_at=? WHERE id=?').run('stopped', new Date().toISOString(), id);
|
|
110
|
+
}
|
|
59
111
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { runCommand } from './command-runner.js';
|
|
2
|
+
export async function launchBrowser(url, options = {}) {
|
|
3
|
+
const platform = options.platform ?? process.platform;
|
|
4
|
+
const run = options.run ?? ((executable, args) => runCommand({ executable, args, timeoutMs: 10_000 }));
|
|
5
|
+
const command = platform === 'darwin'
|
|
6
|
+
? ['open', [url]]
|
|
7
|
+
: platform === 'win32'
|
|
8
|
+
? ['cmd.exe', ['/c', 'start', '', url]]
|
|
9
|
+
: ['xdg-open', [url]];
|
|
10
|
+
try {
|
|
11
|
+
const result = await run(command[0], [...command[1]]);
|
|
12
|
+
if (result.exitCode === 0)
|
|
13
|
+
return { opened: true, message: `Opened ${url}` };
|
|
14
|
+
}
|
|
15
|
+
catch { }
|
|
16
|
+
return { opened: false, message: `Open this URL manually: ${url}` };
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function dashboardPage(): string;
|
package/dist/ui/page.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function dashboardPage() {
|
|
2
|
+
return String.raw `<!doctype html>
|
|
3
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
|
+
<title>Cloudflare Tunnel Kit</title>
|
|
5
|
+
<style>
|
|
6
|
+
:root{color-scheme:light;--ink:#172033;--muted:#68738a;--line:#e4e8f0;--panel:#fff;--bg:#f4f6fa;--blue:#1665d8;--blue2:#0c4fad;--orange:#f48120;--green:#11845b;--red:#c93c4b;--shadow:0 18px 50px rgba(26,39,70,.10)}
|
|
7
|
+
*{box-sizing:border-box}body{margin:0;font:15px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)}button,input,select{font:inherit}button{cursor:pointer}.shell{min-height:100vh}.topbar{height:68px;background:#101827;color:white;display:flex;align-items:center;justify-content:space-between;padding:0 clamp(20px,5vw,72px);box-shadow:0 1px 0 #ffffff18}.brand{display:flex;align-items:center;gap:12px;font-weight:760;letter-spacing:-.02em}.logo{width:34px;height:34px;border-radius:11px;background:linear-gradient(145deg,#ffad38,var(--orange));display:grid;place-items:center;color:white;font-size:19px;box-shadow:0 8px 24px #f4812050}.top-actions{display:flex;gap:10px}.main{max-width:1180px;margin:0 auto;padding:42px 28px 70px}.hero{display:grid;grid-template-columns:1.4fr .8fr;gap:24px;align-items:stretch}.hero-copy,.health-panel,.section{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}.hero-copy{padding:38px;background:radial-gradient(circle at 100% 0,#eaf3ff 0,transparent 42%),#fff}.eyebrow{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--blue)}h1{font-size:clamp(30px,4vw,48px);line-height:1.05;letter-spacing:-.045em;margin:10px 0 14px}.lead{font-size:17px;color:var(--muted);max-width:650px;margin:0 0 26px}.actions{display:flex;flex-wrap:wrap;gap:12px}.btn{border:0;border-radius:11px;padding:11px 16px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;gap:8px}.btn.primary{background:var(--blue);color:white;box-shadow:0 8px 18px #1665d835}.btn.primary:hover{background:var(--blue2)}.btn.secondary{background:#edf3fc;color:#174b91}.btn.ghost{background:white;color:var(--ink);border:1px solid var(--line)}.btn.danger{background:#fff0f1;color:var(--red)}.health-panel{padding:28px}.health-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.health-title h2,.section-head h2{margin:0;font-size:19px;letter-spacing:-.02em}.check{display:flex;gap:12px;padding:12px 0;border-top:1px solid var(--line)}.check:first-of-type{border:0}.dot{width:10px;height:10px;border-radius:50%;margin-top:6px;background:#aab3c3}.dot.ok{background:var(--green);box-shadow:0 0 0 4px #dff5ed}.check b{display:block}.check span{color:var(--muted);font-size:13px}.section{margin-top:24px;padding:28px}.section-head{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:20px}.section-head p{margin:4px 0 0;color:var(--muted)}.projects{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.empty{grid-column:1/-1;border:1px dashed #cfd6e3;border-radius:16px;padding:42px;text-align:center;color:var(--muted);background:#fafbfc}.card{border:1px solid var(--line);border-radius:16px;padding:20px;background:#fff;min-width:0}.card-top{display:flex;justify-content:space-between;gap:12px}.card h3{margin:0;font-size:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge{font-size:12px;font-weight:750;padding:4px 9px;border-radius:999px;background:#eef1f6;color:#536078;white-space:nowrap}.badge.running{background:#dcf6eb;color:#08734d}.badge.attention{background:#fff0d9;color:#9a5b00}.meta{margin:16px 0;color:var(--muted);font-size:13px;display:grid;gap:6px}.meta code{overflow:hidden;text-overflow:ellipsis}.card-actions{display:flex;gap:8px}.card-actions .btn{padding:8px 11px;font-size:13px}.notice{padding:12px 14px;border-radius:11px;background:#fff6e9;color:#854e00;margin:14px 0;font-size:13px}.hidden{display:none!important}
|
|
8
|
+
dialog{border:0;border-radius:22px;padding:0;width:min(680px,calc(100vw - 30px));box-shadow:0 30px 100px #11182755}dialog::backdrop{background:#10182799;backdrop-filter:blur(3px)}.dialog-head{padding:24px 28px 18px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:flex-start}.dialog-head h2{margin:0;font-size:22px}.dialog-head p{margin:5px 0 0;color:var(--muted)}.close{border:0;background:#f0f2f6;border-radius:9px;width:34px;height:34px}.dialog-body{padding:25px 28px}.steps{display:flex;gap:8px;margin-bottom:24px}.step{height:5px;flex:1;border-radius:4px;background:#e3e7ee}.step.active{background:var(--blue)}label{display:block;font-weight:700;margin:15px 0 6px}.hint{font-size:12px;color:var(--muted);margin-top:5px}input,select{width:100%;padding:11px 12px;border:1px solid #cfd6e3;border-radius:10px;background:white;color:var(--ink);outline:none}input:focus,select:focus{border-color:var(--blue);box-shadow:0 0 0 3px #1665d81a}.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}.dialog-foot{padding:18px 28px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:10px}.review{background:#f6f8fb;border-radius:13px;padding:16px}.review ul{margin:8px 0 0;padding-left:20px}.result{white-space:pre-wrap;background:#101827;color:#e7edf8;border-radius:13px;padding:16px;max-height:280px;overflow:auto;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.toast{position:fixed;right:24px;bottom:24px;background:#101827;color:#fff;padding:13px 17px;border-radius:11px;box-shadow:var(--shadow);z-index:20}
|
|
9
|
+
@media(max-width:850px){.hero{grid-template-columns:1fr}.projects{grid-template-columns:1fr 1fr}}@media(max-width:580px){.main{padding:24px 15px}.topbar{padding:0 16px}.top-actions .btn span{display:none}.hero-copy,.health-panel,.section{border-radius:16px}.hero-copy{padding:26px}.projects{grid-template-columns:1fr}.grid2{grid-template-columns:1fr}.dialog-head,.dialog-body,.dialog-foot{padding-left:19px;padding-right:19px}.actions>.btn{width:100%}}
|
|
10
|
+
</style></head><body><div class="shell">
|
|
11
|
+
<header class="topbar"><div class="brand"><div class="logo">☁</div><span>Cloudflare Tunnel Kit</span></div><div class="top-actions"><button class="btn ghost" data-action="doctor">System check</button></div></header>
|
|
12
|
+
<main class="main"><section class="hero"><div class="hero-copy"><div class="eyebrow">Local tunnel manager</div><h1>Your tunnels,<br>without the guesswork.</h1><p class="lead">Expose a local project through a temporary URL or connect your own Cloudflare domain. Every change is reviewed before it runs.</p><div class="actions"><button class="btn primary" data-new="quick">⚡ Create Quick Tunnel</button><button class="btn secondary" data-new="named">◎ Use a custom domain</button></div></div><aside class="health-panel"><div class="health-title"><h2>Environment</h2><button class="btn ghost" data-action="doctor">Run checks</button></div><div id="checks"><div class="check"><i class="dot"></i><div><b>Not checked yet</b><span>Run system checks before your first tunnel.</span></div></div></div></aside></section>
|
|
13
|
+
<section class="section"><div class="section-head"><div><h2>Saved projects</h2><p>Settings and history stored only on this machine.</p></div><button class="btn ghost" data-action="refresh">↻ Refresh</button></div><div class="projects" id="projects"><div class="empty">Loading your projects…</div></div></section></main></div>
|
|
14
|
+
<dialog id="wizard"><div class="dialog-head"><div><h2 id="wizard-title">Create a tunnel</h2><p id="wizard-subtitle">Step-by-step setup</p></div><button class="close" data-close aria-label="Close">×</button></div><div class="dialog-body"><div class="steps"><i class="step active"></i><i class="step"></i><i class="step"></i></div>
|
|
15
|
+
<form id="setup-form"><div id="fields"><label>Project name</label><input name="displayName" required placeholder="My local app"><label>Project folder</label><input name="projectPath" required value="${escapeAttribute(process.cwd())}"><label>Local application URL</label><input name="localUrl" required value="http://127.0.0.1:8000"><div id="named-fields" class="hidden"><div class="grid2"><div><label>Tunnel name</label><input name="tunnelName" placeholder="my-project"></div><div><label>Public hostname</label><input name="hostname" placeholder="dev.example.com"></div></div><p class="hint">The root domain must already be managed by your Cloudflare account.</p></div><label>Project type</label><select name="profile"><option value="custom">Custom</option><option value="laravel">Laravel</option></select></div>
|
|
16
|
+
<div id="review" class="hidden"><div class="review"><b>Review changes</b><ul id="effects"></ul></div><div class="notice" id="named-notice">A custom domain setup may open Cloudflare login in your browser. Existing DNS records are never overwritten automatically.</div></div><div id="running" class="hidden"><div class="result" id="result">Waiting…</div></div></form></div><div class="dialog-foot"><button class="btn ghost" data-close>Cancel</button><button class="btn primary" id="continue">Review changes</button></div></dialog>
|
|
17
|
+
<div class="toast hidden" id="toast"></div>
|
|
18
|
+
<script>
|
|
19
|
+
let mode='quick',stage=0,plan=null,token='';const q=s=>document.querySelector(s),qa=s=>[...document.querySelectorAll(s)];
|
|
20
|
+
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
21
|
+
async function api(url,options={}){const headers={'content-type':'application/json',...(options.headers||{})};if(token)headers['x-confirmation-token']=token;const r=await fetch(url,{...options,headers});const body=await r.json().catch(()=>({error:'Invalid server response'}));if(!r.ok)throw new Error(body.error||body.issues?.[0]?.reason||'Request failed');return body}
|
|
22
|
+
function toast(message){const el=q('#toast');el.textContent=message;el.classList.remove('hidden');setTimeout(()=>el.classList.add('hidden'),3200)}
|
|
23
|
+
async function load(){try{const data=await api('/api/projects');renderProjects(data.projects)}catch(e){q('#projects').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
|
|
24
|
+
function renderProjects(items){q('#projects').innerHTML=items.length?items.map(p=>'<article class="card"><div class="card-top"><h3>'+esc(p.displayName)+'</h3><span class="badge '+(p.status==='Running'?'running':p.status==='Needs attention'?'attention':'')+'">'+esc(p.status)+'</span></div><div class="meta"><span>'+esc(p.kind==='named'?p.hostname||'Custom domain':'Quick Tunnel')+'</span><code>'+esc(p.localUrl||p.path)+'</code></div><div class="card-actions">'+(p.status==='Running'?'<button class="btn ghost" data-project="'+p.id+'" data-command="stop">Stop</button>':'<button class="btn secondary" data-project="'+p.id+'" data-command="start">Start</button>')+'<button class="btn ghost" data-project="'+p.id+'" data-command="retry">Retry setup</button></div></article>').join(''):'<div class="empty"><b>No saved projects yet.</b><br>Create a Quick Tunnel or connect a custom domain to get started.</div>'}
|
|
25
|
+
function openWizard(nextMode){mode=nextMode;stage=0;plan=null;q('#setup-form').reset();q('[name=projectPath]').value=${JSON.stringify(process.cwd())};q('[name=localUrl]').value='http://127.0.0.1:8000';q('#named-fields').classList.toggle('hidden',mode!=='named');q('#named-notice').classList.toggle('hidden',mode!=='named');q('#wizard-title').textContent=mode==='named'?'Set up a custom domain':'Create a Quick Tunnel';q('#wizard-subtitle').textContent=mode==='named'?'Cloudflare login, tunnel, DNS, and connector':'No Cloudflare login required';showStage();q('#wizard').showModal()}
|
|
26
|
+
function showStage(){q('#fields').classList.toggle('hidden',stage!==0);q('#review').classList.toggle('hidden',stage!==1);q('#running').classList.toggle('hidden',stage!==2);qa('.step').forEach((el,i)=>el.classList.toggle('active',i<=stage));q('#continue').textContent=stage===0?'Review changes':stage===1?'Confirm and run':'Done'}
|
|
27
|
+
function values(){const data=Object.fromEntries(new FormData(q('#setup-form')));return {...data,profile:data.profile||'custom'}}
|
|
28
|
+
async function next(){try{if(stage===0){const data=values();if(mode==='named'&&(!data.tunnelName||!data.hostname))throw new Error('Tunnel name and public hostname are required.');plan=await api('/api/plans/'+mode,{method:'POST',body:JSON.stringify(data)});q('#effects').innerHTML=plan.effects.map(x=>'<li>'+esc(x)+'</li>').join('');stage=1;showStage()}else if(stage===1){stage=2;showStage();q('#result').textContent='Running validated workflow…';const result=await api('/api/execute',{method:'POST',body:JSON.stringify({planId:plan.id,confirmations:plan.confirmations})});q('#result').textContent=JSON.stringify(result,null,2);await load()}else q('#wizard').close()}catch(e){toast(e.message)}}
|
|
29
|
+
async function doctor(){try{const data=await api('/api/doctor');q('#checks').innerHTML=data.checks.map(c=>'<div class="check"><i class="dot '+(c.state==='passed'?'ok':'')+'"></i><div><b>'+esc(c.name)+'</b><span>'+esc(c.detail)+'</span></div></div>').join('')}catch(e){toast(e.message)}}
|
|
30
|
+
document.addEventListener('click',async e=>{const t=e.target.closest('button');if(!t)return;if(t.dataset.new)openWizard(t.dataset.new);if(t.dataset.close)q('#wizard').close();if(t.id==='continue'){e.preventDefault();await next()}if(t.dataset.action==='refresh')await load();if(t.dataset.action==='doctor')await doctor();if(t.dataset.project){try{await api('/api/projects/'+t.dataset.project+'/'+t.dataset.command,{method:'POST',body:'{}'});await load()}catch(err){toast(err.message)}}});
|
|
31
|
+
api('/api/session').then(x=>{token=x.confirmationToken;load();doctor()}).catch(e=>toast(e.message));
|
|
32
|
+
</script></body></html>`;
|
|
33
|
+
}
|
|
34
|
+
function escapeAttribute(value) { return value.replace(/[&<>"]/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[character]); }
|
package/dist/ui/server.d.ts
CHANGED
package/dist/ui/server.js
CHANGED
|
@@ -1,38 +1,82 @@
|
|
|
1
1
|
import { createServer as httpServer } from 'node:http';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { dashboardPage } from './page.js';
|
|
4
|
+
const emptyService = {
|
|
5
|
+
listProjects: async () => [], getProject: async () => ({}), doctor: async () => ({ ok: true, checks: [] }),
|
|
6
|
+
prepareQuick: async (input) => ({ id: 'preview', effects: [`Start Quick Tunnel to ${input.localUrl}.`], confirmations: ['start-connector'] }),
|
|
7
|
+
prepareNamed: async (input) => ({ id: 'preview', effects: [`Create ${input.hostname}.`], confirmations: ['cloudflare-resources', 'start-connector'] }),
|
|
8
|
+
execute: async () => ({ state: 'skipped', message: 'No service configured.' }), start: async () => ({}), stop: async () => ({}), retry: async () => ({}), restart: async () => ({}),
|
|
9
|
+
};
|
|
10
|
+
export function createServer(options = {}) {
|
|
11
|
+
const service = options.service ?? emptyService;
|
|
12
|
+
const token = options.sessionToken ?? crypto.randomUUID();
|
|
13
|
+
const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
|
|
8
14
|
return httpServer(async (req, res) => {
|
|
9
|
-
res
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
res
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
if (req.method === 'GET' && req.url === '/api/session')
|
|
15
|
+
secureHeaders(res);
|
|
16
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
17
|
+
if (req.method === 'GET' && url.pathname === '/')
|
|
18
|
+
return html(res, dashboardPage());
|
|
19
|
+
if (req.method === 'GET' && url.pathname === '/api/session')
|
|
16
20
|
return json(res, { confirmationToken: token });
|
|
17
|
-
if (req.method === '
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
if (req.method === 'GET' && url.pathname === '/api/projects')
|
|
22
|
+
return handle(res, async () => ({ projects: await service.listProjects() }));
|
|
23
|
+
if (req.method === 'GET' && url.pathname === '/api/doctor')
|
|
24
|
+
return handle(res, () => service.doctor());
|
|
25
|
+
const detail = url.pathname.match(/^\/api\/projects\/([^/]+)$/);
|
|
26
|
+
if (req.method === 'GET' && detail)
|
|
27
|
+
return handle(res, () => service.getProject(detail[1]));
|
|
28
|
+
if (req.method === 'POST') {
|
|
29
|
+
if (!validMutationRequest(req, token))
|
|
30
|
+
return json(res, { error: 'The local UI session is missing or invalid. Reload the page and try again.' }, 403);
|
|
31
|
+
if (!String(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json'))
|
|
32
|
+
return json(res, { error: 'Requests must use application/json.' }, 415);
|
|
33
|
+
let body;
|
|
20
34
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 });
|
|
35
|
+
body = JSON.parse(await readBody(req, maxBodyBytes));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return json(res, { error: error instanceof Error ? error.message : 'Invalid JSON request.' }, error?.code === 'BODY_TOO_LARGE' ? 413 : 400);
|
|
27
39
|
}
|
|
28
|
-
|
|
29
|
-
return
|
|
40
|
+
if (url.pathname === '/api/plans/quick')
|
|
41
|
+
return handle(res, () => service.prepareQuick(body));
|
|
42
|
+
if (url.pathname === '/api/plans/named')
|
|
43
|
+
return handle(res, () => service.prepareNamed(body));
|
|
44
|
+
if (url.pathname === '/api/execute')
|
|
45
|
+
return handle(res, () => service.execute(body.planId, body.confirmations ?? []));
|
|
46
|
+
const action = url.pathname.match(/^\/api\/projects\/([^/]+)\/(start|stop|retry|restart)$/);
|
|
47
|
+
if (action)
|
|
48
|
+
return handle(res, () => service[action[2]](action[1]));
|
|
49
|
+
if (url.pathname === '/api/plan') {
|
|
50
|
+
const config = body.config ?? {};
|
|
51
|
+
return handle(res, async () => ({ plan: config.operation === 'quick' ? await service.prepareQuick({ projectPath: config.projectRoot ?? process.cwd(), ...config }) : await service.prepareNamed({ projectPath: config.projectRoot ?? process.cwd(), ...config }) }));
|
|
30
52
|
}
|
|
31
53
|
}
|
|
32
|
-
res.
|
|
33
|
-
res.end('Not found');
|
|
54
|
+
return json(res, { error: 'Not found.' }, 404);
|
|
34
55
|
});
|
|
35
56
|
}
|
|
36
|
-
function
|
|
57
|
+
function validMutationRequest(req, token) {
|
|
58
|
+
const received = String(req.headers['x-confirmation-token'] ?? '');
|
|
59
|
+
if (received.length !== token.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(token)))
|
|
60
|
+
return false;
|
|
61
|
+
const host = String(req.headers.host ?? '');
|
|
62
|
+
if (host && !/^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(host))
|
|
63
|
+
return false;
|
|
64
|
+
const origin = String(req.headers.origin ?? '');
|
|
65
|
+
return !origin || /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test(origin);
|
|
66
|
+
}
|
|
67
|
+
function readBody(req, limit) { return new Promise((resolve, reject) => { let data = '', size = 0; req.on('data', chunk => { size += chunk.length; if (size > limit) {
|
|
68
|
+
const error = new Error('Request body is too large.');
|
|
69
|
+
error.code = 'BODY_TOO_LARGE';
|
|
70
|
+
reject(error);
|
|
71
|
+
req.destroy();
|
|
72
|
+
return;
|
|
73
|
+
} data += chunk; }); req.on('end', () => resolve(data || '{}')); req.on('error', reject); }); }
|
|
74
|
+
async function handle(res, operation) { try {
|
|
75
|
+
return json(res, await operation());
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return json(res, { error: error instanceof Error ? error.message : String(error) }, 400);
|
|
79
|
+
} }
|
|
80
|
+
function secureHeaders(res) { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'"); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Cache-Control', 'no-store'); }
|
|
81
|
+
function html(res, value) { res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(value); }
|
|
37
82
|
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloudflare-tunnel-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Safe, reusable Cloudflare Tunnel setup for custom and Laravel projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
},
|
|
35
35
|
"license": "MIT",
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"better-sqlite3": "^
|
|
37
|
+
"better-sqlite3": "^12.11.1"
|
|
38
38
|
}
|
|
39
39
|
}
|