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 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 after it is published:
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
- For a source checkout:
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
- npm install
48
- npm run build
49
- node ./dist/cli/main.js --help
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 `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.
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
- 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.
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 0.1.0\n\nUsage: cf-tunnel <command> [options]\n\nCommands: init create quick start stop status doctor ui\nOptions: --url URL --name NAME --hostname HOST --profile custom|laravel --config PATH --dry-run --yes --no-open`); }
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.listen(0, '127.0.0.1', () => { const a = server.address(); console.log(`UI ready at http://127.0.0.1:${a.port}`); });
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') {
@@ -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 {};
@@ -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
+ }
@@ -1,3 +1,4 @@
1
1
  import type { ValidationIssue } from './types.js';
2
2
  export declare function redact(value: string, key?: string): string;
3
+ export declare function redactValue(value: unknown, key?: string): unknown;
3
4
  export declare function aiPrompt(issue: ValidationIssue): string;
@@ -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
  }
@@ -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;
@@ -1 +1,6 @@
1
- export {};
1
+ export function stepState(value) {
2
+ const values = ['pending', 'running', 'succeeded', 'warning', 'failed', 'skipped', 'cancelled'];
3
+ if (!values.includes(value))
4
+ throw new Error(`Unknown workflow step state: ${value}`);
5
+ return value;
6
+ }
@@ -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
- if (input.hostname && !/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(input.hostname))
32
- issues.push(issue('INPUT_INVALID_HOSTNAME', 'Hostname is not a valid DNS hostname.', 'Use a fully qualified hostname such as tunnel.example.com.', 'hostname'));
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,2 @@
1
+ import Database from 'better-sqlite3';
2
+ export declare function openStateDatabase(filename: string): Database.Database;
@@ -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,5 @@
1
+ export interface Migration {
2
+ version: number;
3
+ sql: string;
4
+ }
5
+ export declare const migrations: Migration[];
@@ -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,9 @@
1
+ export declare function launchBrowser(url: string, options?: {
2
+ platform?: string;
3
+ run?: (executable: string, args: string[]) => Promise<{
4
+ exitCode: number;
5
+ }>;
6
+ }): Promise<{
7
+ opened: boolean;
8
+ message: string;
9
+ }>;
@@ -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
- export interface ProcessResult {
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 new Promise(resolve => { const child = spawn('cloudflared', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = '', stderr = ''; const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs); child.stdout.on('data', (d) => stdout += d); child.stderr.on('data', (d) => stderr += d); child.on('error', (e) => { clearTimeout(timer); resolve({ code: 127, stdout: '', stderr: redact(e.message) }); }); child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? 1, stdout: redact(stdout), stderr: redact(stderr) }); }); });
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.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": { "cf-tunnel": "dist/cli/main.js" },
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": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
10
- "files": ["dist", "README.md", "LICENSE"],
11
- "scripts": { "build": "tsc", "test": "npm run build && node --test tests/**/*.test.js", "dev": "tsc --watch" },
12
- "devDependencies": { "typescript": "^5.7.2" },
13
- "engines": { "node": ">=20" },
14
- "license": "MIT"
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
  }