cloudflare-tunnel-kit 0.1.1 → 0.1.3
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/paths.d.ts +11 -0
- package/dist/app/paths.js +23 -0
- package/dist/cli/main.js +21 -2
- 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 +10 -0
- package/dist/index.js +10 -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/browser.d.ts +9 -0
- package/dist/providers/browser.js +17 -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
|
@@ -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,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/cli/main.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { createServer } from '../ui/server.js';
|
|
3
4
|
import { createTunnelPlan, executeTunnelPlan } from '../index.js';
|
|
4
5
|
import { createLaravelPlan } from '../index.js';
|
|
6
|
+
import { launchBrowser } from '../providers/browser.js';
|
|
5
7
|
import { runWizard } from './wizard.js';
|
|
6
8
|
const args = process.argv.slice(2);
|
|
7
9
|
const command = args[0] ?? 'init';
|
|
10
|
+
const packageVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
8
11
|
const value = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
9
|
-
function help() { console.log(`cloudflare-tunnel-kit
|
|
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 status doctor ui\nOptions: --url URL --name NAME --hostname HOST --profile custom|laravel --config PATH --dry-run --yes --no-open`); }
|
|
10
13
|
async function main() {
|
|
11
14
|
if (command === 'init') {
|
|
12
15
|
if (args.length === 1)
|
|
@@ -15,8 +18,24 @@ async function main() {
|
|
|
15
18
|
if (command === 'help' || command === '--help')
|
|
16
19
|
return help();
|
|
17
20
|
if (command === 'ui') {
|
|
21
|
+
console.log('Starting Cloudflare Tunnel Kit UI on this machine...');
|
|
18
22
|
const server = createServer();
|
|
19
|
-
server.
|
|
23
|
+
server.once('error', error => {
|
|
24
|
+
console.error(`Unable to start the local UI: ${error.message}`);
|
|
25
|
+
console.error('Check whether local server processes are allowed, then run `npx cf-tunnel ui` again.');
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
});
|
|
28
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
29
|
+
const address = server.address();
|
|
30
|
+
if (!address || typeof address === 'string')
|
|
31
|
+
return;
|
|
32
|
+
const url = `http://127.0.0.1:${address.port}`;
|
|
33
|
+
console.log(`UI ready at ${url}`);
|
|
34
|
+
if (!args.includes('--no-open')) {
|
|
35
|
+
const result = await launchBrowser(url);
|
|
36
|
+
console.log(result.message);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
20
39
|
return;
|
|
21
40
|
}
|
|
22
41
|
if (command === 'doctor') {
|
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,16 @@
|
|
|
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';
|
|
16
|
+
export * from './providers/browser.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
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';
|
|
16
|
+
export * from './providers/browser.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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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.3",
|
|
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": "^12.11.1"
|
|
38
|
+
}
|
|
15
39
|
}
|