landfall 0.4.0 → 0.4.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.
package/dist/index.html CHANGED
@@ -358,9 +358,8 @@ function btn(label, fn, cls = "", title = "", f = "") {
358
358
  }
359
359
  document.addEventListener("click", (e) => {
360
360
  const el = e.target.closest("[data-k]");
361
- const menu = $("#menu");
362
361
  // A click outside an open menu only closes it.
363
- if (menu && !e.target.closest("#menu")) {
362
+ if (menuEl && !menuEl.contains(e.target)) {
364
363
  closeMenu(false);
365
364
  e.preventDefault();
366
365
  return void e.stopPropagation();
@@ -384,6 +383,7 @@ function menuBtn(items, title = "More actions", cls = "sm") {
384
383
  return btn("⋯", (e, el) => openMenu(el, items), `${cls} more`, title).replace("<button", `<button aria-haspopup="menu" aria-expanded="false" aria-label="${esc(title)}"`);
385
384
  }
386
385
  let menuAnchor = null;
386
+ let menuEl = null;
387
387
  function openMenu(anchor, items) {
388
388
  closeMenu(false);
389
389
  menuAnchor = anchor;
@@ -410,10 +410,13 @@ function openMenu(anchor, items) {
410
410
  all[e.key === "Home" ? 0 : all.length - 1].focus();
411
411
  }
412
412
  };
413
+ // Keep focus where it is on mousedown: a browser that does not focus clicked buttons would blur the menu and close it before the click.
414
+ m.onmousedown = (e) => e.preventDefault();
413
415
  m.addEventListener("focusout", (e) => {
414
416
  if (!m.contains(e.relatedTarget)) closeMenu(false);
415
417
  });
416
418
  document.body.append(m);
419
+ menuEl = m;
417
420
  const r = anchor.getBoundingClientRect();
418
421
  m.style.left = `${Math.max(8, Math.min(r.right - m.offsetWidth, innerWidth - m.offsetWidth - 8))}px`;
419
422
  m.style.top = `${r.bottom + 4 + m.offsetHeight > innerHeight - 8 ? Math.max(8, r.top - m.offsetHeight - 4) : r.bottom + 4}px`;
@@ -421,8 +424,10 @@ function openMenu(anchor, items) {
421
424
  }
422
425
  /** restore: focus goes back to the "⋯" button (Esc, or an item that opens no dialog). */
423
426
  function closeMenu(restore = true) {
424
- const m = $("#menu");
427
+ const m = menuEl;
425
428
  if (!m) return;
429
+ // Removing the focused item fires focusout, which calls closeMenu again: clear menuEl first so that call is a no-op.
430
+ menuEl = null;
426
431
  m.remove();
427
432
  menuAnchor?.setAttribute("aria-expanded", "false");
428
433
  if (restore && menuAnchor?.isConnected) menuAnchor.focus({ preventScroll: true });
@@ -1358,15 +1363,16 @@ function sitesView(n, o) {
1358
1363
  const older = [...m.releases].sort((a, b) => (a.at < b.at ? 1 : -1)).filter((r) => r.at < (cur?.at ?? ""));
1359
1364
  const proj = (S.state.projects ?? []).find((p) => p.server === n && p.name === name);
1360
1365
  const deleteSite = () =>
1361
- confirmDlg({
1366
+ dialog({
1362
1367
  title: m.adopted ? `Stop and forget ${name}?` : `Delete ${name} from ${n}?`,
1363
1368
  text: m.adopted
1364
1369
  ? `Runs docker compose down in ${m.adopted}; its files and volumes stay.`
1365
- : "Stops it and removes its files and releases. Its named volumes (databases, uploads) stay.\nThis cannot be undone.",
1370
+ : "Stops it and removes its files and releases. Its named volumes (databases, uploads) stay unless you delete them too.\nThis cannot be undone.",
1371
+ fields: m.adopted ? [] : [{ name: "volumes", type: "check", label: "Also delete its volumes", hint: "Databases, uploads: all its data. lf sites rm --volumes" }],
1366
1372
  ok: m.adopted ? `Forget ${name}` : `Delete ${name}`,
1367
1373
  danger: true,
1368
1374
  typeToConfirm: m.adopted ? "" : name,
1369
- onOk: () => run(["sites", "rm", name, ...sv(n), "-y"]),
1375
+ onOk: (v) => run(["sites", "rm", name, ...sv(n), "-y", ...(v.volumes ? ["--volumes"] : [])]),
1370
1376
  });
1371
1377
  const svcMenu = (x) =>
1372
1378
  menuBtn([
@@ -1685,6 +1691,9 @@ function settingsView(n) {
1685
1691
  if (d) run(["traefik", "setup", ...sv(n), "--dashboard", d], reload);
1686
1692
  }, "Apply");
1687
1693
  const sites = S.overview[n]?.data?.sites ?? [];
1694
+ // lf traefik setup refuses to touch a Traefik the server already had.
1695
+ const o = S.overview[n]?.data;
1696
+ const foreign = o?.adopted ? o.proxy : "";
1688
1697
  const external = (d) => S.overview[n]?.data?.others?.find((e) => e.domains.includes(d))?.name;
1689
1698
  const servedBy = (d) => sites.find((s) => s.domains.includes(d))?.meta.name;
1690
1699
  const serve = (d) => {
@@ -1721,8 +1730,8 @@ function settingsView(n) {
1721
1730
  ${x.suggest.length ? `<div class="note">${x.domains.length ? "Other domains" : "Domains"} your sites on this server already use: ${x.suggest.map((g) => `<b>${esc(g.domain)}</b> <span class="faint">(${g.hosts})</span> ${btn("Add", () => run(["server", "domain", n, "--add", g.domain], reload), "sm")}`).join(" ")}</div>` : ""}</div>
1722
1731
  </div>
1723
1732
  <div class="card">
1724
- <h2>Traefik <span class="actions">${btn("Dashboard…", dashboard, "sm")}</span></h2>
1725
- <div class="body">${rows([
1733
+ <h2>Traefik <span class="actions">${foreign ? "" : btn("Dashboard…", dashboard, "sm")}</span></h2>
1734
+ <div class="body">${foreign ? `<div class="note">Sites go through <b class="mono">${esc(foreign)}</b>, a Traefik landfall did not install and never changes: its dashboard is set up in its own config.</div>` : ""}${rows([
1726
1735
  ["image", esc(x.traefik.summary)],
1727
1736
  ["e-mail", esc(x.traefik.email ?? "none")],
1728
1737
  ["dashboard", x.traefik.dashboard ? `<a href="https://${esc(x.traefik.dashboard)}" target="_blank" rel="noopener">${esc(x.traefik.dashboard)}</a>` : "off"],
@@ -1826,7 +1835,7 @@ function render() {
1826
1835
  }
1827
1836
 
1828
1837
  document.addEventListener("keydown", (e) => {
1829
- if (e.key === "Escape" && $("#menu")) return closeMenu();
1838
+ if (e.key === "Escape" && menuEl) return closeMenu();
1830
1839
  if (e.key === "Escape" && !$("#dlg").open && S.job && !e.target.closest?.("input, textarea, select")) {
1831
1840
  S.job = null;
1832
1841
  render();
@@ -0,0 +1,5 @@
1
+ import{A as e,B as t,C as n,D as r,E as i,F as a,I as o,L as s,M as c,N as l,O as u,P as d,R as ee,S as f,T as te,_ as p,a as m,b as h,c as g,d as ne,f as _,g as v,h as y,i as b,j as x,k as S,l as re,m as ie,n as ae,o as oe,p as se,r as ce,s as le,t as C,u as ue,v as de,w as fe,x as w,y as pe,z as T}from"./cli.mjs";import{spawn as me,spawnSync as E}from"node:child_process";import D from"node:fs";import{homedir as O}from"node:os";import{dirname as k,isAbsolute as A,join as j,resolve as M}from"node:path";import{randomBytes as N,timingSafeEqual as P}from"node:crypto";import{createServer as F}from"node:http";import{Readable as I}from"node:stream";const L=/^(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org)$/;function R(){let e=a(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=f().filter(t=>!e.servers[t]&&!L.test(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}function z(e){try{let t=w(s(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function he(e){let[t,n]=e;return e.includes(`--check`)||e.includes(`--key-only`)?!1:t===`firewall`?[`enable`,`disable`,`allow`,`deny`,`update`,`rm`,`remove`].includes(n):t===`traefik`?n===`setup`||n===`use`:t===`env`?e.some(e=>e.includes(`=`)||e===`-u`||e===`--unset`):t===`domains`?e.some(e=>[`-a`,`--add`,`--rm`,`--set`].includes(e)):t===`auth`?e.some(e=>[`-u`,`--user`,`--rm-user`,`--off`].includes(e)):t===`update`||t===`upgrade`||[`setup`,`deploy`,`rollback`,`stop`,`start`,`restart`,`adopt`].includes(t)}function B(e){let t=M(!e||e===`~`?O():d(e)),n=D.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&V(j(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:k(t)===t?null:k(t),home:O(),dirs:n,detected:y(t)}}const V=e=>{try{return D.statSync(e).isDirectory()}catch{return!1}},H=j(l,`uploads`),U=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function W(e,t){D.mkdirSync(k(e),{recursive:!0}),D.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function ge(e){D.mkdirSync(H,{recursive:!0,mode:448});for(let e of D.readdirSync(H)){let t=j(H,e);Date.now()-D.statSync(t).mtimeMs>864e5&&D.rmSync(t,{recursive:!0,force:!0})}let t=D.mkdtempSync(j(H,`u-`)),n=e.getAll(`file`).filter(e=>e instanceof File);if(!n.length)throw Error(`no files in the upload`);let r=e.getAll(`path`).map(String);if(n.length===1&&/\.(zip|tar|tgz|tar\.gz)$/i.test(n[0].name)){let e=j(t,`.upload-`+U(n[0].name).pop());await W(e,n[0]);let r=j(t,`files`);D.mkdirSync(r);let i=/\.zip$/i.test(e),a=i?E(`unzip`,[`-q`,e,`-d`,r]):E(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?E(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return D.rmSync(e),{path:G(r),files:_e(r)}}let i=j(t,`files`);for(let[e,t]of n.entries()){let n=U(r[e]||t.name);n.length&&await W(j(i,...n),t)}return{path:G(i),files:n.length}}function G(e){for(;;){let t=D.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!V(j(e,t[0])))return e;e=j(e,t[0])}}function _e(e){return D.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function ve(e){if(e!=null&&e!==``){if(typeof e!=`string`||!A(e)||!V(e))throw Error(`cwd must be an existing folder`);return e}}const K=()=>[process.execPath,...process.execArgv,process.argv[1]],q=5e3;function ye(e,t,n={}){let r={...process.env,NO_COLOR:`1`,...n.env};n.password&&Object.assign(r,g(n.password));let i=me(K()[0],[...K().slice(1),...e],{stdio:[`ignore`,`pipe`,`pipe`],env:r,cwd:n.cwd,detached:!0}),a={argv:e,lines:[],dropped:0,stop(){if(a.code===void 0){a.stopped=!0;try{process.kill(-i.pid,`SIGTERM`)}catch{}}}},o,s=()=>o??=setTimeout(()=>(o=void 0,t()),50),c=()=>{let e=``;return{push(t){let n=(e+t).split(`
2
+ `);e=n.pop();for(let e of n)a.lines.push(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``));a.lines.length>q&&(a.dropped+=a.lines.splice(0,a.lines.length-q).length),s()},flush:()=>e&&a.lines.push(e)}},l=c(),u=c();return i.stdout.setEncoding(`utf8`).on(`data`,l.push),i.stderr.setEncoding(`utf8`).on(`data`,u.push),i.on(`error`,e=>{a.lines.push(`error: ${e.message}`),a.code=1,t()}),i.on(`close`,e=>{l.flush(),u.flush(),a.code=a.stopped?null:e??1,clearTimeout(o),t()}),a}const be=D.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),J=/* @__PURE__ */ new Map,xe=/* @__PURE__ */ new Set([`ui`,`web`]);function Se(){let e=j(k(x),`ui-token`);try{let t=D.readFileSync(e,`utf8`).trim();if(t.length>=32)return t}catch{}let t=N(24).toString(`hex`);return D.mkdirSync(k(e),{recursive:!0,mode:448}),D.writeFileSync(e,t+`
3
+ `,{mode:384}),t}const Y=(e,t=200)=>Response.json(e,{status:t,headers:{"cache-control":`no-store`}}),X=(e,t=500)=>Y({error:e?.message??String(e)},t);function Z(e,t){let r=n(),i=/* @__PURE__ */ new Set,a=ye(e,()=>i.forEach(e=>e()),{password:t.password,cwd:t.cwd,env:{LANDFALL_RUN:r}}),o={id:r,argv:e,server:t.server,startedAt:(/* @__PURE__ */ new Date()).toISOString(),undoable:he(e),job:a,listeners:i};J.set(r,o);for(let[e,t]of J){if(J.size<=50)break;t.job.code!==void 0&&J.delete(e)}return o}const Ce=e=>e.map((t,n)=>/^(-p|--([\w-]+-)?password)$/.test(e[n-1])?`***`:t.replace(/^(--([\w-]+-)?password=).*/,`$1***`)),Q=e=>({id:e.id,argv:Ce(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped});function we(e,t,n){let r=new TextEncoder,i=t,a=()=>{},o=new ReadableStream({start(t){let o=e=>t.enqueue(r.encode(e)),s=()=>{let{lines:t,dropped:n,code:r}=e.job,s=Math.max(i,n);n+t.length>s&&(o(`data: ${JSON.stringify({from:s,lines:t.slice(s-n)})}\n\n`),i=n+t.length),r!==void 0&&(o(`event: done\ndata: ${JSON.stringify(Q(e))}\n\n`),a())},c=setInterval(()=>o(`: beat
4
+
5
+ `),15e3);a=()=>{clearInterval(c),e.listeners.delete(s);try{t.close()}catch{}},e.listeners.add(s),n.addEventListener(`abort`,a),s()},cancel:()=>a()});return new Response(o,{headers:{"content-type":`text/event-stream`,"cache-control":`no-store`}})}async function Te(n,l){let[d,f,g]=l,y=n.method;if(d===`state`&&y===`GET`){let t=a();return Y({version:C,cwd:process.cwd(),project:o(process.cwd())??null,projects:v(),defaultImage:te,paths:{root:u,sites:S,traefik:e,journal:i,proxy:r},servers:R().map(e=>({...e,target:z(e.name),saved:t.servers[e.name]??null})),jobs:[...J.values()].reverse().map(Q)})}if(d===`servers`&&f){let{server:e}=s(f);if(y===`GET`&&!g)return Y(await ie(e));if(y===`GET`&&g===`firewall`){let{login:t,probe:n,...r}=await m(e);return Y({...r,asRoot:w(t).user===`root`})}if(y===`GET`&&g===`config`){let t=ce(f),[n,r]=await Promise.all([Promise.all(t.domains.map(t=>ae(e,t))),b(e)]);return Y({...t,dns:n,suggest:r})}if(y===`GET`&&g===`history`){let t=(await le(e)).reverse();return Y(t.map(e=>({...e,pending:oe(e).length})))}if(y===`GET`&&g===`facts`){let t=await de(e),n=await h(e,t);return Y({...t,ips:n,sslipIp:pe(n)??null})}if(y===`GET`&&g===`updates`){let t=new URL(n.url).searchParams.get(`refresh`)===`1`,r=await se(e,t),i=await ne(e);return Y({packages:r,images:await _(e),engine:{...i,...ue(i)}})}if(y===`GET`&&g===`dns`){let t=(new URL(n.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>c.test(e)).slice(0,50),r=await h(e),[i,a]=await Promise.all([p(t,r),Promise.all(t.map(re))]);return Y({ips:r,domains:i.map((e,t)=>({...e,https:a[t]}))})}if(y===`POST`&&g==="default")return ee(f),Y({ok:!0})}if(d===`jobs`){if(y===`POST`&&!f){let e=await n.json(),t=e.argv;if(!Array.isArray(t)||!t.length||!t.every(e=>typeof e==`string`)||xe.has(t[0]))return X(`argv must be a list of landfall arguments`,400);let r;try{r=ve(e.cwd)}catch(e){return X(e,400)}return Y(Q(Z(t,{server:e.server,password:e.password||void 0,cwd:r})))}let e=f?J.get(f):void 0;if(f&&!e)return X(`no job ${f}`,404);if(e&&y===`GET`&&g===`events`)return we(e,Number(new URL(n.url).searchParams.get(`from`))||0,n.signal);if(e&&y===`GET`&&!g)return Y({...Q(e),lines:e.job.lines,from:e.job.dropped});if(e&&y===`POST`&&g===`stop`)return e.job.stop(),Y(Q(e))}if(d===`fs`&&y===`GET`)return Y(B(new URL(n.url).searchParams.get(`path`)??void 0));if(d===`projects`&&y===`GET`)return Y(v());if(d===`upload`&&y===`POST`){let e=await ge(await n.formData());return Y({...e,detected:B(e.path).detected})}if(d===`open`&&y===`POST`){let{url:e}=await n.json();return!e||!/^https?:\/\//.test(e)?X(`not a web address`,400):(t(e),Y({ok:!0}))}return X(`not found`,404)}const $=(e,t)=>e.length===t.length&&P(Buffer.from(e),Buffer.from(t)),Ee=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``;async function De(e){fe(!0);let n=Se(),r=F(Oe(async e=>{let t=new URL(e.url),r=e.headers.get(`host`)??``;if(!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(r))return new Response(`forbidden host`,{status:403});let i=e.headers.get(`origin`);if(i&&new URL(i).host!==r)return new Response(`forbidden origin`,{status:403});if(t.pathname===`/login`)return $(t.searchParams.get(`t`)??``,n)?new Response(null,{status:303,headers:{location:`/`,"set-cookie":`landfall=${n}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`}}):new Response(`wrong or missing token, use the link lf ui printed`,{status:403});if(!$(Ee(e),n))return new Response("Open the link `lf ui` printed (it logs this browser in).",{status:401,headers:{"content-type":`text/plain`}});if(t.pathname===`/`)return new Response(be,{headers:{"content-type":`text/html; charset=utf-8`,"cache-control":`no-store`}});if(t.pathname.startsWith(`/api/`))try{return await Te(e,t.pathname.slice(5).split(`/`).filter(Boolean).map(decodeURIComponent))}catch(e){return X(e)}return new Response(`not found`,{status:404})}));r.requestTimeout=0;let i=await new Promise(t=>{r.once(`error`,t),r.listen(e.port,`127.0.0.1`,()=>t(void 0))});if(i){if(i.code!==`EADDRINUSE`)throw i;let r=`http://127.0.0.1:${e.port}/login?t=${n}`;return await fetch(`http://127.0.0.1:${e.port}/api/state`,{headers:{cookie:`landfall=${n}`}}).then(e=>e.ok,()=>!1)?(console.log(`lf ui already runs: ${T.cyan(r)}`),e.open&&t(r),0):(console.error(`${T.red(`error:`)} port ${e.port} is in use; pick another with --port`),1)}let a=`http://127.0.0.1:${r.address().port}/login?t=${n}`;return console.log(`${T.bold(`lf ui`)} ${T.dim(C)} on ${T.cyan(a)}`),console.log(T.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&t(a),new Promise(e=>{let t=()=>{for(let e of J.values())e.job.stop();r.closeAllConnections(),r.close(),e(0)};process.once(`SIGINT`,t),process.once(`SIGTERM`,t)})}function Oe(e){return async(t,n)=>{let r=new AbortController;n.on(`close`,()=>r.abort());let i;try{let n=new Headers;for(let[e,r]of Object.entries(t.headers))for(let t of[r??[]].flat())n.append(e,t);let a=t.method===`GET`||t.method===`HEAD`?void 0:I.toWeb(t);i=await e(new Request(`http://${t.headers.host??`127.0.0.1`}${t.url}`,{method:t.method,headers:n,body:a,signal:r.signal,duplex:`half`}))}catch{i=new Response(`bad request`,{status:400})}for(let[e,t]of i.headers)e!==`set-cookie`&&n.setHeader(e,t);let a=i.headers.getSetCookie();if(a.length&&n.setHeader(`set-cookie`,a),n.writeHead(i.status),!i.body)return n.end();let o=I.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{De as webUi};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Set up servers and deploy sites behind Traefik with automatic HTTPS",
5
5
  "author": "ph1p <me@ph1p.dev>",
6
6
  "license": "MIT",
@@ -1,5 +0,0 @@
1
- import{A as e,C as t,D as n,E as r,F as i,I as a,L as o,M as s,N as c,O as l,P as u,R as d,S as f,T as ee,_ as p,a as te,b as m,c as ne,d as h,f as g,g as _,h as v,i as y,j as b,k as re,l as ie,m as ae,n as oe,o as se,p as ce,r as le,s as ue,t as x,u as de,v as fe,w as pe,x as S,y as me,z as C}from"./cli.mjs";import{spawn as w,spawnSync as T}from"node:child_process";import E from"node:fs";import{homedir as D}from"node:os";import{dirname as O,isAbsolute as k,join as A,resolve as j}from"node:path";import{randomBytes as M,timingSafeEqual as N}from"node:crypto";import{createServer as P}from"node:http";import{Readable as F}from"node:stream";const I=/^(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org)$/;function L(){let e=u(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=f().filter(t=>!e.servers[t]&&!I.test(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}function R(e){try{let t=S(a(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function z(e){let[t,n]=e;return e.includes(`--check`)||e.includes(`--key-only`)?!1:t===`firewall`?[`enable`,`disable`,`allow`,`deny`,`update`,`rm`,`remove`].includes(n):t===`traefik`?n===`setup`||n===`use`:t===`env`?e.some(e=>e.includes(`=`)||e===`-u`||e===`--unset`):t===`domains`?e.some(e=>[`-a`,`--add`,`--rm`,`--set`].includes(e)):t===`auth`?e.some(e=>[`-u`,`--user`,`--rm-user`,`--off`].includes(e)):t===`update`||t===`upgrade`||[`setup`,`deploy`,`rollback`,`stop`,`start`,`restart`,`adopt`].includes(t)}function B(e){let t=j(!e||e===`~`?D():c(e)),n=E.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&V(A(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:O(t)===t?null:O(t),home:D(),dirs:n,detected:v(t)}}const V=e=>{try{return E.statSync(e).isDirectory()}catch{return!1}},H=A(s,`uploads`),U=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function W(e,t){E.mkdirSync(O(e),{recursive:!0}),E.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function he(e){E.mkdirSync(H,{recursive:!0,mode:448});for(let e of E.readdirSync(H)){let t=A(H,e);Date.now()-E.statSync(t).mtimeMs>864e5&&E.rmSync(t,{recursive:!0,force:!0})}let t=E.mkdtempSync(A(H,`u-`)),n=e.getAll(`file`).filter(e=>e instanceof File);if(!n.length)throw Error(`no files in the upload`);let r=e.getAll(`path`).map(String);if(n.length===1&&/\.(zip|tar|tgz|tar\.gz)$/i.test(n[0].name)){let e=A(t,`.upload-`+U(n[0].name).pop());await W(e,n[0]);let r=A(t,`files`);E.mkdirSync(r);let i=/\.zip$/i.test(e),a=i?T(`unzip`,[`-q`,e,`-d`,r]):T(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?T(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return E.rmSync(e),{path:G(r),files:ge(r)}}let i=A(t,`files`);for(let[e,t]of n.entries()){let n=U(r[e]||t.name);n.length&&await W(A(i,...n),t)}return{path:G(i),files:n.length}}function G(e){for(;;){let t=E.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!V(A(e,t[0])))return e;e=A(e,t[0])}}function ge(e){return E.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function _e(e){if(e!=null&&e!==``){if(typeof e!=`string`||!k(e)||!V(e))throw Error(`cwd must be an existing folder`);return e}}const K=()=>[process.execPath,...process.execArgv,process.argv[1]],q=5e3;function ve(e,t,n={}){let r={...process.env,NO_COLOR:`1`,...n.env};n.password&&Object.assign(r,ne(n.password));let i=w(K()[0],[...K().slice(1),...e],{stdio:[`ignore`,`pipe`,`pipe`],env:r,cwd:n.cwd,detached:!0}),a={argv:e,lines:[],dropped:0,stop(){if(a.code===void 0){a.stopped=!0;try{process.kill(-i.pid,`SIGTERM`)}catch{}}}},o,s=()=>o??=setTimeout(()=>(o=void 0,t()),50),c=()=>{let e=``;return{push(t){let n=(e+t).split(`
2
- `);e=n.pop();for(let e of n)a.lines.push(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``));a.lines.length>q&&(a.dropped+=a.lines.splice(0,a.lines.length-q).length),s()},flush:()=>e&&a.lines.push(e)}},l=c(),u=c();return i.stdout.setEncoding(`utf8`).on(`data`,l.push),i.stderr.setEncoding(`utf8`).on(`data`,u.push),i.on(`error`,e=>{a.lines.push(`error: ${e.message}`),a.code=1,t()}),i.on(`close`,e=>{l.flush(),u.flush(),a.code=a.stopped?null:e??1,clearTimeout(o),t()}),a}const ye=E.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),J=/* @__PURE__ */ new Map,be=/* @__PURE__ */ new Set([`ui`,`web`]);function xe(){let t=A(O(e),`ui-token`);try{let e=E.readFileSync(t,`utf8`).trim();if(e.length>=32)return e}catch{}let n=M(24).toString(`hex`);return E.mkdirSync(O(t),{recursive:!0,mode:448}),E.writeFileSync(t,n+`
3
- `,{mode:384}),n}const Y=(e,t=200)=>Response.json(e,{status:t,headers:{"cache-control":`no-store`}}),X=(e,t=500)=>Y({error:e?.message??String(e)},t);function Se(e,t){let n=`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,r=/* @__PURE__ */ new Set,i=ve(e,()=>r.forEach(e=>e()),{password:t.password,cwd:t.cwd,env:{LANDFALL_RUN:n}}),a={id:n,argv:e,server:t.server,startedAt:(/* @__PURE__ */ new Date()).toISOString(),undoable:z(e),job:i,listeners:r};J.set(n,a);for(let[e,t]of J){if(J.size<=50)break;t.job.code!==void 0&&J.delete(e)}return a}const Z=e=>e.map((t,n)=>/^(-p|--([\w-]+-)?password)$/.test(e[n-1])?`***`:t.replace(/^(--([\w-]+-)?password=).*/,`$1***`)),Q=e=>({id:e.id,argv:Z(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped});function Ce(e,t,n){let r=new TextEncoder,i=t,a=()=>{},o=new ReadableStream({start(t){let o=e=>t.enqueue(r.encode(e)),s=()=>{let{lines:t,dropped:n,code:r}=e.job,s=Math.max(i,n);n+t.length>s&&(o(`data: ${JSON.stringify({from:s,lines:t.slice(s-n)})}\n\n`),i=n+t.length),r!==void 0&&(o(`event: done\ndata: ${JSON.stringify(Q(e))}\n\n`),a())},c=setInterval(()=>o(`: beat
4
-
5
- `),15e3);a=()=>{clearInterval(c),e.listeners.delete(s);try{t.close()}catch{}},e.listeners.add(s),n.addEventListener(`abort`,a),s()},cancel:()=>a()});return new Response(o,{headers:{"content-type":`text/event-stream`,"cache-control":`no-store`}})}async function we(e,t){let[s,c,d]=t,f=e.method;if(s===`state`&&f===`GET`){let e=u();return Y({version:x,cwd:process.cwd(),project:i(process.cwd())??null,projects:_(),defaultImage:pe,paths:{root:n,sites:l,traefik:re,journal:ee,proxy:r},servers:L().map(t=>({...t,target:R(t.name),saved:e.servers[t.name]??null})),jobs:[...J.values()].reverse().map(Q)})}if(s===`servers`&&c){let{server:t}=a(c);if(f===`GET`&&!d)return Y(await ae(t));if(f===`GET`&&d===`firewall`){let{login:e,probe:n,...r}=await te(t);return Y({...r,asRoot:S(e).user===`root`})}if(f===`GET`&&d===`config`){let e=le(c),[n,r]=await Promise.all([Promise.all(e.domains.map(e=>oe(t,e))),y(t)]);return Y({...e,dns:n,suggest:r})}if(f===`GET`&&d===`history`){let e=(await ue(t)).reverse();return Y(e.map(e=>({...e,pending:se(e).length})))}if(f===`GET`&&d===`facts`){let e=await fe(t),n=await m(t,e);return Y({...e,ips:n,sslipIp:me(n)??null})}if(f===`GET`&&d===`updates`){let n=new URL(e.url).searchParams.get(`refresh`)===`1`,r=await ce(t,n),i=await h(t);return Y({packages:r,images:await g(t),engine:{...i,...de(i)}})}if(f===`GET`&&d===`dns`){let n=(new URL(e.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>b.test(e)).slice(0,50),r=await m(t),[i,a]=await Promise.all([p(n,r),Promise.all(n.map(ie))]);return Y({ips:r,domains:i.map((e,t)=>({...e,https:a[t]}))})}if(f===`POST`&&d==="default"){let e=u();return e.servers[c]??={host:c},e.default=c,o(e),Y({ok:!0})}}if(s===`jobs`){if(f===`POST`&&!c){let t=await e.json(),n=t.argv;if(!Array.isArray(n)||!n.length||!n.every(e=>typeof e==`string`)||be.has(n[0]))return X(`argv must be a list of landfall arguments`,400);let r;try{r=_e(t.cwd)}catch(e){return X(e,400)}return Y(Q(Se(n,{server:t.server,password:t.password||void 0,cwd:r})))}let t=c?J.get(c):void 0;if(c&&!t)return X(`no job ${c}`,404);if(t&&f===`GET`&&d===`events`)return Ce(t,Number(new URL(e.url).searchParams.get(`from`))||0,e.signal);if(t&&f===`GET`&&!d)return Y({...Q(t),lines:t.job.lines,from:t.job.dropped});if(t&&f===`POST`&&d===`stop`)return t.job.stop(),Y(Q(t))}if(s===`fs`&&f===`GET`)return Y(B(new URL(e.url).searchParams.get(`path`)??void 0));if(s===`projects`&&f===`GET`)return Y(_());if(s===`upload`&&f===`POST`){let t=await he(await e.formData());return Y({...t,detected:B(t.path).detected})}if(s===`open`&&f===`POST`){let{url:t}=await e.json();return!t||!/^https?:\/\//.test(t)?X(`not a web address`,400):(C(t),Y({ok:!0}))}return X(`not found`,404)}const $=(e,t)=>e.length===t.length&&N(Buffer.from(e),Buffer.from(t)),Te=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``;async function Ee(e){t(!0);let n=xe(),r=P(De(async e=>{let t=new URL(e.url),r=e.headers.get(`host`)??``;if(!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(r))return new Response(`forbidden host`,{status:403});let i=e.headers.get(`origin`);if(i&&new URL(i).host!==r)return new Response(`forbidden origin`,{status:403});if(t.pathname===`/login`)return $(t.searchParams.get(`t`)??``,n)?new Response(null,{status:303,headers:{location:`/`,"set-cookie":`landfall=${n}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`}}):new Response(`wrong or missing token, use the link lf ui printed`,{status:403});if(!$(Te(e),n))return new Response("Open the link `lf ui` printed (it logs this browser in).",{status:401,headers:{"content-type":`text/plain`}});if(t.pathname===`/`)return new Response(ye,{headers:{"content-type":`text/html; charset=utf-8`,"cache-control":`no-store`}});if(t.pathname.startsWith(`/api/`))try{return await we(e,t.pathname.slice(5).split(`/`).filter(Boolean).map(decodeURIComponent))}catch(e){return X(e)}return new Response(`not found`,{status:404})}));r.requestTimeout=0;let i=await new Promise(t=>{r.once(`error`,t),r.listen(e.port,`127.0.0.1`,()=>t(void 0))});if(i){if(i.code!==`EADDRINUSE`)throw i;let t=`http://127.0.0.1:${e.port}/login?t=${n}`;return await fetch(`http://127.0.0.1:${e.port}/api/state`,{headers:{cookie:`landfall=${n}`}}).then(e=>e.ok,()=>!1)?(console.log(`lf ui already runs: ${d.cyan(t)}`),e.open&&C(t),0):(console.error(`${d.red(`error:`)} port ${e.port} is in use; pick another with --port`),1)}let a=`http://127.0.0.1:${r.address().port}/login?t=${n}`;return console.log(`${d.bold(`lf ui`)} ${d.dim(x)} on ${d.cyan(a)}`),console.log(d.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&C(a),new Promise(e=>{let t=()=>{for(let e of J.values())e.job.stop();r.closeAllConnections(),r.close(),e(0)};process.once(`SIGINT`,t),process.once(`SIGTERM`,t)})}function De(e){return async(t,n)=>{let r=new AbortController;n.on(`close`,()=>r.abort());let i;try{let n=new Headers;for(let[e,r]of Object.entries(t.headers))for(let t of[r??[]].flat())n.append(e,t);let a=t.method===`GET`||t.method===`HEAD`?void 0:F.toWeb(t);i=await e(new Request(`http://${t.headers.host??`127.0.0.1`}${t.url}`,{method:t.method,headers:n,body:a,signal:r.signal,duplex:`half`}))}catch{i=new Response(`bad request`,{status:400})}for(let[e,t]of i.headers)e!==`set-cookie`&&n.setHeader(e,t);let a=i.headers.getSetCookie();if(a.length&&n.setHeader(`set-cookie`,a),n.writeHead(i.status),!i.body)return n.end();let o=F.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{Ee as webUi};