cloudflare-tunnel-kit 0.1.7 → 0.1.9

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
@@ -6,7 +6,7 @@ An open-source toolkit for creating and integrating Cloudflare Tunnels through t
6
6
 
7
7
  ## Current version
8
8
 
9
- `0.1.7` is the current MVP and includes:
9
+ `0.1.8` is the current MVP and includes:
10
10
 
11
11
  - A reusable TypeScript API for validation, plan generation, execution, and redaction.
12
12
  - The `cf-tunnel` CLI with `init`, `create`, `quick`, `start`, `stop`, `status`, `doctor`, and `ui` commands.
@@ -122,7 +122,7 @@ Start the local UI:
122
122
  npx cf-tunnel ui
123
123
  ```
124
124
 
125
- 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.
125
+ 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. To use a fixed port, pass `--port 8787`; when omitted, `CLOUDFLARE_TUNNEL_KIT_UI_PORT` is used if set, otherwise an available port is selected. Ports are always bound to `127.0.0.1`.
126
126
 
127
127
  The UI binds to loopback by default and does not send the copied prompt anywhere.
128
128
 
@@ -1,4 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
+ import path from 'node:path';
2
3
  import { existsSync, realpathSync, statSync } from 'node:fs';
3
4
  import { resolveAppPaths } from './paths.js';
4
5
  import { openStateDatabase } from '../persistence/database.js';
@@ -7,6 +8,7 @@ import { ProcessSupervisor } from '../providers/process-supervisor.js';
7
8
  import { CloudflaredAdapter } from '../providers/cloudflared.js';
8
9
  import { QuickTunnelWorkflow } from '../core/quick-workflow.js';
9
10
  import { NamedTunnelWorkflow } from '../core/named-workflow.js';
11
+ const PLAN_TTL_MS = 10 * 60_000;
10
12
  export class TunnelKitService {
11
13
  plans = new Map();
12
14
  store;
@@ -46,6 +48,9 @@ export class TunnelKitService {
46
48
  async prepareQuick(input) { return this.savePlan('quick', input, [`Start a temporary Quick Tunnel to ${input.localUrl}.`], ['start-connector']); }
47
49
  async prepareNamed(input) { return this.savePlan('named', input, [`Create or reuse tunnel ${input.tunnelName}.`, `Create DNS route ${input.hostname}.`, `Start a connector to ${input.localUrl}.`], ['cloudflare-resources', 'start-connector']); }
48
50
  savePlan(kind, input, effects, confirmations) {
51
+ for (const [id, saved] of this.plans)
52
+ if (Date.now() - saved.createdAt > PLAN_TTL_MS)
53
+ this.plans.delete(id);
49
54
  const plan = { id: crypto.randomUUID(), kind, input, effects, confirmations, createdAt: Date.now() };
50
55
  this.plans.set(plan.id, plan);
51
56
  return plan;
@@ -54,10 +59,12 @@ export class TunnelKitService {
54
59
  const plan = this.plans.get(id);
55
60
  if (!plan)
56
61
  throw new Error('Plan not found or expired.');
57
- if (Date.now() - plan.createdAt > 10 * 60_000) {
62
+ if (Date.now() - plan.createdAt > PLAN_TTL_MS) {
58
63
  this.plans.delete(id);
59
64
  throw new Error('Plan expired. Review the settings again.');
60
65
  }
66
+ if (!Array.isArray(confirmations))
67
+ throw new Error('Confirmations must be a list of confirmed operations.');
61
68
  const missing = plan.confirmations.filter(item => !confirmations.includes(item));
62
69
  if (missing.length)
63
70
  throw new Error(`Confirmation required for: ${missing.join(', ')}`);
@@ -106,7 +113,7 @@ export class TunnelKitService {
106
113
  close() { this.database?.close(); }
107
114
  }
108
115
  export function createTunnelKitService(options = {}) {
109
- const paths = options.dataDir ? { dataDir: options.dataDir, database: `${options.dataDir}/state.db`, projectsDir: `${options.dataDir}/projects`, backupsDir: `${options.dataDir}/backups` } : resolveAppPaths();
116
+ const paths = options.dataDir ? { dataDir: options.dataDir, database: path.join(options.dataDir, 'state.db'), projectsDir: path.join(options.dataDir, 'projects'), backupsDir: path.join(options.dataDir, 'backups') } : resolveAppPaths();
110
117
  const database = openStateDatabase(paths.database);
111
118
  const store = new StateStore(database);
112
119
  const supervisor = new ProcessSupervisor();
package/dist/cli/main.js CHANGED
@@ -9,15 +9,19 @@ const args = process.argv.slice(2);
9
9
  const command = args[0] ?? 'init';
10
10
  const packageVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
11
11
  const value = (name) => { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; };
12
- function help() { console.log(`cloudflare-tunnel-kit ${packageVersion}\n\nUsage from an installed project:\n npx cf-tunnel\n npx cf-tunnel ui\n\nCommands: init create quick start stop restart status doctor ui\nOptions: --url URL --name NAME --hostname HOST --profile custom|laravel --project ID --dry-run --yes --json --no-open`); }
12
+ function help() { console.log(`cloudflare-tunnel-kit ${packageVersion}\n\nUsage from an installed project:\n npx cf-tunnel\n npx cf-tunnel ui [--port PORT]\n\nCommands: init create quick start stop restart status doctor ui\nOptions: --url URL --name NAME --hostname HOST --path DIR --project-name NAME --profile custom|laravel --project ID --port PORT --dry-run --yes --json --no-open\nUI port precedence: --port, CLOUDFLARE_TUNNEL_KIT_UI_PORT, automatic`); }
13
13
  function interactiveRequired() { console.error('[INTERACTIVE_INPUT_REQUIRED] This command needs wizard input, but the terminal is not interactive.\nRun `npx cf-tunnel ui` or provide all required flags with `--yes`.'); process.exit(2); }
14
+ function resolveUiPort() { const raw = value('--port') ?? process.env.CLOUDFLARE_TUNNEL_KIT_UI_PORT; if (raw === undefined || raw === '')
15
+ return 0; const port = Number(raw); if (!Number.isInteger(port) || port < 0 || port > 65535)
16
+ throw new Error(`Invalid UI port "${raw}". Use an integer from 0 to 65535.`); return port; }
14
17
  async function startUi() {
18
+ const port = resolveUiPort();
15
19
  console.log('Starting Cloudflare Tunnel Kit UI on this machine...');
16
20
  const service = createTunnelKitService({ dataDir: process.env.CLOUDFLARE_TUNNEL_KIT_DATA_DIR });
17
21
  const server = createServer({ service, version: packageVersion });
18
22
  server.once('error', error => { console.error(`Unable to start the local UI: ${error.message}`); console.error('Run `npx cf-tunnel ui` again after checking local server permissions.'); process.exitCode = 1; service.close(); });
19
23
  console.log(`Cloudflare Tunnel Kit v${packageVersion}`);
20
- server.listen(0, '127.0.0.1', async () => { const address = server.address(); if (!address || typeof address === 'string')
24
+ server.listen(port, '127.0.0.1', async () => { const address = server.address(); if (!address || typeof address === 'string')
21
25
  return; const url = `http://127.0.0.1:${address.port}`; console.log(`UI ready at ${url}`); if (!args.includes('--no-open'))
22
26
  console.log((await launchBrowser(url)).message); });
23
27
  process.once('SIGINT', () => server.close(() => { service.close(); process.exit(130); }));
@@ -124,7 +124,15 @@ export class NamedTunnelWorkflow {
124
124
  }
125
125
  await runner.step('dns-route', async () => ({ value: routed.value, effects: [`Routed ${input.hostname} to the tunnel.`] }));
126
126
  const sessionKey = `named:${tunnel.uuid}`;
127
- const session = await this.supervisor.start({ key: sessionKey, executable: this.executable, args: [...this.baseArgs, 'tunnel', '--config', tunnel.configPath, 'run', tunnel.uuid], env: this.env });
127
+ let session;
128
+ try {
129
+ session = await this.supervisor.start({ key: sessionKey, executable: this.executable, args: [...this.baseArgs, 'tunnel', '--config', tunnel.configPath, 'run', tunnel.uuid], env: this.env });
130
+ }
131
+ catch (cause) {
132
+ const error = { code: 'CONNECTOR_START_FAILED', reason: cause instanceof Error ? cause.message : String(cause), fix: 'Stop any running connector for this project, check that cloudflared is installed, and retry.' };
133
+ runner.fail('connector', error);
134
+ return { state: 'failed', projectId, runId: run.id, error, configPath: tunnel.configPath };
135
+ }
128
136
  await runner.step('connector', async () => ({ value: { pid: session.pid }, effects: ['Started the Cloudflare connector.'] }));
129
137
  const info = await this.cloudflare.info(tunnel.uuid);
130
138
  await runner.step('cloudflare-health', async () => ({ state: info.ok && info.value.connectorState === 'healthy' ? 'succeeded' : 'warning', value: info.ok ? info.value : info.error, effects: [] }));
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { validateTunnelConfig } from './validation.js';
2
3
  import { checkOrigin } from './origin-check.js';
3
4
  import { WorkflowRunner } from './workflow.js';
4
5
  export class QuickTunnelWorkflow {
@@ -17,6 +18,9 @@ export class QuickTunnelWorkflow {
17
18
  this.originCheck = options.originCheck ?? (url => checkOrigin(url));
18
19
  }
19
20
  async run(input) {
21
+ const validation = validateTunnelConfig({ profile: input.profile, operation: 'quick', localUrl: input.localUrl, projectRoot: input.projectPath });
22
+ if (!validation.ok)
23
+ return { state: 'failed', error: validation.issues[0], issues: validation.issues };
20
24
  const project = this.store.saveProject({ displayName: input.displayName ?? path.basename(input.projectPath), path: path.resolve(input.projectPath), profile: input.profile });
21
25
  this.store.saveTunnel({ projectId: project.id, kind: 'quick', localUrl: input.localUrl });
22
26
  const run = this.store.createWorkflow({ projectId: project.id, kind: 'quick' });
@@ -28,6 +32,11 @@ export class QuickTunnelWorkflow {
28
32
  }
29
33
  await runner.step('origin', async () => ({ state: origin.warning ? 'warning' : 'succeeded', value: origin, effects: ['Verified the local application is reachable.'] }));
30
34
  const sessionKey = `quick:${project.id}`;
35
+ if (['starting', 'running', 'stopping'].includes(this.supervisor.status(sessionKey).state)) {
36
+ const error = { code: 'CONNECTOR_ALREADY_RUNNING', reason: 'A Quick Tunnel connector is already running for this project.', fix: 'Stop or restart the project instead of starting it again.' };
37
+ runner.fail('connector', error);
38
+ return { state: 'failed', projectId: project.id, runId: run.id, error };
39
+ }
31
40
  try {
32
41
  const session = await this.supervisor.start({ key: sessionKey, executable: this.executable, args: [...this.baseArgs, 'tunnel', '--url', input.localUrl], env: this.env });
33
42
  const logs = await session.waitForOutput(/https:\/\/[-a-z0-9]+\.trycloudflare\.com/i, 15_000);
@@ -6,7 +6,7 @@ export function redact(value, key) {
6
6
  .replace(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi, '[REDACTED PEM]')
7
7
  .replace(/(Bearer\s+)[^\s]+/gi, '$1[REDACTED]')
8
8
  .replace(/([?&](?:token|secret|password|api[_-]?key|signature|credential)=)[^&\s]+/gi, '$1[REDACTED]')
9
- .replace(/([A-Za-z0-9_-]{24,})/g, '[REDACTED]');
9
+ .replace(/(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,}(?![A-Za-z0-9_-]|\.trycloudflare\.com)/g, '[REDACTED]');
10
10
  }
11
11
  export function redactValue(value, key) {
12
12
  if (key && secretKey.test(key))
@@ -10,6 +10,7 @@ export declare class ManagedSession {
10
10
  private logText;
11
11
  private readonly events;
12
12
  constructor(key: string, child: ChildProcess, maxLogBytes: number);
13
+ markExited(): void;
13
14
  append(value: string): void;
14
15
  logs(): string;
15
16
  waitForOutput(pattern: RegExp, timeoutMs: number): Promise<string>;
@@ -16,6 +16,7 @@ export class ManagedSession {
16
16
  this.maxLogBytes = maxLogBytes;
17
17
  this.pid = child.pid;
18
18
  }
19
+ markExited() { this.events.emit('exit'); }
19
20
  append(value) {
20
21
  this.logText = Buffer.from(this.logText + redact(value)).subarray(-this.maxLogBytes).toString('utf8');
21
22
  this.events.emit('output', this.logText);
@@ -31,9 +32,13 @@ export class ManagedSession {
31
32
  cleanup();
32
33
  resolve(logs);
33
34
  };
35
+ const exited = () => { cleanup(); reject(new Error('The process exited before producing the expected output.')); };
34
36
  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); };
37
+ const cleanup = () => { clearTimeout(timeout); this.events.off('output', output); this.events.off('exit', exited); };
36
38
  this.events.on('output', output);
39
+ if (['stopped', 'failed'].includes(this.state))
40
+ return exited();
41
+ this.events.once('exit', exited);
37
42
  });
38
43
  }
39
44
  }
@@ -50,7 +55,7 @@ export class ProcessSupervisor {
50
55
  this.sessions.set(options.key, session);
51
56
  child.stdout?.on('data', chunk => session.append(chunk.toString('utf8')));
52
57
  child.stderr?.on('data', chunk => session.append(chunk.toString('utf8')));
53
- child.on('close', code => { session.state = code === 0 || session.state === 'stopping' ? 'stopped' : 'failed'; });
58
+ child.on('close', code => { session.state = code === 0 || session.state === 'stopping' ? 'stopped' : 'failed'; session.markExited(); });
54
59
  return new Promise((resolve, reject) => {
55
60
  child.once('spawn', () => { session.state = 'running'; resolve(session); });
56
61
  child.once('error', error => { session.state = 'failed'; session.append(error.message); reject(error); });
package/dist/ui/page.js CHANGED
@@ -1,4 +1,4 @@
1
- export function dashboardPage(version = '0.1.7') {
1
+ export function dashboardPage(version = '0.1.8') {
2
2
  return String.raw `<!doctype html>
3
3
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4
4
  <title>Cloudflare Tunnel Kit v${escapeAttribute(version)}</title>
@@ -17,20 +17,25 @@ dialog{border:0;border-radius:22px;padding:0;width:min(680px,calc(100vw - 30px))
17
17
  <div id="review" class="hidden"><div class="review"><b>Review changes</b><ul id="effects"></ul></div><div class="notice" id="named-notice">A custom domain setup may open Cloudflare login in your browser. Existing DNS records are never overwritten automatically.</div></div><div id="running" class="hidden"><div class="result" id="result">Waiting…</div></div></form></div><div class="dialog-foot"><button class="btn ghost" data-close>Cancel</button><button class="btn primary" id="continue">Review changes</button></div></section></div>
18
18
  <div class="toast hidden" id="toast"></div>
19
19
  <script>
20
- let mode='quick',stage=0,plan=null,token='',wizardTrigger=null;const q=s=>document.querySelector(s),qa=s=>[...document.querySelectorAll(s)];
20
+ let mode='quick',stage=0,plan=null,token='',wizardTrigger=null,running=false;const q=s=>document.querySelector(s),qa=s=>[...document.querySelectorAll(s)];
21
21
  const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
22
22
  async function api(url,options={}){const headers={'content-type':'application/json',...(options.headers||{})};if(token)headers['x-confirmation-token']=token;const r=await fetch(url,{...options,headers});const body=await r.json().catch(()=>({error:'Invalid server response'}));if(!r.ok)throw new Error(body.error||body.issues?.[0]?.reason||'Request failed');return body}
23
23
  function toast(message){const el=q('#toast');el.textContent=message;el.classList.remove('hidden');setTimeout(()=>el.classList.add('hidden'),3200)}
24
- async function load(){try{const data=await api('/api/projects');renderProjects(data.projects)}catch(e){q('#projects').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
24
+ async function load(){try{const data=await api('/api/projects');renderProjects(data.projects);enhanceProjectCards(data.projects)}catch(e){q('#projects').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
25
25
  function renderProjects(items){q('#projects').innerHTML=items.length?items.map(p=>'<article class="card"><div class="card-top"><h3>'+esc(p.displayName)+'</h3><span class="badge '+(p.status==='Running'?'running':p.status==='Needs attention'?'attention':'')+'">'+esc(p.status)+'</span></div><div class="meta"><span>'+esc(p.kind==='named'?p.hostname||'Custom domain':'Quick Tunnel')+'</span><code>'+esc(p.localUrl||p.path)+'</code></div><div class="card-actions">'+(p.status==='Running'?'<button class="btn ghost" data-project="'+p.id+'" data-command="stop">Stop</button>':'<button class="btn secondary" data-project="'+p.id+'" data-command="start">Start</button>')+'<button class="btn ghost" data-project="'+p.id+'" data-command="retry">Retry setup</button><button class="btn danger" data-project="'+p.id+'" data-command="remove-local">Remove local</button></div></article>').join(''):'<div class="empty"><b>No saved projects yet.</b><br>Create a Quick Tunnel or connect a custom domain to get started.</div>'}
26
+ function enhanceProjectCards(items){items.forEach((project,index)=>{const card=qa('.card')[index];if(!card)return;const actions=card.querySelector('.card-actions');const details=document.createElement('button');details.type='button';details.className='btn ghost';details.dataset.action='details';details.dataset.project=project.id;details.textContent='Details';actions?.append(details);if(!project.publicUrl)return;const meta=card.querySelector('.meta');const row=document.createElement('div');row.className='public-url';row.innerHTML='<a href="'+esc(project.publicUrl)+'" target="_blank" rel="noreferrer">'+esc(project.publicUrl)+'</a><button type="button" class="btn ghost copy-url" data-copy-url="'+esc(project.publicUrl)+'">Copy URL</button>';meta?.append(row)})}
27
+ async function showDetails(id){try{const project=await api('/api/projects/'+encodeURIComponent(id));const health=project.health||{};const logs=project.logs||'No logs recorded.';toast('Local: '+(health.localProcess||'unknown')+' · Connector: '+(health.cloudflareConnector||'unknown')+' · Hostname: '+(health.publicHostname||'unchecked'));const card=qa('.card').find(item=>item.querySelector('[data-project="'+CSS.escape(id)+'"]'));if(card){let panel=card.querySelector('.details');if(!panel){panel=document.createElement('div');panel.className='details';card.append(panel)}panel.innerHTML='<b>Health</b><div>Local process: '+esc(health.localProcess||'unknown')+'</div><div>Cloudflare connector: '+esc(health.cloudflareConnector||'unknown')+'</div><div>Public hostname: '+esc(health.publicHostname||'unchecked')+'</div><pre>'+esc(logs)+'</pre>'}}catch(e){toast(e.message)}}
26
28
  function openWizard(nextMode,trigger){mode=nextMode;stage=0;plan=null;wizardTrigger=trigger;q('#setup-form').reset();q('[name=projectPath]').value=${JSON.stringify(process.cwd())};q('[name=localUrl]').value='http://127.0.0.1:8000';q('#named-fields').classList.toggle('hidden',mode!=='named');q('#named-notice').classList.toggle('hidden',mode!=='named');q('#wizard-title').textContent=mode==='named'?'Set up a custom domain':'Create a Quick Tunnel';q('#wizard-subtitle').textContent=mode==='named'?'Cloudflare login, tunnel, DNS, and connector':'No Cloudflare login required';showStage();const drawer=q('#wizard');drawer.classList.add('open');drawer.setAttribute('aria-hidden','false');document.body.style.overflow='hidden';q('.drawer-panel').focus()}
27
- function closeWizard(){const drawer=q('#wizard');drawer.classList.remove('open');drawer.setAttribute('aria-hidden','true');document.body.style.overflow='';if(wizardTrigger?.focus)wizardTrigger.focus();wizardTrigger=null}
29
+ function closeWizard(){if(running)return;const drawer=q('#wizard');drawer.classList.remove('open');drawer.setAttribute('aria-hidden','true');document.body.style.overflow='';if(wizardTrigger?.focus)wizardTrigger.focus();wizardTrigger=null}
30
+ function canClose(target){if(running)return false;const overlay=q('.drawer-overlay');return target===overlay||Boolean(target.closest?.('[data-close]'))}
28
31
  function showStage(){q('#fields').classList.toggle('hidden',stage!==0);q('#review').classList.toggle('hidden',stage!==1);q('#running').classList.toggle('hidden',stage!==2);qa('.step').forEach((el,i)=>el.classList.toggle('active',i<=stage));q('#continue').textContent=stage===0?'Review changes':stage===1?'Confirm and run':'Done'}
29
32
  function values(){const data=Object.fromEntries(new FormData(q('#setup-form')));return {...data,profile:data.profile||'custom'}}
30
- async function next(){try{if(stage===0){const data=values();if(mode==='named'&&(!data.tunnelName||!data.hostname))throw new Error('Tunnel name and public hostname are required.');plan=await api('/api/plans/'+mode,{method:'POST',body:JSON.stringify(data)});q('#effects').innerHTML=plan.effects.map(x=>'<li>'+esc(x)+'</li>').join('');stage=1;showStage()}else if(stage===1){stage=2;showStage();q('#result').textContent='Running validated workflow…';const result=await api('/api/execute',{method:'POST',body:JSON.stringify({planId:plan.id,confirmations:plan.confirmations})});q('#result').textContent=JSON.stringify(result,null,2);await load()}else closeWizard()}catch(e){toast(e.message)}}
33
+ async function next(){try{if(stage===0){const data=values();if(mode==='named'&&(!data.tunnelName||!data.hostname))throw new Error('Tunnel name and public hostname are required.');plan=await api('/api/plans/'+mode,{method:'POST',body:JSON.stringify(data)});q('#effects').innerHTML=plan.effects.map(x=>'<li>'+esc(x)+'</li>').join('');stage=1;showStage()}else if(stage===1){stage=2;running=true;showStage();q('#result').textContent='Running validated workflow…';const result=await api('/api/execute',{method:'POST',body:JSON.stringify({planId:plan.id,confirmations:plan.confirmations})});q('#result').textContent=JSON.stringify(result,null,2);await load()}else closeWizard()}catch(e){toast(e.message)}finally{if(stage===2)running=false}}
31
34
  async function doctor(){try{const data=await api('/api/doctor');q('#checks').innerHTML=data.checks.map(c=>'<div class="check"><i class="dot '+(c.state==='passed'?'ok':'')+'"></i><div><b>'+esc(c.name)+'</b><span>'+esc(c.detail)+'</span></div></div>').join('')}catch(e){toast(e.message)}}
32
- document.addEventListener('click',async e=>{const t=e.target.closest('button');if(t?.dataset.new)openWizard(t.dataset.new,t);if(t?.dataset.close)closeWizard();if(t?.id==='continue'){e.preventDefault();await next()}if(t?.dataset.action==='refresh')await load();if(t?.dataset.action==='doctor')await doctor();if(t?.dataset.project){if(t.dataset.command==='remove-local'&&!confirm('Remove this project from the local dashboard? Cloudflare tunnel and DNS resources will not be deleted.'))return;try{await api('/api/projects/'+t.dataset.project+'/'+t.dataset.command,{method:'POST',body:'{}'});await load()}catch(err){toast(err.message)}}});
33
- document.addEventListener('keydown',e=>{if(e.key==='Escape'&&q('#wizard').classList.contains('open'))closeWizard()});
35
+ document.addEventListener('click',async e=>{if(canClose(e.target)){closeWizard();return}const t=e.target.closest('button');if(t?.dataset.new)openWizard(t.dataset.new,t);if(t?.id==='continue'){e.preventDefault();await next()}if(t?.dataset.action==='refresh')await load();if(t?.dataset.action==='doctor')await doctor();if(t?.dataset.project){if(t.dataset.command==='remove-local'&&!confirm('Remove this project from the local dashboard? Cloudflare tunnel and DNS resources will not be deleted.'))return;try{await api('/api/projects/'+t.dataset.project+'/'+t.dataset.command,{method:'POST',body:'{}'});await load()}catch(err){toast(err.message)}}});
36
+ document.addEventListener('keydown',e=>{if(e.key==='Escape'&&q('#wizard').classList.contains('open')&&!running)closeWizard()});
37
+ document.addEventListener('click',async e=>{const button=e.target.closest?.('.copy-url');if(!button)return;try{await navigator.clipboard.writeText(button.dataset.copyUrl||'');toast('Tunnel URL copied')}catch{toast('Copy failed. Select the URL manually.')}});
38
+ document.addEventListener('click',async e=>{const button=e.target.closest?.('[data-action="details"]');if(button?.dataset.project)await showDetails(button.dataset.project)});
34
39
  api('/api/session').then(x=>{token=x.confirmationToken;load();doctor()}).catch(e=>toast(e.message));
35
40
  </script></body></html>`;
36
41
  }
package/dist/ui/server.js CHANGED
@@ -24,7 +24,7 @@ export function createServer(options = {}) {
24
24
  return handle(res, () => service.doctor());
25
25
  const detail = url.pathname.match(/^\/api\/projects\/([^/]+)$/);
26
26
  if (req.method === 'GET' && detail)
27
- return handle(res, () => service.getProject(detail[1]));
27
+ return handle(res, () => service.getProject(decodeURIComponent(detail[1])));
28
28
  if (req.method === 'POST') {
29
29
  if (!validMutationRequest(req, token))
30
30
  return json(res, { error: 'The local UI session is missing or invalid. Reload the page and try again.' }, 403);
@@ -47,10 +47,10 @@ export function createServer(options = {}) {
47
47
  return handle(res, () => service.execute(body.planId, body.confirmations ?? []));
48
48
  const action = url.pathname.match(/^\/api\/projects\/([^/]+)\/(start|stop|retry|restart)$/);
49
49
  if (action)
50
- return handle(res, () => service[action[2]](action[1]));
50
+ return handle(res, () => service[action[2]](decodeURIComponent(action[1])));
51
51
  const management = url.pathname.match(/^\/api\/projects\/([^/]+)\/(relink|remove-local)$/);
52
52
  if (management)
53
- return handle(res, () => management[2] === 'relink' ? service.relinkProject(management[1], body.path) : service.removeLocal(management[1]));
53
+ return handle(res, () => management[2] === 'relink' ? service.relinkProject(decodeURIComponent(management[1]), body.path) : service.removeLocal(decodeURIComponent(management[1])));
54
54
  if (url.pathname === '/api/plan') {
55
55
  const config = body.config ?? {};
56
56
  return handle(res, async () => ({ plan: config.operation === 'quick' ? await service.prepareQuick({ projectPath: config.projectRoot ?? process.cwd(), ...config }) : await service.prepareNamed({ projectPath: config.projectRoot ?? process.cwd(), ...config }) }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-tunnel-kit",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Safe, reusable Cloudflare Tunnel setup for custom and Laravel projects.",
5
5
  "type": "module",
6
6
  "bin": {