cloudflare-tunnel-kit 0.1.7 → 0.1.8

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
 
package/dist/cli/main.js CHANGED
@@ -9,15 +9,18 @@ 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 --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() {
15
18
  console.log('Starting Cloudflare Tunnel Kit UI on this machine...');
16
19
  const service = createTunnelKitService({ dataDir: process.env.CLOUDFLARE_TUNNEL_KIT_DATA_DIR });
17
20
  const server = createServer({ service, version: packageVersion });
18
21
  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
22
  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')
23
+ server.listen(resolveUiPort(), '127.0.0.1', async () => { const address = server.address(); if (!address || typeof address === 'string')
21
24
  return; const url = `http://127.0.0.1:${address.port}`; console.log(`UI ready at ${url}`); if (!args.includes('--no-open'))
22
25
  console.log((await launchBrowser(url)).message); });
23
26
  process.once('SIGINT', () => server.close(() => { service.close(); process.exit(130); }));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-tunnel-kit",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Safe, reusable Cloudflare Tunnel setup for custom and Laravel projects.",
5
5
  "type": "module",
6
6
  "bin": {