cloudflare-tunnel-kit 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -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,15 @@
1
1
  export * from './core/types.js';
2
+ export * from './core/errors.js';
2
3
  export * from './core/validation.js';
4
+ export * from './core/origin-check.js';
3
5
  export * from './core/plan.js';
4
6
  export * from './core/execution.js';
5
7
  export * from './core/redact.js';
6
8
  export * from './adapters/laravel.js';
9
+ export * from './app/paths.js';
10
+ export * from './persistence/database.js';
11
+ export * from './persistence/store.js';
12
+ export * from './providers/command-runner.js';
13
+ export * from './providers/cloudflared.js';
14
+ export * from './providers/git-safety.js';
15
+ export * from './providers/process-supervisor.js';
package/dist/index.js CHANGED
@@ -1,6 +1,15 @@
1
1
  export * from './core/types.js';
2
+ export * from './core/errors.js';
2
3
  export * from './core/validation.js';
4
+ export * from './core/origin-check.js';
3
5
  export * from './core/plan.js';
4
6
  export * from './core/execution.js';
5
7
  export * from './core/redact.js';
6
8
  export * from './adapters/laravel.js';
9
+ export * from './app/paths.js';
10
+ export * from './persistence/database.js';
11
+ export * from './persistence/store.js';
12
+ export * from './providers/command-runner.js';
13
+ export * from './providers/cloudflared.js';
14
+ export * from './providers/git-safety.js';
15
+ export * from './providers/process-supervisor.js';
@@ -0,0 +1,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
+ }
@@ -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.2",
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": "^13.0.3"
38
+ }
15
39
  }