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