landfall 0.4.6 → 0.5.1

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/cli.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import s,{homedir as c,hostname as l,tmpdir as u,userInfo as d}from"node:os";import{basename as f,dirname as p,join as m,relative as h,resolve as ee}from"node:path";import{promises as te}from"node:dns";import ne,{isIP as g}from"node:net";import{parse as _}from"yaml";import{randomBytes as re}from"node:crypto";import ie from"bcryptjs";import v from"node:http";import ae from"node:https";const y=process.stdout.isTTY&&!process.env.NO_COLOR,b=e=>t=>y?`\x1b[${e}m${t}\x1b[0m`:t,x={bold:b(`1`),dim:b(`2`),red:b(`31`),green:b(`32`),yellow:b(`33`),blue:b(`34`),cyan:b(`36`)},S=e=>console.error(`${x.cyan(`==>`)} ${x.bold(e)}`),C=e=>console.error(`${x.green(`✓`)} ${e}`),w=e=>console.error(` ${e}`),T=e=>console.error(`${x.yellow(`!`)} ${e}`);var E=class extends Error{};const D=e=>{throw new E(e)};let O=!1;const k=e=>O=e,oe=()=>!O&&!!process.stdin.isTTY;async function se(e,t){if(O||!process.stdin.isTTY){if(t!==void 0)return t;D(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${x.blue(`?`)} ${e}${t?x.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function ce(e){return O||!process.stdin.isTTY?!1:j(e,!1)}async function A(e,t){O||(process.stdin.isTTY||D(`${e} Pass -y to confirm (no terminal to ask).`),t?await se(`${e} Type ${x.bold(t)} to confirm:`)!==t&&D(`cancelled`):await j(e,!1)||D(`cancelled`))}async function j(e,t=!0){return O?!0:process.stdin.isTTY?(await se(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`):t}function M(e){if(!e.length)return;let t=e[0].map((t,n)=>Math.max(...e.map(e=>a(e[n]??``))));for(let n of e)console.log(n.map((e,n)=>e+` `.repeat(t[n]-a(e))).join(` `).trimEnd())}const le=e=>{let t=(Date.now()-Date.parse(e))/1e3;return t<90?`${Math.round(t)}s ago`:t<5400?`${Math.round(t/60)}m ago`:t<129600?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`};async function ue(e){(O||!process.stdin.isTTY)&&D(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${x.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
3
- `)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new E(`cancelled`));o===``||o===`\b`?n=n.slice(0,-1):n+=o}};t.on(`data`,i)})}finally{t.setRawMode(!1),t.pause(),process.stderr.write(`
4
- `)}}function de(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const N=e=>e,P={type:`string`,short:`s`,desc:`server name or ssh alias`},F={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}};function fe(t,n){let r=n.indexOf(`--`),i=t.passthrough&&r>=0?n.slice(0,r):n,a=t.passthrough&&r>=0?n.slice(r+1):[],o={};for(let[e,{desc:n,...r}]of Object.entries({...F,...t.options}))o[e]=r;try{let{values:t,positionals:n}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});return{values:t,positionals:n,rest:a}}catch(e){return D(`${e.message}\n\n${pe(t)}`)}}function pe(e){let t=[`${x.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,x.bold(`Options:`));let n=Object.entries({...e.options,yes:F.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?x.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?x.dim(` (repeatable)`):``)+r]}),r=Math.max(...n.map(e=>e[0].length));for(let[e,i]of n)t.push(` ${e.padEnd(r)} ${i}`);return t.join(`
5
- `)}function me(e,t,n){let r=e=>e.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.*/s,`$1=…`),i=Object.entries(n).flatMap(([t,n])=>t===`server`||t===`yes`||t===`help`||n===void 0||n===e.options?.[t]?.default?[]:n===!1?[`--no-${t}`]:n===!0?[`--${t}`]:(Array.isArray(n)?n:[n]).map(e=>/pass|secret|token/i.test(t)?`--${t} …`:`--${t} ${r(String(e))}`));return[e.name,...t.map(r),...i].join(` `)}const he=e=>process.env[`LANDFALL_${e}`],I=process.env.XDG_CONFIG_HOME??m(c(),`.config`),ge=he(`CONFIG_DIR`)??m(I,`landfall`),_e=m(ge,`config.json`),L=e=>e.startsWith(`~/`)?m(c(),e.slice(2)):e,R=e=>{let t=c();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function z(){try{let e=JSON.parse(o.readFileSync(_e,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:D(`cannot read ${_e}: ${e.message}`)}}function ve(e){o.mkdirSync(p(_e),{recursive:!0,mode:448}),o.writeFileSync(_e,JSON.stringify(e,null,2)+`
6
- `,{mode:384})}function ye(e){let t=z();delete t.servers[e],t.default===e&&delete t.default,ve(t)}function be(e){let t=z();t.servers[e]??={host:e},t.default=e,ve(t)}function B(e){let t=z();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?D("no server given. Use --server <ssh-alias> or `lf server add`"):D(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}let n=t.servers[e]??{host:e};return{name:e,server:n}}const V=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,xe=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return V.test(t)||D(`not a domain: ${e}`),t},Se=/^[a-z0-9][a-z0-9._\-/:@]*$/i,Ce=e=>Se.test(e)?e:D(`not an image: ${e}`);function we(e,t,n,r=2**53-1){let i=typeof e==`number`?e:/^\s*\d+\s*$/.test(String(e))?Number(e):NaN;return(!Number.isInteger(i)||i<n||i>r)&&D(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const Te=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,Ee=e=>e.domains?.[0];function De(e,t){let n=Ee(t);return e===`@`?n??D("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const Oe=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),ke=e=>{let t=e.toLowerCase().split(`.`),n=t.length>=3&&t.at(-1).length===2&&t.at(-2).length<=3?3:2;return t.slice(-n).join(`.`)};function Ae(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!V.test(n))continue;let e=ke(n);Oe.has(e)||t.set(e,(t.get(e)??0)+1)}return[...t].filter(([,e])=>e>=2).sort((e,t)=>t[1]-e[1]).map(([e,t])=>({domain:e,hosts:t}))}const je=`.landfall.json`,Me=e=>m(e,je);function Ne(e){try{return JSON.parse(o.readFileSync(Me(e),`utf8`))}catch{return}}function Pe(e,t){o.writeFileSync(m(e,je),JSON.stringify(t,null,2)+`
7
- `)}const H=`/opt/landfall`,U=`${H}/sites`,W=`${H}/traefik`,Fe=`${W}/dynamic`,Ie=8080,Le=`dashboard-tunnel`,Re=`traefik:v3`,G=`${H}/proxy.env`,K=`${H}/journal.log`,ze=`/etc/ssh/sshd_config.d/00-landfall.conf`,Be={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},Ve=`"$(sed -n 's/^container=//p' ${G} 2>/dev/null | grep . || echo traefik)"`;function He(e){let{container:t,network:n,entrypoint:r,resolver:i}=Object.fromEntries(e.split(/\s+/).filter(e=>e.includes(`=`)&&!e.startsWith(`#`)).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)]));return t&&n&&r&&i?{container:t,network:n,entrypoint:r,resolver:i}:void 0}const Ue=e=>`# landfall routes its sites through this existing traefik; delete the file to let landfall run its own\ncontainer=${e.container}\nnetwork=${e.network}\nentrypoint=${e.entrypoint}\nresolver=${e.resolver}\n`,q=String.raw`
2
+ import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import s,{homedir as c,hostname as l,tmpdir as u,userInfo as d}from"node:os";import{basename as f,dirname as p,join as m,normalize as ee,relative as te,resolve as ne}from"node:path";import{promises as h}from"node:dns";import g,{isIP as _}from"node:net";import{parse as re}from"yaml";import{randomBytes as v}from"node:crypto";import y from"bcryptjs";import ie from"node:http";import b from"node:https";const x=async()=>{process.stdin.isTTY&&k(`expected the value on stdin, e.g. printf %s "$SECRET" | lf …`);let e=``;for await(let t of process.stdin)e+=t;return e},ae=process.stdout.isTTY&&!process.env.NO_COLOR,S=e=>t=>ae?`\x1b[${e}m${t}\x1b[0m`:t,C={bold:S(`1`),dim:S(`2`),red:S(`31`),green:S(`32`),yellow:S(`33`),blue:S(`34`),cyan:S(`36`)},w=e=>console.error(`${C.cyan(`==>`)} ${C.bold(e)}`),T=e=>console.error(`${C.green(`✓`)} ${e}`),E=e=>console.error(` ${e}`),D=e=>console.error(`${C.yellow(`!`)} ${e}`);var O=class extends Error{};function oe(e){process.env.LANDFALL_REPORT&&process.stderr.write(`${JSON.stringify(e)}\n`)}const k=e=>{throw new O(e)};let A=!1;const j=e=>A=e,se=()=>!A&&!!process.stdin.isTTY;async function ce(e,t){if(A||!process.stdin.isTTY){if(t!==void 0)return t;k(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${C.blue(`?`)} ${e}${t?C.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function le(e){return A||!process.stdin.isTTY?!1:N(e,!1)}async function M(e,t){A||(process.stdin.isTTY||k(`${e} Pass -y to confirm (no terminal to ask).`),t?await ce(`${e} Type ${C.bold(t)} to confirm:`)!==t&&k(`cancelled`):await N(e,!1)||k(`cancelled`))}async function N(e,t=!0){return A?!0:(process.stdin.isTTY||k(`${e} Pass -y to confirm (no terminal to ask).`),(await ce(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`))}function ue(e){if(!e.length)return;let t=e[0].map((t,n)=>Math.max(...e.map(e=>a(e[n]??``))));for(let n of e)console.log(n.map((e,n)=>e+` `.repeat(t[n]-a(e))).join(` `).trimEnd())}const de=e=>{let t=(Date.now()-Date.parse(e))/1e3;return t<90?`${Math.round(t)}s ago`:t<5400?`${Math.round(t/60)}m ago`:t<129600?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`};async function fe(e){(A||!process.stdin.isTTY)&&k(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${C.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
3
+ `)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new O(`cancelled`));o===``||o===`\b`?n=n.slice(0,-1):n+=o}};t.on(`data`,i)})}finally{t.setRawMode(!1),t.pause(),process.stderr.write(`
4
+ `)}}function pe(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const P=e=>e,F={type:`string`,short:`s`,desc:`server name or ssh alias`},I={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}},L=e=>`LANDFALL_SECRET_${e.toUpperCase().replace(/-/g,`_`)}`;function me(t,n){let r=n.indexOf(`--`),i=t.passthrough&&r>=0?n.slice(0,r):n,a=t.passthrough&&r>=0?n.slice(r+1):[],o={};for(let[e,{desc:n,...r}]of Object.entries({...I,...t.options}))o[e]=r;try{let{values:n,positionals:r}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});for(let e of Object.keys(t.options??{}))/(^|-)password$/.test(e)&&n[e]===void 0&&process.env[L(e)]&&(n[e]=process.env[L(e)]);return{values:n,positionals:r,rest:a}}catch(e){return k(`${e.message}\n\n${he(t)}`)}}function he(e){let t=[`${C.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,C.bold(`Options:`));let n=Object.entries({...e.options,yes:I.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?C.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?C.dim(` (repeatable)`):``)+r]}),r=Math.max(...n.map(e=>e[0].length));for(let[e,i]of n)t.push(` ${e.padEnd(r)} ${i}`);return t.join(`
5
+ `)}function ge(e,t,n){let r=e=>e.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.*/s,`$1=…`),i=Object.entries(n).flatMap(([t,n])=>t===`server`||t===`yes`||t===`help`||n===void 0||n===e.options?.[t]?.default?[]:n===!1?[`--no-${t}`]:n===!0?[`--${t}`]:(Array.isArray(n)?n:[n]).map(e=>/pass|secret|token/i.test(t)?`--${t} …`:`--${t} ${r(String(e))}`));return[e.name,...t.map(r),...i].join(` `)}const _e=e=>process.env[`LANDFALL_${e}`],R=process.env.XDG_CONFIG_HOME??m(c(),`.config`),ve=_e(`CONFIG_DIR`)??m(R,`landfall`),ye=m(ve,`config.json`),z=e=>e.startsWith(`~/`)?m(c(),e.slice(2)):e,B=e=>{let t=c();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function V(){try{let e=JSON.parse(o.readFileSync(ye,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:k(`cannot read ${ye}: ${e.message}`)}}function be(e){o.mkdirSync(p(ye),{recursive:!0,mode:448}),o.writeFileSync(ye,JSON.stringify(e,null,2)+`
6
+ `,{mode:384})}function xe(e){let t=V();delete t.servers[e],t.default===e&&delete t.default,be(t)}function Se(e){let t=V();t.servers[e]??={host:e},t.default=e,be(t)}function H(e){let t=V();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?k("no server given. Use --server <ssh-alias> or `lf server add`"):k(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}(!/^[A-Za-z0-9._@:\[\]-]+$/.test(e)||e.startsWith(`-`))&&k(`not a server name: ${e}`);let n=t.servers[e]??{host:e};return{name:e,server:n}}const Ce=e=>(/^[A-Za-z0-9_@:\[\]][A-Za-z0-9._@:\[\]-]*$/.test(e)||k(`not a server name: ${e} (letters, digits and . _ - @ : only)`),e),we=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,Te=/^[A-Za-z0-9._@-]{1,64}$/,Ee=(e,t=`user`)=>(Te.test(e)||k(`${t} may only have letters, digits and . _ @ -`),e),De=e=>(/^[^\s@"'`$\\]+@[^\s@"'`$\\]+\.[^\s@"'`$\\]+$/.test(e)||k(`not an e-mail address: ${e}`),e),Oe=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return we.test(t)||k(`not a domain: ${e}`),t},ke=/^[a-z0-9][a-z0-9._\-/:@]*$/i,Ae=e=>ke.test(e)?e:k(`not an image: ${e}`);function je(e,t,n,r=2**53-1){let i=typeof e==`number`?e:/^\s*\d+\s*$/.test(String(e))?Number(e):NaN;return(!Number.isInteger(i)||i<n||i>r)&&k(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const Me=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,Ne=e=>e.domains?.[0];function Pe(e,t){let n=Ne(t);return e===`@`?n??k("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const Fe=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),Ie=e=>{let t=e.toLowerCase().split(`.`),n=t.length>=3&&t.at(-1).length===2&&t.at(-2).length<=3?3:2;return t.slice(-n).join(`.`)};function Le(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!we.test(n))continue;let e=Ie(n);Fe.has(e)||t.set(e,(t.get(e)??0)+1)}return[...t].filter(([,e])=>e>=2).sort((e,t)=>t[1]-e[1]).map(([e,t])=>({domain:e,hosts:t}))}const Re=`.landfall.json`,ze=e=>m(e,Re);function Be(e){try{return JSON.parse(o.readFileSync(ze(e),`utf8`))}catch{return}}function Ve(e,t){o.writeFileSync(m(e,Re),JSON.stringify(t,null,2)+`
7
+ `)}const U=`/opt/landfall`,W=`${U}/sites`,G=`${U}/traefik`,He=`${G}/dynamic`,Ue=8080,We=`dashboard-tunnel`,Ge=`traefik:v3`,K=`${U}/proxy.env`,q=`${U}/journal.log`,Ke=`/etc/ssh/sshd_config.d/00-landfall.conf`,qe={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},Je=`"$(sed -n 's/^container=//p' ${K} 2>/dev/null | grep . || echo traefik)"`;function Ye(e){let{container:t,network:n,entrypoint:r,resolver:i}=Object.fromEntries(e.split(/\s+/).filter(e=>e.includes(`=`)&&!e.startsWith(`#`)).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)]));return t&&n&&r&&i?{container:t,network:n,entrypoint:r,resolver:i}:void 0}const Xe=e=>`# landfall routes its sites through this existing traefik; delete the file to let landfall run its own\ncontainer=${e.container}\nnetwork=${e.network}\nentrypoint=${e.entrypoint}\nresolver=${e.resolver}\n`,J=String.raw`
8
8
  log() { printf '\033[36m ->\033[0m %s\n' "$*" >&2; }
9
9
  SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
10
10
  # Files a container wrote may be root's.
@@ -65,19 +65,19 @@ _clean() { printf %s "$1" | tr '\t\n' ' '; }
65
65
  _journal() { # _journal <do|undo> <what> <undo shell>
66
66
  line=$(printf '%s\t' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$LANDFALL_RUN" "$(_clean "$LANDFALL_ACTOR")" "$(id -un)" "$(_clean "$LANDFALL_CMD")" "$1" "$(_clean "$2")")
67
67
  line="$line$(printf %s "$3" | base64 | tr -d '\n')"
68
- if [ -w ${K} ] || { [ ! -e ${K} ] && [ -w ${H} ]; }; then
69
- printf '%s\n' "$line" >> ${K}
68
+ if [ -w ${q} ] || { [ ! -e ${q} ] && [ -w ${U} ]; }; then
69
+ printf '%s\n' "$line" >> ${q}
70
70
  elif [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then false
71
71
  else
72
- { $SUDO install -d ${H} && printf '%s\n' "$line" | $SUDO tee -a ${K} >/dev/null; } 2>/dev/null
73
- fi || { log "warning: could not write ${K}"; return 0; }
72
+ { $SUDO install -d ${U} && printf '%s\n' "$line" | $SUDO tee -a ${q} >/dev/null; } 2>/dev/null
73
+ fi || { log "warning: could not write ${q}"; return 0; }
74
74
  # A root (or sudo) first entry would leave the file root-owned, and deploy users could no longer
75
- # append; it belongs to whoever owns ${H}.
76
- [ -O ${K} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${H})" ${K} 2>/dev/null || true
75
+ # append; it belongs to whoever owns ${U}.
76
+ [ -O ${q} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${U})" ${q} 2>/dev/null || true
77
77
  }
78
78
  # record <what> [undo shell]: journal a change; no undo shell = not reversible.
79
79
  record() { _journal do "$1" "$(shift; printf %s "$*")"; }
80
- # Run as $SUDO only when <dir> is not writable: deploy users own ${H} and need no sudo.
80
+ # Run as $SUDO only when <dir> is not writable: deploy users own ${U} and need no sudo.
81
81
  _as() { if [ -w "$1" ]; then shift; "$@"; else shift; $SUDO "$@"; fi; }
82
82
  # saved <file>: back the file up, print the shell that puts it back (or removes it if it was new).
83
83
  saved() {
@@ -101,9 +101,9 @@ drop_line() { # drop_line <file> <line>: remove an exact line, keeping the file'
101
101
  t=$($SUDO grep -vxF -- "$2" "$1" || true)
102
102
  { [ -z "$t" ] || printf '%s\n' "$t"; } | $SUDO tee "$1" >/dev/null
103
103
  }
104
- ufw_add() { # ufw_add [prepend] <rule...>: add a ufw rule, journaled with its delete
104
+ ufw_add() { # ufw_add [prepend | insert N] <rule...>: add a ufw rule, journaled with its delete
105
105
  out=$($SUDO ufw "$@") || return 1
106
- [ "$1" != prepend ] || shift
106
+ case "$1" in prepend) shift ;; insert) shift 2 ;; esac
107
107
  case "$out" in
108
108
  *Skipping*) ;;
109
109
  # ufw rewrote a rule that differed only in action/comment (limit 22 -> allow 22): deleting it
@@ -117,7 +117,7 @@ ufw_enable() {
117
117
  *inactive*) $SUDO ufw --force enable >/dev/null; record "enabled ufw" '$SUDO ufw disable >/dev/null' ;;
118
118
  esac
119
119
  }
120
- `,We=String.raw`
120
+ `,Ze=String.raw`
121
121
  compose_install() {
122
122
  arch=$(uname -m); case "$arch" in armv7l) arch=armv7 ;; armv6l) arch=armv6 ;; esac
123
123
  f=${`/usr/local/lib/docker/cli-plugins/docker-compose`}
@@ -128,27 +128,27 @@ compose_install() {
128
128
  $SUDO mv $f.new $f
129
129
  record "installed docker compose $(docker compose version --short 2>/dev/null) to $f" "$undo"
130
130
  }
131
- `,Ge=`This server is run by landfall (https://www.npmjs.com/package/landfall).
131
+ `,Qe=`This server is run by landfall (https://www.npmjs.com/package/landfall).
132
132
  Everything here works without it: it is plain Docker Compose.
133
133
 
134
- ${W}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
135
- ${U}/<site>/ one Compose project per site
134
+ ${G}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
135
+ ${W}/<site>/ one Compose project per site
136
136
  .env the site's environment (lf env), mode 600
137
137
  site.json landfall's notes: domains, releases
138
138
  current -> releases/<id> the live release
139
139
  releases/<id>/compose.yaml the site's compose file as deployed
140
140
  releases/<id>/compose.override.yaml landfall's: Traefik labels, proxy network, project name
141
- ${K} every change landfall made, with the shell that undoes it
141
+ ${q} every change landfall made, with the shell that undoes it
142
142
 
143
143
  Run a site by hand:
144
- cd ${U}/<site>/current && docker compose ps (logs, restart, up -d, down …)
144
+ cd ${W}/<site>/current && docker compose ps (logs, restart, up -d, down …)
145
145
  Roll back by hand:
146
- cd ${U}/<site> && ln -sfn releases/<id> current && cd -P current && docker compose up -d
146
+ cd ${W}/<site> && ln -sfn releases/<id> current && cd -P current && docker compose up -d
147
147
 
148
148
  Named volumes (databases, uploads) belong to the project, not the release: they survive
149
149
  deploys and rollbacks. "lf sites rm" keeps them; "lf sites rm --volumes" deletes them.
150
- `,Ke=String.raw`
151
- ${We}
150
+ `,$e=String.raw`
151
+ ${Ze}
152
152
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then
153
153
  echo "setup needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
154
154
  exit 1
@@ -210,16 +210,16 @@ else
210
210
  OWNER="$(id -un)"
211
211
  fi
212
212
 
213
- log "preparing ${H}"
214
- new_sites=$([ -d ${U} ] || echo 1)
215
- new_traefik=$([ -d ${W} ] || echo 1)
216
- $SUDO install -d -o "$OWNER" -g "$OWNER" ${H} ${U} ${W}
217
- $SUDO chown "$OWNER:$OWNER" ${K} 2>/dev/null || true
218
- $SUDO tee ${H}/README >/dev/null <<'LANDFALL_README'
219
- ${Ge}LANDFALL_README
220
- # ${H} itself stays: it holds the journal. Each dir is only removed if setup created it.
221
- [ -z "$new_sites" ] || record "created ${U}" "_as ${H} rmdir ${U}"
222
- [ -z "$new_traefik" ] || record "created ${W}" "_as ${H} rm -rf ${W}"
213
+ log "preparing ${U}"
214
+ new_sites=$([ -d ${W} ] || echo 1)
215
+ new_traefik=$([ -d ${G} ] || echo 1)
216
+ $SUDO install -d -o "$OWNER" -g "$OWNER" ${U} ${W} ${G}
217
+ $SUDO chown "$OWNER:$OWNER" ${q} 2>/dev/null || true
218
+ $SUDO tee ${U}/README >/dev/null <<'LANDFALL_README'
219
+ ${Qe}LANDFALL_README
220
+ # ${U} itself stays: it holds the journal. Each dir is only removed if setup created it.
221
+ [ -z "$new_sites" ] || record "created ${W}" "_as ${U} rmdir ${W}"
222
+ [ -z "$new_traefik" ] || record "created ${G}" "_as ${U} rm -rf ${G}"
223
223
 
224
224
  DOCKER="$SUDO docker" ensure_network ${`web`}
225
225
 
@@ -228,7 +228,7 @@ if [ "$FIREWALL" = 1 ] && command -v ufw >/dev/null 2>&1; then
228
228
  ufw_add allow "$SSH_PORT/tcp"
229
229
  ufw_add allow 80/tcp
230
230
  ufw_add allow 443/tcp
231
- ufw_add allow 443/udp # HTTP/3
231
+ [ "$HTTP3" != 1 ] || ufw_add allow 443/udp
232
232
  ufw_enable
233
233
  fi
234
234
 
@@ -239,25 +239,30 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
239
239
  if [ "$($SUDO sshd -T 2>/dev/null | awk '$1 == "permitrootlogin" {print $2}')" = no ]; then root_login=no; fi
240
240
  # 00- so it wins over cloud-init's 50-cloud-init.conf: sshd keeps the first value it reads.
241
241
  want=$(printf '%s\n' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' "PermitRootLogin $root_login")
242
- if [ "$($SUDO cat ${ze} 2>/dev/null || true)" != "$want" ]; then
242
+ undo=""
243
+ if [ "$($SUDO cat ${Ke} 2>/dev/null || true)" != "$want" ]; then
243
244
  $SUDO install -d /etc/ssh/sshd_config.d
244
- undo=$(saved ${ze})
245
- printf '%s\n' "$want" | $SUDO tee ${ze} >/dev/null
246
- if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
247
- undo="$undo; $(saved /etc/ssh/sshd_config)"
248
- printf 'Include /etc/ssh/sshd_config.d/*.conf\n%s' "$($SUDO cat /etc/ssh/sshd_config)" | $SUDO tee /etc/ssh/sshd_config >/dev/null
249
- fi
250
- if $SUDO sshd -t; then
251
- sshd_reload
252
- record "hardened sshd (keys only, root login $root_login)" "$undo; sshd_reload"
253
- else
254
- log "sshd config test failed, putting the old config back"
255
- eval "$undo"
256
- fi
245
+ undo=$(saved ${Ke})
246
+ printf '%s\n' "$want" | $SUDO tee ${Ke} >/dev/null
257
247
  fi
248
+ if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
249
+ if [ -n "$undo" ]; then undo="$undo; "; fi
250
+ undo="$undo$(saved /etc/ssh/sshd_config)"
251
+ printf 'Include /etc/ssh/sshd_config.d/*.conf\n%s' "$($SUDO cat /etc/ssh/sshd_config)" | $SUDO tee /etc/ssh/sshd_config >/dev/null
252
+ fi
253
+ if [ -z "$undo" ]; then :
254
+ elif $SUDO sshd -t; then
255
+ sshd_reload
256
+ record "hardened sshd (keys only, root login $root_login)" "$undo; sshd_reload"
257
+ else
258
+ log "sshd config test failed, putting the old config back"
259
+ eval "$undo"
260
+ fi
261
+ pw=$($SUDO sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" || $1 == "kbdinteractiveauthentication" {print $2}' | sort -u | tr -d '\n')
262
+ [ -z "$pw" ] || [ "$pw" = no ] || log "warning: sshd still accepts passwords; a Match block or another file in /etc/ssh overrides ${Ke} (check: sudo sshd -T)"
258
263
  fi
259
264
  log "bootstrap done"
260
- `,qe=String.raw`
265
+ `,et=String.raw`
261
266
  set +e
262
267
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
263
268
  kv() { printf '%s=%s
@@ -286,15 +291,18 @@ if [ -n "$DEPLOY_USER" ] && id "$DEPLOY_USER" >/dev/null 2>&1; then
286
291
  else
287
292
  kv user 0
288
293
  fi
289
- kv root_dir "$(yn test -d ${U})"
290
- kv sites "$(ls ${U} 2>/dev/null | wc -l | tr -d ' ')"
294
+ kv root_dir "$(yn test -d ${W})"
295
+ kv sites "$(ls ${W} 2>/dev/null | wc -l | tr -d ' ')"
291
296
  kv network "$(yn S docker network inspect ${`web`})"
292
- kv proxy "$(grep -v '^#' ${G} 2>/dev/null | tr '\n' ' ')"
297
+ kv proxy "$(grep -v '^#' ${K} 2>/dev/null | tr '\n' ' ')"
293
298
  if command -v ufw >/dev/null 2>&1; then kv ufw "$(S ufw status 2>/dev/null | awk 'NR == 1 {print $2}')"; else kv ufw missing; fi
294
- kv hardened "$(yn test -f ${ze})"
299
+ # Hardened = what sshd really uses (a Match block or an earlier file can override ours). Without
300
+ # root, sshd -T prints nothing: then our file is all there is to go by.
301
+ pw=$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" || $1 == "kbdinteractiveauthentication" {print $2}' | sort -u | tr -d '\n')
302
+ if [ -f ${Ke} ] && { [ -z "$pw" ] || [ "$pw" = no ]; }; then kv hardened 1; else kv hardened 0; fi
295
303
  kv password_auth "$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" {print $2}')"
296
304
  kv password_users "$(S awk -F: '$2 ~ /^\$/ {printf "%s ", $1}' /etc/shadow 2>/dev/null)"
297
- f=${W}/docker-compose.yml
305
+ f=${G}/docker-compose.yml
298
306
  if [ ! -f "$f" ]; then kv traefik_compose none
299
307
  elif grep -q "managed by landfall" "$f"; then kv traefik_compose landfall
300
308
  else kv traefik_compose foreign; fi
@@ -321,14 +329,14 @@ else
321
329
  S netstat -ltunp 2>/dev/null | awk '$1 ~ /^tcp/ {print "listen=tcp " $4 " " $7} $1 ~ /^udp/ {print "listen=udp " $4 " " $6}'
322
330
  fi
323
331
  exit 0
324
- `,Je=e=>e.replace(/\$/g,`$$$$`),Ye=e=>JSON.stringify(e);function Xe(e){let t=[`--providers.docker=true`,`--providers.docker.exposedbydefault=false`,`--providers.docker.network=web`,`--providers.file.directory=/etc/traefik/dynamic`,`--entrypoints.web.address=:80`,`--entrypoints.web.http.redirections.entrypoint.to=websecure`,`--entrypoints.web.http.redirections.entrypoint.scheme=https`,`--entrypoints.websecure.address=:443`,`--entrypoints.websecure.http.tls.certresolver=le`,`--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json`,`--certificatesresolvers.le.acme.httpchallenge.entrypoint=web`,`--log.level=INFO`,`--accesslog=true`];e.email&&t.push(`--certificatesresolvers.le.acme.email=${e.email}`),e.http3&&t.push(`--entrypoints.websecure.http3=true`),e.staging&&t.push(`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`);let n=[];return t.push(`--api.dashboard=true`,`--entrypoints.tunnel.address=${e.hostNetwork?`127.0.0.1`:``}:${Ie}`),n.push(`traefik.enable=true`,`traefik.http.routers.${Le}.rule=PathPrefix(\`/\`)`,`traefik.http.routers.${Le}.entrypoints=tunnel`,`traefik.http.routers.${Le}.service=api@internal`,`traefik.http.routers.${Le}.middlewares=tunnel-only`,"traefik.http.middlewares.tunnel-only.ipallowlist.sourcerange=127.0.0.1/32,${LANDFALL_GATEWAY:-127.0.0.1}/32"),e.dashboard&&n.push(`traefik.http.routers.dashboard.rule=Host(\`${e.dashboard.host}\`)`,`traefik.http.routers.dashboard.entrypoints=websecure`,`traefik.http.routers.dashboard.service=api@internal`,`traefik.http.routers.dashboard.middlewares=dashboard-auth`,`traefik.http.middlewares.dashboard-auth.basicauth.users=${Je(e.dashboard.htpasswd)}`),`# managed by landfall — regenerate with \`lf traefik setup\`
332
+ `,tt=e=>e.replace(/\$/g,`$$$$`),nt=e=>JSON.stringify(e);function rt(e){let t=[`--providers.docker=true`,`--providers.docker.exposedbydefault=false`,`--providers.docker.network=web`,`--providers.file.directory=/etc/traefik/dynamic`,`--entrypoints.web.address=:80`,`--entrypoints.web.http.redirections.entrypoint.to=websecure`,`--entrypoints.web.http.redirections.entrypoint.scheme=https`,`--entrypoints.websecure.address=:443`,`--entrypoints.websecure.http.tls.certresolver=le`,`--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json`,`--certificatesresolvers.le.acme.httpchallenge.entrypoint=web`,`--log.level=INFO`,`--accesslog=true`];e.email&&t.push(`--certificatesresolvers.le.acme.email=${e.email}`),e.http3&&t.push(`--entrypoints.websecure.http3=true`),e.staging&&t.push(`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`);let n=[];return t.push(`--api.dashboard=true`,`--entrypoints.tunnel.address=${e.hostNetwork?`127.0.0.1`:``}:${Ue}`),n.push(`traefik.enable=true`,`traefik.http.routers.${We}.rule=PathPrefix(\`/\`)`,`traefik.http.routers.${We}.entrypoints=tunnel`,`traefik.http.routers.${We}.service=api@internal`,`traefik.http.routers.${We}.middlewares=tunnel-only`,"traefik.http.middlewares.tunnel-only.ipallowlist.sourcerange=127.0.0.1/32,${LANDFALL_GATEWAY:-127.0.0.1}/32"),e.dashboard&&n.push(`traefik.http.routers.dashboard.rule=Host(\`${e.dashboard.host}\`)`,`traefik.http.routers.dashboard.entrypoints=websecure`,`traefik.http.routers.dashboard.service=api@internal`,`traefik.http.routers.dashboard.middlewares=dashboard-auth`,`traefik.http.middlewares.dashboard-auth.basicauth.users=${tt(e.dashboard.htpasswd)}`),`# managed by landfall — regenerate with \`lf traefik setup\`
325
333
  services:
326
334
  traefik:
327
335
  image: ${e.image}
328
336
  container_name: traefik
329
337
  restart: unless-stopped
330
338
  command:
331
- ${t.map(e=>` - ${Ye(e)}`).join(`
339
+ ${t.map(e=>` - ${nt(e)}`).join(`
332
340
  `)}
333
341
  ${e.hostNetwork?` network_mode: host
334
342
  `:` ports:
@@ -341,20 +349,20 @@ ${e.http3?` - "443:443/udp"
341
349
  - ./dynamic:/etc/traefik/dynamic:ro
342
350
  ${e.hostNetwork?``:` networks:
343
351
  - web
344
- `}${n.length?` labels:\n${n.map(e=>` - ${Ye(e)}`).join(`
352
+ `}${n.length?` labels:\n${n.map(e=>` - ${nt(e)}`).join(`
345
353
  `)}\n`:``}${e.hostNetwork?``:`networks:
346
354
  web:
347
355
  external: true
348
- `}`}let Ze=!1;const Qe=e=>Ze=e,J=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,$e=(e=he(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],et=e=>e.user?`${e.user}@${e.host}`:e.host;function tt(e){let t=[...$e(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,L(e.key),`-o`,`IdentitiesOnly=yes`),t}let nt;function rt(){return nt||(nt=m(process.platform===`darwin`?`/tmp`:u(),`landfall-${d().uid}`),o.mkdirSync(nt,{recursive:!0,mode:448})),nt}function it(e,t={}){let n=[...tt(e),`-o`,`ServerAliveInterval=15`];return(t.batch||Ze)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${rt()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,et(e)),n}function at(e){return new Promise(t=>{let r=[...tt(e),`-o`,`ControlPath=${rt()}/%C`,`-O`,`exit`,et(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function ot(e,t,r){return new Promise((i,a)=>{let o=n(e,t,{...r,stdio:[r.input===void 0?`inherit`:`pipe`,r.capture?`pipe`:`inherit`,Ze?`pipe`:`inherit`]}),s=``,c=``;o.stdout?.on(`data`,e=>s+=e),o.stderr?.on(`data`,e=>c+=e),o.on(`error`,a),o.on(`close`,e=>i({code:e??1,stdout:s,stderr:c})),r.input!==void 0&&o.stdin.end(r.input)})}async function st(e,t=[]){let{code:n}=await ot(`ssh`,[...it(e,{tty:process.stdin.isTTY}),...t],{});return n}const ct=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,lt={run:process.env.LANDFALL_RUN||ct(),actor:`${d().username}@${l().split(`.`)[0]}`,cmd:``};async function Y(e,t,n={}){let r={LANDFALL_RUN:lt.run,LANDFALL_ACTOR:lt.actor,LANDFALL_CMD:lt.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${J(typeof t==`boolean`?+!!t:t)}`).join(`
349
- `),a=await ot(`ssh`,[...it(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${i}\n${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
350
- `).at(-1);D(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function ut(e){let{code:t}=await ot(`ssh`,[...it(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}async function dt(e){let{code:t}=await ot(`ssh`,[...it(e,{batch:!0}),`test -d ${H}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const ft=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`];let pt;async function mt(){if(!pt){let{stdout:e}=await ot(`tar`,[`--version`],{capture:!0});pt=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return pt}async function ht(e,t,r,i=ft){o.existsSync(t)||D(`directory not found: ${t}`);let a=[...await mt(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${J(r)} && tar -xzof - -C ${J(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[...it(e),s],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(o.stdin),i.on(`error`,r),o.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(o)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const X=(e,t,n=`644`)=>`printf %s ${J(Buffer.from(t).toString(`base64`))} | base64 -d > ${J(e)} && chmod ${n} ${J(e)}`,gt=he(`SSH_CONFIG`)??m(c(),`.ssh`,`config`),_t=/* @__PURE__ */ new Map;function Z(e){let n=[...tt(e),`-G`,et(e)],r=_t.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return D(`ssh cannot resolve ${et(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>L(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return _t.set(n.join(`\0`),s),s}function vt(e){return(e.key?[L(e.key)]:Z(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function yt(e=gt,t=/* @__PURE__ */ new Set){if(t.has(e)||!o.existsSync(e))return[];t.add(e);let n=[];for(let r of o.readFileSync(e,`utf8`).split(`
351
- `)){let e=r.trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!e)continue;let[,i,a]=e;if(i.toLowerCase()===`host`)n.push(...a.split(/\s+/).filter(e=>!/[*?!]/.test(e)));else if(i.toLowerCase()===`include`)for(let e of a.split(/\s+/)){let r=L(e),i=r.startsWith(`/`)?r:m(c(),`.ssh`,r);for(let e of bt(i))n.push(...yt(e,t))}}return[...new Set(n)]}function bt(e){if(!/[*?[]/.test(e))return[e];let t=p(e);return o.existsSync(t)?o.globSync(f(e),{cwd:t}).map(e=>m(t,e)):[]}const xt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],St=(e,t)=>RegExp(`# >>> landfall:${At(e)}\\n[\\s\\S]*?# <<< landfall:${At(e)}${t}`),Ct=(e,t)=>e===`# >>> landfall:${t}`;function wt(e){let[t,n]=xt(e.alias);return[t,`Host ${e.alias}`,` HostName ${e.hostname}`,` User ${e.user}`,...e.port===22?[]:[` Port ${e.port}`],` IdentityFile ${e.key}`,` IdentitiesOnly yes`,n].join(`
352
- `)}function Tt(e,t=gt){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`),i=wt(e),a=St(e.alias,`\\n?`),s=a.test(r)?r.replace(a,i+`
353
- `):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),_t.clear(),{created:n,block:i}}function Et(e,t=gt,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
354
- `);for(let i=0;i<r.length;i++){let a=r[i].trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!a)continue;let o=a[1].toLowerCase();if(o===`host`&&a[2].split(/\s+/).includes(e)){let n=i+1;for(;n<r.length&&!/^\s*(host|match)\b/i.test(r[n])&&!r[n].startsWith(`# <<< landfall:`);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:Ct(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=L(t);for(let t of bt(r.startsWith(`/`)?r:m(c(),`.ssh`,r))){let r=Et(e,t,n);if(r)return r}}}}function Dt(e,t){let n=Et(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
355
- `),i=r.slice(n.start+1,n.end),a=e=>e.trim().match(/^identityfile\s*=?\s*(.+)$/i)?.[1].replace(/^"(.*)"$/,`$1`),s=i.filter(e=>{let n=a(e);return n!==void 0&&(!o.existsSync(L(n))||L(n)===t)}),c=`${i.find(e=>e.trim())?.match(/^\s*/)?.[0]||` `}IdentityFile ${t}`,l=i.filter(e=>!s.includes(e));r.splice(n.start+1,i.length,c,...l);let u=`${n.file}.landfall-bak`;return o.existsSync(u)||o.copyFileSync(n.file,u),o.writeFileSync(n.file,r.join(`
356
- `)),_t.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function Ot(e,t=gt){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=St(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const kt=(e,t=gt)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
357
- `).some(t=>Ct(t,e)),At=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),jt=String.raw`
356
+ `}`}let it=!1;const at=e=>it=e,Y=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,ot=(e=_e(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],st=e=>((/^-|\s/.test(e.host)||e.user&&/^-|\s/.test(e.user))&&k(`not a server: ${e.user?`${e.user}@`:``}${e.host}`),e.user?`${e.user}@${e.host}`:e.host);function ct(e){let t=[...ot(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,z(e.key),`-o`,`IdentitiesOnly=yes`),t}let lt;function ut(){return lt||(lt=m(process.platform===`darwin`?`/tmp`:u(),`landfall-${d().uid}`),o.mkdirSync(lt,{recursive:!0,mode:448})),lt}function dt(e,t={}){let n=[...ct(e),`-o`,`ServerAliveInterval=15`];return(t.batch||it)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${ut()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,st(e)),n}function ft(e){return new Promise(t=>{let r=[...ct(e),`-o`,`ControlPath=${ut()}/%C`,`-O`,`exit`,st(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function pt(e,t,r){return new Promise((i,a)=>{let o=n(e,t,{...r,stdio:[r.input===void 0?`inherit`:`pipe`,r.capture?`pipe`:`inherit`,it?`pipe`:`inherit`]}),s=``,c=``;o.stdout?.on(`data`,e=>s+=e),o.stderr?.on(`data`,e=>c+=e),o.on(`error`,a),o.on(`close`,e=>i({code:e??1,stdout:s,stderr:c})),r.input!==void 0&&o.stdin.end(r.input)})}async function mt(e,t=[]){let{code:n}=await pt(`ssh`,[...dt(e,{tty:process.stdin.isTTY}),...t],{});return n}const ht=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,gt={run:process.env.LANDFALL_RUN||ht(),actor:`${d().username}@${l().split(`.`)[0]}`,cmd:``};async function X(e,t,n={}){let r={LANDFALL_RUN:gt.run,LANDFALL_ACTOR:gt.actor,LANDFALL_CMD:gt.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${Y(typeof t==`boolean`?+!!t:t)}`).join(`
357
+ `),a=await pt(`ssh`,[...dt(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${i}\n${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
358
+ `).at(-1);k(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function _t(e){let{code:t}=await pt(`ssh`,[...dt(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}async function vt(e){let{code:t}=await pt(`ssh`,[...dt(e,{batch:!0}),`test -d ${U}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const yt=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`];let bt;async function xt(){if(!bt){let{stdout:e}=await pt(`tar`,[`--version`],{capture:!0});bt=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return bt}async function St(e,t,r,i=yt){o.existsSync(t)||k(`directory not found: ${t}`);let a=[...await xt(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${Y(r)} && tar -xzof - -C ${Y(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[...dt(e),s],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(o.stdin),i.on(`error`,r),o.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(o)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const Z=(e,t,n=`644`)=>`printf %s ${Y(Buffer.from(t).toString(`base64`))} | base64 -d > ${Y(e)} && chmod ${n} ${Y(e)}`,Ct=_e(`SSH_CONFIG`)??m(c(),`.ssh`,`config`),wt=/* @__PURE__ */ new Map;function Q(e){let n=[...ct(e),`-G`,st(e)],r=wt.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return k(`ssh cannot resolve ${st(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>z(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return wt.set(n.join(`\0`),s),s}function Tt(e){return(e.key?[z(e.key)]:Q(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function Et(e=Ct,t=/* @__PURE__ */ new Set){if(t.has(e)||!o.existsSync(e))return[];t.add(e);let n=[];for(let r of o.readFileSync(e,`utf8`).split(`
359
+ `)){let e=r.trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!e)continue;let[,i,a]=e;if(i.toLowerCase()===`host`)n.push(...a.split(/\s+/).filter(e=>!/[*?!]/.test(e)));else if(i.toLowerCase()===`include`)for(let e of a.split(/\s+/)){let r=z(e),i=r.startsWith(`/`)?r:m(c(),`.ssh`,r);for(let e of Dt(i))n.push(...Et(e,t))}}return[...new Set(n)]}function Dt(e){if(!/[*?[]/.test(e))return[e];let t=p(e);return o.existsSync(t)?o.globSync(f(e),{cwd:t}).map(e=>m(t,e)):[]}const Ot=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],kt=(e,t)=>RegExp(`# >>> landfall:${Lt(e)}\\n[\\s\\S]*?# <<< landfall:${Lt(e)}${t}`),At=(e,t)=>e===`# >>> landfall:${t}`;function jt(e){let[t,n]=Ot(e.alias);return[t,`Host ${e.alias}`,` HostName ${e.hostname}`,` User ${e.user}`,...e.port===22?[]:[` Port ${e.port}`],` IdentityFile ${e.key}`,` IdentitiesOnly yes`,n].join(`
360
+ `)}function Mt(e,t=Ct){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`);Ce(e.alias);let i=jt(e),a=kt(e.alias,`\\n?`),s=a.test(r)?r.replace(a,()=>i+`
361
+ `):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),wt.clear(),{created:n,block:i}}function Nt(e,t=Ct,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
362
+ `);for(let i=0;i<r.length;i++){let a=r[i].trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!a)continue;let o=a[1].toLowerCase();if(o===`host`&&a[2].split(/\s+/).includes(e)){let n=i+1;for(;n<r.length&&!/^\s*(host|match)\b/i.test(r[n])&&!r[n].startsWith(`# <<< landfall:`);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:At(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=z(t);for(let t of Dt(r.startsWith(`/`)?r:m(c(),`.ssh`,r))){let r=Nt(e,t,n);if(r)return r}}}}function Pt(e,t){let n=Nt(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
363
+ `),i=r.slice(n.start+1,n.end),a=e=>e.trim().match(/^identityfile\s*=?\s*(.+)$/i)?.[1].replace(/^"(.*)"$/,`$1`),s=i.filter(e=>{let n=a(e);return n!==void 0&&(z(n)===t||!/[%$]/.test(n)&&!o.existsSync(z(n)))}),c=`${i.find(e=>e.trim())?.match(/^\s*/)?.[0]||` `}IdentityFile ${t}`,l=i.filter(e=>!s.includes(e));r.splice(n.start+1,i.length,c,...l);let u=`${n.file}.landfall-bak`;return o.existsSync(u)||o.copyFileSync(n.file,u),o.writeFileSync(n.file,r.join(`
364
+ `)),wt.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function Ft(e,t=Ct){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=kt(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const It=(e,t=Ct)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
365
+ `).some(t=>At(t,e)),Lt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Rt=String.raw`
358
366
  set +e
359
367
  kv() { printf '%s=%s\n' "$1" "$2"; }
360
368
  PRETTY_NAME=unknown; ID=; VERSION_ID=; ID_LIKE=; . /etc/os-release 2>/dev/null
@@ -376,10 +384,10 @@ kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\
376
384
  kv ip6 "$(ip -6 route get 2606:4700:4700::1111 2>/dev/null | sed -n 's/.* src \([0-9a-fA-F:]*\).*/\1/p' | head -n1)"
377
385
  kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
378
386
  exit 0
379
- `;function Mt(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
380
- `)){let e=t.indexOf(`=`);e>0&&(n[t.slice(0,e)]=t.slice(e+1).trim())}let r=(e=``)=>e.split(/\s+/).map(e=>Number(e)*1024),[i=0,a=0]=r(n.mem),[o=0,s=0,c=0]=r(n.disk);return{os:n.os||`unknown`,id:n.id??``,version:n.version??``,like:n.like??``,arch:n.arch??``,kernel:n.kernel??``,uptime:Math.round(Number(n.uptime)||0),cpus:Number(n.cpus)||0,load:n.load??``,mem:{total:i||0,available:a||0},disk:{total:o||0,used:s||0,available:c||0},pkg:n.pkg||`none`,docker:n.docker??``,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:Ft(n.id??``,n.version??``,n.pkg||`none`,t)}}async function Nt(e){let{stdout:t}=await Y(e,q+jt,{capture:!0});return Mt(t)}const Pt={"ubuntu 18.04":`2023-05-31`,"ubuntu 20.04":`2025-05-31`,"ubuntu 22.04":`2027-06-01`,"ubuntu 23.10":`2024-07-11`,"ubuntu 24.04":`2029-05-31`,"ubuntu 24.10":`2025-07-10`,"ubuntu 25.04":`2026-01-15`,"ubuntu 25.10":`2026-07-09`,"ubuntu 26.04":`2031-05-31`,"debian 9":`2022-06-30`,"debian 10":`2024-06-30`,"debian 11":`2026-08-31`,"debian 12":`2028-06-30`,"debian 13":`2030-06-30`,"centos 7":`2024-06-30`,"centos 8":`2021-12-31`,"rhel 8":`2029-05-31`,"rhel 9":`2032-05-31`,"rocky 8":`2029-05-31`,"rocky 9":`2032-05-31`,"almalinux 8":`2029-05-31`,"almalinux 9":`2032-05-31`,"alpine 3.17":`2024-11-22`,"alpine 3.18":`2025-05-09`,"alpine 3.19":`2025-11-01`,"alpine 3.20":`2026-04-01`,"alpine 3.21":`2026-11-01`,"alpine 3.22":`2027-05-01`};function Ft(e,t,n,r=/* @__PURE__ */ new Date){if(n===`none`)return{level:`unsupported`,note:`no apt, dnf, yum, apk or zypper: setup cannot install packages`};let i=t.split(`.`)[0],a=t.split(`.`).slice(0,2).join(`.`),o=Pt[`${e} ${t}`]??Pt[`${e} ${a}`]??Pt[`${e} ${i}`];if(!o)return{level:`ok`};let s=(Date.parse(o)-r.getTime())/864e5;return s<0?{level:`eol`,note:`${e} ${t} reached end of life on ${o}: no more security updates, upgrade the release`}:s<90?{level:`ok`,note:`${e} ${t} reaches end of life on ${o}`}:{level:`ok`}}const It=/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/,Lt=e=>g(e)===4&&!It.test(e);async function Rt(e,t){let n=Z(e).hostname,r=g(n)?[n]:await te.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const zt=e=>e.find(Lt)??e[0],Bt=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function Vt(e,t){return Promise.all(e.map(async e=>{let n=await te.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const Ht=e=>{let t=[`B`,`K`,`M`,`G`,`T`],n=0;for(;e>=1024&&n<t.length-1;)e/=1024,n++;return`${e<10&&n?e.toFixed(1):Math.round(e)}${t[n]}`},Ut=e=>{let t=Math.floor(e/86400),n=Math.floor(e%86400/3600);return t?`${t}d ${n}h`:n?`${n}h ${Math.floor(e%3600/60)}m`:`${Math.floor(e/60)}m`},Wt=e=>e.v!==2,Gt=e=>e.project??e.name,Kt=e=>Wt(e)?void 0:e.releases.find(t=>t.id===e.current),qt=e=>!!(e.port||e.own),Jt=e=>!!e.port&&!e.domains&&!e.own,Yt=e=>e?.services.find(Jt),Xt=e=>e.adopted?void 0:Yt(Kt(e)),Zt=e=>`landfall-${e}`,Qt=e=>e.adopted?`adopted`:Wt(e)?`old`:`managed`,$t=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[Qt(e)],en=e=>/^up/i.test(e),tn=e=>/^exited \(0\)/i.test(e);function nn(e){let t=Kt(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...Yt(t)?e.domains:[],...n])]}const rn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,an=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],on=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],sn=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),cn=/^traefik\.http\.routers\.[^.]+\.rule$/,ln=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function un(e,t,n=e=>e){let r=[],i=[],a=[];for(let[o,s]of Object.entries(e?.services??{})){let e=s[`x-landfall`]??{},c=Array.isArray(s.labels)?Object.fromEntries(s.labels.map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)])):s.labels??{},l={name:o,networks:s.network_mode?[]:Object.keys(s.networks??{default:null})};s.build&&(l.build=!0);let u=Object.entries(c).filter(([e])=>cn.test(e));if(typeof e!=`object`||Array.isArray(e))i.push(`${o}: x-landfall must be a mapping, like x-landfall: { port: 3000 }`);else if(u.length)l.own=!0,l.domains=[...new Set(u.flatMap(([,e])=>sn(e)))],Object.keys(e).some(e=>e!==`publish`)&&a.push(`${o} has its own traefik router labels: its x-landfall routing is ignored`);else if(s[`x-landfall`]!==void 0){let t=Object.keys(e).filter(e=>![`port`,`domains`,`publish`].includes(e));t.length&&a.push(`${o}: x-landfall knows port, domains and publish, not ${t.join(`, `)}`);let r=(s.expose??[]).map(String),c=e.port??(r.length===1?r[0]:void 0),u=Number(c);if(c!==void 0&&Number.isInteger(u)&&u>=1&&u<=65535?l.port=u:e.port===void 0?(e.domains||!e.publish)&&i.push(`${o}: x-landfall needs the port Traefik should send requests to, like x-landfall: { port: 3000 }`):i.push(`${o}: x-landfall.port must be a port number, got ${JSON.stringify(e.port)}`),e.domains!==void 0){let t=(Array.isArray(e.domains)?e.domains:[e.domains]).map(e=>n(String(e).trim())),r=t.filter(e=>!V.test(e));r.length&&i.push(`${o}: not a domain in x-landfall.domains: ${r.join(`, `)}`),t.length||i.push(`${o}: x-landfall.domains is empty; leave it out to serve the site's domains`),l.domains=t}}if(s.network_mode&&(qt(l)?i.push(`${o}: a routed service cannot use network_mode: ${s.network_mode}; Traefik reaches it over a network`):s.network_mode===`host`&&a.push(`${o} uses network_mode: host: it binds ports on the server itself`)),s.ports?.length&&!e.publish){let e=s.ports.map(e=>e.published?`${e.host_ip?e.host_ip+`:`:``}${e.published}:${e.target}`:String(e.target));i.push(`${o} publishes ${e.join(`, `)} on the server, past Traefik and the firewall. Move ports: to compose.override.yaml (local only)${l.port||l.own?``:` and route it with x-landfall: { port: ${s.ports[0].target} }`}, or allow it with x-landfall: { publish: true }`)}for(let e of s.volumes??[]){let n=String(e.source??``);if(e.type===`bind`&&!e.read_only&&(n===t||n.startsWith(t+`/`))){let r=n===t?`.`:`./`+n.slice(t.length+1);i.push(`${o} writes to ${r}, which stays behind in the release on the next deploy: use a named volume (e.g. ${o}-data:${e.target}), or mount it read-only (:ro)`)}}s.container_name&&a.push(`${o} sets container_name ${s.container_name}: names are global on the server, leave it out unless something needs it`),r.push(l)}r.some(qt)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(Jt);o.length>1&&i.push(`${o.map(e=>e.name).join(` and `)} both serve the site's domains: give all but one their own x-landfall.domains`);let s=/* @__PURE__ */ new Map;for(let e of r)for(let t of e.domains??[])s.has(t)&&i.push(`${t} is routed to both ${s.get(t)} and ${e.name}`),s.set(t,e.name);return{services:r,errors:i,warnings:a}}const dn=Ye,fn=`landfall-proxy`;function pn(e,t,n,r,i=[]){let a=`${e}-${t.name.toLowerCase()}`,o={"traefik.enable":`true`,"traefik.docker.network":r.network,[`traefik.http.routers.${a}.rule`]:n.map(e=>`Host(\`${e}\`)`).join(` || `),[`traefik.http.routers.${a}.entrypoints`]:r.entrypoint,[`traefik.http.routers.${a}.tls.certresolver`]:r.resolver,[`traefik.http.routers.${a}.service`]:a,[`traefik.http.services.${a}.loadbalancer.server.port`]:String(t.port)};return i.length&&(o[`traefik.http.middlewares.${e}-auth.basicauth.users`]=i.join(`,`),o[`traefik.http.routers.${a}.middlewares`]=`${e}-auth`),o}function mn(e,t,n=Be){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${dn(Gt(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${dn(a.name)}:`),a.build&&r.push(` image: ${dn(rn(e.name,a.name,t.id))}`);let o={};qt(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${dn(e)}: {}`),` ${fn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=pn(e.name,a,a.domains??e.domains,n,e.auth))),o[`landfall.managed`]=`true`,o[`landfall.site`]=e.name,r.push(` labels:`,...Object.entries(o).map(([e,t])=>` ${dn(e)}: ${dn(Je(t))}`))}return i&&r.push(`networks:`,` ${fn}:`,` name: ${dn(n.network)}`,` external: true`),r.join(`
387
+ `;function zt(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
388
+ `)){let e=t.indexOf(`=`);e>0&&(n[t.slice(0,e)]=t.slice(e+1).trim())}let r=(e=``)=>e.split(/\s+/).map(e=>Number(e)*1024),[i=0,a=0]=r(n.mem),[o=0,s=0,c=0]=r(n.disk);return{os:n.os||`unknown`,id:n.id??``,version:n.version??``,like:n.like??``,arch:n.arch??``,kernel:n.kernel??``,uptime:Math.round(Number(n.uptime)||0),cpus:Number(n.cpus)||0,load:n.load??``,mem:{total:i||0,available:a||0},disk:{total:o||0,used:s||0,available:c||0},pkg:n.pkg||`none`,docker:n.docker??``,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:Ht(n.id??``,n.version??``,n.pkg||`none`,t)}}async function Bt(e){let{stdout:t}=await X(e,J+Rt,{capture:!0});return zt(t)}const Vt={"ubuntu 18.04":`2023-05-31`,"ubuntu 20.04":`2025-05-31`,"ubuntu 22.04":`2027-06-01`,"ubuntu 23.10":`2024-07-11`,"ubuntu 24.04":`2029-05-31`,"ubuntu 24.10":`2025-07-10`,"ubuntu 25.04":`2026-01-15`,"ubuntu 25.10":`2026-07-09`,"ubuntu 26.04":`2031-05-31`,"debian 9":`2022-06-30`,"debian 10":`2024-06-30`,"debian 11":`2026-08-31`,"debian 12":`2028-06-30`,"debian 13":`2030-06-30`,"centos 7":`2024-06-30`,"centos 8":`2021-12-31`,"rhel 8":`2029-05-31`,"rhel 9":`2032-05-31`,"rocky 8":`2029-05-31`,"rocky 9":`2032-05-31`,"almalinux 8":`2029-05-31`,"almalinux 9":`2032-05-31`,"alpine 3.17":`2024-11-22`,"alpine 3.18":`2025-05-09`,"alpine 3.19":`2025-11-01`,"alpine 3.20":`2026-04-01`,"alpine 3.21":`2026-11-01`,"alpine 3.22":`2027-05-01`};function Ht(e,t,n,r=/* @__PURE__ */ new Date){if(n===`none`)return{level:`unsupported`,note:`no apt, dnf, yum, apk or zypper: setup cannot install packages`};let i=t.split(`.`)[0],a=t.split(`.`).slice(0,2).join(`.`),o=Vt[`${e} ${t}`]??Vt[`${e} ${a}`]??Vt[`${e} ${i}`];if(!o)return{level:`ok`};let s=(Date.parse(o)-r.getTime())/864e5;return s<0?{level:`eol`,note:`${e} ${t} reached end of life on ${o}: no more security updates, upgrade the release`}:s<90?{level:`ok`,note:`${e} ${t} reaches end of life on ${o}`}:{level:`ok`}}const Ut=/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/,Wt=e=>_(e)===4&&!Ut.test(e);async function Gt(e,t){let n=Q(e).hostname,r=_(n)?[n]:await h.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const Kt=e=>e.find(Wt)??e[0],qt=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function Jt(e,t){return Promise.all(e.map(async e=>{let n=await h.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const Yt=e=>{let t=[`B`,`K`,`M`,`G`,`T`],n=0;for(;e>=1024&&n<t.length-1;)e/=1024,n++;return`${e<10&&n?e.toFixed(1):Math.round(e)}${t[n]}`},Xt=e=>{let t=Math.floor(e/86400),n=Math.floor(e%86400/3600);return t?`${t}d ${n}h`:n?`${n}h ${Math.floor(e%3600/60)}m`:`${Math.floor(e/60)}m`},Zt=e=>e.v!==2,Qt=e=>e.project??e.name,$t=e=>Zt(e)?void 0:e.releases.find(t=>t.id===e.current),en=e=>!!(e.port||e.own),tn=e=>!!e.port&&!e.domains&&!e.own,nn=e=>e?.services.find(tn),rn=e=>e.adopted?void 0:nn($t(e)),an=e=>`landfall-${e}`,on=e=>e.adopted?`adopted`:Zt(e)?`old`:`managed`,sn=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[on(e)],cn=e=>/^up/i.test(e),ln=e=>/^exited \(0\)/i.test(e);function un(e){let t=$t(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...nn(t)?e.domains:[],...n])]}const dn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,fn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],pn=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],mn=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),hn=/^traefik\.http\.routers\.[^.]+\.rule$/,gn=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function _n(e,t,n=e=>e){let r=[],i=[],a=[];for(let[o,s]of Object.entries(e?.services??{})){let e=s[`x-landfall`]??{},c=Array.isArray(s.labels)?Object.fromEntries(s.labels.map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)])):s.labels??{},l={name:o,networks:s.network_mode?[]:Object.keys(s.networks??{default:null})};s.build&&(l.build=!0);let u=Object.entries(c).filter(([e])=>hn.test(e));if(typeof e!=`object`||Array.isArray(e))i.push(`${o}: x-landfall must be a mapping, like x-landfall: { port: 3000 }`);else if(u.length)l.own=!0,l.domains=[...new Set(u.flatMap(([,e])=>mn(e)))],Object.keys(e).some(e=>e!==`publish`)&&a.push(`${o} has its own traefik router labels: its x-landfall routing is ignored`);else if(s[`x-landfall`]!==void 0){let t=Object.keys(e).filter(e=>![`port`,`domains`,`publish`].includes(e));t.length&&a.push(`${o}: x-landfall knows port, domains and publish, not ${t.join(`, `)}`);let r=(s.expose??[]).map(String),c=e.port??(r.length===1?r[0]:void 0),u=Number(c);if(c!==void 0&&Number.isInteger(u)&&u>=1&&u<=65535?l.port=u:e.port===void 0?(e.domains||!e.publish)&&i.push(`${o}: x-landfall needs the port Traefik should send requests to, like x-landfall: { port: 3000 }`):i.push(`${o}: x-landfall.port must be a port number, got ${JSON.stringify(e.port)}`),e.domains!==void 0){let t=(Array.isArray(e.domains)?e.domains:[e.domains]).map(e=>n(String(e).trim())),r=t.filter(e=>!we.test(e));r.length&&i.push(`${o}: not a domain in x-landfall.domains: ${r.join(`, `)}`),t.length||i.push(`${o}: x-landfall.domains is empty; leave it out to serve the site's domains`),l.domains=t}}if(s.network_mode&&(en(l)?i.push(`${o}: a routed service cannot use network_mode: ${s.network_mode}; Traefik reaches it over a network`):s.network_mode===`host`&&a.push(`${o} uses network_mode: host: it binds ports on the server itself`)),s.ports?.length&&!e.publish){let e=s.ports.map(e=>e.published?`${e.host_ip?e.host_ip+`:`:``}${e.published}:${e.target}`:String(e.target));i.push(`${o} publishes ${e.join(`, `)} on the server, past Traefik and the firewall. Move ports: to compose.override.yaml (local only)${l.port||l.own?``:` and route it with x-landfall: { port: ${s.ports[0].target} }`}, or allow it with x-landfall: { publish: true }`)}for(let e of s.volumes??[]){let n=String(e.source??``);if(e.type===`bind`&&!e.read_only&&(n===t||n.startsWith(t+`/`))){let r=n===t?`.`:`./`+n.slice(t.length+1);i.push(`${o} writes to ${r}, which stays behind in the release on the next deploy: use a named volume (e.g. ${o}-data:${e.target}), or mount it read-only (:ro)`)}}s.container_name&&a.push(`${o} sets container_name ${s.container_name}: names are global on the server, leave it out unless something needs it`),r.push(l)}r.some(en)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(tn);o.length>1&&i.push(`${o.map(e=>e.name).join(` and `)} both serve the site's domains: give all but one their own x-landfall.domains`);let s=/* @__PURE__ */ new Map;for(let e of r)for(let t of e.domains??[])s.has(t)&&i.push(`${t} is routed to both ${s.get(t)} and ${e.name}`),s.set(t,e.name);return{services:r,errors:i,warnings:a}}const vn=nt,yn=`landfall-proxy`;function bn(e,t,n,r,i=[]){let a=`${e}_${t.name.toLowerCase().replace(/\./g,`-`)}`,o={"traefik.enable":`true`,"traefik.docker.network":r.network,[`traefik.http.routers.${a}.rule`]:n.map(e=>`Host(\`${e}\`)`).join(` || `),[`traefik.http.routers.${a}.entrypoints`]:r.entrypoint,[`traefik.http.routers.${a}.tls.certresolver`]:r.resolver,[`traefik.http.routers.${a}.service`]:a,[`traefik.http.services.${a}.loadbalancer.server.port`]:String(t.port)};return i.length&&(o[`traefik.http.middlewares.${e}-auth.basicauth.users`]=i.join(`,`),o[`traefik.http.routers.${a}.middlewares`]=`${e}-auth`),o}function xn(e,t,n=qe){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${vn(Qt(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${vn(a.name)}:`),a.build&&r.push(` image: ${vn(dn(e.name,a.name,t.id))}`);let o={};en(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${vn(e)}: {}`),` ${yn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=bn(e.name,a,a.domains??e.domains,n,e.auth))),o[`landfall.managed`]=`true`,o[`landfall.site`]=e.name,r.push(` labels:`,...Object.entries(o).map(([e,t])=>` ${vn(e)}: ${vn(tt(t))}`))}return i&&r.push(`networks:`,` ${yn}:`,` name: ${vn(n.network)}`,` external: true`),r.join(`
381
389
  `)+`
382
- `}function hn(e,t={}){let n=`# generated by landfall for a ${e===`static`?`static folder`:e===`image`?`prebuilt image`:`Dockerfile`}; \`lf init\` writes one you can edit into your project\nservices:\n`;return e===`static`?`${n} web:
390
+ `}function Sn(e,t={}){let n=`# generated by landfall for a ${e===`static`?`static folder`:e===`image`?`prebuilt image`:`Dockerfile`}; \`lf init\` writes one you can edit into your project\nservices:\n`;return e===`static`?`${n} web:
383
391
  image: nginx:alpine
384
392
  restart: unless-stopped
385
393
  volumes:
@@ -388,14 +396,14 @@ exit 0
388
396
  x-landfall:
389
397
  port: 80
390
398
  `:`${n} app:
391
- ${e===`image`?`image: ${dn(t.image)}`:`build: .`}
399
+ ${e===`image`?`image: ${vn(t.image)}`:`build: .`}
392
400
  restart: unless-stopped
393
401
  env_file:
394
402
  - path: .env
395
403
  required: false
396
404
  x-landfall:
397
405
  port: ${t.port??3e3}
398
- `}function gn(e){return`# generated by landfall
406
+ `}function Cn(e){return`# generated by landfall
399
407
  server {
400
408
  listen 80;
401
409
  server_name _;
@@ -419,33 +427,34 @@ server {
419
427
  error_page 404 /404.html;
420
428
  location = /404.html { internal; }
421
429
  }
422
- `}const _n=[`dist`,`build`,`out`,`public`,`_site`,`site`],vn=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``);function yn(e){let t=t=>o.existsSync(m(e,t)),n=t=>{try{return o.readFileSync(m(e,t),`utf8`)}catch{return``}},r=e=>{for(let r of e)if(t(r))return n(r);return``},i=e=>[`ts`,`mts`,`js`,`mjs`,`cjs`].map(t=>`${e}.${t}`),a={};try{a=JSON.parse(n(`package.json`)||`{}`)}catch{}if(a.scripts?.build){let e=t(`bun.lock`)||t(`bun.lockb`)?`bun run build`:t(`pnpm-lock.yaml`)?`pnpm build`:t(`yarn.lock`)?`yarn build`:`npm run build`,o=e=>e in{...a.dependencies,...a.devDependencies},s=(t,n)=>({tool:t,dir:n,build:e});if(o(`next`))return s(`Next.js (output: 'export')`,vn(r(i(`next.config`)),`distDir`)??`out`);if(o(`nuxt`))return s(`Nuxt (nuxt generate)`,`.output/public`);if(o(`astro`))return s(`Astro`,vn(r(i(`astro.config`)),`outDir`)??`dist`);if(o(`@sveltejs/kit`))return s(`SvelteKit (adapter-static)`,vn(r(i(`svelte.config`)),`pages`)??`build`);if(o(`@angular/core`)){let e=`dist`;try{let t=Object.values(JSON.parse(n(`angular.json`)).projects)[0]?.architect?.build,r=t?.options?.outputPath;typeof r==`string`?e=/:application$/.test(t.builder??``)?`${r}/browser`:r:r?.base&&(e=r.base)}catch{}return s(`Angular`,e)}return o(`gatsby`)?s(`Gatsby`,`public`):o(`@docusaurus/core`)?s(`Docusaurus`,`build`):o(`@11ty/eleventy`)?s(`Eleventy`,vn(r([`.eleventy.js`,`eleventy.config.js`,`eleventy.config.mjs`,`eleventy.config.cjs`]),`output`)??`_site`):o(`react-scripts`)?s(`Create React App`,`build`):o(`@vue/cli-service`)?s(`Vue CLI`,vn(n(`vue.config.js`),`outputDir`)??`dist`):o(`vite`)?s(`Vite`,vn(r(i(`vite.config`)),`outDir`)??`dist`):o(`parcel`)?s(`Parcel`,`dist`):{build:e}}return[`hugo.toml`,`hugo.yaml`,`hugo.json`].some(t)?{tool:`Hugo`,dir:`public`,build:`hugo --minify`}:t(`_config.yml`)&&n(`Gemfile`).includes(`jekyll`)?{tool:`Jekyll`,dir:`_site`,build:`bundle exec jekyll build`}:t(`mkdocs.yml`)?{tool:`MkDocs`,dir:vn(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function bn(e,t=yn(e)){return t.dir?t.dir:o.existsSync(m(e,`index.html`))?`.`:_n.find(t=>o.existsSync(m(e,t,`index.html`)))??`.`}function xn(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const Sn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function Cn(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return an.find(t=>o.existsSync(m(e,t)))}function wn(e){let t=Ne(e),n=t?.name??Sn(f(e)),r=Cn(e,t);if(r)return{path:e,name:n,kind:`compose`,compose:r,project:t};if(t?.image&&!t.static)return{path:e,name:n,kind:`image`,port:t.port,project:t};let i=t=>o.existsSync(m(e,t));if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??xn(m(e,`Dockerfile`))??3e3,project:t};let a=yn(e),s=t?.dir??bn(e,a);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:t?.build??a.build,tool:!t?.dir||t.dir===a.dir?a.tool:void 0,project:t}}const Tn=m(ge,`projects.json`);function En(){try{return JSON.parse(o.readFileSync(Tn,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function Dn(e){let t=ee(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...En().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(ge,{recursive:!0,mode:448}),o.writeFileSync(Tn,JSON.stringify(n,null,2)+`
423
- `,{mode:384})}function On(e,t,n){let r=En();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=Ne(i.path);e?.name===t&&Pe(i.path,{...e,domains:n})}r.length&&o.writeFileSync(Tn,JSON.stringify(r,null,2)+`
424
- `,{mode:384})}const Q=e=>`${U}/${e}`;function kn(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||D(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const An=`
430
+ `}const wn=[`dist`,`build`,`out`,`public`,`_site`,`site`],Tn=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``);function En(e){let t=t=>o.existsSync(m(e,t)),n=t=>{try{return o.readFileSync(m(e,t),`utf8`)}catch{return``}},r=e=>{for(let r of e)if(t(r))return n(r);return``},i=e=>[`ts`,`mts`,`js`,`mjs`,`cjs`].map(t=>`${e}.${t}`),a={};try{a=JSON.parse(n(`package.json`)||`{}`)}catch{}if(a.scripts?.build){let e=t(`bun.lock`)||t(`bun.lockb`)?`bun run build`:t(`pnpm-lock.yaml`)?`pnpm build`:t(`yarn.lock`)?`yarn build`:`npm run build`,o=e=>e in{...a.dependencies,...a.devDependencies},s=(t,n)=>({tool:t,dir:n,build:e});if(o(`next`))return s(`Next.js (output: 'export')`,Tn(r(i(`next.config`)),`distDir`)??`out`);if(o(`nuxt`))return s(`Nuxt (nuxt generate)`,`.output/public`);if(o(`astro`))return s(`Astro`,Tn(r(i(`astro.config`)),`outDir`)??`dist`);if(o(`@sveltejs/kit`))return s(`SvelteKit (adapter-static)`,Tn(r(i(`svelte.config`)),`pages`)??`build`);if(o(`@angular/core`)){let e=`dist`;try{let t=Object.values(JSON.parse(n(`angular.json`)).projects)[0]?.architect?.build,r=t?.options?.outputPath;typeof r==`string`?e=/:application$/.test(t.builder??``)?`${r}/browser`:r:r?.base&&(e=r.base)}catch{}return s(`Angular`,e)}return o(`gatsby`)?s(`Gatsby`,`public`):o(`@docusaurus/core`)?s(`Docusaurus`,`build`):o(`@11ty/eleventy`)?s(`Eleventy`,Tn(r([`.eleventy.js`,`eleventy.config.js`,`eleventy.config.mjs`,`eleventy.config.cjs`]),`output`)??`_site`):o(`react-scripts`)?s(`Create React App`,`build`):o(`@vue/cli-service`)?s(`Vue CLI`,Tn(n(`vue.config.js`),`outputDir`)??`dist`):o(`vite`)?s(`Vite`,Tn(r(i(`vite.config`)),`outDir`)??`dist`):o(`parcel`)?s(`Parcel`,`dist`):{build:e}}return[`hugo.toml`,`hugo.yaml`,`hugo.json`].some(t)?{tool:`Hugo`,dir:`public`,build:`hugo --minify`}:t(`_config.yml`)&&n(`Gemfile`).includes(`jekyll`)?{tool:`Jekyll`,dir:`_site`,build:`bundle exec jekyll build`}:t(`mkdocs.yml`)?{tool:`MkDocs`,dir:Tn(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function Dn(e,t=En(e)){return t.dir?t.dir:o.existsSync(m(e,`index.html`))?`.`:wn.find(t=>o.existsSync(m(e,t,`index.html`)))??`.`}function On(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const kn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function An(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return fn.find(t=>o.existsSync(m(e,t)))}function jn(e){let t=Be(e),n=t?.name??kn(f(e)),r=An(e,t);if(r)return{path:e,name:n,kind:`compose`,compose:r,project:t};if(t?.image&&!t.static)return{path:e,name:n,kind:`image`,port:t.port,project:t};let i=t=>o.existsSync(m(e,t));if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??On(m(e,`Dockerfile`))??3e3,project:t};let a=En(e),s=t?.dir??Dn(e,a);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:t?.build??a.build,tool:!t?.dir||t.dir===a.dir?a.tool:void 0,project:t}}const Mn=m(ve,`projects.json`);function Nn(){try{return JSON.parse(o.readFileSync(Mn,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function Pn(e){let t=ne(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...Nn().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(ve,{recursive:!0,mode:448}),o.writeFileSync(Mn,JSON.stringify(n,null,2)+`
431
+ `,{mode:384})}function Fn(e,t,n){let r=Nn();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=Be(i.path);e?.name===t&&Ve(i.path,{...e,domains:n})}r.length&&o.writeFileSync(Mn,JSON.stringify(r,null,2)+`
432
+ `,{mode:384})}const $=e=>`${W}/${e}`;function In(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||k(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const Ln=`
425
433
  --8<--landfall--8<--
426
- `;async function jn(e,t){let n=Q(t),{stdout:r}=await Y(e,[...[`${n}/site.json`,`${n}/.env`,G].map(e=>`cat ${J(e)} 2>/dev/null || true`),`[ ! -d ${J(n)} ] || echo 1`].join(`; printf %s ${J(An)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(An),c;if(i.trim())try{c=JSON.parse(i)}catch{D(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:He(o)??Be,exists:s.trim()===`1`}}async function Mn(e,t,n={}){let r=await jn(e,t);r.meta||D(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return Wt(i)&&D(`${t} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`),n.deployed&&i.adopted&&D(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const Nn=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),Pn=(e,t,n)=>st(e,[`docker logs --tail ${J(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]),Fn=e=>[...new Set([...e.matchAll(/(?:^|[\s>])(\d+)\/tcp/g)].map(e=>Number(e[1])))].sort((e,t)=>e-t);function In(e){return Ln(e).flatMap(e=>e.hosts)}function Ln(e){return Object.entries(e).map(([e,t])=>[e.match(/^traefik\.http\.routers\.([^.]+)\.service$/)?.[1],t]).filter(([e,t])=>e&&t===`api@internal`).map(([t])=>({router:t,hosts:sn(e[`traefik.http.routers.${t}.rule`]??``)}))}const Rn=e=>e[`traefik.http.routers.${Le}.service`]===`api@internal`;function zn(e,t,n,r=/* @__PURE__ */ new Set){let i=/* @__PURE__ */ new Map;for(let{name:a,labels:o}of e){if(a===n||o[`landfall.managed`]||o[`traefik.enable`]===`false`)continue;let e=o[`com.docker.compose.project`];if(e&&r.has(e))continue;let s=Object.entries(o).filter(([e])=>cn.test(e)).flatMap(([,e])=>sn(e));if(!s.length)continue;let c=e||a,l=i.get(c)??{name:c,domains:[],status:``,containers:[],dir:o[`com.docker.compose.project.working_dir`],files:o[`com.docker.compose.project.config_files`]};l.domains=[.../* @__PURE__ */ new Set([...l.domains,...s])],l.containers.push(a);let u=t.get(a)??`not running`;l.status&&en(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function Bn(e,t){if(!t.length)return`not running`;let n=Kt(e),r=new Set(n?.services.filter(qt).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!en(e.status))??t.find(e=>!r.has(e.service)&&!en(e.status)&&!tn(e.status))??i[0]??t[0]).status}const Vn=e=>Wt(e)?Zt(e.name):Gt(e);async function Hn(e){let{stdout:t}=await Y(e,`printf 'READY\\t%s\\n' "$([ -d ${U} ] && command -v docker >/dev/null 2>&1 && echo 1)"
427
- P=${Ve}
428
- printf 'TRAEFIK\\t%s\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P" "$([ -f ${G} ] && echo 1)"
429
- for f in ${U}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
434
+ `;async function Rn(e,t){let n=$(t),{stdout:r}=await X(e,[...[`${n}/site.json`,`${n}/.env`,K].map(e=>`cat ${Y(e)} 2>/dev/null || true`),`[ ! -d ${Y(n)} ] || echo 1`].join(`; printf %s ${Y(Ln)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(Ln),c;if(i.trim())try{c=JSON.parse(i)}catch{k(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:Ye(o)??qe,exists:s.trim()===`1`}}async function zn(e,t,n={}){let r=await Rn(e,t);r.meta||k(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return Zt(i)&&k(`${t} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`),n.deployed&&i.adopted&&k(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const Bn=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),Vn=(e,t,n)=>mt(e,[`docker logs --tail ${Y(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]),Hn=e=>[...new Set([...e.matchAll(/(?:^|[\s>])(\d+)\/tcp/g)].map(e=>Number(e[1])))].sort((e,t)=>e-t);function Un(e){return Wn(e).flatMap(e=>e.hosts)}function Wn(e){return Object.entries(e).map(([e,t])=>[e.match(/^traefik\.http\.routers\.([^.]+)\.service$/)?.[1],t]).filter(([e,t])=>e&&t===`api@internal`).map(([t])=>({router:t,hosts:mn(e[`traefik.http.routers.${t}.rule`]??``)}))}const Gn=e=>e[`traefik.http.routers.${We}.service`]===`api@internal`;function Kn(e,t,n,r=/* @__PURE__ */ new Set){let i=/* @__PURE__ */ new Map;for(let{name:a,labels:o}of e){if(a===n||o[`landfall.managed`]||o[`traefik.enable`]===`false`)continue;let e=o[`com.docker.compose.project`];if(e&&r.has(e))continue;let s=Object.entries(o).filter(([e])=>hn.test(e)).flatMap(([,e])=>mn(e));if(!s.length)continue;let c=e||a,l=i.get(c)??{name:c,domains:[],status:``,containers:[],dir:o[`com.docker.compose.project.working_dir`],files:o[`com.docker.compose.project.config_files`]};l.domains=[.../* @__PURE__ */ new Set([...l.domains,...s])],l.containers.push(a);let u=t.get(a)??`not running`;l.status&&cn(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function qn(e,t){if(!t.length)return`not running`;let n=$t(e),r=new Set(n?.services.filter(en).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!cn(e.status))??t.find(e=>!r.has(e.service)&&!cn(e.status)&&!ln(e.status))??i[0]??t[0]).status}const Jn=e=>Zt(e)?an(e.name):Qt(e);async function Yn(e){let{stdout:t}=await X(e,`printf 'READY\\t%s\\n' "$([ -d ${W} ] && command -v docker >/dev/null 2>&1 && echo 1)"
435
+ P=${Je}
436
+ printf 'TRAEFIK\\t%s\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P" "$([ -f ${K} ] && echo 1)"
437
+ for f in ${W}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
430
438
  docker ps -a --format 'CTR\\t{{.Names}}\\t{{.Status}}\\t{{.Label "com.docker.compose.project"}}\\t{{.Label "com.docker.compose.service"}}\\t{{.Image}}\\t{{.Ports}}' 2>/dev/null || true
431
439
  ids=$(docker ps -aq 2>/dev/null || true)
432
440
  # inspect, unlike ps, does not expand \\t: hence {{"\\t"}}.
433
441
  [ -z "$ids" ] || docker inspect -f 'LABELS{{"\\t"}}{{.Name}}{{"\\t"}}{{json .Config.Labels}}' $ids 2>/dev/null || true`,{capture:!0}),n=/* @__PURE__ */ new Map,r=/* @__PURE__ */ new Map,i=[],a=[],o={ready:!1,traefik:``,proxy:`traefik`,adopted:!1,sites:[],others:[],dashboard:{hosts:[],tunnel:!1},unrouted:[]},s=[];for(let c of t.split(`
434
- `)){let[t,l=``,u=``,d=``,f=``,p=``,m=``]=c.split(` `);if(t===`READY`&&(o.ready=l===`1`),t===`TRAEFIK`&&(o.traefik=l,o.proxy=u||`traefik`,o.adopted=d===`1`),t===`SITE`)try{i.push(JSON.parse(c.slice(t.length+1)))}catch{T(`skipped a corrupt site.json on ${e.host}: ${c.slice(5,80)}`)}if(t===`CTR`&&(n.set(l,u),s.push({container:l,status:u,image:p,ports:Fn(m),...d&&{project:d,service:f}}),d&&r.set(d,[...r.get(d)??[],{service:f||l,container:l,status:u}])),t===`LABELS`)try{a.push({name:l.replace(/^\//,``),labels:JSON.parse(u??`null`)??{}})}catch{}}o.others=zn(a,n,o.proxy,new Set(i.map(Vn))),o.dashboard={hosts:[...new Set(a.flatMap(e=>In(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&(Rn(e.labels)||In(e.labels).length>0))};let c=/* @__PURE__ */ new Set([o.proxy,...o.others.flatMap(e=>e.containers)]),l=new Set(i.map(Vn));return o.unrouted=s.filter(e=>en(e.status)&&!c.has(e.container)&&!(e.project&&l.has(e.project))).sort((e,t)=>e.container.localeCompare(t.container)),o.sites=i.map(e=>{let t=(r.get(Vn(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:Bn(e,t),services:t,domains:nn(e),main:Xt(e)?.name,state:Qt(e),kind:$t(e)}}),o}async function Un(e,t,n,r={}){let i=`${Q(t)}/releases/${n}`,a=[...an,...on].filter(e=>e!==`compose.yaml`),{stdout:o}=await Y(e,[q,`v=$(dc version --short 2>/dev/null | sed 's/^v//'); compose_ok "$v" || { echo "sites need docker compose 2.24 or newer, the server has \${v:-none}: run lf update --compose" >&2; exit 1; }`,`mkdir -p ${J(i)} && cd -P ${J(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>X(e,t)),...r.file&&r.file!==`compose.yaml`?[`mv -f ${J(r.file)} compose.yaml`]:[],`rm -f ${a.map(J).join(` `)}`,...r.env===void 0?[]:[X(`.env.next`,r.env,`600`)],`[ -f compose.yaml ] || { echo "no compose.yaml in the upload" >&2; cfg=; false; } && cfg=$(dc ${r.env===void 0?``:`--env-file .env.next `}-f compose.yaml config 2>&1) || {`,` printf '%s\\n' "$cfg" >&2; exit 1`,`}`,`rm -f .env.next`,`pwd -P; printf %s ${J(An)}; printf '%s\\n' "$cfg"`].join(`
435
- `),{capture:!0}),[s,c=``]=o.split(An),l;try{l=_(c)}catch(e){D(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:s.trim(),config:l}}async function Wn(e,t){let n=Q(t.name);await Y(e,[q,`[ -r ${J(t.adopted)} ] && [ -x ${J(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${J(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${J(n)} && cd ${J(n)}`,`ln -s ${J(t.adopted)} current`,X(`site.json`,JSON.stringify(t,null,2)+`
436
- `),`record ${J(`adopted ${t.project} as site ${t.name}`)} ${J(`rm -rf ${n}`)}`].join(`
437
- `))}async function Gn(e,t,n){let r=Q(t);await Y(e,`cd / && rm -rf ${J(`${r}/releases/${n}`)}; [ -f ${J(r+`/site.json`)} ] || { rmdir ${J(r+`/releases`)} 2>/dev/null; rm -f ${J(r+`/.env`)}; rmdir ${J(r)} 2>/dev/null; } || true`)}const Kn=e=>[`log "starting ${e}"`,`(cd -P current && dc up -d --wait --wait-timeout 300 --remove-orphans --quiet-pull) || { log "${e} did not start:"; (cd -P current && dc ps -a >&2; dc logs --tail 30 >&2) || true; exit 1; }`],qn=e=>`docker image rm ${J(e)} >/dev/null 2>&1 || true`;function Jn(e,t,n={}){let r=Q(e.name),i=Kt(e),a=J(`releases/`+e.current),o=[`mkdir -p ${J(r)} && cd ${J(r)}`,X(`site.json`,JSON.stringify(e,null,2)+`
438
- `),n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:X(`.env`,n.env,`600`),`(cd -P ${a} && ${X(`compose.override.yaml`,mn(e,i,t))}) || exit 1`];return n.build&&i.services.some(e=>e.build)&&o.push(`log "building ${e.name}"`,`(cd -P ${a} && dc build -q) || exit 1`),n.pull&&o.push(`log "pulling images of ${e.name}"`,`(cd -P ${a} && dc pull -q --ignore-buildable) || exit 1`),o.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...Kn(e.name)),o}const Yn=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>rn(e.name,n.name,t.id)),Xn=(e,t)=>[`rmf ${J(`${Q(e.name)}/releases/${t.id}`)}`,...Yn(e,t).map(qn)];function Zn(e){let t=Q(e.name),n=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${J(t)}`,`log "removing the old container of ${e.name}"`,`dc -p ${J(Zt(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${J(Zt(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.map(e=>`rm -rf ${J(`releases/`+e.id)}`),...n.map(qn)]}async function Qn(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await $n(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function $n(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=Nn(t),o=a.filter((e,n)=>n<i||e.id===t.current||e.id===r.prev?.current),s=a.filter(e=>!o.includes(e));t.releases=o;let c=Q(t.name),l=[q];if(r.takeover){l.push(`record ${J(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...Jn(t,n,r),...Zn(r.takeover)),await Y(e,l.join(`
439
- `));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${J(c+`/.env`)})`),!r.prev)u=`[ ! -d ${J(c)} ] || {\n${nr(t,{keepDir:r.existed}).join(`
442
+ `)){let[t,l=``,u=``,d=``,f=``,p=``,m=``]=c.split(` `);if(t===`READY`&&(o.ready=l===`1`),t===`TRAEFIK`&&(o.traefik=l,o.proxy=u||`traefik`,o.adopted=d===`1`),t===`SITE`)try{i.push(JSON.parse(c.slice(t.length+1)))}catch{D(`skipped a corrupt site.json on ${e.host}: ${c.slice(5,80)}`)}if(t===`CTR`&&(n.set(l,u),s.push({container:l,status:u,image:p,ports:Hn(m),...d&&{project:d,service:f}}),d&&r.set(d,[...r.get(d)??[],{service:f||l,container:l,status:u}])),t===`LABELS`)try{a.push({name:l.replace(/^\//,``),labels:JSON.parse(u??`null`)??{}})}catch{}}o.others=Kn(a,n,o.proxy,new Set(i.map(Jn))),o.dashboard={hosts:[...new Set(a.flatMap(e=>Un(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&(Gn(e.labels)||Un(e.labels).length>0))};let c=/* @__PURE__ */ new Set([o.proxy,...o.others.flatMap(e=>e.containers)]),l=new Set(i.map(Jn));return o.unrouted=s.filter(e=>cn(e.status)&&!c.has(e.container)&&!(e.project&&l.has(e.project))).sort((e,t)=>e.container.localeCompare(t.container)),o.sites=i.map(e=>{let t=(r.get(Jn(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:qn(e,t),services:t,domains:un(e),main:rn(e)?.name,state:on(e),kind:sn(e)}}),o}async function Xn(e,t,n,r={}){let i=`${$(t)}/releases/${n}`,a=r.file?.replace(/^(\.\/)+/,``);a?.includes(`/`)&&k(`-f takes a compose file in the project folder itself, not ${r.file}`);let o=[...fn,...pn].filter(e=>e!==`compose.yaml`),{stdout:s}=await X(e,[J,`v=$(dc version --short 2>/dev/null | sed 's/^v//'); compose_ok "$v" || { echo "sites need docker compose 2.24 or newer, the server has \${v:-none}: run lf update --compose" >&2; exit 1; }`,`mkdir -p ${Y(i)} && cd -P ${Y(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>Z(e,t)),...a&&a!==`compose.yaml`?[`mv -f ${Y(a)} compose.yaml`]:[],`rm -f ${o.map(Y).join(` `)}`,...r.env===void 0?[]:[Z(`.env.next`,r.env,`600`)],`[ -f compose.yaml ] || { echo "no compose.yaml in the upload" >&2; exit 1; }`,`cfg=$(dc ${r.env===void 0?``:`--env-file .env.next `}-f compose.yaml config 2>.config.err) || { cat .config.err >&2; rm -f .config.err; exit 1; }`,`cat .config.err >&2; rm -f .config.err`,`rm -f .env.next`,`pwd -P; printf %s ${Y(Ln)}; printf '%s\\n' "$cfg"`].join(`
443
+ `),{capture:!0}),[c,l=``]=s.split(Ln),u;try{u=re(l)}catch(e){k(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:c.trim(),config:u}}async function Zn(e,t){let n=$(t.name);await X(e,[J,`[ -r ${Y(t.adopted)} ] && [ -x ${Y(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${Y(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${Y(n)} && cd ${Y(n)}`,`ln -s ${Y(t.adopted)} current`,Z(`site.json`,JSON.stringify(t,null,2)+`
444
+ `),`record ${Y(`adopted ${t.project} as site ${t.name}`)} ${Y(`rm -rf ${n}`)}`].join(`
445
+ `))}async function Qn(e,t,n){let r=$(t);await X(e,`cd / && rm -rf ${Y(`${r}/releases/${n}`)}; [ -f ${Y(r+`/site.json`)} ] || { rmdir ${Y(r+`/releases`)} 2>/dev/null; { [ -s ${Y(r+`/.env`)} ] || rm -f ${Y(r+`/.env`)}; }; rmdir ${Y(r)} 2>/dev/null; } || true`)}const $n=e=>[`log "starting ${e}"`,`(cd -P current && dc up -d --wait --wait-timeout 300 --remove-orphans --quiet-pull) || { log "${e} did not start:"; (cd -P current && dc ps -a >&2; dc logs --tail 30 >&2) || true; exit 1; }`],er=e=>`docker image rm ${Y(e)} >/dev/null 2>&1 || true`;function tr(e,t,n={}){let r=$(e.name),i=$t(e),a=Y(`releases/`+e.current),o=Qt(e),s=[`mkdir -p ${Y(r)} && cd ${Y(r)}`,`here=$(pwd -P)/`,`other=$(docker ps -a --filter ${Y(`label=com.docker.compose.project=${o}`)} --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null | while read -r d; do case "$d/" in ("$here"*) ;; (*) echo "\${d:-a container without a folder}"; break ;; esac; done || true)`,`[ -z "$other" ] || { echo ${Y(`a Compose project named ${o} already runs from`)} "$other:" ${Y(`deploying would replace its containers. Pick another site name, or make it a site with lf adopt ${o}`)} >&2; exit 1; }`,n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:Z(`.env`,n.env,`600`),`(cd -P ${a} && ${Z(`compose.override.yaml`,xn(e,i,t))}) || exit 1`];return n.build&&i.services.some(e=>e.build)&&s.push(`log "building ${e.name}"`,`(cd -P ${a} && dc build -q) || exit 1`),n.pull&&s.push(`log "pulling images of ${e.name}"`,`(cd -P ${a} && dc pull -q --ignore-buildable) || exit 1`),s.push(Z(`site.json`,JSON.stringify(e,null,2)+`
446
+ `)),s.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...$n(e.name)),s}const nr=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>dn(e.name,n.name,t.id)),rr=(e,t)=>[`rmf ${Y(`${$(e.name)}/releases/${t.id}`)}`,...nr(e,t).map(er)];function ir(e,t){let n=$(e.name),r=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${Y(n)}`,`log "removing the old container of ${e.name}"`,`dc -p ${Y(an(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${Y(an(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.filter(e=>e.id!==t).map(e=>`rm -rf ${Y(`releases/`+e.id)}`),...r.map(er)]}async function ar(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await or(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function or(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=Bn(t),o=a.filter((e,n)=>n<i||e.id===t.current||e.id===r.prev?.current),s=a.filter(e=>!o.includes(e));t.releases=o;let c=$(t.name),l=[J];if(r.takeover){l.push(`record ${Y(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...ir(r.takeover,t.current),...tr(t,n,r)),await X(e,l.join(`
447
+ `));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${Y(c+`/.env`)})`),!r.prev)u=`[ ! -d ${Y(c)} ] || {\n${lr(t,{keepDir:r.existed}).join(`
440
448
  `)}\n`,d=`
441
- }`;else{let e=r.prev,i={...e,releases:e.releases.filter(e=>o.some(t=>t.id===e.id))},a=o.filter(t=>!e.releases.some(e=>e.id===t.id));u=`[ -d ${J(c)} ] || { log ${J(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
442
- `+[...Jn(i,n),...a.flatMap(e=>Xn(t,e))].join(`
443
- `)}l.push(`record ${J(r.what)} ${J(u)}${f?`"$u"`:``}${J(d)}`),l.push(...Jn(t,n,r));for(let e of s)l.push(...Xn(t,e));await Y(e,l.join(`
444
- `))}async function er(e,t,n,r=``){await Y(e,`${q}\ncd ${J(Q(t))}\n${r}\ncd -P current && dc ${n}`)}const tr=(e,t,n)=>st(e,[`cd -P ${J(Q(t)+`/current`)} && docker compose logs --tail ${J(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?J(n.service):``}`]);function nr(e,t={}){let n=Q(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>Yn(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(J).join(` `);return[`cd ${J(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${J(Vn(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(qn),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${J(n)}`]]}async function rr(e,t,n={}){let r=`removed site ${t.name} (${nn(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await Y(e,[q,...nr(t,n),`record ${J(r)}`].join(`
445
- `))}function ir(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
449
+ }`;else{let e=r.prev,i={...e,releases:e.releases.filter(e=>o.some(t=>t.id===e.id))},a=o.filter(t=>!e.releases.some(e=>e.id===t.id));u=`[ -d ${Y(c)} ] || { log ${Y(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
450
+ `+[...tr(i,n),...a.flatMap(e=>rr(t,e))].join(`
451
+ `)}l.push(`record ${Y(r.what)} ${Y(u)}${f?`"$u"`:``}${Y(d)}`),l.push(...tr(t,n,r));for(let e of s)l.push(...rr(t,e));await X(e,l.join(`
452
+ `))}async function sr(e,t,n,r=``){await X(e,`${J}\ncd ${Y($(t))}\n${r}\ncd -P current && dc ${n}`)}const cr=(e,t,n)=>mt(e,[`cd -P ${Y($(t)+`/current`)} && docker compose logs --tail ${Y(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?Y(n.service):``}`]);function lr(e,t={}){let n=$(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>nr(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(Y).join(` `);return[`cd ${Y(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${Y(Jn(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(er),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${Y(n)}`]]}async function ur(e,t,n={}){let r=`removed site ${t.name} (${un(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await X(e,[J,...lr(t,n),`record ${Y(r)}`].join(`
453
+ `))}function dr(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
446
454
  `)){let e=t.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1];e&&n.includes(e)||(e&&i.has(e)?(r.push(`${e}=${i.get(e)}`),i.delete(e)):(t.trim()||r.length)&&r.push(t))}for(;r.length&&!r[r.length-1].trim();)r.pop();for(let[e,t]of i)r.push(`${e}=${t}`);return r.length?r.join(`
447
455
  `)+`
448
- `:``}function ar(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&D(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function or(e,t){let{stdout:n}=await Y(e,`{ [ "$(id -u)" = 0 ] || sudo -n true 2>/dev/null; } && echo yes || true`,{capture:!0});if(n.trim()===`yes`)return e;let r={...e,user:`root`};return Z(e).user!==`root`&&await ut(r)?r:D(`${t} needs root or passwordless sudo; ${Z(e).user} has neither and root's key login does not work`)}const sr=String.raw`
456
+ `:``}const fr=e=>e.split(`
457
+ `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`)),pr=async e=>mr(fr(e===`-`?await x():o.readFileSync(e,`utf8`)));function mr(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&k(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function hr(e,t){let{stdout:n}=await X(e,`{ [ "$(id -u)" = 0 ] || sudo -n true 2>/dev/null; } && echo yes || true`,{capture:!0});if(n.trim()===`yes`)return e;let r=Q(e).user;for(let t of[`root`,e.admin])if(t&&t!==r&&await _t({...e,user:t}))return{...e,user:t};return k(`${t} needs root or passwordless sudo; ${r} has neither and root's key login does not work`)}const gr=String.raw`
449
458
  set +e
450
459
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
451
460
  m=$(pkg_mgr); echo "mgr=$m"
@@ -462,11 +471,15 @@ case "$m" in
462
471
  apk)
463
472
  refresh S apk update -q
464
473
  apk version -l '<' 2>/dev/null | awk 'NR > 1 {print "apk=" $1 "\t" $3}' ;;
474
+ zypper)
475
+ refresh S zypper -q -n refresh
476
+ # v | Repository | Name | Current Version | Available Version | Arch
477
+ zypper -q -n list-updates 2>/dev/null | awk -F' *[|] *' '$1 == "v" {print "pkg=" $3 "\t" $4 "\t" $5 "\t" $2}' ;;
465
478
  esac
466
479
  [ -f /var/run/reboot-required ] && echo reboot=1
467
480
  exit 0
468
- `;function cr(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
469
- `)){let e=r.indexOf(`=`);if(e<0)continue;let[i,a]=[r.slice(0,e),r.slice(e+1)];if(i===`mgr`&&(t.mgr=a.trim()),i===`refreshed`&&(t.refreshed=!0),i===`reboot`&&(t.reboot=!0),i===`sec`&&n.push(a.trim()),i===`pkg`){let[e,n,r,i=``]=a.split(` `);t.updates.push({name:e,from:n||void 0,to:r,security:/security/i.test(i)})}if(i===`apk`){let[e,n]=a.split(` `),r=e.match(/^(.*)-(\d[^-]*-r\d+)$/);t.updates.push({name:r?.[1]??e,from:r?.[2],to:n,security:!1})}}for(let e of t.updates){let t=e.name.replace(/\.[^.]+$/,``);n.some(e=>e.startsWith(t+`-`))&&(e.security=!0)}return t}async function lr(e,t=!0){let{stdout:n}=await Y(e,q+sr,{capture:!0,env:{REFRESH:t}});return cr(n)}const ur=String.raw`
481
+ `;function _r(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
482
+ `)){let e=r.indexOf(`=`);if(e<0)continue;let[i,a]=[r.slice(0,e),r.slice(e+1)];if(i===`mgr`&&(t.mgr=a.trim()),i===`refreshed`&&(t.refreshed=!0),i===`reboot`&&(t.reboot=!0),i===`sec`&&n.push(a.trim()),i===`pkg`){let[e,n,r,i=``]=a.split(` `);t.updates.push({name:e,from:n||void 0,to:r,security:/security/i.test(i)})}if(i===`apk`){let[e,n]=a.split(` `),r=e.match(/^(.*)-(\d[^-]*-r\d+)$/);t.updates.push({name:r?.[1]??e,from:r?.[2],to:n,security:!1})}}for(let e of t.updates){let t=e.name.replace(/\.[^.]+$/,``);n.some(e=>e.startsWith(t+`-`))&&(e.security=!0)}return t}async function vr(e,t=!0){let{stdout:n}=await X(e,J+gr,{capture:!0,env:{REFRESH:t}});return _r(n)}const yr=String.raw`
470
483
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
471
484
  keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
472
485
  case "$(pkg_mgr)" in
@@ -486,8 +499,8 @@ if [ "$REBOOT" = 1 ] && { [ -f /var/run/reboot-required ] || { command -v needs-
486
499
  # Detached, so this ssh session can end cleanly first.
487
500
  (sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
488
501
  fi
489
- `;async function dr(e,t){await Y(e,q+ur,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function fr(e){let[t,n]=e.split(`@`),r=`latest`,i=t.lastIndexOf(`:`);i>t.lastIndexOf(`/`)&&(r=t.slice(i+1),t=t.slice(0,i));let a=t.split(`/`)[0],o=`registry-1.docker.io`;return t.includes(`/`)&&(a.includes(`.`)||a.includes(`:`)||a===`localhost`)&&(o=a===`docker.io`?o:a,t=t.slice(a.length+1)),o===`registry-1.docker.io`&&!t.includes(`/`)&&(t=`library/${t}`),{registry:o,repo:t,tag:r,digest:n}}async function pr(e,t,n=`GET`){let r=t.startsWith(`https://`)?t:`https://${e.registry}/v2/${e.repo}/${t}`,i=AbortSignal.timeout(1e4),a=e=>fetch(r,{method:n,signal:i,headers:{accept:`application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json`,...e?{authorization:e}:{}}});try{let t=await a();if(t.status===401){let n=t.headers.get(`www-authenticate`)??``,r=Object.fromEntries([...n.matchAll(/(\w+)="([^"]*)"/g)].map(e=>[e[1],e[2]]));if(!/^bearer/i.test(n)||!r.realm)return;let o=new URL(r.realm);r.service&&o.searchParams.set(`service`,r.service),o.searchParams.set(`scope`,r.scope??`repository:${e.repo}:pull`);let s=await fetch(o,{signal:i}).then(e=>e.ok?e.json():void 0),c=s?.token??s?.access_token;if(!c)return;t=await a(`Bearer ${c}`)}return t.ok?t:void 0}catch{return}}async function mr(e){let t=fr(e);return t.digest?t.digest:(await pr(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function hr(e){let t=fr(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await pr(t,r);if(!e)break;n.push(...(await e.json().catch(()=>({}))).tags??[]);let i=e.headers.get(`link`)?.match(/<([^>]+)>;\s*rel="next"/)?.[1];r=i?`https://${t.registry}${i}`:void 0}return n}function gr(e,t){let n=e.replace(/^v/,``).split(/[.+-]/).map(Number),r=t.replace(/^v/,``).split(/[.+-]/).map(Number);for(let e=0;e<Math.max(n.length,r.length);e++){let t=(n[e]||0)-(r[e]||0);if(t)return t}return 0}const _r=/^(v?)(\d+(?:\.\d+){0,2})$/;function vr(e,t){let n=_r.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=_r.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&gr(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const yr=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,br=/* @__PURE__ */ new Map;function xr(e){return br.has(e)||br.set(e,fetch(`https://github.com/${e}/releases/latest`,{method:`HEAD`,redirect:`manual`,signal:AbortSignal.timeout(1e4)}).then(e=>e.headers.get(`location`)?.match(/\/tag\/(?:docker-)?v?([^/]+)$/)?.[1]).catch(()=>void 0)),br.get(e)}const Sr=()=>xr(`moby/moby`),Cr=()=>xr(`docker/compose`);async function wr(e){let t=String.raw`
490
- ${q}
502
+ `;async function br(e,t){await X(e,J+yr,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function xr(e){let[t,n]=e.split(`@`),r=`latest`,i=t.lastIndexOf(`:`);i>t.lastIndexOf(`/`)&&(r=t.slice(i+1),t=t.slice(0,i));let a=t.split(`/`)[0],o=`registry-1.docker.io`;return t.includes(`/`)&&(a.includes(`.`)||a.includes(`:`)||a===`localhost`)&&(o=a===`docker.io`?o:a,t=t.slice(a.length+1)),o===`registry-1.docker.io`&&!t.includes(`/`)&&(t=`library/${t}`),{registry:o,repo:t,tag:r,digest:n}}async function Sr(e,t,n=`GET`){let r=t.startsWith(`https://`)?t:`https://${e.registry}/v2/${e.repo}/${t}`,i=AbortSignal.timeout(1e4),a=e=>fetch(r,{method:n,signal:i,headers:{accept:`application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json`,...e?{authorization:e}:{}}});try{let t=await a();if(t.status===401){let n=t.headers.get(`www-authenticate`)??``,r=Object.fromEntries([...n.matchAll(/(\w+)="([^"]*)"/g)].map(e=>[e[1],e[2]]));if(!/^bearer/i.test(n)||!r.realm)return;let o=new URL(r.realm);r.service&&o.searchParams.set(`service`,r.service),o.searchParams.set(`scope`,r.scope??`repository:${e.repo}:pull`);let s=await fetch(o,{signal:i}).then(e=>e.ok?e.json():void 0),c=s?.token??s?.access_token;if(!c)return;t=await a(`Bearer ${c}`)}return t.ok?t:void 0}catch{return}}async function Cr(e){let t=xr(e);return t.digest?t.digest:(await Sr(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function wr(e){let t=xr(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await Sr(t,r);if(!e)break;n.push(...(await e.json().catch(()=>({}))).tags??[]);let i=e.headers.get(`link`)?.match(/<([^>]+)>;\s*rel="next"/)?.[1];r=i?`https://${t.registry}${i}`:void 0}return n}function Tr(e,t){let n=e.replace(/^v/,``).split(/[.+-]/).map(Number),r=t.replace(/^v/,``).split(/[.+-]/).map(Number);for(let e=0;e<Math.max(n.length,r.length);e++){let t=(n[e]||0)-(r[e]||0);if(t)return t}return 0}const Er=/^(v?)(\d+(?:\.\d+){0,2})$/;function Dr(e,t){let n=Er.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=Er.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&Tr(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const Or=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,kr=/* @__PURE__ */ new Map;function Ar(e){return kr.has(e)||kr.set(e,fetch(`https://github.com/${e}/releases/latest`,{method:`HEAD`,redirect:`manual`,signal:AbortSignal.timeout(1e4)}).then(e=>e.headers.get(`location`)?.match(/\/tag\/(?:docker-)?v?([^/]+)$/)?.[1]).catch(()=>void 0)),kr.get(e)}const jr=()=>Ar(`moby/moby`),Mr=()=>Ar(`docker/compose`);async function Nr(e){let t=String.raw`
503
+ ${J}
491
504
  set +e
492
505
  printf 'docker=%s
493
506
  ' "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
@@ -497,11 +510,11 @@ printf 'path=%s
497
510
  ' "$(docker info --format '{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}' 2>/dev/null)"
498
511
  printf 'ce=%s
499
512
  ' "$(pkg_has docker-ce && echo 1)"
500
- exit 0`,[{stdout:n},r,i]=await Promise.all([Y(e,t,{capture:!0}),Sr(),Cr()]),a=Object.fromEntries(n.split(`
501
- `).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1).trim()]));return{docker:a.docker??``,compose:a.compose??``,composePath:a.path??``,dockerCe:a.ce===`1`,latestDocker:r,latestCompose:i}}function Tr(e){let t=(e,t)=>!!e&&!!t&&gr(e,t)<0,n=t(e.docker,e.latestDocker)?`docker ${e.docker}, ${e.latestDocker} is current${e.dockerCe?` (comes with the package upgrades)`:` (distro package: switch to Docker's repo for the newest)`}`:void 0,r=t(e.compose,e.latestCompose)&&e.composePath===`/usr/local/lib/docker/cli-plugins/docker-compose`;return{dockerNote:n,composeNote:e.compose?t(e.compose,e.latestCompose)?`compose ${e.compose}, ${e.latestCompose} is current${r?``:e.dockerCe?` (comes with the package upgrades)`:` (from ${e.composePath||`the distro`})`}`:void 0:`docker compose missing: lf setup installs it`,composeUpdate:r}}async function Er(e){await Y(e,q+We+`compose_install || { log "could not download docker compose"; exit 1; }
502
- `)}const Dr=String.raw`
513
+ exit 0`,[{stdout:n},r,i]=await Promise.all([X(e,t,{capture:!0}),jr(),Mr()]),a=Object.fromEntries(n.split(`
514
+ `).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1).trim()]));return{docker:a.docker??``,compose:a.compose??``,composePath:a.path??``,dockerCe:a.ce===`1`,latestDocker:r,latestCompose:i}}function Pr(e){let t=(e,t)=>!!e&&!!t&&Tr(e,t)<0,n=t(e.docker,e.latestDocker)?`docker ${e.docker}, ${e.latestDocker} is current${e.dockerCe?` (comes with the package upgrades)`:` (distro package: switch to Docker's repo for the newest)`}`:void 0,r=t(e.compose,e.latestCompose)&&e.composePath===`/usr/local/lib/docker/cli-plugins/docker-compose`;return{dockerNote:n,composeNote:e.compose?t(e.compose,e.latestCompose)?`compose ${e.compose}, ${e.latestCompose} is current${r?``:e.dockerCe?` (comes with the package upgrades)`:` (from ${e.composePath||`the distro`})`}`:void 0:`docker compose missing: lf setup installs it`,composeUpdate:r}}async function Fr(e){await X(e,J+Ze+`compose_install || { log "could not download docker compose"; exit 1; }
515
+ `)}const Ir=String.raw`
503
516
  set +e
504
- P=${Ve}
517
+ P=${Je}
505
518
  img() { # img <kind> <name> <container> <dir> [service]
506
519
  svc=; [ $# -lt 5 ] || svc=$5
507
520
  x=$(docker inspect -f '{{.Config.Image}} {{.Image}}' "$3" 2>/dev/null) || { printf 'IMG\t%s\t%s\t%s\t%s\t%s\t\t\n' "$1" "$2" "$3" "$4" "$svc"; return 0; }
@@ -510,11 +523,11 @@ $x
510
523
  EOF
511
524
  printf 'IMG\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$svc" "$ref" "$(docker image inspect -f '{{range .RepoDigests}}{{.}} {{end}}' "$id" 2>/dev/null)"
512
525
  }
513
- if [ "$P" = traefik ] && grep -q "managed by landfall" ${W}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${W}
526
+ if [ "$P" = traefik ] && grep -q "managed by landfall" ${G}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${G}
514
527
  else img proxy "$P" "$P" ""; fi
515
528
  # All containers at once: <name> <service> <project>, the last two empty outside Compose.
516
529
  PS=$(docker ps -a --format '{{.Names}} {{.Label "com.docker.compose.service"}} {{.Label "com.docker.compose.project"}}' 2>/dev/null)
517
- for f in ${U}/*/site.json; do
530
+ for f in ${W}/*/site.json; do
518
531
  [ -f "$f" ] || continue
519
532
  d=$(dirname "$f"); n=$(basename "$d")
520
533
  # Sites of landfall before Compose sites: one container, its compose file next to site.json.
@@ -523,15 +536,15 @@ for f in ${U}/*/site.json; do
523
536
  printf '%s\n' "$PS" | while read -r c svc pr; do [ "$pr" != "$p" ] || img site "$n" "$c" "$d/current" "$svc"; done
524
537
  done
525
538
  exit 0
526
- `;async function Or(e){let{stdout:t}=await Y(e,Dr,{capture:!0}),n=t.split(`
527
- `).filter(e=>e.startsWith(`IMG `)).map(e=>e.split(` `)),r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,a=async e=>{let t=e.slice(0,e.lastIndexOf(`:`));if(!_r.test(fr(e).tag))return;i.has(t)||i.set(t,hr(e));let n=vr(fr(e).tag,await i.get(t));return n&&yr(e,n)};return Promise.all(n.map(async([,e,t,n,i,o=``,s=``,c=``])=>{let l={name:t,container:n,dir:i,ref:s,service:o||void 0};if(e===`site`&&o&&s.startsWith(rn(t,o,``)))return{...l,kind:`site`,state:`rebuild`,note:`built on the server: redeploy to rebuild`};if(!s)return{...l,kind:e,state:`missing`,note:`no container`};if(e===`proxy`)return{...l,kind:`proxy`,state:`foreign`,note:`not landfall's traefik: update it where it was set up`};if(s.includes(`@`))return{...l,kind:e,state:`pinned`,note:`pinned to a digest`};r.has(s)||r.set(s,mr(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>fr(e).tag.replace(/^v/,``).split(`.`)[0],m=f===`traefik`?`lf traefik setup --image ${d}`:`redeploy with it`,h=d?{newer:d,note:`newer version ${d} (${p(d)===p(s)?``:`new major, read its upgrade notes; `}${m})`}:{};return u?{...l,kind:f,state:c.split(` `).some(e=>e.endsWith(`@${u}`))?`current`:`update`,...h}:{...l,kind:f,state:`unknown`,note:`registry did not answer (private image?)`,...h}}))}async function kr(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[q];for(let[e,t]of n){r.push(`log ${J(`pulling ${e}`)}`,`docker pull -q ${J(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${J(e)})`);for(let n of t){let t=n.service?` ${J(n.service)}`:``,i=`docker tag $old ${J(e)} && cd -P ${J(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${J(n.container)})`,`if [ "$old" = "$new" ]; then log ${J(`${n.name} already runs the newest ${e}`)}; else`,` record ${J(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${J(`recreating ${n.container}`)}`,` (cd -P ${J(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${J(n.container)} || exit 1`,`fi`)}}await Y(e,r.join(`
528
- `))}function Ar(e){let t=e.split(` `),n=t[0]===`udp`?`udp`:`tcp`;/^(tcp|udp)/.test(t[0])&&t.shift();let[r,i=``]=t,a=r.lastIndexOf(`:`);return{proto:n,addr:r.slice(0,a),port:Number(r.slice(a+1)),proc:i.match(/"([^"]+)"/)?.[1]??i.split(`/`)[1]??(i||`?`)}}function jr(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
529
- `)){let e=n.indexOf(`=`);if(e<0)continue;let r=n.slice(0,e),i=n.slice(e+1).trim();r===`listen`||r===`published`?t[r].push(Ar(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function Mr(e,t={}){let{stdout:n}=await Y(e,q+qe,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return jr(n)}const Nr=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),Pr=e=>e.includes(`:`),Fr=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,Ir=[68,546,5353];function Lr(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=Wr(t?t[1]:e),r=e=>n.includes(e)?n[n.indexOf(e)+1]:void 0,i=[`allow`,`deny`,`reject`,`limit`].find(e=>e===n[0])??`allow`,a=r(`port`),o=r(`proto`);if(!n.includes(`to`)&&!n.includes(`from`)){let e=n.find(e=>/^[\d,:]+(\/(tcp|udp))?$/.test(e));[a,o]=e?.split(`/`)??[]}let s=r(`from`);return{spec:e,action:i,port:a,proto:o===`tcp`||o===`udp`?o:void 0,from:s&&s!==`any`?s:void 0,comment:t?.[2],out:n[1]===`out`}}function Rr(e,t){let n=e.findIndex(e=>!e.out&&(!e.proto||e.proto===t.proto)&&!!e.port?.split(`,`).some(e=>{let[n,r=n]=e.split(`:`).map(Number);return t.port>=n&&t.port<=r}));return n<0?void 0:{rule:e[n],n:n+1}}const zr=(e,t)=>{let n=Rr(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Br=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[Fr(e),t(e)])).values()];function Vr(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=Hr(e,[t.sshPort,80,443]);return r.length?n(`conflict`,`enabling ufw would block ${r.join(`, `)}`):n(`todo`,`enable ufw (ssh/80/443)${i.length?`; docker keeps ${i.join(` `)} open`:``}`)}function Hr(e,t){let n=e.values,r=e.rules.map(Lr),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&Ir.includes(e.port)||zr(r,e),a=e=>!Nr(e.addr)&&!i(e),o=e.listen.filter(e=>e.proc!==`docker-proxy`&&a(e)),s=[...e.published,...e.listen.filter(e=>e.proc===`docker-proxy`)].filter(a),c=n.docker_fw===`nftables`,l=s.filter(e=>n[Pr(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Br(o,e=>`${Fr(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Br(l,e=>`${Fr(e)} (docker${Pr(e.addr)?`, IPv6`:``})`)],kept:c?[]:Br(s.filter(e=>!l.includes(e)),Fr)}}function Ur(e){let t=e.values,n=e.rules.map(Lr),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[Pr(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(Nr(e.addr))return{state:`local`,why:`localhost only`};if(!r)return{state:`open`,why:`open, firewall off`};if(a&&i(e))return{state:`open`,why:`open, docker skips ufw`};let o=Rr(n,e);if(o){let{rule:e,n:t}=o;return e.action===`deny`||e.action===`reject`?{state:`blocked`,why:`blocked by rule ${t}`}:e.from?{state:`limited`,why:`only from ${e.from} (rule ${t})`}:{state:`open`,why:`allowed by rule ${t}${e.action===`limit`?`, rate-limited`:``}`}}return e.proto===`udp`&&Ir.includes(e.port)?{state:`open`,why:`ufw default rule`}:t.ufw_in===`ACCEPT`?{state:`open`,why:`default policy accepts`}:{state:`blocked`,why:`blocked, no rule`}},o=[...e.published,...e.listen.filter(e=>e.proc===`docker-proxy`)],s=[...e.listen.filter(e=>e.proc!==`docker-proxy`).map(e=>({l:e,docker:!1})),...o.map(e=>({l:e,docker:!0}))],c=/* @__PURE__ */ new Map;for(let{l:e,docker:t}of s.sort((e,t)=>e.l.port-t.l.port||e.l.proto.localeCompare(t.l.proto))){let n=`${Fr(e)} ${t?`docker`:e.proc}`,r={port:Fr(e),proc:t?`docker`:e.proc,...a(e,t)};c.has(n)&&c.get(n).state!==`local`||c.set(n,r)}return[...c.values()]}const Wr=(e=``)=>e.split(/\s+/).filter(Boolean);function Gr(e){let t=(e,t=``)=>({part:`docker`,label:`docker`,status:e,detail:t});if(!e.docker)return t(`todo`,`install docker and compose`);let n=e.docker.match(/\d+(\.\d+)+/)?.[0]??e.docker;if(e.docker_podman===`1`)return t(`conflict`,`${e.docker} is podman's docker shim; landfall needs Docker Engine (remove podman-docker, then re-run setup)`);let r=[e.docker_running===`1`?``:`start it`,Number(e.compose?.split(`.`)[0])>=2?``:`install compose`].filter(Boolean);if(r.length)return t(`todo`,`docker ${n}, ${r.join(` + `)}`);let i=(e,t=``)=>t&&gr(e,t)<0?` (${t} is current)`:``;return t(`done`,`docker ${n}${i(n,e.docker_latest)}, compose ${e.compose}${i(e.compose,e.compose_latest)}`)}function Kr(e,t){let n=e.values,r=(e,t,n,r=``)=>({part:e,label:t,status:n,detail:r}),i=[],a=n.pkgs_missing??``;i.push(n.pkg===`none`?r(`packages`,`base packages`,`done`,`no supported package manager, skipped`):a.trim()?r(`packages`,`base packages`,`todo`,`install ${a.trim()}`):r(`packages`,`base packages`,`done`)),i.push(Gr(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,H,o?`done`:`todo`,o?`${s} site${s===1?``:`s`} (never touched by setup)`:`create dir and network`)),t.deployUser){let e=`deploy user ${t.deployUser}`;if(n.user!==`1`)i.push(r(`user`,e,`todo`,`create`));else{let t=[n.user_docker===`1`?``:`docker group`,n.user_key===`1`?``:`ssh key`].filter(Boolean);i.push(r(`user`,e,t.length?`todo`:`done`,t.length?`exists, add ${t.join(` + `)}`:``))}}if(t.firewall?n.ufw===`missing`?i.push(r(`firewall`,`firewall`,`done`,`ufw not available, skipped`)):n.ufw===`active`?i.push(r(`firewall`,`firewall`,`done`,`ufw active (landfall only adds ssh/80/443)`)):i.push(Vr(e,t)):i.push(r(`firewall`,`firewall`,`off`)),!t.harden)i.push(r(`harden`,`ssh hardening`,`off`));else if(n.hardened===`1`)i.push(r(`harden`,`ssh hardening`,`done`));else{let e=Wr(n.password_users).filter(e=>e!==`root`&&e!==t.loginUser&&e!==t.deployUser);i.push(e.length&&n.password_auth!==`no`?r(`harden`,`ssh hardening`,`conflict`,`${e.join(`, `)} would lose ssh password login`):r(`harden`,`ssh hardening`,`todo`,`keys only`))}return t.traefik?i.push(Yr(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function qr(e=``){let[t=``,n=``,r=``,i=``]=e.split(`|`);if(!/traefik/.test(n))return;let a=t.replace(/^\//,``),o=e=>i.match(e)?.[1],s=o(/--entrypoints\.([\w-]+)\.address=[^\s]*:443\b/),c=r.split(`,`).filter(e=>e&&e!==`bridge`&&e!==`host`),l={container:a,network:o(/--providers\.docker\.network=(\S+)/)??(c.length===1?c[0]:void 0),entrypoint:s,resolver:(s&&o(RegExp(`--entrypoints\\.${s}\\.http\\.tls\\.certresolver=(\\S+)`)))??o(/--certificatesresolvers\.([\w-]+)\./)},u=[`network`,`entrypoint`,`resolver`].filter(e=>!l[e]);return{name:a,proxy:u.length?void 0:l,missing:u}}const Jr=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function Yr(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=He(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${Jr(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${W}/docker-compose.yml was not written by landfall`);let[i,a]=Wr(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=qr(t.foreign_proxy);if(!e||e.name!==`traefik`)return n(`conflict`,`a container named traefik exists that landfall did not create`)}for(let r of[80,443]){let i=Wr(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=qr(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${Jr(e.proxy)}`,e.proxy):e?n(`conflict`,`traefik ${e.name} owns 80/443, its flags don't name the ${e.missing.join(`, `)}: lf traefik use ${e.name} ${e.missing.map(e=>`--${e} …`).join(` `)}`):n(`conflict`,`port ${r} is published by container ${i.join(`, `)}`)}let a=e.listen.find(e=>e.port===r&&e.proc!==`docker-proxy`&&!(o&&e.proc===`traefik`));if(a)return n(`conflict`,`port ${r} is in use by ${a.proc}`)}return o&&i===`running`?n(`done`,`running (config refreshed)`):n(`todo`,o?`${i}, start`:`install`)}function Xr(e){let t={done:x.green(`✓`),todo:x.cyan(`→`),conflict:x.yellow(`!`),off:x.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?x.dim(`disabled`):r.status===`conflict`?x.yellow(r.detail):x.dim(r.detail);w(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function Zr(e,t){let{stdout:n}=await Y(e,`id=$(docker ps -q ${t.map(e=>`--filter ${J(e)}`).join(` `)} | head -n1)
530
- [ -z "$id" ] || docker inspect -f '{{json .Name}}{{"\\t"}}{{json .HostConfig.NetworkMode}}{{"\\t"}}{{json .NetworkSettings.Networks}}{{"\\t"}}{{json .Config.Labels}}{{"\\t"}}{{json .Args}}{{"\\t"}}{{json .Config.ExposedPorts}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s,c,l]=r.split(` `).map(e=>JSON.parse(e)),u=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e)),d=Object.keys(l??{}).map(e=>e.match(/^(\d+)\/tcp$/)?.[1]).filter(e=>!!e).map(Number).sort((e,t)=>e-t);return{name:String(i).replace(/^\//,``),host:a===`host`,ips:u,labels:s??{},args:c??[],ports:d}}function Qr(e,t){if(e.host)return`127.0.0.1`;let n=e.ips[t]??Object.values(e.ips)[0];return n||D(`${e.name} has no IP address to tunnel to`),n}function $r(e,t){let n=n=>e.labels[`traefik.http.routers.${t}.${n}`],r=new Map(e.args.flatMap(e=>{let t=e.match(/^--entrypoints\.([^.=]+)\.address=.*:(\d+)/i);return t?[[t[1].toLowerCase(),Number(t[2])]]:[]})),i=n(`entrypoints`)?.split(`,`).map(e=>e.trim().toLowerCase())??[...r.keys()],a=n(`tls`)===`true`||!!n(`tls.certresolver`),o=i.map(e=>r.get(e)).filter(e=>e!==void 0),s=o.find(e=>e===443===a)??o[0];return s===void 0?void 0:{port:s,tls:a}}const ei={25:`smtp`,110:`pop3`,143:`imap`,465:`smtps`,587:`smtp`,993:`imaps`,995:`pop3s`,3306:`mysql`,5432:`postgresql`,5672:`amqp`,6379:`redis`,11211:`memcached`,27017:`mongodb`},ti=e=>ei[e]??`http`;async function ni(e,t=`127.0.0.1`){let n=e=>new Promise(n=>{let r=ne.createServer().once(`error`,()=>n(void 0));r.listen(e,t,()=>{let e=r.address().port;r.close(()=>n(e))})});return e&&await n(e)||await n(0)}const ri=e=>new Promise(t=>{let n=ne.connect(e,`127.0.0.1`);n.once(`connect`,()=>(n.destroy(),t(!0))),n.once(`error`,()=>t(!1))});function ii(e,t,n,r){let i=n.tls?ae:v,a=v.createServer((e,r)=>{let a=i.request({host:`127.0.0.1`,port:t,servername:n.name,rejectUnauthorized:!1,method:e.method,path:e.url,headers:{...e.headers,host:n.name}},e=>(r.writeHead(e.statusCode??502,e.headers),e.pipe(r)));a.on(`error`,e=>r.headersSent?r.destroy():r.writeHead(502).end(`${n.name}: ${e.message}\n`)),e.pipe(a)});return new Promise((t,n)=>a.once(`error`,n).listen(e,r,()=>t(a)))}const ai=()=>Object.values(s.networkInterfaces()).flatMap(e=>e??[]).filter(e=>e.family===`IPv4`&&!e.internal).map(e=>e.address);async function oi(e,t,r,i){let a=i.lan?`0.0.0.0`:`127.0.0.1`,o=i.local===void 0?void 0:we(i.local,`--local`,1,65535),s=await ni(o??(r>=1024?r:8080),a);o&&s!==o&&D(`local port ${o} is taken; pick another with --local`);let c=i.as?await ni():s,l=[...tt(e),`-o`,`ControlPath=none`,`-o`,`BatchMode=yes`,`-o`,`ExitOnForwardFailure=yes`,`-o`,`ServerAliveInterval=15`],u=n(`ssh`,[...l,`-N`,`-L`,`${i.as?`127.0.0.1`:a}:${c}:${t}:${r}`,et(e)],{stdio:[`ignore`,`inherit`,`inherit`]}),d=new Promise(e=>u.on(`close`,t=>e(t??1))),f=!1;for(let e of[`SIGINT`,`SIGTERM`])process.once(e,()=>(f=!0,u.kill()));for(let e=0;e<100&&u.exitCode===null&&!await ri(c);e++)await new Promise(e=>setTimeout(e,150));let p;if(u.exitCode===null){i.as&&(p=await ii(s,c,i.as,a));let e=i.scheme??`http`,t=t=>`${e}://${t}:${s}${e===`http`?i.path??`/`:``}`;if(C(`${i.what}: ${x.cyan(t(`localhost`))}`),i.lan){let e=ai();w(e.length?`on your network: ${e.map(e=>x.cyan(t(e))).join(` `)}`:`on your network: this machine has no network address yet`),T(`anyone on that network reaches it too: close it when you are done`)}i.note&&w(x.dim(i.note)),w(x.dim(`the tunnel stays open until Ctrl-C`))}let m=await d;p?.close(),!f&&m!==0&&D(`the tunnel closed (ssh exit ${m})`)}async function si(e,t){return`${e}:${(await ie.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const ci=e=>e.slice(0,e.indexOf(`:`)),li=`admin`;async function ui(e,t,n=`user`){/^[A-Za-z0-9._@-]{1,64}$/.test(e)||D(`${n} may only have letters, digits and . _ @ -`);let r=t??await ue(`Password for ${e}:`);return r.length<8&&D(`use a password of at least 8 characters`),si(e,r)}function di(e,t){return{email:t.email??e.email,staging:!!(t.staging??e.staging),image:t.image||e.traefikImage||`traefik:v3`,hostNetwork:!!(t.hostNetwork??e.hostNetwork),http3:(t.http3??e.http3)!==!1}}const fi=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.http3?`HTTP/3`:`no HTTP/3`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function pi(e,t,n){let r=di(t,n);Ce(r.image);let{email:i,staging:a}=r;S(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?De(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){V.test(c)||D(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=n.dashboard?n.dashboardUser:o?.user??n.dashboardUser;s=n.dashboardPassword??re(12).toString(`base64url`),o={host:c,user:e,htpasswd:await si(e,s)}}let l=Xe({...r,dashboard:o});await Y(t,`${q}
539
+ `;async function Lr(e){let{stdout:t}=await X(e,Ir,{capture:!0}),n=t.split(`
540
+ `).filter(e=>e.startsWith(`IMG `)).map(e=>e.split(` `)),r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,a=async e=>{let t=e.slice(0,e.lastIndexOf(`:`));if(!Er.test(xr(e).tag))return;i.has(t)||i.set(t,wr(e));let n=Dr(xr(e).tag,await i.get(t));return n&&Or(e,n)};return Promise.all(n.map(async([,e,t,n,i,o=``,s=``,c=``])=>{let l={name:t,container:n,dir:i,ref:s,service:o||void 0};if(e===`site`&&o&&s.startsWith(dn(t,o,``)))return{...l,kind:`site`,state:`rebuild`,note:`built on the server: redeploy to rebuild`};if(!s)return{...l,kind:e,state:`missing`,note:`no container`};if(e===`proxy`)return{...l,kind:`proxy`,state:`foreign`,note:`not landfall's traefik: update it where it was set up`};if(s.includes(`@`))return{...l,kind:e,state:`pinned`,note:`pinned to a digest`};r.has(s)||r.set(s,Cr(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>xr(e).tag.replace(/^v/,``).split(`.`)[0],m=f===`traefik`?`lf traefik setup --image ${d}`:`redeploy with it`,ee=d?{newer:d,note:`newer version ${d} (${p(d)===p(s)?``:`new major, read its upgrade notes; `}${m})`}:{};return u?{...l,kind:f,state:c.split(` `).some(e=>e.endsWith(`@${u}`))?`current`:`update`,...ee}:{...l,kind:f,state:`unknown`,note:`registry did not answer (private image?)`,...ee}}))}async function Rr(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[J];for(let[e,t]of n){r.push(`log ${Y(`pulling ${e}`)}`,`docker pull -q ${Y(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${Y(e)})`);for(let n of t){let t=n.service?` ${Y(n.service)}`:``,i=`docker tag $old ${Y(e)} && cd -P ${Y(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${Y(n.container)})`,`if [ "$old" = "$new" ]; then log ${Y(`${n.name} already runs the newest ${e}`)}; else`,` record ${Y(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${Y(`recreating ${n.container}`)}`,` (cd -P ${Y(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${Y(n.container)} || exit 1`,`fi`)}}await X(e,r.join(`
541
+ `))}function zr(e){let t=e.split(` `),n=t[0]===`udp`?`udp`:`tcp`;/^(tcp|udp)/.test(t[0])&&t.shift();let[r,i=``]=t,a=r.lastIndexOf(`:`);return{proto:n,addr:r.slice(0,a),port:Number(r.slice(a+1)),proc:i.match(/"([^"]+)"/)?.[1]??i.split(`/`)[1]??(i||`?`)}}function Br(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
542
+ `)){let e=n.indexOf(`=`);if(e<0)continue;let r=n.slice(0,e),i=n.slice(e+1).trim();r===`listen`||r===`published`?t[r].push(zr(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function Vr(e,t={}){let{stdout:n}=await X(e,J+et,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return Br(n)}const Hr=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),Ur=e=>e.includes(`:`),Wr=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,Gr=[68,546,5353];function Kr(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=$r(t?t[1]:e),r=e=>n.includes(e)?n[n.indexOf(e)+1]:void 0,i=[`allow`,`deny`,`reject`,`limit`].find(e=>e===n[0])??`allow`,a=r(`port`),o=r(`proto`);if(!n.includes(`to`)&&!n.includes(`from`)){let e=n.find(e=>/^[\d,:]+(\/(tcp|udp))?$/.test(e));[a,o]=e?.split(`/`)??[]}let s=r(`from`);return{spec:e,action:i,port:a,proto:o===`tcp`||o===`udp`?o:void 0,from:s&&s!==`any`?s:void 0,comment:t?.[2],out:n[1]===`out`}}function qr(e,t){let n=e.findIndex(e=>!e.out&&(!e.proto||e.proto===t.proto)&&!!e.port?.split(`,`).some(e=>{let[n,r=n]=e.split(`:`).map(Number);return t.port>=n&&t.port<=r}));return n<0?void 0:{rule:e[n],n:n+1}}const Jr=(e,t)=>{let n=qr(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Yr=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[Wr(e),t(e)])).values()];function Xr(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=Zr(e,[t.sshPort,80,443]);return r.length?n(`conflict`,`enabling ufw would block ${r.join(`, `)}`):n(`todo`,`enable ufw (ssh/80/443)${i.length?`; docker keeps ${i.join(` `)} open`:``}`)}function Zr(e,t){let n=e.values,r=e.rules.map(Kr),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&Gr.includes(e.port)||Jr(r,e),a=e=>!Hr(e.addr)&&!i(e),o=e.listen.filter(e=>e.proc!==`docker-proxy`&&a(e)),s=[...e.published,...e.listen.filter(e=>e.proc===`docker-proxy`)].filter(a),c=n.docker_fw===`nftables`,l=s.filter(e=>n[Ur(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Yr(o,e=>`${Wr(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Yr(l,e=>`${Wr(e)} (docker${Ur(e.addr)?`, IPv6`:``})`)],kept:c?[]:Yr(s.filter(e=>!l.includes(e)),Wr)}}function Qr(e){let t=e.values,n=e.rules.map(Kr),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[Ur(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(Hr(e.addr))return{state:`local`,why:`localhost only`};if(!r)return{state:`open`,why:`open, firewall off`};if(a&&i(e))return{state:`open`,why:`open, docker skips ufw`};let o=qr(n,e);if(o){let{rule:e,n:t}=o;return e.action===`deny`||e.action===`reject`?{state:`blocked`,why:`blocked by rule ${t}`}:e.from?{state:`limited`,why:`only from ${e.from} (rule ${t})`}:{state:`open`,why:`allowed by rule ${t}${e.action===`limit`?`, rate-limited`:``}`}}return e.proto===`udp`&&Gr.includes(e.port)?{state:`open`,why:`ufw default rule`}:t.ufw_in===`ACCEPT`?{state:`open`,why:`default policy accepts`}:{state:`blocked`,why:`blocked, no rule`}},o=[...e.published,...e.listen.filter(e=>e.proc===`docker-proxy`)],s=[...e.listen.filter(e=>e.proc!==`docker-proxy`).map(e=>({l:e,docker:!1})),...o.map(e=>({l:e,docker:!0}))],c=/* @__PURE__ */ new Map;for(let{l:e,docker:t}of s.sort((e,t)=>e.l.port-t.l.port||e.l.proto.localeCompare(t.l.proto))){let n=`${Wr(e)} ${t?`docker`:e.proc}`,r={port:Wr(e),proc:t?`docker`:e.proc,...a(e,t)};c.has(n)&&c.get(n).state!==`local`||c.set(n,r)}return[...c.values()]}const $r=(e=``)=>e.split(/\s+/).filter(Boolean);function ei(e){let t=(e,t=``)=>({part:`docker`,label:`docker`,status:e,detail:t});if(!e.docker)return t(`todo`,`install docker and compose`);let n=e.docker.match(/\d+(\.\d+)+/)?.[0]??e.docker;if(e.docker_podman===`1`)return t(`conflict`,`${e.docker} is podman's docker shim; landfall needs Docker Engine (remove podman-docker, then re-run setup)`);let r=[e.docker_running===`1`?``:`start it`,Number(e.compose?.split(`.`)[0])>=2?``:`install compose`].filter(Boolean);if(r.length)return t(`todo`,`docker ${n}, ${r.join(` + `)}`);let i=(e,t=``)=>t&&Tr(e,t)<0?` (${t} is current)`:``;return t(`done`,`docker ${n}${i(n,e.docker_latest)}, compose ${e.compose}${i(e.compose,e.compose_latest)}`)}function ti(e,t){let n=e.values,r=(e,t,n,r=``)=>({part:e,label:t,status:n,detail:r}),i=[],a=n.pkgs_missing??``;i.push(n.pkg===`none`?r(`packages`,`base packages`,`done`,`no supported package manager, skipped`):a.trim()?r(`packages`,`base packages`,`todo`,`install ${a.trim()}`):r(`packages`,`base packages`,`done`)),i.push(ei(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,U,o?`done`:`todo`,o?`${s} site${s===1?``:`s`} (never touched by setup)`:`create dir and network`)),t.deployUser){let e=`deploy user ${t.deployUser}`;if(n.user!==`1`)i.push(r(`user`,e,`todo`,`create`));else{let t=[n.user_docker===`1`?``:`docker group`,n.user_key===`1`?``:`ssh key`].filter(Boolean);i.push(r(`user`,e,t.length?`todo`:`done`,t.length?`exists, add ${t.join(` + `)}`:``))}}if(t.firewall?n.ufw===`missing`?i.push(r(`firewall`,`firewall`,`done`,`ufw not available, skipped`)):n.ufw===`active`?i.push(r(`firewall`,`firewall`,`done`,`ufw active (landfall only adds ssh/80/443)`)):i.push(Xr(e,t)):i.push(r(`firewall`,`firewall`,`off`)),!t.harden)i.push(r(`harden`,`ssh hardening`,`off`));else if(n.hardened===`1`)i.push(r(`harden`,`ssh hardening`,`done`));else{let e=$r(n.password_users).filter(e=>e!==`root`&&e!==t.loginUser&&e!==t.deployUser);i.push(e.length&&n.password_auth!==`no`?r(`harden`,`ssh hardening`,`conflict`,`${e.join(`, `)} would lose ssh password login`):r(`harden`,`ssh hardening`,`todo`,`keys only`))}return t.traefik?i.push(ii(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function ni(e=``){let[t=``,n=``,r=``,i=``]=e.split(`|`);if(!/traefik/.test(n))return;let a=t.replace(/^\//,``),o=e=>i.match(e)?.[1],s=o(/--entrypoints\.([\w-]+)\.address=[^\s]*:443\b/),c=r.split(`,`).filter(e=>e&&e!==`bridge`&&e!==`host`),l={container:a,network:o(/--providers\.docker\.network=(\S+)/)??(c.length===1?c[0]:void 0),entrypoint:s,resolver:(s&&o(RegExp(`--entrypoints\\.${s}\\.http\\.tls\\.certresolver=(\\S+)`)))??o(/--certificatesresolvers\.([\w-]+)\./)},u=[`network`,`entrypoint`,`resolver`].filter(e=>!l[e]);return{name:a,proxy:u.length?void 0:l,missing:u}}const ri=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function ii(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=Ye(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${ri(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${G}/docker-compose.yml was not written by landfall`);let[i,a]=$r(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=ni(t.foreign_proxy);if(!e||e.name!==`traefik`)return n(`conflict`,`a container named traefik exists that landfall did not create`)}for(let r of[80,443]){let i=$r(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=ni(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${ri(e.proxy)}`,e.proxy):e?n(`conflict`,`traefik ${e.name} owns 80/443, its flags don't name the ${e.missing.join(`, `)}: lf traefik use ${e.name} ${e.missing.map(e=>`--${e} …`).join(` `)}`):n(`conflict`,`port ${r} is published by container ${i.join(`, `)}`)}let a=e.listen.find(e=>e.port===r&&e.proc!==`docker-proxy`&&!(o&&e.proc===`traefik`));if(a)return n(`conflict`,`port ${r} is in use by ${a.proc}`)}return o&&i===`running`?n(`done`,`running (config refreshed)`):n(`todo`,o?`${i}, start`:`install`)}function ai(e){let t={done:C.green(`✓`),todo:C.cyan(`→`),conflict:C.yellow(`!`),off:C.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?C.dim(`disabled`):r.status===`conflict`?C.yellow(r.detail):C.dim(r.detail);E(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function oi(e,t){let{stdout:n}=await X(e,`id=$(docker ps -q ${t.map(e=>`--filter ${Y(e)}`).join(` `)} | head -n1)
543
+ [ -z "$id" ] || docker inspect -f '{{json .Name}}{{"\\t"}}{{json .HostConfig.NetworkMode}}{{"\\t"}}{{json .NetworkSettings.Networks}}{{"\\t"}}{{json .Config.Labels}}{{"\\t"}}{{json .Args}}{{"\\t"}}{{json .Config.ExposedPorts}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s,c,l]=r.split(` `).map(e=>JSON.parse(e)),u=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e)),d=Object.keys(l??{}).map(e=>e.match(/^(\d+)\/tcp$/)?.[1]).filter(e=>!!e).map(Number).sort((e,t)=>e-t);return{name:String(i).replace(/^\//,``),host:a===`host`,ips:u,labels:s??{},args:c??[],ports:d}}function si(e,t){if(e.host)return`127.0.0.1`;let n=e.ips[t]??Object.values(e.ips)[0];return n||k(`${e.name} has no IP address to tunnel to`),n}function ci(e,t){let n=n=>e.labels[`traefik.http.routers.${t}.${n}`],r=new Map(e.args.flatMap(e=>{let t=e.match(/^--entrypoints\.([^.=]+)\.address=.*:(\d+)/i);return t?[[t[1].toLowerCase(),Number(t[2])]]:[]})),i=n(`entrypoints`)?.split(`,`).map(e=>e.trim().toLowerCase())??[...r.keys()],a=n(`tls`)===`true`||!!n(`tls.certresolver`),o=i.map(e=>r.get(e)).filter(e=>e!==void 0),s=o.find(e=>e===443===a)??o[0];return s===void 0?void 0:{port:s,tls:a}}const li={25:`smtp`,110:`pop3`,143:`imap`,465:`smtps`,587:`smtp`,993:`imaps`,995:`pop3s`,3306:`mysql`,5432:`postgresql`,5672:`amqp`,6379:`redis`,11211:`memcached`,27017:`mongodb`},ui=e=>li[e]??`http`;async function di(e,t=`127.0.0.1`){let n=e=>new Promise(n=>{let r=g.createServer().once(`error`,()=>n(void 0));r.listen(e,t,()=>{let e=r.address().port;r.close(()=>n(e))})});return e&&await n(e)||await n(0)}const fi=e=>new Promise(t=>{let n=g.connect(e,`127.0.0.1`);n.once(`connect`,()=>(n.destroy(),t(!0))),n.once(`error`,()=>t(!1))});function pi(e,t,n,r){let i=n.tls?b:ie,a=ie.createServer((e,r)=>{let a=i.request({host:`127.0.0.1`,port:t,servername:n.name,rejectUnauthorized:!1,method:e.method,path:e.url,headers:{...e.headers,host:n.name}},e=>(r.writeHead(e.statusCode??502,e.headers),e.pipe(r)));a.on(`error`,e=>r.headersSent?r.destroy():r.writeHead(502).end(`${n.name}: ${e.message}\n`)),e.pipe(a)});return new Promise((t,n)=>a.once(`error`,n).listen(e,r,()=>t(a)))}const mi=()=>Object.values(s.networkInterfaces()).flatMap(e=>e??[]).filter(e=>e.family===`IPv4`&&!e.internal).map(e=>e.address);async function hi(e,t,r,i){let a=i.lan?`0.0.0.0`:`127.0.0.1`,o=i.local===void 0?void 0:je(i.local,`--local`,1,65535),s=await di(o??(r>=1024?r:8080),a);o&&s!==o&&k(`local port ${o} is taken; pick another with --local`);let c=i.as?await di():s,l=[...ct(e),`-o`,`ControlPath=none`,`-o`,`BatchMode=yes`,`-o`,`ExitOnForwardFailure=yes`,`-o`,`ServerAliveInterval=15`],u=n(`ssh`,[...l,`-N`,`-L`,`${i.as?`127.0.0.1`:a}:${c}:${t}:${r}`,st(e)],{stdio:[`ignore`,`inherit`,`inherit`]}),d=new Promise(e=>u.on(`close`,t=>e(t??1))),f=!1;for(let e of[`SIGINT`,`SIGTERM`])process.once(e,()=>(f=!0,u.kill()));for(let e=0;e<100&&u.exitCode===null&&!await fi(c);e++)await new Promise(e=>setTimeout(e,150));let p;if(u.exitCode===null){i.as&&(p=await pi(s,c,i.as,a));let e=i.scheme??`http`,t=t=>`${e}://${t}:${s}${e===`http`?i.path??`/`:``}`;T(`${i.what}: ${C.cyan(t(`localhost`))}`);let n=i.lan?mi():[];oe({url:t(`localhost`),lan:n.map(t)}),i.lan&&(E(n.length?`on your network: ${n.map(e=>C.cyan(t(e))).join(` `)}`:`on your network: this machine has no network address yet`),D(`anyone on that network reaches it too: close it when you are done`)),i.note&&E(C.dim(i.note)),E(C.dim(`the tunnel stays open until Ctrl-C`))}let m=await d;p?.close(),!f&&m!==0&&k(`the tunnel closed (ssh exit ${m})`)}async function gi(e,t){return`${e}:${(await y.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const _i=e=>e.slice(0,e.indexOf(`:`)),vi=`admin`;async function yi(e,t,n=`user`){Ee(e,n);let r=t??await fe(`Password for ${e}:`);return r.length<8&&k(`use a password of at least 8 characters`),gi(e,r)}function bi(e,t){return{email:t.email??e.email,staging:!!(t.staging??e.staging),image:t.image||e.traefikImage||`traefik:v3`,hostNetwork:!!(t.hostNetwork??e.hostNetwork),http3:(t.http3??e.http3)!==!1}}const xi=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.http3?`HTTP/3`:`no HTTP/3`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function Si(e,t,n){let r=bi(t,n);Ae(r.image),r.email&&De(r.email);let{email:i,staging:a}=r;w(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?Pe(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){we.test(c)||k(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=Ee(n.dashboard?n.dashboardUser:o?.user??n.dashboardUser,`--dashboard-user`);s=n.dashboardPassword??v(12).toString(`base64url`),o={host:c,user:e,htpasswd:await gi(e,s)}}let l=rt({...r,dashboard:o});r.hostNetwork&&await Ti(t,r.http3),await X(t,`${J}
531
544
  ensure_network web
532
- [ -w ${W} ] || { $SUDO install -d -o "$(id -un)" ${W}; }
533
- mkdir -p ${W}/letsencrypt ${Fe} && cd ${W}
534
- ${X(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
545
+ [ -w ${G} ] || { $SUDO install -d -o "$(id -un)" ${G}; }
546
+ mkdir -p ${G}/letsencrypt ${He} && cd ${G}
547
+ ${Z(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
535
548
  tls:
536
549
  options:
537
550
  default:
@@ -542,11 +555,11 @@ tls:
542
555
  gw=$(docker network inspect web -f '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>/dev/null | tr ' ' '\\n' | grep -m1 '[.]' || true)
543
556
  printf 'LANDFALL_GATEWAY=%s\\n' "\${gw:-127.0.0.1}" > .env
544
557
  if [ -e docker-compose.yml ]; then
545
- ${X(`.docker-compose.yml.new`,l)}
558
+ ${Z(`.docker-compose.yml.new`,l)}
546
559
  # .landfall.sha256: what landfall wrote last. A different file was edited by hand.
547
560
  if [ -f .landfall.sha256 ] && ! sha256sum -c --status .landfall.sha256 2>/dev/null && ! cmp -s .docker-compose.yml.new docker-compose.yml && [ -z "${n.force?`1`:``}" ]; then
548
561
  diff -u docker-compose.yml .docker-compose.yml.new >&2; rm -f .docker-compose.yml.new
549
- echo "${W}/docker-compose.yml was edited by hand; the diff above is what landfall would change. Keep your edits: change dynamic/ or the flags instead; overwrite them: lf traefik setup --force" >&2
562
+ echo "${G}/docker-compose.yml was edited by hand; the diff above is what landfall would change. Keep your edits: change dynamic/ or the flags instead; overwrite them: lf traefik setup --force" >&2
550
563
  exit 1
551
564
  fi
552
565
  # Same config again: nothing to journal.
@@ -554,44 +567,48 @@ if [ -e docker-compose.yml ]; then
554
567
  else
555
568
  u=$(saved docker-compose.yml)
556
569
  mv .docker-compose.yml.new docker-compose.yml
557
- record ${J(`reconfigured traefik (${fi(r)})`)} "cd ${W} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
570
+ record ${Y(`reconfigured traefik (${xi(r)})`)} "cd ${G} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
558
571
  fi
559
572
  else
560
- ${X(`docker-compose.yml`,l)}
573
+ ${Z(`docker-compose.yml`,l)}
561
574
  # Certificates in letsencrypt/ stay, a later install reuses them.
562
- record ${J(`installed traefik (${fi(r)})`)} "cd ${W} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
563
- fi
564
- # Host mode: ufw decides who reaches 443/udp (published ports bypass it). Setup without ufw: nothing to open.
565
- if [ -n "${r.hostNetwork&&r.http3?`1`:``}" ] && command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -q "Status: active"; then
566
- ufw_add allow 443/udp
575
+ record ${Y(`installed traefik (${xi(r)})`)} "cd ${G} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
567
576
  fi
577
+ # Written now: a start that fails below must not make landfall's own file look edited by hand.
578
+ sha256sum docker-compose.yml > .landfall.sha256
568
579
  log "starting traefik"
569
580
  dc up -d --remove-orphans --quiet-pull
570
581
  sleep 2 # traefik exits shortly after start on a bad config
571
- ensure_running traefik
572
- sha256sum docker-compose.yml > .landfall.sha256`);let u=z();u.servers[e]&&(Object.assign(u.servers[e],{email:i,staging:a||void 0,traefikImage:r.image===`traefik:v3`?void 0:r.image,hostNetwork:r.hostNetwork||void 0,http3:r.http3?void 0:!1,dashboard:o}),ve(u)),C(`traefik running (${fi(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&w(x.dim(`host network: ufw now decides who reaches 80/443 (landfall's firewall allows both)`)),o&&(C(`dashboard: https://${o.host}`),w(s?`user ${x.bold(o.user)} password ${x.bold(s)}`:x.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function mi(e,t){S(`using traefik ${x.bold(t.container)} for lf sites`),await Y(e,`${q}
573
- docker network inspect ${t.network} >/dev/null 2>&1 || { echo "docker network ${t.network} does not exist" >&2; exit 1; }
574
- [ -w ${H} ] || $SUDO install -d -o "$(id -un)" ${H}
575
- u=$(saved ${G})
576
- ${X(G,Ue(t))}
577
- record ${J(`sites use traefik ${t.container}`)} "$u"`),C(`sites join network ${x.bold(t.network)} with entrypoint ${x.bold(t.entrypoint)} and resolver ${x.bold(t.resolver)}`),w(x.dim(`redeploy existing sites to switch them over; delete ${G} to let landfall run its own traefik again`))}async function hi(e){let{stdout:t}=await Y(e,`[ ! -f ${G} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${G})"
578
- f=${W}/docker-compose.yml
582
+ ensure_running traefik`);let u=V();u.servers[e]&&(Object.assign(u.servers[e],{email:i,staging:a||void 0,traefikImage:r.image===`traefik:v3`?void 0:r.image,hostNetwork:r.hostNetwork||void 0,http3:r.http3?void 0:!1,dashboard:o}),be(u)),T(`traefik running (${xi(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&E(C.dim(`host network: ufw now decides who reaches 80/443`)),o&&(T(`dashboard: https://${o.host}`),E(s?`user ${C.bold(o.user)} password ${C.bold(s)}`:C.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function Ci(e,t){w(`using traefik ${C.bold(t.container)} for lf sites`),await X(e,`${J}
583
+ docker network inspect ${Y(t.network)} >/dev/null 2>&1 || { echo ${Y(`docker network ${t.network} does not exist`)} >&2; exit 1; }
584
+ [ -w ${U} ] || $SUDO install -d -o "$(id -un)" ${U}
585
+ u=$(saved ${K})
586
+ ${Z(K,Xe(t))}
587
+ record ${Y(`sites use traefik ${t.container}`)} "$u"`),T(`sites join network ${C.bold(t.network)} with entrypoint ${C.bold(t.entrypoint)} and resolver ${C.bold(t.resolver)}`),E(C.dim(`redeploy existing sites to switch them over; delete ${K} to let landfall run its own traefik again`))}async function wi(e){let{stdout:t}=await X(e,`[ ! -f ${K} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${K})"
588
+ f=${G}/docker-compose.yml
579
589
  [ ! -f $f ] || { grep -q "managed by landfall" $f && echo compose ours || echo compose foreign; }
580
- for d in ${U}/*/; do [ -d "$d" ] && printf 'site\\t%s\\n' "$(basename "$d")"; done; true`,{capture:!0}),n={sites:[]};for(let e of t.split(`
581
- `)){let[t,r]=e.split(/[\t ]/);t===`proxy`&&(n.proxy=r||`?`),t===`compose`&&(n.compose=r),t===`site`&&n.sites.push(r)}return n}async function gi(e,t={}){S(`removing traefik`),await Y(e,`${q}
582
- cd ${W}
590
+ for d in ${W}/*/; do [ -d "$d" ] && printf 'site\\t%s\\n' "$(basename "$d")"; done; true`,{capture:!0}),n={sites:[]};for(let e of t.split(`
591
+ `)){let[t,r]=e.split(/[\t ]/);t===`proxy`&&(n.proxy=r||`?`),t===`compose`&&(n.compose=r),t===`site`&&n.sites.push(r)}return n}async function Ti(e,t){let n=[`80/tcp`,`443/tcp`,...t?[`443/udp`]:[]],r;try{r=await hr(e,`checking ufw`)}catch{return D(`can't check ufw without root; if it is on, allow ${n.join(`, `)} (lf firewall add)`)}await X(r,`${J}
592
+ command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -q "Status: active" || exit 0
593
+ ${n.map(e=>`ufw_add allow ${e}`).join(`
594
+ `)}`)}async function Ei(e,t={}){w(`removing traefik`),await X(e,`${J}
595
+ cd ${G}
583
596
  log "stopping traefik"
584
597
  dc down --remove-orphans
585
- u="$(saved ${W}/docker-compose.yml); $(saved ${W}/.landfall.sha256); $(saved ${Fe}/landfall.yml)"
586
- rm -f docker-compose.yml .landfall.sha256 ${Fe}/landfall.yml
598
+ # One assignment each: an assignment's status is only its last $(...), so a failed backup would go unnoticed.
599
+ u1=$(saved ${G}/docker-compose.yml)
600
+ u2=$(saved ${G}/.landfall.sha256)
601
+ u3=$(saved ${He}/landfall.yml)
602
+ u="$u1; $u2; $u3"
603
+ rm -f docker-compose.yml .landfall.sha256 ${He}/landfall.yml
587
604
  ${t.certs?`rmf letsencrypt`:``}
588
605
  # Certificates removed with --certs come back from Let's Encrypt on the next start.
589
- record ${J(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${W} && dc up -d --remove-orphans"`)}const _i={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},vi=N({name:`traefik setup`,summary:`Install or reconfigure Traefik (reverse proxy + automatic HTTPS) on a server. Safe to re-run; refuses if ports 80/443 belong to something else.`,options:{server:P,email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},image:{type:`string`,desc:`traefik image, remembered (default: ${Re})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},http3:{type:`boolean`,desc:`HTTP/3 over QUIC on 443/udp, on by default; remembered (--no-http3 turns it off)`},dashboard:{type:`string`,desc:"serve the traefik dashboard on this domain (basic auth), remembered; `traefik` = traefik.<main domain>, `off` removes it"},"dashboard-user":{type:`string`,desc:`dashboard user`,default:li},"dashboard-password":{type:`string`,desc:`dashboard password (default: generated)`},force:{type:`boolean`,desc:`overwrite a docker-compose.yml that was edited by hand`}},async run(e,t){let{name:n,server:r}=B(t.server),i=Yr(await Mr(r));i.status===`conflict`&&D(`not touching ${n}: ${i.detail}`),i.proxy&&D(`${n} already has its own traefik: ${i.detail}. landfall does not reconfigure it (lf traefik use)`),await pi(n,r,{email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`],force:t.force})}}),yi=N({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
606
+ record ${Y(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${G} && dc up -d --remove-orphans"`)}const Di={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},Oi=P({name:`traefik setup`,summary:`Install or reconfigure Traefik (reverse proxy + automatic HTTPS) on a server. Safe to re-run; refuses if ports 80/443 belong to something else.`,options:{server:F,email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},image:{type:`string`,desc:`traefik image, remembered (default: ${Ge})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},http3:{type:`boolean`,desc:`HTTP/3 over QUIC on 443/udp, on by default; remembered (--no-http3 turns it off)`},dashboard:{type:`string`,desc:"serve the traefik dashboard on this domain (basic auth), remembered; `traefik` = traefik.<main domain>, `off` removes it"},"dashboard-user":{type:`string`,desc:`dashboard user`,default:vi},"dashboard-password":{type:`string`,desc:`dashboard password (default: generated)`},force:{type:`boolean`,desc:`overwrite a docker-compose.yml that was edited by hand`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await Vr(r),a=ii(i);if(a.status===`conflict`&&k(`not touching ${n}: ${a.detail}`),a.proxy&&k(`${n} already has its own traefik: ${a.detail}. landfall does not reconfigure it (lf traefik use)`),bi(r,{hostNetwork:t[`host-network`]}).hostNetwork){let e=i.listen.find(e=>e.port===8080&&e.proc!==`traefik`);e&&k(`port ${Ue} is in use by ${e.proc}; traefik on the host network needs it for lf tunnel`)}await Si(n,r,{email:t.email&&De(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`],force:t.force})}}),ki=P({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
590
607
 
591
608
  landfall reads network, entrypoint and cert resolver from the container's flags;
592
- pass them when it is configured by a file. That Traefik is never changed.`,options:{server:P,network:{type:`string`,desc:`docker network traefik watches (providers.docker.network)`},entrypoint:{type:`string`,desc:`HTTPS entrypoint (the one on :443)`},resolver:{type:`string`,desc:`certificate resolver`}},async run([e],t){let{name:n,server:r}=B(t.server),i=qr((await Mr(r)).values.foreign_proxy),a=e??i?.name;a||D(`no traefik container publishes port 443 on ${n}; name it: lf traefik use <container>`);let o=i?.name===a?i:void 0,s={container:a,network:t.network??o?.proxy?.network,entrypoint:t.entrypoint??o?.proxy?.entrypoint,resolver:t.resolver??o?.proxy?.resolver},c=[`network`,`entrypoint`,`resolver`].filter(e=>!s[e]);c.length&&D(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await mi(r,s)}}),bi=N({name:`traefik rm`,summary:"Stop landfall's Traefik and remove its config. Sites stay but are unreachable until `lf traefik setup`.\n\nCertificates in letsencrypt/ stay for a later install unless --certs. Undo with `lf undo`.\nWith a Traefik of your own (lf traefik use), landfall only stops routing sites through it.",options:{server:P,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=B(t.server),i=await hi(r);if(i.proxy)return await j(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||D(`cancelled`),await Y(r,`${q}\nu=$(saved ${G})\n_as ${H} rm -f ${G}\nrecord ${J(`sites stop using traefik ${i.proxy}`)} "$u"`),C(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),w(x.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&D(`${W}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return C(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await A(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await gi(r,{certs:t.certs}),C(`traefik removed${t.certs?``:x.dim(` (certificates stay for a later install; --certs deletes them)`)}`),w(x.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),xi=N({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:P},async run(e,t){let{server:n}=B(t.server);await Y(n,`P=${Ve}
609
+ pass them when it is configured by a file. That Traefik is never changed.`,options:{server:F,network:{type:`string`,desc:`docker network traefik watches (providers.docker.network)`},entrypoint:{type:`string`,desc:`HTTPS entrypoint (the one on :443)`},resolver:{type:`string`,desc:`certificate resolver`}},async run([e],t){let{name:n,server:r}=H(t.server),i=ni((await Vr(r)).values.foreign_proxy),a=e??i?.name;a||k(`no traefik container publishes port 443 on ${n}; name it: lf traefik use <container>`);let o=i?.name===a?i:void 0,s={container:a,network:t.network??o?.proxy?.network,entrypoint:t.entrypoint??o?.proxy?.entrypoint,resolver:t.resolver??o?.proxy?.resolver},c=[`network`,`entrypoint`,`resolver`].filter(e=>!s[e]);c.length&&k(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await Ci(r,s)}}),Ai=P({name:`traefik rm`,summary:"Stop landfall's Traefik and remove its config. Sites stay but are unreachable until `lf traefik setup`.\n\nCertificates in letsencrypt/ stay for a later install unless --certs. Undo with `lf undo`.\nWith a Traefik of your own (lf traefik use), landfall only stops routing sites through it.",options:{server:F,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await wi(r);if(i.proxy)return await N(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||k(`cancelled`),await X(r,`${J}\nu=$(saved ${K})\n_as ${U} rm -f ${K}\nrecord ${Y(`sites stop using traefik ${i.proxy}`)} "$u"`),T(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),E(C.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&k(`${G}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return T(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await M(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await Ei(r,{certs:t.certs}),T(`traefik removed${t.certs?``:C.dim(` (certificates stay for a later install; --certs deletes them)`)}`),E(C.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),ji=P({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:F},async run(e,t){let{server:n}=H(t.server);await X(n,`P=${Je}
593
610
  if ! docker inspect "$P" >/dev/null 2>&1; then echo "traefik is not installed (lf traefik setup)"; exit 0; fi
594
- [ -f ${G} ] && echo "your own traefik, landfall only routes sites through it (${G})"
611
+ [ -f ${K} ] && echo "your own traefik, landfall only routes sites through it (${K})"
595
612
  docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
596
613
  echo
597
614
  echo "routes:"
@@ -615,38 +632,98 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
615
632
  printf " %s %s (%s)\\n", n, x, s
616
633
  }
617
634
  }' || true
618
- done | sort`)}}),Si=N({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:P,..._i},async run(e,t){return Pn(B(t.server).server,Ve,t)}}),Ci=N({name:`traefik restart`,summary:`Restart traefik.`,options:{server:P},async run(e,t){let{server:n}=B(t.server);await Y(n,`${q}\ndocker restart ${Ve} >/dev/null\nrecord "restarted traefik"`),C(`traefik restarted`)}}),wi=N({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
635
+ done | sort`)}}),Mi=P({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:F,...Di},async run(e,t){return Vn(H(t.server).server,Je,t)}}),Ni=P({name:`traefik restart`,summary:`Restart traefik.`,options:{server:F},async run(e,t){let{server:n}=H(t.server);await X(n,`${J}\ndocker restart ${Je} >/dev/null\nrecord "restarted traefik"`),T(`traefik restarted`)}}),Pi=P({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
619
636
 
620
637
  No domain or password needed: landfall's Traefik serves the dashboard to the server
621
638
  itself, and only there. To also serve it on a domain behind a password:
622
639
  lf traefik setup --dashboard traefik (traefik.<main domain>).
623
640
 
624
641
  A Traefik of your own works too when it routes the dashboard to a domain: the tunnel
625
- goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:P,local:{type:`string`,short:`l`,desc:`local port (default: 8080, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`},port:{type:`string`,desc:`with a Traefik of your own: the port of its API (api.insecure: 8080)`}},async run(e,t){let{name:n,server:r}=B(t.server),i=await hi(r),a=await Zr(r,[`name=^${i.proxy??`traefik`}$`]);a||D(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=In(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?we(t.port,`--port`,1,65535):!i.proxy&&Rn(a.labels)?Ie:void 0,l=`traefik dashboard of ${n}`;if(c)return oi(r,Qr(a,`web`),c,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:s});let u=Ln(a.labels).flatMap(e=>e.hosts[0]?[{host:e.hosts[0],at:$r(a,e.router)}]:[]).find(e=>e.at);u||D(i.proxy?`${i.proxy} is your own traefik and serves no dashboard on a domain; if it serves its API (api.insecure), pass its port: --port 8080`:`traefik on ${n} was set up by an older landfall: \`lf traefik setup -s ${n}\` adds the dashboard tunnel`);let{host:d,at:f}=u;await oi(r,Qr(a,`web`),f.port,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:`through ${d}'s router (it asks for its login)`,as:{name:d,tls:f.tls}})}});function $(e,t){let n=Ne(process.cwd()),r=e??n?.name;return r||D(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...B(t??n?.server)}}const Ti=e=>en(e)?x.green(e):tn(e)?x.dim(e):x.red(e),Ei=N({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:P,json:{type:`boolean`,desc:`machine-readable output`},others:{type:`boolean`,desc:`with --json: {sites, others}, others = containers Traefik routes that landfall did not deploy`}},async run(e,t){let{name:n,server:r}=B(t.server??Ne(process.cwd())?.server),{sites:i,others:a}=await Hn(r);if(t.json)return console.log(JSON.stringify(t.others?{sites:i,others:a}:i,null,2));if(a.length&&(console.log(x.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),M([[x.dim(`NAME`),x.dim(`DOMAINS`),x.dim(`FOLDER`),x.dim(`STATUS`)],...a.map(e=>[e.name,e.domains.join(`, `),e.dir??x.dim(e.containers.join(`, `)),Ti(e.status)])]),i.length&&console.log()),!i.length)return console.log(`no landfall sites on ${n} yet — ${x.bold(`lf deploy --domain example.com`)}`);M([[x.dim(`NAME`),x.dim(`KIND`),x.dim(`DOMAINS`),x.dim(`SERVICES`),x.dim(`RELEASE`),x.dim(`UPDATED`),x.dim(`STATUS`)],...i.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[x.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||x.dim(`-`),e.current||x.dim(`-`),le(e.updatedAt),Ti(t)])])}}),Di=N({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:P},async run([e],t){let{site:n,server:r}=$(e,t.server),i=(await Hn(r)).sites.find(e=>e.meta.name===n);i||D(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${x.bold(a.name)} ${i.kind} ${Ti(i.status)}`);for(let e of i.domains)console.log(` ${x.cyan(`https://`+e)}`);if(console.log(x.dim(` ${a.adopted??Q(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${x.bold(`Services`)}`),M(i.services.map(e=>[` ${e.service}`,x.dim(e.container),Ti(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${x.bold(`Releases`)}`);for(let e of Nn(a)){let t=e.id===a.current?x.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${x.dim(le(e.at))} ${x.dim(e.services.map(e=>e.name).join(`, `))}${n.length?x.dim(` → ${n.join(`, `)}`):``}`)}}}}),Oi=N({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:P,..._i},async run([e,t],n){let{site:r,server:i}=$(e,n.server);return tr(i,r,{...n,service:t})}}),ki=(e,t,n,r,i)=>N({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:P},async run([a,o],s){let{site:c,server:l}=$(a,s.server);await Mn(l,c);let u=o?` ${J(o)}`:``,d=`record ${J(`${e} ${c}${o?` `+o:``}`)}`,f=`[ -n "$(cd -P current && dc ps -q --status running${u} 2>/dev/null)" ]`,p=r?`if ${i?``:`! `}${f}; then ${d} ${J(`cd -P ${Q(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await er(l,c,t+u,p),C(`${o?`${c} ${o}`:c} ${n}`)}}),Ai=ki(`restart`,`restart`,`restarted`),ji=ki(`stop`,`stop`,`stopped`,`start`,!0),Mi=ki(`start`,`up -d --wait`,`started`,`stop`,!1),Ni=N({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:P,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=$(e,t.server),{meta:i,proxy:a}=await Mn(r,n,{deployed:`roll back`}),o=Nn(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&D(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||D(t.to?`no release ${t.to}`:`no release older than ${i.current}`),await Qn(r,i,a,`rollback ${n} from ${i.current} to ${c.id}`,e=>void(e.current=c.id)),C(`${n} rolled back to ${x.bold(c.id)}`)}}),Pi=N({name:`adopt`,args:`<project>`,summary:"Make a Compose project landfall did not deploy a site: `lf sites`, logs, restart,\nstop and start then work for it. It keeps running from its folder, with its own files and\nTraefik labels; landfall does not deploy, roll back or change its domains. `lf sites rm`\nstops it and forgets it again. The projects you can adopt are listed under `lf sites`.",options:{server:P,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||D("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=B(t.server),i=await Hn(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>Gt(t.meta)===e);D(t?`${e} is already the site ${t.meta.name}`:`no Compose project "${e}" with Traefik routes on ${r}. See \`lf sites\``)}let o=a;o.dir||D(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=kn(t.name??Sn(e));i.sites.some(e=>e.meta.name===s)&&D(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...an,...on].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&D(`${e} was started with ${l.join(`, `)}; rename it to compose.yaml in ${o.dir} (or adopt it after moving it there)`);let u=(/* @__PURE__ */ new Date()).toISOString();await Wn(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),C(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(x.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),Fi=N({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
642
+ goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:F,local:{type:`string`,short:`l`,desc:`local port (default: 8080, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`},port:{type:`string`,desc:`with a Traefik of your own: the port of its API (api.insecure: 8080)`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await wi(r),a=await oi(r,[`name=^${i.proxy??`traefik`}$`]);a||k(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=Un(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?je(t.port,`--port`,1,65535):!i.proxy&&Gn(a.labels)?Ue:void 0,l=`traefik dashboard of ${n}`;if(c)return hi(r,si(a,`web`),c,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:s});let u=Wn(a.labels).flatMap(e=>e.hosts[0]?[{host:e.hosts[0],at:ci(a,e.router)}]:[]).find(e=>e.at);u||k(i.proxy?`${i.proxy} is your own traefik and serves no dashboard on a domain; if it serves its API (api.insecure), pass its port: --port 8080`:`traefik on ${n} was set up by an older landfall: \`lf traefik setup -s ${n}\` adds the dashboard tunnel`);let{host:d,at:f}=u;await hi(r,si(a,`web`),f.port,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:`through ${d}'s router (it asks for its login)`,as:{name:d,tls:f.tls}})}});function Fi(e,t){let n=Be(process.cwd()),r=e??n?.name;return r||k(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...H(t??n?.server)}}const Ii=e=>cn(e)?C.green(e):ln(e)?C.dim(e):C.red(e),Li=P({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:F,json:{type:`boolean`,desc:`machine-readable output`},others:{type:`boolean`,desc:`with --json: {sites, others}, others = containers Traefik routes that landfall did not deploy`}},async run(e,t){let{name:n,server:r}=H(t.server??Be(process.cwd())?.server),i=await Yn(r),{sites:a,others:o}=i;if(t.json)return console.log(JSON.stringify(t.others?i:a,null,2));if(o.length&&(console.log(C.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),ue([[C.dim(`NAME`),C.dim(`DOMAINS`),C.dim(`FOLDER`),C.dim(`STATUS`)],...o.map(e=>[e.name,e.domains.join(`, `),e.dir??C.dim(e.containers.join(`, `)),Ii(e.status)])]),a.length&&console.log()),!a.length)return console.log(`no landfall sites on ${n} yet — ${C.bold(`lf deploy --domain example.com`)}`);ue([[C.dim(`NAME`),C.dim(`KIND`),C.dim(`DOMAINS`),C.dim(`SERVICES`),C.dim(`RELEASE`),C.dim(`UPDATED`),C.dim(`STATUS`)],...a.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[C.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||C.dim(`-`),e.current||C.dim(`-`),de(e.updatedAt),Ii(t)])])}}),Ri=P({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:F},async run([e],t){let{site:n,server:r}=Fi(e,t.server),i=(await Yn(r)).sites.find(e=>e.meta.name===n);i||k(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${C.bold(a.name)} ${i.kind} ${Ii(i.status)}`);for(let e of i.domains)console.log(` ${C.cyan(`https://`+e)}`);if(console.log(C.dim(` ${a.adopted??$(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${C.bold(`Services`)}`),ue(i.services.map(e=>[` ${e.service}`,C.dim(e.container),Ii(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${C.bold(`Releases`)}`);for(let e of Bn(a)){let t=e.id===a.current?C.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${C.dim(de(e.at))} ${C.dim(e.services.map(e=>e.name).join(`, `))}${n.length?C.dim(` → ${n.join(`, `)}`):``}`)}}}}),zi=P({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:F,...Di},async run([e,t],n){let{site:r,server:i}=Fi(e,n.server);return cr(i,r,{...n,service:t})}}),Bi=(e,t,n,r,i)=>P({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:F},async run([a,o],s){let{site:c,server:l}=Fi(a,s.server);await zn(l,c);let u=o?` ${Y(o)}`:``,d=`record ${Y(`${e} ${c}${o?` `+o:``}`)}`,f=`[ -n "$(cd -P current && dc ps -q --status running${u} 2>/dev/null)" ]`,p=r?`if ${i?``:`! `}${f}; then ${d} ${Y(`cd -P ${$(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await sr(l,c,t+u,p),T(`${o?`${c} ${o}`:c} ${n}`)}}),Vi=Bi(`restart`,`restart`,`restarted`),Hi=Bi(`stop`,`stop`,`stopped`,`start`,!0),Ui=Bi(`start`,`up -d --wait`,`started`,`stop`,!1),Wi=P({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:F,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=Fi(e,t.server),{meta:i,proxy:a}=await zn(r,n,{deployed:`roll back`}),o=Bn(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&k(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||k(t.to?`no release ${t.to}`:`no release older than ${i.current}`),await ar(r,i,a,`rollback ${n} from ${i.current} to ${c.id}`,e=>void(e.current=c.id)),T(`${n} rolled back to ${C.bold(c.id)}`)}}),Gi=P({name:`adopt`,args:`<project>`,summary:"Make a Compose project landfall did not deploy a site: `lf sites`, logs, restart,\nstop and start then work for it. It keeps running from its folder, with its own files and\nTraefik labels; landfall does not deploy, roll back or change its domains. `lf sites rm`\nstops it and forgets it again. The projects you can adopt are listed under `lf sites`.",options:{server:F,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||k("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=H(t.server),i=await Yn(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>Qt(t.meta)===e);k(t?`${e} is already the site ${t.meta.name}`:`no Compose project "${e}" with Traefik routes on ${r}. See \`lf sites\``)}let o=a;o.dir||k(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=In(t.name??kn(e));i.sites.some(e=>e.meta.name===s)&&k(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...fn,...pn].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&k(`${e} was started with ${l.join(`, `)}; rename it to compose.yaml in ${o.dir} (or adopt it after moving it there)`);let u=(/* @__PURE__ */ new Date()).toISOString();await Zn(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),T(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(C.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),Ki=P({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
626
643
 
627
644
  Named volumes (databases, uploads) stay, so deploying the site again finds its data;
628
- --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:P,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||D(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=$(e,t.server),{meta:a}=await jn(r,n);a||D(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=nn(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await A(o.adopted?`Stop ${n} (${s}) on ${i} and forget it${c}? ${o.adopted} stays.`:`Delete ${n} (${s})${c} from ${i}? This cannot be undone.`,n),await rr(r,o,{volumes:t.volumes}),C(`removed ${n}${t.volumes?` and its volumes`:x.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),Ii=N({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:P,unset:{type:`string`,short:`u`,multiple:!0,desc:`remove KEY`}},async run(e,t){let n=e.filter(e=>e.includes(`=`)),[r]=e.filter(e=>!e.includes(`=`)),{site:i,server:a}=$(r,t.server),{env:o}=await Mn(a,i,{deployed:`manage the .env of`});if(!n.length&&!t.unset?.length)return void process.stdout.write(o||x.dim(`(empty)
629
- `));let s=ir(o,ar(n),t.unset??[]),c=[...n.map(e=>e.slice(0,e.indexOf(`=`))),...(t.unset??[]).map(e=>`-${e}`)];await er(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
630
- record ${J(`env ${i} ${c.join(` `)}`)} "cd ${Q(i)} && $u && cd -P current && dc up -d"
631
- ${X(`.env`,s,`600`)}`),C(`updated env of ${i}`)}}),Li=N({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
645
+ --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:F,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||k(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=Fi(e,t.server),{meta:a}=await Rn(r,n);a||k(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=un(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await M(o.adopted?`Stop ${n} (${s}) on ${i} and forget it${c}? ${o.adopted} stays.`:`Delete ${n} (${s})${c} from ${i}? This cannot be undone.`,n),await ur(r,o,{volumes:t.volumes}),T(`removed ${n}${t.volumes?` and its volumes`:C.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),qi=P({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:F,unset:{type:`string`,short:`u`,multiple:!0,desc:`remove KEY`},"env-file":{type:`string`,desc:`also set the KEY=VALUE lines of this file; - reads stdin (keeps values out of ps)`}},async run(e,t){let[n]=e.filter(e=>!e.includes(`=`)),r={...t[`env-file`]?await pr(t[`env-file`]):{},...mr(e.filter(e=>e.includes(`=`)))},{site:i,server:a}=Fi(n,t.server),{env:o}=await zn(a,i,{deployed:`manage the .env of`});if(!Object.keys(r).length&&!t.unset?.length)return void process.stdout.write(o||C.dim(`(empty)
646
+ `));let s=dr(o,r,t.unset??[]),c=[...Object.keys(r),...(t.unset??[]).map(e=>`-${e}`)];await sr(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
647
+ record ${Y(`env ${i} ${c.join(` `)}`)} "cd ${$(i)} && $u && cd -P current && dc up -d"
648
+ ${Z(`.env`,s,`600`)}`),T(`updated env of ${i}`)}}),Ji=P({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
632
649
 
633
650
  Nothing changes on the server: no port is published and its domains stay as they are,
634
651
  so it also works for a site with no domain. Traefik is bypassed: no HTTPS and no login.
635
652
  Works for any Compose project or container on the server too, not only landfall's sites.
636
- The tunnel stays open until Ctrl-C.`,options:{server:P,port:{type:`string`,desc:`container port (default: the one Traefik routes to)`},local:{type:`string`,short:`l`,desc:`local port (default: the container port, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`}},async run([e,t],n){let{site:r,server:i}=$(e,n.server),{meta:a,proxy:o}=await jn(i,r);if(!a)return Ri(i,r,t,o.network,n);Wt(a)&&D(`${r} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`);let s=Kt(a),c=t?s?.services.find(e=>e.name===t):Xt(a)??s?.services.find(qt);t&&s&&!c&&D(`${r} has no service ${t} (${s.services.map(e=>e.name).join(`, `)})`);let l=c?.name??t;l||D(`which service of ${r}? lf tunnel ${r} <service>`);let u=await Zr(i,[`label=com.docker.compose.project=${Gt(a)}`,`label=com.docker.compose.service=${l}`]);u||D(`${l} of ${r} is not running (lf start ${r})`),await zi(i,u,o.network,n.port??c?.port,{local:n.local,lan:n.lan,what:`${r}${t?` (${l})`:``}`})}});async function Ri(e,t,n,r,i){let a=[`label=com.docker.compose.project=${t}`,...n?[`label=com.docker.compose.service=${n}`]:[]],o=await Zr(e,[...a,`label=traefik.enable=true`])??await Zr(e,a)??(n?void 0:await Zr(e,[`name=^/?${t}$`]));o||D(`no site, Compose project or container "${t}"${n?` with a service ${n}`:``} running on ${e.host}. See \`lf sites list\``);let s=o.labels[`com.docker.compose.service`];await zi(e,o,r,i.port,{local:i.local,lan:i.lan,what:`${t}${s&&s!==t?` (${s})`:``}`})}async function zi(e,t,n,r,i){let a=Object.entries(t.labels).find(([e])=>ln.test(e))?.[1],o=r??a??t.ports[0];o===void 0&&D(`which port of ${t.name}? pass --port`);let s=we(String(o),`--port`,1,65535);r===void 0&&a===void 0&&t.ports.length>1&&w(x.dim(`${t.name} also exposes ${t.ports.slice(1).join(`, `)}: pick one with --port`));let c=ti(s);await oi(e,Qr(t,n),s,{...i,scheme:c,note:c===`http`?`straight to the container: no HTTPS, no login, and the app sees Host: ${i.lan?`localhost or this machine's address`:`localhost`}`:`straight to the container: point a ${c} client at it, with the container's own credentials`})}async function Bi(e){try{return{status:(await fetch(`https://${e}`,{method:`HEAD`,signal:AbortSignal.timeout(8e3),redirect:`manual`})).status}}catch(e){return{error:e?.cause?.code??e?.code??e?.message??String(e)}}}async function Vi(e,t,n,r,i,a){let o=await Hn(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&T(`${t.join(`, `)} ${t.length>1?`are`:`is`} also routed to ${e.name}${e.dir?` (${e.dir})`:``}, which landfall did not deploy: Traefik sends visitors to only one of them. Remove the labels there to hand ${t.length>1?`them`:`it`} over.`)}let s=o.sites.map(e=>e.meta).filter(e=>e.name!==n&&nn(e).some(e=>r.includes(e)));for(let e of s){let t=nn(e).filter(e=>r.includes(e)),i=Xt(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=Wt(e)?`redeploy it first (lf deploy) so it can hand them over`:e.adopted?`its compose files in ${e.adopted} route ${i.length>1?`them`:`it`}`:`its compose.yaml routes ${i.length>1?`them`:`it`} (x-landfall.domains)`;D(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&D(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`);let o=t.length===e.domains.length?` ${e.name} keeps running with no domain.`:``;await A(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?${o}`)}return async()=>{for(let a of s)await Qn(e,a,i,`moved ${a.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`,e=>void(e.domains=e.domains.filter(e=>!r.includes(e)))),On(t,a.name,a.domains),w(x.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const Hi=N({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
653
+ The tunnel stays open until Ctrl-C.`,options:{server:F,port:{type:`string`,desc:`container port (default: the one Traefik routes to)`},local:{type:`string`,short:`l`,desc:`local port (default: the container port, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`}},async run([e,t],n){let{site:r,server:i}=Fi(e,n.server),{meta:a,proxy:o}=await Rn(i,r);if(!a)return Yi(i,r,t,o.network,n);Zt(a)&&k(`${r} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`);let s=$t(a),c=t?s?.services.find(e=>e.name===t):rn(a)??s?.services.find(en);t&&s&&!c&&k(`${r} has no service ${t} (${s.services.map(e=>e.name).join(`, `)})`);let l=c?.name??t;l||k(`which service of ${r}? lf tunnel ${r} <service>`);let u=await oi(i,[`label=com.docker.compose.project=${Qt(a)}`,`label=com.docker.compose.service=${l}`]);u||k(`${l} of ${r} is not running (lf start ${r})`),await Xi(i,u,o.network,n.port??c?.port,{local:n.local,lan:n.lan,what:`${r}${t?` (${l})`:``}`})}});async function Yi(e,t,n,r,i){let a=[`label=com.docker.compose.project=${t}`,...n?[`label=com.docker.compose.service=${n}`]:[]],o=await oi(e,[...a,`label=traefik.enable=true`])??await oi(e,a)??(n?void 0:await oi(e,[`name=^/?${t}$`]));o||k(`no site, Compose project or container "${t}"${n?` with a service ${n}`:``} running on ${e.host}. See \`lf sites list\``);let s=o.labels[`com.docker.compose.service`];await Xi(e,o,r,i.port,{local:i.local,lan:i.lan,what:`${t}${s&&s!==t?` (${s})`:``}`})}async function Xi(e,t,n,r,i){let a=Object.entries(t.labels).find(([e])=>gn.test(e))?.[1],o=r??a??t.ports[0];o===void 0&&k(`which port of ${t.name}? pass --port`);let s=je(String(o),`--port`,1,65535);r===void 0&&a===void 0&&t.ports.length>1&&E(C.dim(`${t.name} also exposes ${t.ports.slice(1).join(`, `)}: pick one with --port`));let c=ui(s);await hi(e,si(t,n),s,{...i,scheme:c,note:c===`http`?`straight to the container: no HTTPS, no login, and the app sees Host: ${i.lan?`localhost or this machine's address`:`localhost`}`:`straight to the container: point a ${c} client at it, with the container's own credentials`})}async function Zi(e){let t=e,n=await Vr(t);n.values.sudo!==`1`&&(t=await hr(e,`the firewall`),n=await Vr(t));let r=n.values,i=Number(r.ssh_port)||Q(e).port;return{login:t,ufw:r.ufw===`active`?`active`:r.ufw===`missing`?`missing`:`inactive`,policy:r.ufw_in||`DROP`,sshPort:i,rules:n.rules.map(Kr),ports:Qr(n),blocked:Zr(n,[i]).blocked,probe:n}}const Qi=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,$i=/^[0-9a-fA-F.:]+(\/\d+)?$/;function ea(e){Qi.test(e.port)||k(`not a port: ${e.port} (e.g. 5432, 51820/udp, 6000:6007/tcp)`);let[t,n]=e.port.split(`/`);t.split(/[,:]/).some(e=>Number(e)<1||Number(e)>65535)&&k(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&k(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!$i.test(e.from)&&k(`not an address or network: ${e.from} (e.g. 203.0.113.7 or 10.0.0.0/8)`);let r=e.from?[e.action,`from`,e.from,`to`,`any`,`port`,t,...n?[`proto`,n]:[]]:[e.action,e.port];return e.comment?[...r,`comment`,e.comment]:r}const ta=e=>`ufw_add ${e.map(Y).join(` `)}`,na=e=>e[0]===`deny`?`${ta([`prepend`,...e])} 2>/dev/null || ${ta(e)}`:ta(e),ra=(e,t)=>`${t?`$SUDO ufw insert ${t} ${e.spec} >/dev/null 2>&1 || `:``}$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`,ia=(e,t)=>`$SUDO ufw --force delete ${e.spec} >/dev/null
654
+ record ${Y(`ufw delete ${e.spec}`)} ${Y(ra(e,t))}`,aa=(e,t)=>e.rules.slice(0,t).some(e=>e.from?.includes(`:`))?void 0:t,oa=e=>Kr(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),sa=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function ca(e,t,n){if(e.ufw!==`active`)return;let r=qr(t,{proto:`tcp`,port:e.sshPort})?.rule;if(r&&(r.action===`allow`||r.action===`limit`)&&!r.from)return;let i=r?.from?`only ${r.from} could still reach ssh port ${e.sshPort}`:`this blocks ssh port ${e.sshPort} and can lock you out`;n||await le(`${i}. Do it anyway?`)||k(`${i} (--force to do it anyway)`)}function la(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||k(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const ua={server:F,from:{type:`string`,desc:`only from this address or network (e.g. 10.0.0.0/8)`},comment:{type:`string`,desc:`note stored with the rule`}};function da(e){return P({name:`firewall ${e}`,args:`<port[/proto]>`,summary:e===`allow`?"Open a port in ufw, e.g. `lf firewall allow 5432 --from 10.0.0.0/8` or `lf firewall allow 51820/udp`.":"Block a port in ufw, e.g. `lf firewall deny 5432`. Goes before the other rules, so it wins over an allow.",options:{...ua,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||k(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=ea({action:e,port:t,from:n.from,comment:n.comment}),i=await Zi(H(n.server).server);e===`deny`&&await ca(i,[oa(r),...i.rules],n.force),await X(i.login,`${J}\n${sa}\n${na(r)}`),T(`${r.join(` `)}${i.ufw===`active`?``:C.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const fa=da(`allow`),pa=da(`deny`),ma=P({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:F,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=H(t.server);if(t.json)return void console.log(JSON.stringify(await uo(r),null,2));let i=await Zi(r),a=i.ufw===`active`?C.green(`on`):i.ufw===`missing`?C.yellow(`not installed`):C.yellow(`off`);w(`firewall on ${n}: ${a}${i.ufw===`active`?C.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${C.bold(`Rules`)}`),i.rules.length||E(C.dim(`none`)),ue(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?C.green(e.action):C.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${C.bold(`Listening ports`)}`);let o={open:C.green,limited:C.yellow,blocked:C.red,local:C.dim};ue(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?D(`enabling would block ${i.blocked.join(`, `)}: allow them first`):E(C.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),ha=P({name:`firewall enable`,summary:`Turn ufw on (installs it if missing). Always allows the ssh port first; refuses if it would block a port in use.`,options:{server:F,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await Zi(r);if(i.ufw===`active`)return T(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await le(`${e}. Turn it on anyway?`)||k(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await ca({...i,ufw:`active`},[...i.rules,oa([`allow`,`${i.sshPort}/tcp`])],t.force),await X(i.login,`${J}
655
+ command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
656
+ ${ta([`allow`,`${i.sshPort}/tcp`])}
657
+ ufw_enable`),T(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),ga=P({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:F},async run(e,t){let{name:n,server:r}=H(t.server),i=await Zi(r);if(i.ufw!==`active`)return T(`firewall on ${n} is already off`);await N(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||k(`cancelled`),await X(i.login,`${J}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),T(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),_a=P({name:`firewall rm`,args:`<number|rule>`,summary:"Remove a rule by its number in `lf firewall`, or by its text (e.g. 'allow 5432').",options:{server:F,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||k(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=H(t.server),i=await Zi(r),{rule:a,n:o}=la(i,e);await ca(i,i.rules.filter(e=>e!==a),t.force),await M(`Remove rule ${o} (${a.spec}) on ${n}?`),await X(i.login,`${J}\n${sa}\n${ia(a,aa(i,o))}`),T(`removed rule ${o}: ${a.spec}`)}}),va=P({name:`firewall update`,args:`<number|rule>`,summary:`Change a rule: what you pass replaces that part, the rest stays. If ufw rejects the new rule, the old one is restored.`,options:{...ua,port:{type:`string`,desc:`port[/proto], e.g. 5432 or 51820/udp`},from:{type:`string`,desc:`only from this address or network; 'any' for everyone`},action:{type:`string`,desc:`allow, deny or limit`},force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||k(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await Zi(H(t.server).server),{rule:r,n:i}=la(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||k(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||k(`rule ${i} (${r.spec}) names no port; pass --port`);let s=ea({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=aa(n,i),l=n.rules.filter(e=>e!==r);await ca(n,c?n.rules.map(e=>e===r?oa(s):e):a===`deny`?[oa(s),...l]:[...l,oa(s)],t.force);let u=c&&c<n.rules.length?`${ta([`insert`,String(c),...s])} || ${na(s)}`:na(s);await X(n.login,`${J}
658
+ ${sa}
659
+ ${ia(r,c)}
660
+ ${u} || { ${ra(r,c)}; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),T(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}}),ya=e=>e.steps.filter(e=>e.undo&&!e.undone);function ba(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
661
+ `).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}const xa=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Sa=RegExp(`${xa(W)}/[A-Za-z0-9._-]+|${xa(G)}`,`g`);function Ca(e){let t=/* @__PURE__ */ new Set;for(let n of e.matchAll(Sa))t.add(n[0]);for(let n of e.matchAll(/restore_file \S+ '?(\/[^\s';]+)/g))t.add(n[1]);return t}function wa(e,t,n){let r=new Set(n.filter(e=>e.undo&&!e.undone).flatMap(e=>[...Ca(e.undo)])),i=[];for(let n of e.filter(e=>e.n>t)){let e=ya(n).flatMap(e=>[...Ca(e.undo)]).find(e=>r.has(e));e&&i.push({run:n,subject:e})}return i}async function Ta(e){let{stdout:t}=await X(e,`cat ${q} 2>/dev/null || sudo -n cat ${q} 2>/dev/null || true`,{capture:!0});return ba(t)}function Ea(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||k(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||k(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function Da(e,t){let n=Q(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await X(e,`sudo -n true 2>/dev/null && echo yes || true`,{capture:!0});if(r.trim()===`yes`)return{login:e,sudo:!0};let i={...e,user:`root`};return await _t(i)?{login:i}:k(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const Oa=String.raw`
662
+ undo_step() { # undo_step <line> <undo shell, base64>
663
+ # Two undos at once must not both revert a step: the second waits here, then finds it done.
664
+ [ -e ${U}/.undo.lock ] || touch ${U}/.undo.lock 2>/dev/null || true
665
+ if [ -r ${U}/.undo.lock ] && command -v flock >/dev/null 2>&1; then exec 9<${U}/.undo.lock; flock 9; fi
666
+ if awk -F'\t' -v l="$1" '$6 == "undo" && $7 == l {f = 1} END {exit !f}' ${q} 2>/dev/null; then
667
+ echo "journal line $1 was already undone" >&2
668
+ else
669
+ set +e
670
+ ( set -e; eval "$(printf %s "$2" | base64 -d)" )
671
+ rc=$?
672
+ set -e
673
+ [ "$rc" = 0 ] || { echo "could not undo journal line $1 (exit $rc); later steps are undone, the rest is kept" >&2; exit 1; }
674
+ _journal undo "$1" ""
675
+ fi
676
+ exec 9<&-
677
+ }`;async function ka(e,t){let n=t.filter(e=>e.undo&&!e.undone).sort((e,t)=>t.line-e.line),r=[e];for(let t=0;t<n.length;){let i=t;for(;i<n.length&&n[i].user===n[t].user;)i++;let a=n.slice(t,i),o=a.map(e=>`undo_step ${e.line} ${Y(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await Da(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(ft)),await X(s,[J,Oa,...o].join(`
678
+ `),{sudo:c}),r.push(s),t=i}return n.length}function Aa(e,t){let n=C.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${C.dim(`↩ `+e.what)}`:e.undone?`${n} ${C.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${C.cyan(`↺`)} ${e.what}`:`${n} ${C.dim(`· `+e.what+` (not reversible)`)}`}const ja=e=>e.trim().replace(/\\(.)/g,`$1`),Ma=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function Na(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await Zi(e),i=[...r.rules],a=r.ufw;for(let e of[...t].sort((e,t)=>t.line-e.line))for(let t of e.undo.split(`
679
+ `)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend |insert (\d+) )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=Kr(ja(e[1])),n=i.findIndex(e=>Ma(e,t));n>=0&&i.splice(n,1)}else if(n){let e=Kr(ja(n[3]));n[2]?i.splice(Number(n[2])-1,0,e):i=n[1]?[e,...i]:[...i,e]}else/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await ca({...r,ufw:a},i,n)}const Pa=e=>`${C.bold(`#${e.n}`.padEnd(5))} ${C.dim(de(e.time).padEnd(9))} ${C.bold(e.cmd||`?`)} ${C.dim(`${e.actor} · ${e.id}`)}`,Fa=P({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:F,n:{type:`string`,short:`n`,desc:`runs to show`,default:`20`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=H(t.server);if(t.json)return console.log(JSON.stringify(await mo(r,Number(t.n)||20),null,2));let i=(await Ta(r)).reverse().slice(0,Number(t.n)||20);if(!i.length)return E(`nothing in ${q} on ${n} yet`);for(let e of i)console.log(Pa(e)),e.steps.forEach((e,t)=>console.log(Aa(e,t)));let a=i.find(e=>ya(e).length);a&&console.log(C.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),Ia=P({name:`undo`,args:`[run|run.step]`,summary:"Revert what landfall did: the latest run, run <n> from `lf history`, one step <n>.<m>, or --all.",options:{server:F,all:{type:`boolean`,desc:`everything landfall did that can be reverted`},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule, or a later run changed the same site or file`}},async run([e],t){let{name:n,server:r}=H(t.server),i=await Ta(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let r=e?Ea(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>ya(e.run).length);if(!r)return T(`nothing to undo on ${n}`);({steps:a}=r),o=`#${r.run.n} ${r.run.cmd} (${de(r.run.time)})`;let s=wa(i,r.run.n,a);s.length&&!t.force&&k(`later runs changed the same things: ${s.map(e=>`#${e.run.n} ${e.run.cmd} (${e.subject})`).join(`, `)}. Undo those first, newest first, or pass --force`);let c=i.filter(e=>e.n>r.run.n&&ya(e).length);c.length&&D(`later runs build on it and stay: ${c.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return T(`nothing left to undo in ${o}`);w(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${C.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${C.dim(`· ${e.what} stays (not reversible)`)}`);await Na(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await M(l,n):await N(l,!1)||k(`cancelled`),await ka(r,s),T(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&E(C.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),La=/^(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org)$/;function Ra(){let e=V(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=Et().filter(t=>!e.servers[t]&&!La.test(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}function za(e){try{let t=Q(H(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function Ba(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)}let Va;function Ha(){if(Va)return Va;let e=o.mkdtempSync(m(u(),`landfall-`));return Va=m(e,`askpass`),o.writeFileSync(Va,`#!/bin/sh
680
+ printf '%s\\n' "$LANDFALL_PASSWORD"
681
+ `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Va}const Ua=e=>({SSH_ASKPASS:Ha(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Wa(e){if(e){let e=(await x()).replace(/\r?\n$/,``);return e||k(`--password-stdin: nothing on stdin`),e}return _e(`PASSWORD`)||void 0}const Ga=e=>m(c(),`.ssh`,`landfall_${Ce(e)}`);function Ka(e){let t=Ga(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(m(c(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&k(`ssh-keygen failed`),{path:t,created:!0})}async function qa(e,t,n){let i=Q(t),a=t.key?z(t.key):void 0;if(a&&!o.existsSync(a)&&k(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&k(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await _t(t);if(!s&&!t.key&&o.existsSync(Ga(e))&&await _t({...t,key:Ga(e)})&&(t.key=a=Ga(e),s=!0),s)T(`key login works`),a??=Tt(t);else if(n.check){D("no key login yet — `lf setup` would install a key first"),oe({needsKey:!0});return}let c=!1;if(!a&&!n.check){let t=Ka(e);a=t.path,c=t.created,c&&T(`generated key ${B(a)}`)}let l=t.key&&z(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await _t(t))){w(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Ua(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...ct(t),st(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await _t(t)||k(`could not log in with the key — check host, user and password`),T(`key installed`)}return a}const Ja=e=>{for(let t of e)console.log(C.dim(` `)+t)};function Ya(e,t,n,r){let i=Q(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Mt({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});T(`${r.created?`created`:`updated`} ${B(Ct)} with ${C.bold(`Host ${e}`)} — try \`ssh ${e}\``),Ja(r.block.split(`
682
+ `)),a.host=e,delete a.user,delete a.port,delete a.key}else{t.user&&(a.user=t.user),r.port&&(a.port=Number(r.port));let o=r.keyGiven||n!==Tt({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Pt(e,n):void 0;s?(T(`added the key to your ${C.bold(`Host ${e}`)} in ${B(s.file)} ${C.dim(`(backup: ${B(s.backup)})`)}`),Ja([...s.removed.map(e=>C.red(`- ${e}`)),C.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=B(n)),r.ownAlias&&E(C.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Xa=P({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
683
+
684
+ Without --host, <name> is used as an ssh alias from ~/.ssh/config,
685
+ so \`lf server add vps\` works whenever \`ssh vps\` works.
686
+
687
+ With --host lf logs in with --key, or installs its own key
688
+ (~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
689
+ to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
690
+ The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||k(`usage: lf server add <name> [--host ...]`);let n=t.domain&&Oe(t.domain),r=V(),i=r.servers[e],a=Et().includes(e)&&!It(e);t.host&&a&&k(`${e} is already a Host in your ssh config; pick another name or drop --host`);let o={host:t.host??i?.host??e,user:t.user??(t.host?`root`:i?.user),port:t.port?Number(t.port):i?.port,key:t.key??i?.key},s=await Wa(t[`password-stdin`]),c=await qa(e,o,{check:!s&&!se(),password:s}),l=c?Ya(e,o,c,{existing:i,sshConfig:t[`ssh-config`],ownAlias:a,port:t.port,keyGiven:!!t.key}):{...i,...o};l.key||delete l.key,l.user||delete l.user,l.port||delete l.port,t.email&&(l.email=De(t.email)),n&&(l.domains=Me(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),be(r),T(`added ${C.bold(e)}${r.default===e?C.dim(` (default)`):``}`),E(c?C.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${C.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),Za=P({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`},json:{type:`boolean`,desc:`machine-readable output (what the web UI lists)`}},async run(e,t){let n=V();if(t.json){let e=Ra().filter(e=>e.registered||t.all),r=e=>`${e.user?e.user+`@`:``}${e.host}${e.port?`:`+e.port:``}`;console.log(JSON.stringify(e.map(e=>({...e,target:e.registered?r(n.servers[e.name]):e.name})),null,2));return}let r=[[C.dim(`NAME`),C.dim(`TARGET`),C.dim(`SOURCE`)]];for(let[e,t]of Object.entries(n.servers)){let i=`${t.user?t.user+`@`:``}${t.host}${t.port?`:`+t.port:``}`;r.push([e+(n.default===e?C.green(` *`):``),i,`landfall`])}if(t.all)for(let e of Et())n.servers[e]||r.push([e,e,It(e)?`ssh (landfall)`:`ssh`]);r.length===1?console.log(`no servers yet — ${C.bold(`lf server add <ssh-alias>`)} or ${C.bold(`lf setup <name> --host <ip>`)}`):(ue(r),t.all||console.log(C.dim(`
691
+ Any ~/.ssh/config alias works too (--all to list them).`)))}}),Qa=P({name:`server rm`,args:`<name>`,summary:`Forget a server (nothing is changed on the server itself).`,options:{"ssh-config":{type:`boolean`,desc:`also remove the landfall-managed Host block from ~/.ssh/config`}},async run([e],t){e||k(`usage: lf server rm <name>`),!V().servers[e]&&!It(e)&&k(`unknown server "${e}"`),await M(`Forget server ${e}? Nothing on the server changes.`),xe(e),t[`ssh-config`]&&Ft(e)&&T(`removed Host ${e} from ~/.ssh/config`),T(`removed ${e}`)}}),$a=/^(setup|traefik setup|traefik use)( |$)/,eo=P({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${U} and what setup changed.
692
+
693
+ Deletes every site landfall deployed (named volumes stay unless --volumes) and forgets
694
+ adopted ones, which keep running. Stops landfall's Traefik (a Traefik of your own is left
695
+ alone), reverts what \`lf setup\` changed (deploy user, ufw, ssh hardening, docker network)
696
+ and deletes ${U}, its journal included. Then forgets the server locally.
697
+ Docker, packages, landfall's key for root and rules from \`lf firewall\` stay.`,options:{volumes:{type:`boolean`,desc:`also delete the sites' named volumes (their data)`},"keep-setup":{type:`boolean`,desc:`keep the deploy user, ufw and ssh hardening; only remove sites, traefik and `+U},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||k(`usage: lf server uninstall <name>`);let{name:n,server:r}=H(e),i=await Da(r,`root`),[a,s,c]=await Promise.all([Yn(r),wi(r),Ta(r)]);s.compose===`foreign`&&k(`${G}/docker-compose.yml was not written by landfall: move it out of ${U} first`);let l=t[`keep-setup`]?[]:c.filter(e=>$a.test(e.cmd)).flatMap(ya).filter(e=>!e.undo.includes(U));w(`uninstall landfall from ${C.bold(n)}`);for(let e of a.sites){let n=e.meta.adopted?`forget ${e.meta.name} (keeps running from ${e.meta.adopted})`:`delete ${e.meta.name} (${e.domains.join(`, `)||`no domains`})`;console.error(` ${C.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${C.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${C.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${C.cyan(`↺`)} ${e.what}`);console.error(` ${C.red(`-`)} delete ${U}, its journal included`),console.error(` ${C.red(`-`)} forget ${n} locally`),console.error(` ${C.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await Na(r,l,t.force),await M(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[J];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${Y($(e.name))}`]:lr(e,{volumes:t.volumes});u.push(`log ${Y(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${Y(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${G} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await X(r,u.join(`
698
+ `)),l.length&&(w(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await ka(r,l)),await X(i.login,`rm -rf ${U}`,{sudo:i.sudo}),T(`landfall removed from ${n}`),xe(n),Ft(n)&&T(`removed Host ${n} from ~/.ssh/config`),T(`forgot ${n}`),o.existsSync(Ga(n))&&E(C.dim(`${B(Ga(n))} stays: root on ${n} still accepts it`))}}),to=P({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||k(`usage: lf server use <name>`),Se(e),T(`default server: ${C.bold(e)}`)}}),no=P({name:`ssh`,args:`[name] [-- command...]`,summary:`Open a shell on a server, or run a command there.`,passthrough:!0,async run([e],t,n){let{server:r}=H(e);return mt(r,n)}}),ro=P({name:`server info`,args:`[name]`,summary:`Show OS, hardware, load, memory, disk, addresses and containers of a server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=H(e);if(t.json)return void console.log(JSON.stringify(await po(n,r),null,2));let i=await Bt(r),a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(C.bold(n)+C.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:C.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Xt(i.uptime)}${i.reboot?C.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${Yt(o)} / ${Yt(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${Yt(i.disk.used)} / ${Yt(i.disk.total)}${a(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`docker`,i.docker||`not installed or no access (run lf setup)`]];for(let[e,t]of s)console.log(`${C.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:D)(i.support.note),i.docker&&(console.log(),await X(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function io(e){let t=V(),n=t.servers[e],r=n??{host:e},i=Q(r),a=Nt(r.host),s=bi(r,{});return{name:e,isDefault:t.default===e,configFile:B(ye),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],traefik:{...s,summary:xi(s),dashboard:r.dashboard?.host??null},ssh:{hostname:i.hostname,user:i.user,port:i.port,keys:(r.key?[r.key]:i.identityFiles).map(e=>({file:B(e),exists:o.existsSync(z(e))})),block:a?{file:B(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
699
+ `)}:null}}}const ao=P({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
700
+ the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
701
+ and the Host block in ~/.ssh/config. Nothing connects to the server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=H(e);if(t.json)return void console.log(JSON.stringify(await fo(n,r),null,2));let i=io(n),a=(e,t)=>console.log(` ${C.dim(e.padEnd(10))} ${t}`);if(console.log(`${C.bold(n)}${i.isDefault?C.dim(` (default)`):``}`),console.log(`\n${C.bold(`landfall`)} ${C.dim(i.configFile)}`),!i.saved)a(`entry`,C.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(i.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&a(e,String(t));i.domains.length||a(`domain`,C.dim(`none (lf server domain ${n} example.com)`)),i.domains.forEach((e,t)=>a(t?``:i.domains.length>1?`domains`:`domain`,t?e:`${e} ${C.dim(`(primary: new sites get <site>.${e})`)}`)),a(`traefik`,`${i.traefik.summary}${i.traefik.email?`, ${i.traefik.email}`:``} ${C.dim(`(when landfall installs it)`)}`),a(`dashboard`,i.traefik.dashboard?`https://${i.traefik.dashboard}`:C.dim(`off`)),console.log(`\n${C.bold(`ssh`)} ${C.dim(`ssh -G ${i.saved?.host??n}`)}`),a(`login`,`${i.ssh.user}@${i.ssh.hostname}:${i.ssh.port}`);for(let e of i.ssh.keys)a(`key`,e.exists?e.file:`${e.file} ${C.dim(`(missing)`)}`);if(i.ssh.block){console.log(`\n${C.bold(`ssh config`)} ${C.dim(`${i.ssh.block.file}:${i.ssh.block.line}${i.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of i.ssh.block.text.split(`
702
+ `))console.log(` ${e}`)}else console.log(`\n${C.bold(`ssh config`)} ${C.dim(`no Host block; ssh uses its defaults`)}`)}});async function oo(e,t){let n=await Gt(e),[r,i]=await Jt([t,`landfall-${v(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function so(e){let{stdout:t}=await X(e,`docker ps -q | xargs -r docker inspect -f '{{range $k, $v := .Config.Labels}}{{println $v}}{{end}}' 2>/dev/null || true`,{capture:!0});return[...t.matchAll(/Host\(`([^`]+)`\)/g)].map(e=>e[1])}const co=async e=>Le(await so(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),lo=P({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
703
+
704
+ The first main domain is the primary: \`lf deploy\` serves a new site at
705
+ <site>.<primary>, \`--domain blog\` means blog.<primary>, and
706
+ \`lf traefik setup --dashboard traefik\` puts the dashboard at traefik.<primary>.
707
+ More main domains are listed and DNS-checked; use them with full names
708
+ (\`lf domains blog --add blog.other.org\`). Point a wildcard A record
709
+ (*.<domain>) at the server once per domain, and every site works without more DNS.
710
+
711
+ Domains given as arguments replace the list (the first becomes primary).
712
+ Without any, it lists them and, if there are none, suggests the base domains
713
+ the server's sites already use.`,options:{add:{type:`string`,short:`a`,multiple:!0,desc:`add a main domain (after the primary)`},primary:{type:`string`,desc:`make this main domain the primary (added if new)`},rm:{type:`string`,multiple:!0,desc:`remove a main domain`},clear:{type:`boolean`,desc:`forget all main domains`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=V(),r=e.length>0&&!(e=>!!n.servers[e]||Et().includes(e))(e[0])&&we.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=H(i);if(a.length||t.add||t.rm||t.primary||t.clear){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(Oe),i=t.clear?[]:a.length?a.map(Oe):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(Oe)])].filter(e=>!r.includes(e)),i=Me(i,t.primary&&Oe(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&D(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await M(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,be(n),s.domains=e.domains,T(i.length?`main domains of ${C.bold(o)}: ${i.map((e,t)=>t?e:C.bold(e)).join(`, `)}${i.length>1?C.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let c=s.domains??[];if(!c.length){let e=await co(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:c,suggest:e}));E(`${o} has no main domain yet`);for(let t of e)E(`${t.hosts} routed hosts use ${C.bold(t.domain)} — ${C.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||E(`set one: ${C.bold(`lf server domain ${o} example.com`)}`);return}let l=await Promise.all(c.map(e=>oo(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:c.map((e,t)=>({domain:e,primary:t===0,...l[t]}))},null,2));let u=Math.max(...c.map(e=>e.length))+2,d=[];c.forEach((e,t)=>{let n=l[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?C.green(`dns ok`):e.ips.length?C.yellow(`dns → ${e.ips.join(`, `)}`):C.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?C.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(C.dim(`
714
+ add these DNS records at your DNS provider:
715
+ `)+d.join(`
716
+ `));let f=Ne(s);E(C.dim(`sites: lf deploy -d blog → blog.${f} · dashboard: lf traefik setup --dashboard traefik`))}});async function uo(e){let{login:t,probe:n,...r}=await Zi(e);return{...r,rules:r.rules.map((e,t)=>({n:t+1,...e})),asRoot:Q(t).user===`root`}}async function fo(e,t){let n=io(e),[r,i]=await Promise.all([Promise.all(n.domains.map(e=>oo(t,e))),co(t)]);return{...n,dns:r,suggest:i}}async function po(e,t){let n=await Bt(t),r=await Gt(t,n),i=Kt(r);return{name:e,...n,ips:r,sslipIp:i??null,sslipSuffix:i?qt(``,i).slice(1):null}}async function mo(e,t){return(await Ta(e)).reverse().slice(0,t).map(e=>({...e,pending:ya(e).length}))}async function ho(e,t=!1){let n=await vr(e,t),r=await Nr(e);return{packages:n,images:await Lr(e),engine:{...r,...Pr(r)}}}async function go(e,t){let n=await Gt(e),[r,i]=await Promise.all([Jt(t,n),Promise.all(t.map(_o))]);return{ips:n,domains:r.map((e,t)=>({...e,https:i[t]}))}}async function _o(e){try{return{status:(await fetch(`https://${e}`,{method:`HEAD`,signal:AbortSignal.timeout(8e3),redirect:`manual`})).status}}catch(e){return{error:e?.cause?.code??e?.code??e?.message??String(e)}}}async function vo(e,t,n,r,i,a){let o=await Yn(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&D(`${t.join(`, `)} ${t.length>1?`are`:`is`} also routed to ${e.name}${e.dir?` (${e.dir})`:``}, which landfall did not deploy: Traefik sends visitors to only one of them. Remove the labels there to hand ${t.length>1?`them`:`it`} over.`)}let s=o.sites.map(e=>e.meta).filter(e=>e.name!==n&&un(e).some(e=>r.includes(e)));for(let e of s){let t=un(e).filter(e=>r.includes(e)),i=rn(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=Zt(e)?`redeploy it first (lf deploy) so it can hand them over`:e.adopted?`its compose files in ${e.adopted} route ${i.length>1?`them`:`it`}`:`its compose.yaml routes ${i.length>1?`them`:`it`} (x-landfall.domains)`;k(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&k(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`);let o=t.length===e.domains.length?` ${e.name} keeps running with no domain.`:``;await M(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?${o}`)}return async()=>{for(let a of s)await ar(e,a,i,`moved ${a.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`,e=>void(e.domains=e.domains.filter(e=>!r.includes(e)))),Fn(t,a.name,a.domains),E(C.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const yo=P({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
637
717
 
638
718
  Without flags it lists and checks; --add/--rm/--set change the site (undoable)
639
- and update .landfall.json of remembered projects.`,options:{server:P,add:{type:`string`,short:`a`,multiple:!0,desc:"add a domain (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},rm:{type:`string`,multiple:!0,desc:`remove a domain`},set:{type:`string`,multiple:!0,desc:`replace all domains`},json:{type:`boolean`,desc:`machine-readable output (listing)`}},async run([e],t){let{site:n,name:r,server:i}=$(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await Mn(i,n,a?{deployed:`change the domains of`}:{});if(!a){let e=nn(o),r=await Rt(i),[a,s]=await Promise.all([Vt(e,r),Promise.all(e.map(Bi))]),c=a.map((e,t)=>({...e,https:s[t]}));if(t.json)return void console.log(JSON.stringify({site:n,serverIps:r,domains:c},null,2));for(let e of c){let t=e.ok?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`),n=e.https.status?(e.https.status<400?x.green:e.https.status===401?x.cyan:x.yellow)(`https ${e.https.status}`):x.red(`https ${e.https.error}`);console.log(`${x.cyan(`https://${e.domain}`)} ${t} ${n}`)}c.some(e=>!e.ok)&&console.log(x.dim(`\npoint an A record at ${r.join(` or `)} for each domain`));return}Xt(o)||D(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>De(e.trim(),i)).filter(Boolean),l=t.set?c(t.set):[...o.domains];l=[.../* @__PURE__ */ new Set([...l,...c(t.add)])].filter(e=>!c(t.rm).includes(e));let u=l.filter(e=>!V.test(e));u.length&&D(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&T(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return C(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));if(f.length){let e=l.length?``:` ${n} keeps running, unreachable until it gets a domain again.`;await A(`Remove ${f.join(`, `)} from ${n}? ${f.length>1?`They stop`:`It stops`} serving it.${e}`)}let p=l.filter(e=>!o.domains.includes(e));await(await Vi(i,r,n,p,s))(),await Qn(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),On(r,n,l);let m=Ne(process.cwd());m?.name===n&&Pe(process.cwd(),{...m,domains:l}),l.length?C(`${n} serves ${l.map(e=>x.cyan(`https://${e}`)).join(`, `)}`):C(`${n} serves no domain now ${x.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let h=await Rt(i);for(let e of await Vt(p,h))e.ok||T(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${h.join(` or `)}: HTTPS waits for it`)}}),Ui=N({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
719
+ and update .landfall.json of remembered projects.`,options:{server:F,add:{type:`string`,short:`a`,multiple:!0,desc:"add a domain (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},rm:{type:`string`,multiple:!0,desc:`remove a domain`},set:{type:`string`,multiple:!0,desc:`replace all domains`},json:{type:`boolean`,desc:`machine-readable output (listing)`}},async run([e],t){let{site:n,name:r,server:i}=Fi(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await zn(i,n,a?{deployed:`change the domains of`}:{});if(!a){let{ips:e,domains:r}=await go(i,un(o));if(t.json)return void console.log(JSON.stringify({site:n,ips:e,domains:r},null,2));for(let e of r){let t=e.ok?C.green(`dns ok`):e.ips.length?C.yellow(`dns → ${e.ips.join(`, `)}`):C.red(`no dns`),n=e.https.status?(e.https.status<400?C.green:e.https.status===401?C.cyan:C.yellow)(`https ${e.https.status}`):C.red(`https ${e.https.error}`);console.log(`${C.cyan(`https://${e.domain}`)} ${t} ${n}`)}r.some(e=>!e.ok)&&console.log(C.dim(`\npoint an A record at ${e.join(` or `)} for each domain`));return}rn(o)||k(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>Pe(e.trim(),i)).filter(Boolean),l=t.set?c(t.set):[...o.domains];l=[.../* @__PURE__ */ new Set([...l,...c(t.add)])].filter(e=>!c(t.rm).includes(e));let u=l.filter(e=>!we.test(e));u.length&&k(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&D(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return T(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));if(f.length){let e=l.length?``:` ${n} keeps running, unreachable until it gets a domain again.`;await M(`Remove ${f.join(`, `)} from ${n}? ${f.length>1?`They stop`:`It stops`} serving it.${e}`)}let p=l.filter(e=>!o.domains.includes(e));await(await vo(i,r,n,p,s))(),await ar(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),Fn(r,n,l);let m=Be(process.cwd());m?.name===n&&Ve(process.cwd(),{...m,domains:l}),l.length?T(`${n} serves ${l.map(e=>C.cyan(`https://${e}`)).join(`, `)}`):T(`${n} serves no domain now ${C.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let ee=await Gt(i);for(let e of await Jt(p,ee))e.ok||D(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),bo=P({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
640
720
 
641
721
  lf auth my-site --user anna add or change a login (asks the password)
642
722
  lf auth my-site --rm-user anna remove a login
643
723
  lf auth my-site --off remove all logins: the site is public again
644
- lf auth my-site show who can log in`,options:{server:P,user:{type:`string`,short:`u`,desc:`login to add or change (default with --password: admin)`},password:{type:`string`,short:`p`,desc:`its password (asked when missing)`},"rm-user":{type:`string`,multiple:!0,desc:`remove a login`},off:{type:`boolean`,desc:`remove every login`}},async run([e],t){let{site:n,server:r}=$(e,t.server),{meta:i,proxy:a}=await Mn(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(ci),s=t.user??(t.password?`admin`:void 0);if(!s&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${x.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>x.bold(e)).join(`, `)}`);return}let c=t.off?[]:[...i.auth??[]],l=new Set(t[`rm-user`]??[]);for(let e of l)o.includes(e)||T(`no login ${e} on ${n}`);if(s&&l.add(s),c=c.filter(e=>!l.has(ci(e))),s&&c.push(await ui(s,t.password)),c.join(`
724
+ lf auth my-site show who can log in`,options:{server:F,user:{type:`string`,short:`u`,desc:`login to add or change (default with --password: admin)`},password:{type:`string`,short:`p`,desc:`its password (asked when missing; visible in ps, prefer --password-stdin)`},"password-stdin":{type:`boolean`,desc:`read the password from stdin`},"rm-user":{type:`string`,multiple:!0,desc:`remove a login`},off:{type:`boolean`,desc:`remove every login`}},async run([e],t){let{site:n,server:r}=Fi(e,t.server),{meta:i,proxy:a}=await zn(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(_i),s=t[`password-stdin`]?(await x()).replace(/\r?\n$/,``):t.password,c=t.user??(s?`admin`:void 0);if(!c&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${C.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>C.bold(e)).join(`, `)}`);return}let l=t.off?[]:[...i.auth??[]],u=new Set(t[`rm-user`]??[]);for(let e of u)o.includes(e)||D(`no login ${e} on ${n}`);if(c&&u.add(c),l=l.filter(e=>!u.has(_i(e))),c&&l.push(await yi(c,s)),l.join(`
645
725
  `)===(i.auth??[]).join(`
646
- `))return C(`nothing to change`);let u=c.map(ci),d=o.filter(e=>!u.includes(e));u.length?d.length&&await A(`Remove the login${d.length>1?`s`:``} ${d.join(`, `)} from ${n}?`):await A(`Remove every login from ${n}? Anyone can open it then.`),await Qn(r,i,a,u.length?`login for ${n}: ${u.join(`, `)}`:`removed the login of ${n}`,e=>void(e.auth=c.length?c:void 0)),C(u.length?`${n} asks for a login (${u.join(`, `)})`:`${n} is public again`)}}),Wi=N({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:P},async run([e],t){let{site:n,server:r}=$(e,t.server),{meta:i}=await Mn(r,n),a=nn(i)[0];a||D(`${n} serves no domain`);let o=`https://${a}`;console.log(x.cyan(o)),de(o)}}),Gi=async e=>{let t=``;for await(let n of e)t+=n;return t};let Ki;function qi(){if(Ki)return Ki;let e=o.mkdtempSync(m(u(),`landfall-`));return Ki=m(e,`askpass`),o.writeFileSync(Ki,`#!/bin/sh
647
- printf '%s\\n' "$LANDFALL_PASSWORD"
648
- `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Ki}const Ji=e=>({SSH_ASKPASS:qi(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Yi(e){if(e){let e=(await Gi(process.stdin)).replace(/\r?\n$/,``);return e||D(`--password-stdin: nothing on stdin`),e}return he(`PASSWORD`)||void 0}const Xi=e=>m(c(),`.ssh`,`landfall_${e}`);function Zi(e){let t=Xi(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(m(c(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&D(`ssh-keygen failed`),{path:t,created:!0})}async function Qi(e,t,n){let i=Z(t),a=t.key?L(t.key):void 0;if(a&&!o.existsSync(a)&&D(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&D(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await ut(t);if(!s&&!t.key&&o.existsSync(Xi(e))&&await ut({...t,key:Xi(e)})&&(t.key=a=Xi(e),s=!0),s)C(`key login works`),a??=vt(t);else if(n.check){T("no key login yet — `lf setup` would install a key first");return}let c=!1;if(!a&&!n.check){let t=Zi(e);a=t.path,c=t.created,c&&C(`generated key ${R(a)}`)}let l=t.key&&L(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await ut(t))){S(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Ji(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...tt(t),et(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await ut(t)||D(`could not log in with the key — check host, user and password`),C(`key installed`)}return a}const $i=e=>{for(let t of e)console.log(x.dim(` `)+t)};function ea(e,t,n,r){let i=Z(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Tt({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});C(`${r.created?`created`:`updated`} ${R(gt)} with ${x.bold(`Host ${e}`)} — try \`ssh ${e}\``),$i(r.block.split(`
649
- `)),a.host=e,delete a.user,delete a.port,delete a.key}else{t.user&&(a.user=t.user),r.port&&(a.port=Number(r.port));let o=r.keyGiven||n!==vt({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Dt(e,n):void 0;s?(C(`added the key to your ${x.bold(`Host ${e}`)} in ${R(s.file)} ${x.dim(`(backup: ${R(s.backup)})`)}`),$i([...s.removed.map(e=>x.red(`- ${e}`)),x.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=R(n)),r.ownAlias&&w(x.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const ta=N({name:`keygen`,args:`<name>`,summary:`Create an ed25519 key pair at ~/.ssh/landfall_<name> (skips if it exists) and print the public key.`,async run([e]){e||D(`usage: lf keygen <name>`);let{path:t,created:n}=Zi(e);C(`${n?`created`:`key exists:`} ${R(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function na(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?T(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await ce(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):T(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function ra(e,t){S(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await se(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(w(x.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await j(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&(t.email=await se(`Let's Encrypt e-mail (optional)`,``)||void 0);let n=Ee(e);t.dashboard??=await se(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function ia(e,t){let n=Object.fromEntries(Object.entries(aa.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return aa.run([e],{...n,...t},[])}const aa=N({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
726
+ `))return T(`nothing to change`);let d=l.map(_i),f=o.filter(e=>!d.includes(e));d.length?f.length&&await M(`Remove the login${f.length>1?`s`:``} ${f.join(`, `)} from ${n}?`):await M(`Remove every login from ${n}? Anyone can open it then.`),await ar(r,i,a,d.length?`login for ${n}: ${d.join(`, `)}`:`removed the login of ${n}`,e=>void(e.auth=l.length?l:void 0)),T(d.length?`${n} asks for a login (${d.join(`, `)})`:`${n} is public again`)}}),xo=P({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:F},async run([e],t){let{site:n,server:r}=Fi(e,t.server),{meta:i}=await zn(r,n),a=un(i)[0];a||k(`${n} serves no domain`);let o=`https://${a}`;console.log(C.cyan(o)),pe(o)}}),So=P({name:`keygen`,args:`<name>`,summary:`Create an ed25519 key pair at ~/.ssh/landfall_<name> (skips if it exists) and print the public key.`,async run([e]){e||k(`usage: lf keygen <name>`);let{path:t,created:n}=Ka(e);T(`${n?`created`:`key exists:`} ${B(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Co(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?D(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await le(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):D(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function wo(e,t){if(w(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await ce(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(E(C.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await N(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0){let e=(await ce(`Let's Encrypt e-mail (optional)`,``)).trim();t.email=e?De(e):void 0}let n=Ne(e);t.dashboard??=await ce(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function To(e,t){let n=Object.fromEntries(Object.entries(Eo.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return Eo.run([e],{...n,...t},[])}const Eo=P({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
650
727
 
651
728
  Turns a fresh box into a deploy target (safe to re-run):
652
729
  1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
@@ -666,7 +743,7 @@ missing. Steps that would break services already running there are skipped:
666
743
  a Traefik already running there is used instead (lf traefik use)
667
744
  Use --check to only see that report, --force to apply firewall/hardening anyway.
668
745
  In a terminal, a first Traefik install asks for image, host network, e-mail and
669
- dashboard unless given as flags.`,options:{host:{type:`string`,desc:`IP or hostname of a new server`},user:{type:`string`,short:`u`,desc:`initial login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port`},key:{type:`string`,short:`i`,desc:`use this private key instead of generating one`},"deploy-user":{type:`string`,desc:`user landfall deploys as (empty = keep login user)`,default:`deploy`},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},harden:{type:`boolean`,desc:`disable ssh password auth (--no-harden to skip)`,default:!0},firewall:{type:`boolean`,desc:`enable ufw (--no-firewall to skip)`,default:!0},traefik:{type:`boolean`,desc:`install traefik (--no-traefik to skip)`,default:!0},"ssh-config":{type:`boolean`,desc:`write Host block to ~/.ssh/config (--no-ssh-config)`,default:!0},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain> (lf server domain)`},dashboard:{type:`string`,desc:"traefik dashboard domain (`traefik` = traefik.<main domain>)"},image:{type:`string`,desc:`traefik image, remembered (default: ${Re})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},http3:{type:`boolean`,desc:`traefik serves HTTP/3 on 443/udp, on by default; remembered (--no-http3)`},check:{type:`boolean`,desc:`only report what is set up and what setup would change`},force:{type:`boolean`,desc:`apply firewall/ssh hardening despite conflicts`},"key-only":{type:`boolean`,desc:`only install the ssh key, change nothing else`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin instead of asking (also: $LANDFALL_PASSWORD)`}},async run([e],t){e||D(`usage: lf setup <name> [--host <ip>]`);let n=z(),r=n.servers[e],i=yt().includes(e)&&!kt(e),a={host:t.host??r?.host??e,user:t.user??(t.host?`root`:r?.user),port:t.port?Number(t.port):r?.port,key:t.key??r?.key};!t.user&&t[`deploy-user`]&&Z(a).user===t[`deploy-user`]&&await ut({...a,user:`root`})&&(a.user=`root`);let s=Z(a);S(`setting up ${x.bold(e)} ${x.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>S(`[${++c}/5] ${e}`);l(`ssh key`);let u=await Qi(e,a,{check:t.check,password:await Yi(t[`password-stdin`])});if(!u||t[`key-only`])return;let d=o.readFileSync(`${u}.pub`,`utf8`).trim(),f=t[`deploy-user`]===s.user?``:t[`deploy-user`];l(`checking what is already set up`);let p={PUBKEY:d,DEPLOY_USER:f},[m,h,ee]=await Promise.all([Mr(a,p),Sr(),Cr()]);Object.assign(m.values,{docker_latest:h??``,compose_latest:ee??``});let te=Number(m.values.ssh_port)||s.port,ne=Kr(m,{sshPort:te,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),g=ne.find(e=>e.part===`traefik`),_={email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:li},re=g&&!g.proxy&&g.status!==`conflict`&&g.status!==`off`;re&&(g.detail+=x.dim(` · ${fi(di(r??a,_))}`));let ie=Ft(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(w(`os: ${m.values.os??`unknown`}${m.values.pkg?x.dim(` · ${m.values.pkg}`):``}`),ie.note&&(ie.level===`ok`?w:T)(ie.note),Xr(ne),t.check)return;let v=await na(ne,t.force),ae=re&&g.status===`todo`&&oe(),y=t.domain?xe(t.domain):r&&Ee(r);if(y===void 0&&ae){let e=await se(`main domain for sites, e.g. example.com (optional)`,``);y=e.trim()?xe(e):void 0}ae&&(await ra({...r??a,domains:Te(r?.domains,y)},_),w(`traefik: ${fi(di(r??a,_))}${_.dashboard?`, dashboard ${_.dashboard}`:``}`));let b=ne.filter(e=>e.status!==`done`&&v.has(e.part)).length;if(b&&!await j(`apply the ${b} change${b===1?``:`s`} above?`))return;let E=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];ne.some(e=>E.includes(e.part)&&e.status!==`done`&&v.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await Y(a,q+Ke,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:te,HARDEN:v.has(`harden`),FIREWALL:v.has(`firewall`)}}),C(`server bootstrapped`)):(l(`bootstrap`),C(`already bootstrapped, nothing to change`));let O={...a};if(f){let e={...a,user:f};await ut(e)?(C(`deploy user ${x.bold(f)} ready`),O=e):T(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let k=ea(e,O,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});y&&(k.domains=Te(k.domains,y)),n.servers[e]=k,n.default??=e,ve(n),l(`traefik`),g?.proxy?v.has(`traefik`)&&g.status===`todo`?await mi(k,g.proxy):C(g.detail):v.has(`traefik`)?await pi(e,k,_):w(x.dim(`skipped`)),y?(console.log(`\n${x.green(`Done.`)} Point ${x.bold(y)} and ${x.bold(`*.${y}`)} (A records) at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${x.dim(`# https://blog.${y}`)}`),console.log(` lf server domain ${e} ${x.dim(`# check the DNS`)}\n`)):(console.log(`\n${x.green(`Done.`)} Point a domain's A record at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),oa={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function sa(e,t){if(!t)return e;let n=ci(t);return[...(e??[]).filter(e=>ci(e)!==n),t]}const ca=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function la(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(ca(new Date(n)));)n+=1e3;return ca(new Date(n))}const ua=N({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
746
+ dashboard unless given as flags.`,options:{host:{type:`string`,desc:`IP or hostname of a new server`},user:{type:`string`,short:`u`,desc:`initial login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port`},key:{type:`string`,short:`i`,desc:`use this private key instead of generating one`},"deploy-user":{type:`string`,desc:`user landfall deploys as (empty = keep login user)`,default:`deploy`},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},harden:{type:`boolean`,desc:`disable ssh password auth (--no-harden to skip)`,default:!0},firewall:{type:`boolean`,desc:`enable ufw (--no-firewall to skip)`,default:!0},traefik:{type:`boolean`,desc:`install traefik (--no-traefik to skip)`,default:!0},"ssh-config":{type:`boolean`,desc:`write Host block to ~/.ssh/config (--no-ssh-config)`,default:!0},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain> (lf server domain)`},dashboard:{type:`string`,desc:"traefik dashboard domain (`traefik` = traefik.<main domain>)"},image:{type:`string`,desc:`traefik image, remembered (default: ${Ge})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},http3:{type:`boolean`,desc:`traefik serves HTTP/3 on 443/udp, on by default; remembered (--no-http3)`},check:{type:`boolean`,desc:`only report what is set up and what setup would change`},force:{type:`boolean`,desc:`apply firewall/ssh hardening despite conflicts`},"key-only":{type:`boolean`,desc:`only install the ssh key, change nothing else`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin instead of asking (also: $LANDFALL_PASSWORD)`}},async run([e],t){e||k(`usage: lf setup <name> [--host <ip>]`);let n=V(),r=n.servers[e],i=Et().includes(e)&&!It(e),a={host:t.host??r?.host??e,user:t.user??(t.host?`root`:r?.user),port:t.port?Number(t.port):r?.port,key:t.key??r?.key};if(!t.user&&t[`deploy-user`]&&Q(a).user===t[`deploy-user`]){let e;for(let t of[`root`,r?.admin])!e&&t&&await _t({...a,user:t})&&(e=t);e?a.user=e:D(`${t[`deploy-user`]} can't change the system and root's key login does not work; pass -u <a sudo user> if setup fails`)}let s=Q(a);w(`setting up ${C.bold(e)} ${C.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>w(`[${++c}/5] ${e}`);l(`ssh key`);let u=await qa(e,a,{check:t.check,password:await Wa(t[`password-stdin`])});if(!u||t[`key-only`])return;let d=o.readFileSync(`${u}.pub`,`utf8`).trim(),f=t[`deploy-user`]===s.user?``:t[`deploy-user`];l(`checking what is already set up`);let p={PUBKEY:d,DEPLOY_USER:f},[m,ee,te]=await Promise.all([Vr(a,p),jr(),Mr()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let ne=Number(m.values.ssh_port)||s.port,h=ti(m,{sshPort:ne,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),g=h.find(e=>e.part===`traefik`),_={email:t.email&&De(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:vi},re=g&&!g.proxy&&g.status!==`conflict`&&g.status!==`off`;re&&(g.detail+=C.dim(` · ${xi(bi(r??a,_))}`));let v=Ht(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(E(`os: ${m.values.os??`unknown`}${m.values.pkg?C.dim(` · ${m.values.pkg}`):``}`),v.note&&(v.level===`ok`?E:D)(v.note),ai(h),oe({checks:h.map(({part:e,label:t,status:n,detail:r})=>({part:e,label:t,status:n,detail:r}))}),t.check)return;let y=await Co(h,t.force),ie=re&&g.status===`todo`&&se(),b=t.domain?Oe(t.domain):r&&Ne(r);if(b===void 0&&ie){let e=await ce(`main domain for sites, e.g. example.com (optional)`,``);b=e.trim()?Oe(e):void 0}ie&&(await wo({...r??a,domains:Me(r?.domains,b)},_),E(`traefik: ${xi(bi(r??a,_))}${_.dashboard?`, dashboard ${_.dashboard}`:``}`));let x=h.filter(e=>e.status!==`done`&&y.has(e.part)).length;if(x&&!await N(`apply the ${x} change${x===1?``:`s`} above?`))return;let ae=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];h.some(e=>ae.includes(e.part)&&e.status!==`done`&&y.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await X(a,J+$e,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:ne,HARDEN:y.has(`harden`),FIREWALL:y.has(`firewall`),HTTP3:(_.http3??r?.http3)!==!1}}),T(`server bootstrapped`)):(l(`bootstrap`),T(`already bootstrapped, nothing to change`));let S={...a};if(f){let e={...a,user:f};await _t(e)?(T(`deploy user ${C.bold(f)} ready`),S=e):D(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let O=Ya(e,S,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});b&&(O.domains=Me(O.domains,b)),s.user!==`root`&&Q(S).user!==s.user&&(O.admin=s.user),n.servers[e]=O,n.default??=e,be(n),l(`traefik`),g?.proxy?y.has(`traefik`)&&g.status===`todo`?await Ci(O,g.proxy):T(g.detail):y.has(`traefik`)?await Si(e,O,_):E(C.dim(`skipped`)),b?(console.log(`\n${C.green(`Done.`)} Point ${C.bold(b)} and ${C.bold(`*.${b}`)} (A records) at ${C.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${C.dim(`# https://blog.${b}`)}`),console.log(` lf server domain ${e} ${C.dim(`# check the DNS`)}\n`)):(console.log(`\n${C.green(`Done.`)} Point a domain's A record at ${C.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),Do={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function Oo(e,t){if(!t)return e;let n=_i(t);return[...(e??[]).filter(e=>_i(e)!==n),t]}const ko=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function Ao(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(ko(new Date(n)));)n+=1e3;return ko(new Date(n))}const jo=P({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
670
747
 
671
748
  Kinds:
672
749
  compose the folder has a compose.yaml: its services run on the server as one
@@ -680,16 +757,15 @@ so \`docker compose\` works there without landfall. Settings are saved to
680
757
  ./.landfall.json, so later deploys are just \`lf deploy\`.
681
758
 
682
759
  A server landfall has not set up yet is set up first (\`lf setup\`), asking
683
- before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:P,name:{type:`string`,short:`n`,desc:`site name (default: folder name)`},domain:{type:`string`,short:`d`,multiple:!0,desc:"domain to serve (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},"no-domain":{type:`boolean`,desc:`serve no domain: reach it with lf tunnel until lf domains --add gives it one`},file:{type:`string`,short:`f`,desc:`compose file to deploy (default: compose.yaml)`},spa:{type:`boolean`,desc:`single-page app: unknown paths serve index.html`},image:{type:`string`,desc:`deploy a docker image instead of files`},port:{type:`string`,desc:`container port (image/dockerfile; default: EXPOSE or 3000)`},static:{type:`boolean`,desc:`force static even if a Dockerfile or compose file exists`},env:{type:`string`,short:`e`,multiple:!0,desc:`set KEY=VALUE in the site's .env`},"env-file":{type:`string`,desc:`merge a local .env file into the site's .env`},build:{type:`string`,short:`b`,desc:`run this command locally first (e.g. 'npm run build')`},keep:{type:`string`,desc:`releases to keep for rollback`,default:`5`},save:{type:`boolean`,desc:`write .landfall.json (--no-save to skip)`,default:!0},host:{type:`string`,desc:`IP or hostname of a new server: set it up as --server first`},user:{type:`string`,short:`u`,desc:`with --host: initial login user (default: root)`},"ssh-port":{type:`string`,desc:`with --host: ssh port`},"password-stdin":{type:`boolean`,desc:`setting up first: read the login password from stdin (also: $LANDFALL_PASSWORD)`},"auth-user":{type:`string`,desc:`protect the site with this login (HTTP basic auth, as lf auth; default with --auth-password: admin)`},"auth-password":{type:`string`,desc:`password of the login (asked when only --auth-user is given)`}},async run([e],t){let n=process.cwd(),i=Ne(n)??{};i.name&&w(x.dim(`using ${je}`));let{name:a,server:s}=B(t.server??i.server);t.host&&!t.server&&!i.server&&!z().default&&D(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await dt(s);if(c!==`ready`){let e=c===`bare`?`is not set up by landfall yet`:`does not let landfall log in with a key`;!t.host&&!(oe()&&await j(`${a} ${e}. Set it up now (lf setup)?`))&&D(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await ia(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=B(a),await dt(s)!==`ready`&&D(`${a} is still not set up`)}let l=kn(t.name??i.name??Sn(f(n))),u=we(t.keep,`--keep`,1),d=!!t[`no-domain`]||(t.domain?.includes(!1)??!1),p=t.domain?.filter(e=>typeof e==`string`);d&&p?.length&&D(`--no-domain and --domain exclude each other`);let te=(p?.length?p:i.domains??[]).map(e=>De(e,s)).filter(e=>!V.test(e));te.length&&D(`not a domain: ${te.join(`, `)}`);let ne=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),g=ne?await ui(ne,t[`auth-password`],`--auth-user`):void 0,_=t.build??i.build;if(_){S(`building locally: ${_}`);let e=r(_,{shell:!0,stdio:`inherit`});e.status!==0&&D(`build failed (exit ${e.status})`)}let re=t.static??i.static,ie=t.image??(re?void 0:i.image),v=ie&&Ce(ie),ae=ee(n,e??i.dir??`.`),y=v||re?void 0:t.file??Cn(ae,i);y&&!o.existsSync(m(ae,y))&&D(`no ${y} in ${h(n,ae)||`.`}`);let b=yn(n),E=y||v?ae:ee(n,e??i.dir??bn(n,b)),O=y?`compose`:v?`image`:!re&&o.existsSync(m(E,`Dockerfile`))?`dockerfile`:`static`,k=O===`static`&&!e&&!i.dir?b.tool:void 0;k&&w(x.dim(`publishing ${h(n,E)}: where ${k} builds the site (another folder: lf deploy <dir>)`)),O===`static`&&!o.existsSync(m(E,`index.html`))&&T(`no index.html in ${h(n,E)||`.`}${k&&!_?`: build first, or let deploy do it (--build "${b.build}")`:``}`);let ce=oa[O].port?we(t.port??i.port??(O===`dockerfile`?xn(m(E,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,A=O===`static`&&!!(t.spa??i.spa);S(`deploying ${x.bold(l)} (${O}) to ${x.bold(a)}`);let M=await jn(s,l),{env:le,proxy:ue,exists:de}=M,N=M.meta&&Wt(M.meta)?M.meta:void 0,P=N?void 0:M.meta;P?.adopted&&D(`${l} is adopted (${P.adopted}): deploy it there with docker compose, or pick another --name`),N?T(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):P&&P.kind!==O&&w(x.dim(`site was ${P.kind}, now ${O}`));let F;(t.env?.length||t[`env-file`])&&(F=le,t[`env-file`]&&(F=ir(F,ar(da(o.readFileSync(t[`env-file`],`utf8`))))),t.env?.length&&(F=ir(F,ar(t.env))));let fe=la((P?.releases??[]).map(e=>e.id)),pe=`${Q(l)}/releases/${fe}`,me=O===`compose`?{}:{"compose.yaml":hn(O,{port:ce,image:v}),...O===`static`&&{"nginx.conf":gn(A)}},he,I,ge;try{let e=oa[O].files;e!==void 0&&(S(`uploading ${h(n,E)||`.`}`),await ht(s,E,pe+e));let t=await Un(s,l,fe,{file:y,generated:me,env:F}),r=un(t.config,t.dir,e=>De(e,s));for(let e of r.warnings)T(e);r.errors.length&&D(`${y??`compose.yaml`} cannot be deployed:\n${r.errors.map(e=>` - ${e}`).join(`
684
- `)}`),he={id:fe,at:(/* @__PURE__ */ new Date()).toISOString(),services:r.services};let o=r.services.flatMap(e=>e.domains??[]);I=[];let c=!1;if(Yt(he)){c=!p?.length&&!!i.domains?.length,I=d?[]:[p,i.domains,P?.domains,N?.domains].find(e=>e?.length)??[];let e=d||!I.length&&(!!P&&!!Xt(P)&&!P.domains.length||i.domains?.length===0);if(!I.length&&!e){let t=Ee(s),n=t?void 0:zt(await Rt(s)),r=await se(`Domain(s) to serve (comma separated; none: reach it by tunnel only):`,t?`${l}.${t}`:n?Bt(l,n):void 0);e=r===`none`,e||(I=r.split(/[\s,]+/).filter(Boolean))}I=[...new Set(I.map(e=>De(e,s)))];let t=I.filter(e=>!V.test(e)),n=I.filter(e=>o.includes(e));!I.length&&!e&&D(`at least one --domain is required`),e&&w(x.dim(`${l} serves no domain: reach it with lf tunnel ${l}, give it one with lf domains ${l} --add <domain>`)),t.length&&D(`not a domain: ${t.join(`, `)}`),n.length&&D(`${n.join(`, `)} is already routed by x-landfall.domains`)}else p?.length&&T(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);ge=await Vi(s,a,l,[...I,...o],ue,c?`${je} lists it; deploy with -d to move it`:void 0)}catch(e){throw await Gn(s,l,fe).catch(()=>{}),e}let _e=(/* @__PURE__ */ new Date()).toISOString(),L={v:2,name:l,kind:O,domains:I,auth:sa(P?.auth??N?.auth,g),current:fe,releases:[...P?.releases??[],he],createdAt:P?.createdAt??N?.createdAt??_e,updatedAt:_e};await ge(),await $n(s,L,ue,{env:F,build:!0,pull:!0,keep:u,prev:P,takeover:N,existed:!P&&!N&&de,what:`deploy ${l} release ${fe}${P?``:` (new site, ${nn(L).join(`, `)})`}`});let R=nn(L);if(t.save){let e={name:l,server:a,domains:I};if(O===`image`)Object.assign(e,{image:v,port:ce});else{let r=h(n,E)||`.`;O===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=y)):r!==bn(n,b)&&(e.dir=r),O===`dockerfile`&&(e.port=ce),A&&(e.spa=!0),re&&O===`static`&&(e.static=!0)}_&&(e.build=_),Pe(n,e),Dn({path:n,name:l,server:a,domains:R,kind:O})}C(`release ${x.bold(fe)} live`);for(let e of R)console.log(` ${x.cyan(`https://${e}`)}`);R[0]&&await fa(R[0])}});function da(e){return e.split(`
685
- `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function fa(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?C(`https://${e} responded ${t.status}`):T(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);T(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const pa=N({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
760
+ before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:F,name:{type:`string`,short:`n`,desc:`site name (default: folder name)`},domain:{type:`string`,short:`d`,multiple:!0,desc:"domain to serve (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},"no-domain":{type:`boolean`,desc:`serve no domain: reach it with lf tunnel until lf domains --add gives it one`},file:{type:`string`,short:`f`,desc:`compose file to deploy (default: compose.yaml)`},spa:{type:`boolean`,desc:`single-page app: unknown paths serve index.html`},image:{type:`string`,desc:`deploy a docker image instead of files`},port:{type:`string`,desc:`container port (image/dockerfile; default: EXPOSE or 3000)`},static:{type:`boolean`,desc:`force static even if a Dockerfile or compose file exists`},env:{type:`string`,short:`e`,multiple:!0,desc:`set KEY=VALUE in the site's .env`},"env-file":{type:`string`,desc:`merge a local .env file into the site's .env (- reads stdin)`},build:{type:`string`,short:`b`,desc:`run this command locally first (e.g. 'npm run build')`},keep:{type:`string`,desc:`releases to keep for rollback`,default:`5`},save:{type:`boolean`,desc:`write .landfall.json (--no-save to skip)`,default:!0},host:{type:`string`,desc:`IP or hostname of a new server: set it up as --server first`},user:{type:`string`,short:`u`,desc:`with --host: initial login user (default: root)`},"ssh-port":{type:`string`,desc:`with --host: ssh port`},"password-stdin":{type:`boolean`,desc:`setting up first: read the login password from stdin (also: $LANDFALL_PASSWORD)`},"auth-user":{type:`string`,desc:`protect the site with this login (HTTP basic auth, as lf auth; default with --auth-password: admin)`},"auth-password":{type:`string`,desc:`password of the login (asked when only --auth-user is given)`}},async run([e],t){let n=process.cwd(),i=Be(n)??{};i.name&&E(C.dim(`using ${Re}`));let{name:a,server:s}=H(t.server??i.server);t.host&&!t.server&&!i.server&&!V().default&&k(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await vt(s);if(c!==`ready`){let e=c===`bare`?`is not set up by landfall yet`:`does not let landfall log in with a key`;!t.host&&!(se()&&await N(`${a} ${e}. Set it up now (lf setup)?`))&&k(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await To(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=H(a),await vt(s)!==`ready`&&k(`${a} is still not set up`)}let l=In(t.name??i.name??kn(f(n))),u=je(t.keep,`--keep`,1),d=!!t[`no-domain`]||(t.domain?.includes(!1)??!1),h=t.domain?.filter(e=>typeof e==`string`);d&&h?.length&&k(`--no-domain and --domain exclude each other`);let g=(h?.length?h:i.domains??[]).map(e=>Pe(e,s)).filter(e=>!we.test(e));g.length&&k(`not a domain: ${g.join(`, `)}`);let _=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),re=_?await yi(_,t[`auth-password`],`--auth-user`):void 0,v=t.build??i.build;if(v){w(`building locally: ${v}`);let e=r(v,{shell:!0,stdio:`inherit`});e.status!==0&&k(`build failed (exit ${e.status})`)}let y=t.static??i.static,ie=t.image??(y?void 0:i.image),b=ie&&Ae(ie),x=ne(n,e??i.dir??`.`),ae=b||y?void 0:t.file??An(x,i),S=ae&&ee(ae);S?.includes(`/`)&&k(`${ae} is not in the folder deployed; deploy its folder instead: lf deploy ${p(m(te(n,x)||`.`,S))} -f ${f(S)}`),S&&!o.existsSync(m(x,S))&&k(`no ${S} in ${te(n,x)||`.`}`);let O=En(n),A=S||b?x:ne(n,e??i.dir??Dn(n,O)),j=S?`compose`:b?`image`:!y&&o.existsSync(m(A,`Dockerfile`))?`dockerfile`:`static`,le=j===`static`&&!e&&!i.dir?O.tool:void 0;le&&E(C.dim(`publishing ${te(n,A)}: where ${le} builds the site (another folder: lf deploy <dir>)`)),j===`static`&&!o.existsSync(m(A,`index.html`))&&D(`no index.html in ${te(n,A)||`.`}${le&&!v?`: build first, or let deploy do it (--build "${O.build}")`:``}`);let M=Do[j].port?je(t.port??i.port??(j===`dockerfile`?On(m(A,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,ue=j===`static`&&!!(t.spa??i.spa);w(`deploying ${C.bold(l)} (${j}) to ${C.bold(a)}`);let de=await Rn(s,l),{env:fe,proxy:pe,exists:P}=de,F=de.meta&&Zt(de.meta)?de.meta:void 0,I=F?void 0:de.meta;I?.adopted&&k(`${l} is adopted (${I.adopted}): deploy it there with docker compose, or pick another --name`),F?D(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):I&&I.kind!==j&&E(C.dim(`site was ${I.kind}, now ${j}`));let L;(t.env?.length||t[`env-file`])&&(L=fe,t[`env-file`]&&(L=dr(L,await pr(t[`env-file`]))),t.env?.length&&(L=dr(L,mr(t.env))));let me=Ao((de.meta?.releases??[]).map(e=>e.id)),he=`${$(l)}/releases/${me}`,ge=j===`compose`?{}:{"compose.yaml":Sn(j,{port:M,image:b}),...j===`static`&&{"nginx.conf":Cn(ue)}},_e,R,ve;try{let e=Do[j].files;e!==void 0&&(w(`uploading ${te(n,A)||`.`}`),await St(s,A,he+e));let t=await Xn(s,l,me,{file:S,generated:ge,env:L}),r=_n(t.config,t.dir,e=>Pe(e,s));for(let e of r.warnings)D(e);r.errors.length&&k(`${S??`compose.yaml`} cannot be deployed:\n${r.errors.map(e=>` - ${e}`).join(`
761
+ `)}`),_e={id:me,at:(/* @__PURE__ */ new Date()).toISOString(),services:r.services};let o=r.services.flatMap(e=>e.domains??[]);R=[];let c=!1;if(nn(_e)){c=!h?.length&&!!i.domains?.length,R=d?[]:[h,i.domains,I?.domains,F?.domains].find(e=>e?.length)??[];let e=d||!R.length&&(!!I&&!!rn(I)&&!I.domains.length||i.domains?.length===0);if(!R.length&&!e){let t=Ne(s),n=t?void 0:Kt(await Gt(s)),r=await ce(`Domain(s) to serve (comma separated; none: reach it by tunnel only):`,t?`${l}.${t}`:n?qt(l,n):void 0);e=r===`none`,e||(R=r.split(/[\s,]+/).filter(Boolean))}R=[...new Set(R.map(e=>Pe(e,s)))];let t=R.filter(e=>!we.test(e)),n=R.filter(e=>o.includes(e));!R.length&&!e&&k(`at least one --domain is required`),e&&E(C.dim(`${l} serves no domain: reach it with lf tunnel ${l}, give it one with lf domains ${l} --add <domain>`)),t.length&&k(`not a domain: ${t.join(`, `)}`),n.length&&k(`${n.join(`, `)} is already routed by x-landfall.domains`)}else h?.length&&D(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);ve=await vo(s,a,l,[...R,...o],pe,c?`${Re} lists it; deploy with -d to move it`:void 0)}catch(e){throw await Qn(s,l,me).catch(()=>{}),e}let ye=(/* @__PURE__ */ new Date()).toISOString(),z={v:2,name:l,kind:j,domains:R,auth:Oo(I?.auth??F?.auth,re),current:me,releases:[...I?.releases??[],_e],createdAt:I?.createdAt??F?.createdAt??ye,updatedAt:ye};await ve(),await or(s,z,pe,{env:L,build:!0,pull:!0,keep:u,prev:I,takeover:F,existed:!I&&!F&&P,what:`deploy ${l} release ${me}${I?``:` (new site, ${un(z).join(`, `)})`}`});let B=un(z);if(t.save){let e={name:l,server:a,domains:R};if(j===`image`)Object.assign(e,{image:b,port:M});else{let r=te(n,A)||`.`;j===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=S)):r!==Dn(n,O)&&(e.dir=r),j===`dockerfile`&&(e.port=M),ue&&(e.spa=!0),y&&j===`static`&&(e.static=!0)}v&&(e.build=v),Ve(n,e),Pn({path:n,name:l,server:a,domains:B,kind:j})}T(`release ${C.bold(me)} live`);for(let e of B)console.log(` ${C.cyan(`https://${e}`)}`);B[0]&&oe({url:`https://${B[0]}`}),B[0]&&await Mo(B[0])}});async function Mo(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?T(`https://${e} responded ${t.status}`):D(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);D(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const No=P({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
686
762
 
687
763
  compose.yaml what runs on the server; x-landfall routes a service through Traefik
688
764
  compose.override.yaml local only (docker compose merges it, lf deploy leaves it out):
689
765
  publishes the port on localhost
690
766
 
691
767
  \`docker compose up\` then runs the site locally, \`lf deploy\` on a server. Existing
692
- files are never overwritten.`,options:{port:{type:`string`,desc:`container port (default: EXPOSE, the saved port or 3000)`},postgres:{type:`boolean`,desc:`add a Postgres service with a volume; the app gets DATABASE_URL`}},async run([e],t){let n=ee(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||D(`${e} is not a folder`);let r=wn(n);r.kind===`compose`&&D(`${r.compose} exists already: add x-landfall: { port: <port> } to the service Traefik should serve`);let i={"compose.yaml":``,"compose.override.yaml":``},a=Object.keys(i).filter(e=>o.existsSync(m(n,e)));a.length&&D(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:we(t.port??r.port??3e3,`--port`,1,65535),c=r.kind===`static`?8080:s,l=!!t.postgres,u;if(r.kind===`static`){let e=r.dir??`dist`;r.dir||w(x.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
768
+ files are never overwritten.`,options:{port:{type:`string`,desc:`container port (default: EXPOSE, the saved port or 3000)`},postgres:{type:`boolean`,desc:`add a Postgres service with a volume; the app gets DATABASE_URL`}},async run([e],t){let n=ne(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||k(`${e} is not a folder`);let r=jn(n);r.kind===`compose`&&k(`${r.compose} exists already: add x-landfall: { port: <port> } to the service Traefik should serve`);let i={"compose.yaml":``,"compose.override.yaml":``},a=Object.keys(i).filter(e=>o.existsSync(m(n,e)));a.length&&k(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:je(t.port??r.port??3e3,`--port`,1,65535),c=r.kind===`static`?8080:s,l=!!t.postgres,u;if(r.kind===`static`){let e=r.dir??`dist`;r.dir||E(C.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
693
769
  image: nginx:alpine
694
770
  restart: unless-stopped
695
771
  volumes:
@@ -734,63 +810,11 @@ services:
734
810
  ${d}:
735
811
  ports:
736
812
  - "127.0.0.1:${c}:${s}"
737
- `;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=h(process.cwd(),n)||`.`;C(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(m(n,`.env`))&&w(`locally: put POSTGRES_PASSWORD=… in ${m(f,`.env`)} (keep it out of git)`),w(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),w(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),ma=e=>e.steps.filter(e=>e.undo&&!e.undone);function ha(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
738
- `).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}async function ga(e){let{stdout:t}=await Y(e,`cat ${K} 2>/dev/null || sudo -n cat ${K} 2>/dev/null || true`,{capture:!0});return ha(t)}function _a(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||D(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||D(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function va(e,t){let n=Z(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await Y(e,`sudo -n true 2>/dev/null && echo yes || true`,{capture:!0});if(r.trim()===`yes`)return{login:e,sudo:!0};let i={...e,user:`root`};return await ut(i)?{login:i}:D(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const ya=String.raw`
739
- undo_step() { # undo_step <line> <undo shell, base64>
740
- set +e
741
- ( set -e; eval "$(printf %s "$2" | base64 -d)" )
742
- rc=$?
743
- set -e
744
- [ "$rc" = 0 ] || { echo "could not undo journal line $1 (exit $rc); later steps are undone, the rest is kept" >&2; exit 1; }
745
- _journal undo "$1" ""
746
- }`;async function ba(e,t){let n=t.filter(e=>e.undo&&!e.undone).sort((e,t)=>t.line-e.line),r=[e];for(let t=0;t<n.length;){let i=t;for(;i<n.length&&n[i].user===n[t].user;)i++;let a=n.slice(t,i),o=a.map(e=>`undo_step ${e.line} ${J(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await va(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(at)),await Y(s,[q,ya,...o].join(`
747
- `),{sudo:c}),r.push(s),t=i}return n.length}async function xa(e){let t=e,n=await Mr(t);if(n.values.sudo!==`1`){let r={...e,user:`root`};Z(e).user!==`root`&&await ut(r)||D(`the firewall needs root or passwordless sudo; ${Z(e).user} has no sudo and root's key login does not work`),t=r,n=await Mr(t)}let r=n.values,i=Number(r.ssh_port)||Z(e).port;return{login:t,ufw:r.ufw===`active`?`active`:r.ufw===`missing`?`missing`:`inactive`,policy:r.ufw_in||`DROP`,sshPort:i,rules:n.rules.map(Lr),ports:Ur(n),blocked:Hr(n,[i]).blocked,probe:n}}const Sa=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,Ca=/^[0-9a-fA-F.:]+(\/\d+)?$/;function wa(e){Sa.test(e.port)||D(`not a port: ${e.port} (e.g. 5432, 51820/udp, 6000:6007/tcp)`);let[t,n]=e.port.split(`/`);t.split(/[,:]/).some(e=>Number(e)<1||Number(e)>65535)&&D(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&D(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!Ca.test(e.from)&&D(`not an address or network: ${e.from} (e.g. 203.0.113.7 or 10.0.0.0/8)`);let r=e.from?[e.action,`from`,e.from,`to`,`any`,`port`,t,...n?[`proto`,n]:[]]:[e.action,e.port];return e.comment?[...r,`comment`,e.comment]:r}const Ta=e=>`ufw_add ${e.map(J).join(` `)}`,Ea=e=>e[0]===`deny`?`${Ta([`prepend`,...e])} 2>/dev/null || ${Ta(e)}`:Ta(e),Da=e=>`$SUDO ufw --force delete ${e.spec} >/dev/null
748
- record ${J(`ufw delete ${e.spec}`)} ${J(`$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`)}`,Oa=e=>Lr(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),ka=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function Aa(e,t,n){if(e.ufw!==`active`)return;let r=Rr(t,{proto:`tcp`,port:e.sshPort})?.rule;if(r&&(r.action===`allow`||r.action===`limit`)&&!r.from)return;let i=r?.from?`only ${r.from} could still reach ssh port ${e.sshPort}`:`this blocks ssh port ${e.sshPort} and can lock you out`;n||await ce(`${i}. Do it anyway?`)||D(`${i} (--force to do it anyway)`)}function ja(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||D(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const Ma={server:P,from:{type:`string`,desc:`only from this address or network (e.g. 10.0.0.0/8)`},comment:{type:`string`,desc:`note stored with the rule`}};function Na(e){return N({name:`firewall ${e}`,args:`<port[/proto]>`,summary:e===`allow`?"Open a port in ufw, e.g. `lf firewall allow 5432 --from 10.0.0.0/8` or `lf firewall allow 51820/udp`.":"Block a port in ufw, e.g. `lf firewall deny 5432`. Goes before the other rules, so it wins over an allow.",options:{...Ma,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||D(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=wa({action:e,port:t,from:n.from,comment:n.comment}),i=await xa(B(n.server).server);e===`deny`&&await Aa(i,[Oa(r),...i.rules],n.force),await Y(i.login,`${q}\n${ka}\n${Ea(r)}`),C(`${r.join(` `)}${i.ufw===`active`?``:x.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const Pa=Na(`allow`),Fa=Na(`deny`),Ia=N({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:P,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=B(t.server),i=await xa(r);if(t.json){let{ufw:e,policy:t,sshPort:n,rules:r,ports:a,blocked:o}=i;console.log(JSON.stringify({ufw:e,policy:t,sshPort:n,rules:r.map((e,t)=>({n:t+1,...e})),ports:a,blocked:o},null,2));return}let a=i.ufw===`active`?x.green(`on`):i.ufw===`missing`?x.yellow(`not installed`):x.yellow(`off`);S(`firewall on ${n}: ${a}${i.ufw===`active`?x.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${x.bold(`Rules`)}`),i.rules.length||w(x.dim(`none`)),M(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?x.green(e.action):x.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${x.bold(`Listening ports`)}`);let o={open:x.green,limited:x.yellow,blocked:x.red,local:x.dim};M(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?T(`enabling would block ${i.blocked.join(`, `)}: allow them first`):w(x.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),La=N({name:`firewall enable`,summary:`Turn ufw on (installs it if missing). Always allows the ssh port first; refuses if it would block a port in use.`,options:{server:P,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=B(t.server),i=await xa(r);if(i.ufw===`active`)return C(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await ce(`${e}. Turn it on anyway?`)||D(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await Aa({...i,ufw:`active`},[...i.rules,Oa([`allow`,`${i.sshPort}/tcp`])],t.force),await Y(i.login,`${q}
749
- command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
750
- ${Ta([`allow`,`${i.sshPort}/tcp`])}
751
- ufw_enable`),C(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),Ra=N({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:P},async run(e,t){let{name:n,server:r}=B(t.server),i=await xa(r);if(i.ufw!==`active`)return C(`firewall on ${n} is already off`);await j(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||D(`cancelled`),await Y(i.login,`${q}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),C(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),za=N({name:`firewall rm`,args:`<number|rule>`,summary:"Remove a rule by its number in `lf firewall`, or by its text (e.g. 'allow 5432').",options:{server:P,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||D(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=B(t.server),i=await xa(r),{rule:a,n:o}=ja(i,e);await Aa(i,i.rules.filter(e=>e!==a),t.force),await A(`Remove rule ${o} (${a.spec}) on ${n}?`),await Y(i.login,`${q}\n${ka}\n${Da(a)}`),C(`removed rule ${o}: ${a.spec}`)}}),Ba=N({name:`firewall update`,args:`<number|rule>`,summary:`Change a rule: what you pass replaces that part, the rest stays. If ufw rejects the new rule, the old one is restored.`,options:{...Ma,port:{type:`string`,desc:`port[/proto], e.g. 5432 or 51820/udp`},from:{type:`string`,desc:`only from this address or network; 'any' for everyone`},action:{type:`string`,desc:`allow, deny or limit`},force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||D(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await xa(B(t.server).server),{rule:r,n:i}=ja(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||D(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||D(`rule ${i} (${r.spec}) names no port; pass --port`);let s=wa({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=n.rules.filter(e=>e!==r);await Aa(n,a===`deny`?[Oa(s),...c]:[...c,Oa(s)],t.force),await Y(n.login,`${q}
752
- ${ka}
753
- ${Da(r)}
754
- ${Ea(s)} || { $SUDO ufw ${r.spec} >/dev/null; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),C(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}});function Va(e,t){let n=x.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${x.dim(`↩ `+e.what)}`:e.undone?`${n} ${x.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${x.cyan(`↺`)} ${e.what}`:`${n} ${x.dim(`· `+e.what+` (not reversible)`)}`}const Ha=e=>e.trim().replace(/\\(.)/g,`$1`),Ua=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function Wa(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await xa(e),i=[...r.rules],a=r.ufw;for(let e of[...t].sort((e,t)=>t.line-e.line))for(let t of e.undo.split(`
755
- `)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=Lr(Ha(e[1])),n=i.findIndex(e=>Ua(e,t));n>=0&&i.splice(n,1)}else n?i=n[1]?[Lr(Ha(n[2])),...i]:[...i,Lr(Ha(n[2]))]:/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await Aa({...r,ufw:a},i,n)}const Ga=e=>`${x.bold(`#${e.n}`.padEnd(5))} ${x.dim(le(e.time).padEnd(9))} ${x.bold(e.cmd||`?`)} ${x.dim(`${e.actor} · ${e.id}`)}`,Ka=N({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:P,n:{type:`string`,short:`n`,desc:`runs to show`,default:`20`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=B(t.server),i=(await ga(r)).reverse().slice(0,Number(t.n)||20);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return w(`nothing in ${K} on ${n} yet`);for(let e of i)console.log(Ga(e)),e.steps.forEach((e,t)=>console.log(Va(e,t)));let a=i.find(e=>ma(e).length);a&&console.log(x.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),qa=N({name:`undo`,args:`[run|run.step]`,summary:"Revert what landfall did: the latest run, run <n> from `lf history`, one step <n>.<m>, or --all.",options:{server:P,all:{type:`boolean`,desc:`everything landfall did that can be reverted`},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){let{name:n,server:r}=B(t.server),i=await ga(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let t=e?_a(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>ma(e.run).length);if(!t)return C(`nothing to undo on ${n}`);({steps:a}=t),o=`#${t.run.n} ${t.run.cmd} (${le(t.run.time)})`;let r=i.filter(e=>e.n>t.run.n&&ma(e).length);r.length&&T(`later runs build on it and stay: ${r.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return C(`nothing left to undo in ${o}`);S(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${x.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${x.dim(`· ${e.what} stays (not reversible)`)}`);await Wa(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await A(l,n):await j(l,!1)||D(`cancelled`),await ba(r,s),C(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&w(x.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),Ja=N({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
756
-
757
- Without --host, <name> is used as an ssh alias from ~/.ssh/config,
758
- so \`lf server add vps\` works whenever \`ssh vps\` works.
759
-
760
- With --host lf logs in with --key, or installs its own key
761
- (~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
762
- to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
763
- The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||D(`usage: lf server add <name> [--host ...]`);let n=t.domain&&xe(t.domain),r=z(),i=r.servers[e],a=yt().includes(e)&&!kt(e);t.host&&a&&D(`${e} is already a Host in your ssh config; pick another name or drop --host`);let o={host:t.host??i?.host??e,user:t.user??(t.host?`root`:i?.user),port:t.port?Number(t.port):i?.port,key:t.key??i?.key},s=await Yi(t[`password-stdin`]),c=await Qi(e,o,{check:!s&&!oe(),password:s}),l=c?ea(e,o,c,{existing:i,sshConfig:t[`ssh-config`],ownAlias:a,port:t.port,keyGiven:!!t.key}):{...i,...o};l.key||delete l.key,l.user||delete l.user,l.port||delete l.port,t.email&&(l.email=t.email),n&&(l.domains=Te(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),ve(r),C(`added ${x.bold(e)}${r.default===e?x.dim(` (default)`):``}`),w(c?x.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${x.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),Ya=N({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`}},async run(e,t){let n=z(),r=[[x.dim(`NAME`),x.dim(`TARGET`),x.dim(`SOURCE`)]];for(let[e,t]of Object.entries(n.servers)){let i=`${t.user?t.user+`@`:``}${t.host}${t.port?`:`+t.port:``}`;r.push([e+(n.default===e?x.green(` *`):``),i,`landfall`])}if(t.all)for(let e of yt())n.servers[e]||r.push([e,e,kt(e)?`ssh (landfall)`:`ssh`]);r.length===1?console.log(`no servers yet — ${x.bold(`lf server add <ssh-alias>`)} or ${x.bold(`lf setup <name> --host <ip>`)}`):(M(r),t.all||console.log(x.dim(`
764
- Any ~/.ssh/config alias works too (--all to list them).`)))}}),Xa=N({name:`server rm`,args:`<name>`,summary:`Forget a server (nothing is changed on the server itself).`,options:{"ssh-config":{type:`boolean`,desc:`also remove the landfall-managed Host block from ~/.ssh/config`}},async run([e],t){e||D(`usage: lf server rm <name>`),!z().servers[e]&&!kt(e)&&D(`unknown server "${e}"`),await A(`Forget server ${e}? Nothing on the server changes.`),ye(e),t[`ssh-config`]&&Ot(e)&&C(`removed Host ${e} from ~/.ssh/config`),C(`removed ${e}`)}}),Za=/^(setup|traefik setup|traefik use)( |$)/,Qa=N({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${H} and what setup changed.
765
-
766
- Deletes every site landfall deployed (named volumes stay unless --volumes) and forgets
767
- adopted ones, which keep running. Stops landfall's Traefik (a Traefik of your own is left
768
- alone), reverts what \`lf setup\` changed (deploy user, ufw, ssh hardening, docker network)
769
- and deletes ${H}, its journal included. Then forgets the server locally.
770
- Docker, packages, landfall's key for root and rules from \`lf firewall\` stay.`,options:{volumes:{type:`boolean`,desc:`also delete the sites' named volumes (their data)`},"keep-setup":{type:`boolean`,desc:`keep the deploy user, ufw and ssh hardening; only remove sites, traefik and `+H},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||D(`usage: lf server uninstall <name>`);let{name:n,server:r}=B(e),i=await va(r,`root`),[a,s,c]=await Promise.all([Hn(r),hi(r),ga(r)]);s.compose===`foreign`&&D(`${W}/docker-compose.yml was not written by landfall: move it out of ${H} first`);let l=t[`keep-setup`]?[]:c.filter(e=>Za.test(e.cmd)).flatMap(ma).filter(e=>!e.undo.includes(H));S(`uninstall landfall from ${x.bold(n)}`);for(let e of a.sites){let n=e.meta.adopted?`forget ${e.meta.name} (keeps running from ${e.meta.adopted})`:`delete ${e.meta.name} (${e.domains.join(`, `)||`no domains`})`;console.error(` ${x.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${x.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${x.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${x.cyan(`↺`)} ${e.what}`);console.error(` ${x.red(`-`)} delete ${H}, its journal included`),console.error(` ${x.red(`-`)} forget ${n} locally`),console.error(` ${x.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await Wa(r,l,t.force),await A(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[q];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${J(Q(e.name))}`]:nr(e,{volumes:t.volumes});u.push(`log ${J(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${J(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${W} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await Y(r,u.join(`
771
- `)),l.length&&(S(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await ba(r,l)),await Y(i.login,`rm -rf ${H}`,{sudo:i.sudo}),C(`landfall removed from ${n}`),ye(n),Ot(n)&&C(`removed Host ${n} from ~/.ssh/config`),C(`forgot ${n}`),o.existsSync(Xi(n))&&w(x.dim(`${R(Xi(n))} stays: root on ${n} still accepts it`))}}),$a=N({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||D(`usage: lf server use <name>`),be(e),C(`default server: ${x.bold(e)}`)}}),eo=N({name:`ssh`,args:`[name] [-- command...]`,summary:`Open a shell on a server, or run a command there.`,passthrough:!0,async run([e],t,n){let{server:r}=B(e);return st(r,n)}}),to=N({name:`server info`,args:`[name]`,summary:`Show OS, hardware, load, memory, disk, addresses and containers of a server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=B(e),i=await Nt(r);if(t.json)return void console.log(JSON.stringify({name:n,...i,ips:await Rt(r,i)},null,2));let a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(x.bold(n)+x.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:x.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Ut(i.uptime)}${i.reboot?x.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${Ht(o)} / ${Ht(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${Ht(i.disk.used)} / ${Ht(i.disk.total)}${a(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`docker`,i.docker||`not installed or no access (run lf setup)`]];for(let[e,t]of s)console.log(`${x.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:T)(i.support.note),i.docker&&(console.log(),await Y(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function no(e){let t=z(),n=t.servers[e],r=n??{host:e},i=Z(r),a=Et(r.host),s=di(r,{});return{name:e,isDefault:t.default===e,configFile:R(_e),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],traefik:{...s,summary:fi(s),dashboard:r.dashboard?.host??null},ssh:{hostname:i.hostname,user:i.user,port:i.port,keys:(r.key?[r.key]:i.identityFiles).map(e=>({file:R(e),exists:o.existsSync(L(e))})),block:a?{file:R(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
772
- `)}:null}}}const ro=N({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
773
- the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
774
- and the Host block in ~/.ssh/config. Nothing connects to the server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n}=B(e),r=no(n);if(t.json)return void console.log(JSON.stringify(r,null,2));let i=(e,t)=>console.log(` ${x.dim(e.padEnd(10))} ${t}`);if(console.log(`${x.bold(n)}${r.isDefault?x.dim(` (default)`):``}`),console.log(`\n${x.bold(`landfall`)} ${x.dim(r.configFile)}`),!r.saved)i(`entry`,x.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(r.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&i(e,String(t));r.domains.length||i(`domain`,x.dim(`none (lf server domain ${n} example.com)`)),r.domains.forEach((e,t)=>i(t?``:r.domains.length>1?`domains`:`domain`,t?e:`${e} ${x.dim(`(primary: new sites get <site>.${e})`)}`)),i(`traefik`,`${r.traefik.summary}${r.traefik.email?`, ${r.traefik.email}`:``} ${x.dim(`(when landfall installs it)`)}`),i(`dashboard`,r.traefik.dashboard?`https://${r.traefik.dashboard}`:x.dim(`off`)),console.log(`\n${x.bold(`ssh`)} ${x.dim(`ssh -G ${r.saved?.host??n}`)}`),i(`login`,`${r.ssh.user}@${r.ssh.hostname}:${r.ssh.port}`);for(let e of r.ssh.keys)i(`key`,e.exists?e.file:`${e.file} ${x.dim(`(missing)`)}`);if(r.ssh.block){console.log(`\n${x.bold(`ssh config`)} ${x.dim(`${r.ssh.block.file}:${r.ssh.block.line}${r.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of r.ssh.block.text.split(`
775
- `))console.log(` ${e}`)}else console.log(`\n${x.bold(`ssh config`)} ${x.dim(`no Host block; ssh uses its defaults`)}`)}});async function io(e,t){let n=await Rt(e),[r,i]=await Vt([t,`landfall-${re(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function ao(e){let{stdout:t}=await Y(e,`docker ps -q | xargs -r docker inspect -f '{{range $k, $v := .Config.Labels}}{{println $v}}{{end}}' 2>/dev/null || true`,{capture:!0});return[...t.matchAll(/Host\(`([^`]+)`\)/g)].map(e=>e[1])}const oo=async e=>Ae(await ao(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),so=N({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
776
-
777
- The first main domain is the primary: \`lf deploy\` serves a new site at
778
- <site>.<primary>, \`--domain blog\` means blog.<primary>, and
779
- \`lf traefik setup --dashboard traefik\` puts the dashboard at traefik.<primary>.
780
- More main domains are listed and DNS-checked; use them with full names
781
- (\`lf domains blog --add blog.other.org\`). Point a wildcard A record
782
- (*.<domain>) at the server once per domain, and every site works without more DNS.
783
-
784
- Domains given as arguments replace the list (the first becomes primary).
785
- Without any, it lists them and, if there are none, suggests the base domains
786
- the server's sites already use.`,options:{add:{type:`string`,short:`a`,multiple:!0,desc:`add a main domain (after the primary)`},primary:{type:`string`,desc:`make this main domain the primary (added if new)`},rm:{type:`string`,multiple:!0,desc:`remove a main domain`},clear:{type:`boolean`,desc:`forget all main domains`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=z(),r=e.length>0&&!(e=>!!n.servers[e]||yt().includes(e))(e[0])&&V.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=B(i);if(a.length||t.add||t.rm||t.primary||t.clear){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(xe),i=t.clear?[]:a.length?a.map(xe):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(xe)])].filter(e=>!r.includes(e)),i=Te(i,t.primary&&xe(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&T(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await A(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,ve(n),s.domains=e.domains,C(i.length?`main domains of ${x.bold(o)}: ${i.map((e,t)=>t?e:x.bold(e)).join(`, `)}${i.length>1?x.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let c=s.domains??[];if(!c.length){let e=await oo(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:c,suggest:e}));w(`${o} has no main domain yet`);for(let t of e)w(`${t.hosts} routed hosts use ${x.bold(t.domain)} — ${x.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||w(`set one: ${x.bold(`lf server domain ${o} example.com`)}`);return}let l=await Promise.all(c.map(e=>io(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:c.map((e,t)=>({domain:e,primary:t===0,...l[t]}))},null,2));let u=Math.max(...c.map(e=>e.length))+2,d=[];c.forEach((e,t)=>{let n=l[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?x.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(x.dim(`
787
- add these DNS records at your DNS provider:
788
- `)+d.join(`
789
- `));let f=Ee(s);w(x.dim(`sites: lf deploy -d blog → blog.${f} · dashboard: lf traefik setup --dashboard traefik`))}}),co=N({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
790
- Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on (default: 4777, else the next free one)`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-c9LxeAd1.mjs`);return n({port:t.port===void 0?void 0:we(t.port,`--port`,1,65535),open:t.open})}}),lo={current:x.green,update:x.yellow,unknown:x.dim,pinned:x.dim,rebuild:x.dim,foreign:x.dim,missing:x.red};function uo(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return w(`packages: no supported package manager`);let r=e.updates.length?`${x.bold(String(e.updates.length))} package updates${n?`, ${x.yellow(`${n} security`)}`:``}`:x.green(`packages up to date`);console.log(`${r}${e.reboot?x.yellow(` · reboot required`):``}${e.refreshed?``:x.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);M(i.map(e=>[` `+e.name,x.dim(e.from??``),`→ `+e.to,e.security?x.yellow(`security`):``])),i.length<e.updates.length&&console.log(x.dim(` … ${e.updates.length-i.length} more (--all)`))}function fo(e){e.length&&(console.log(`\n${x.bold(`Images`)}`),M(e.map(e=>[` `+e.name,x.dim(e.ref||`-`),lo[e.state](e.state===`update`?`newer image`:e.state),x.dim(e.note??``)])))}function po(e){let t=Tr(e);console.log(`\n${x.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||x.red(`missing`)}`:x.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+x.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(x.dim(` newest releases`))}const mo=N({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:P,refresh:{type:`boolean`,desc:`refresh package lists first (apt update; --no-refresh to skip)`,default:!0},all:{type:`boolean`,desc:`list every package`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{server:n}=B(t.server),r=await lr(n,t.refresh),i=await Or(n),a=await wr(n);if(t.json)return void console.log(JSON.stringify({packages:r,images:i,engine:{...a,...Tr(a)}},null,2));uo(r,t.all),po(a),fo(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(x.dim(`\napply with: lf update${o}`)),Tr(a).composeUpdate&&console.log(x.dim(`newest compose: lf update --compose${o}`))}}),ho=N({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
813
+ `;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=te(process.cwd(),n)||`.`;T(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(m(n,`.env`))&&E(`locally: put POSTGRES_PASSWORD=… in ${m(f,`.env`)} (keep it out of git)`),E(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),E(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),Po=P({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
814
+ Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on (default: 4777, else the next free one)`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-DA-e5nav.mjs`);return n({port:t.port===void 0?void 0:je(t.port,`--port`,1,65535),open:t.open})}}),Fo={current:C.green,update:C.yellow,unknown:C.dim,pinned:C.dim,rebuild:C.dim,foreign:C.dim,missing:C.red};function Io(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return E(`packages: no supported package manager`);let r=e.updates.length?`${C.bold(String(e.updates.length))} package updates${n?`, ${C.yellow(`${n} security`)}`:``}`:C.green(`packages up to date`);console.log(`${r}${e.reboot?C.yellow(` · reboot required`):``}${e.refreshed?``:C.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);ue(i.map(e=>[` `+e.name,C.dim(e.from??``),`→ `+e.to,e.security?C.yellow(`security`):``])),i.length<e.updates.length&&console.log(C.dim(` … ${e.updates.length-i.length} more (--all)`))}function Lo(e){e.length&&(console.log(`\n${C.bold(`Images`)}`),ue(e.map(e=>[` `+e.name,C.dim(e.ref||`-`),Fo[e.state](e.state===`update`?`newer image`:e.state),C.dim(e.note??``)])))}function Ro(e){let t=Pr(e);console.log(`\n${C.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||C.red(`missing`)}`:C.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+C.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(C.dim(` newest releases`))}const zo=P({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:F,refresh:{type:`boolean`,desc:`refresh package lists first (apt update; --no-refresh to skip)`,default:!0},all:{type:`boolean`,desc:`list every package`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{server:n}=H(t.server);if(t.json)return void console.log(JSON.stringify(await ho(n,t.refresh),null,2));let r=await vr(n,t.refresh),i=await Lr(n),a=await Nr(n);Io(r,t.all),Ro(a),Lo(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(C.dim(`\napply with: lf update${o}`)),Pr(a).composeUpdate&&console.log(C.dim(`newest compose: lf update --compose${o}`))}}),Bo=P({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
791
815
 
792
816
  Without --packages/--images both are updated. Package upgrades cannot be undone;
793
817
  image updates can (\`lf undo\` puts the old image back). --compose replaces the
794
- compose plugin setup installed with the newest release (undoable too).`,options:{server:P,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf)`},reboot:{type:`boolean`,desc:`reboot afterwards if the upgrade needs it`},images:{type:`boolean`,desc:`pull newer images and recreate their containers`},compose:{type:`boolean`,desc:`update the compose plugin landfall installed to the newest release`}},async run(e,t){let{name:n,server:r}=B(t.server);if(t.compose){(e.length||t.packages||t.images)&&D(`--compose goes alone`);let i=await or(r,`updating docker compose`),a=await wr(i),o=Tr(a);if(!o.composeUpdate)return void(o.composeNote?w(o.composeNote):C(`compose ${a.compose} is the newest`));await j(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(S(`downloading docker compose`),await Er(i),C(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images;e.length&&t.packages&&D(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await or(r,`upgrading packages`),i=await lr(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?T(`no supported package manager, skipping packages`):a.length?(uo({...i,updates:a},!1),t.security&&i.mgr===`apk`&&D(`apk has no security-only upgrades; drop --security`),await j(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(S(`upgrading packages on ${n}`),await dr(e,{only:t.security&&i.mgr===`apt-get`?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),C(`packages upgraded`),t.reboot&&w(`rebooting if required; give it a minute`))):C(t.security?`no security updates`:`packages up to date`)}if(o){let t=await Or(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&D(`no site or service ${r.join(`, `)} on ${n}`)}let i=e.length?t.filter(t=>e.includes(t.name)):t;for(let e of i.filter(e=>e.state!==`update`&&e.state!==`current`&&e.state!==`unknown`))w(x.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(fo(a),await j(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(S(`updating images`),await kr(r,a),C(`images updated (lf undo puts the old ones back)`))):C(`images up to date`)}}});var go=`0.4.6`;const _o=[aa,pa,ua,Ei,Di,Oi,Ii,Ni,Ai,ji,Mi,Fi,Pi,Li,Hi,Ui,Wi,mo,ho,Ja,Ya,to,ro,so,$a,Xa,Qa,eo,vi,yi,xi,Si,Ci,bi,wi,Ia,La,Ra,Pa,Fa,Ba,za,Ka,qa,ta,co],vo={ls:`sites list`,sites:`sites list`,servers:`server list`,"server ls":`server list`,"server ssh":`ssh`,"sites logs":`logs`,"sites env":`env`,"sites rollback":`rollback`,"sites restart":`restart`,"sites stop":`stop`,"sites start":`start`,"sites ls":`sites list`,"sites remove":`sites rm`,"server remove":`server rm`,uninstall:`server uninstall`,"traefik remove":`traefik rm`,traefik:`traefik status`,firewall:`firewall status`,"firewall remove":`firewall rm`,log:`history`,revert:`undo`,web:`ui`,info:`server info`,"sites domains":`domains`,"sites auth":`auth`,"sites open":`open`,"sites tunnel":`tunnel`,upgrade:`update`},yo=new Map(_o.map(e=>[e.name,e]));function bo(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=yo.get(vo[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function xo(){let e=[[`Servers`,[`setup`,`server add`,`server list`,`server info`,`server config`,`server domain`,`server use`,`server rm`,`server uninstall`,`ssh`,`keygen`]],[`Traefik`,[`traefik setup`,`traefik status`,`traefik dashboard`,`traefik logs`,`traefik restart`,`traefik rm`]],[`Firewall`,[`firewall status`,`firewall enable`,`firewall disable`,`firewall allow`,`firewall deny`,`firewall update`,`firewall rm`]],[`Sites`,[`deploy`,`sites list`,`sites info`,`domains`,`auth`,`open`,`tunnel`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`Dashboard`,[`ui`]]],t=[`${x.bold(`landfall`)} ${x.dim(go)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${x.bold(`Usage:`)} lf <command> [options] ${x.dim(`lf <command> --help for details`)}`,` lf ui ${x.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,x.bold(n));for(let e of r){let n=yo.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
795
- `)[0]}`)}}t.push(``,x.bold(`Quick start`),` lf setup vps --check ${x.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${x.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${x.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${x.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${x.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${x.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${x.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${x.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${x.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${x.dim(`# what landfall changed, and revert the last of it`)}`,``,x.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
796
- `))}async function So(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return xo(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(go),0;let{cmd:t,rest:n}=bo(e);if(!t)return console.error(`${x.red(`error:`)} unknown command "${e.join(` `)}"\n`),xo(),2;let{values:r,positionals:i,rest:a}=fe(t,n);if(r.help)return console.log(pe(t)),0;k(!!r.yes),lt.cmd=me(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function Co(e){try{return await So(e)}catch(e){return e instanceof E?console.error(`${x.red(`error:`)} ${e.message}`):console.error(e),1}}Co(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{W as A,de as B,ct as C,G as D,K as E,z as F,Ne as I,B as L,V as M,ge as N,H as O,L as P,be as R,yt as S,Re as T,Vt as _,xa as a,Rt as b,Ji as c,wr as d,Or as f,En as g,wn as h,oo as i,_e as j,U as k,Bi as l,Hn as m,io as n,ma as o,lr as p,no as r,ga as s,go as t,Tr as u,Nt as v,Qe as w,Z as x,zt as y,x as z};
818
+ compose plugin setup installed with the newest release (undoable too).`,options:{server:F,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf/yum); implies --packages`},reboot:{type:`boolean`,desc:`reboot afterwards if the upgrade needs it`},images:{type:`boolean`,desc:`pull newer images and recreate their containers`},compose:{type:`boolean`,desc:`update the compose plugin landfall installed to the newest release`}},async run(e,t){let{name:n,server:r}=H(t.server);if(t.compose){(e.length||t.packages||t.images)&&k(`--compose goes alone`);let i=await hr(r,`updating docker compose`),a=await Nr(i),o=Pr(a);if(!o.composeUpdate)return void(o.composeNote?E(o.composeNote):T(`compose ${a.compose} is the newest`));await N(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(w(`downloading docker compose`),await Fr(i),T(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images&&!t.security&&!t.reboot;e.length&&t.packages&&k(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await hr(r,`upgrading packages`),i=await vr(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?D(`no supported package manager, skipping packages`):a.length?(Io({...i,updates:a},!1),t.security&&(i.mgr===`apk`||i.mgr===`zypper`)&&k(`${i.mgr} has no security-only upgrades here; drop --security`),await N(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(w(`upgrading packages on ${n}`),await br(e,{only:t.security?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),T(`packages upgraded`),t.reboot&&E(`rebooting if required; give it a minute`))):T(t.security?`no security updates`:`packages up to date`)}if(o){let t=await Lr(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&k(`no site or service ${r.join(`, `)} on ${n}`)}let i=e.length?t.filter(t=>e.includes(t.name)):t;for(let e of i.filter(e=>e.state!==`update`&&e.state!==`current`&&e.state!==`unknown`))E(C.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(Lo(a),await N(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(w(`updating images`),await Rr(r,a),T(`images updated (lf undo puts the old ones back)`))):T(`images up to date`)}}});var Vo=`0.5.1`;const Ho=[Eo,No,jo,Li,Ri,zi,qi,Wi,Vi,Hi,Ui,Ki,Gi,Ji,yo,bo,xo,zo,Bo,Xa,Za,ro,ao,lo,to,Qa,eo,no,Oi,ki,ji,Mi,Ni,Ai,Pi,ma,ha,ga,fa,pa,va,_a,Fa,Ia,So,Po],Uo={ls:`sites list`,sites:`sites list`,servers:`server list`,"server ls":`server list`,"server ssh":`ssh`,"sites logs":`logs`,"sites env":`env`,"sites rollback":`rollback`,"sites restart":`restart`,"sites stop":`stop`,"sites start":`start`,"sites ls":`sites list`,"sites remove":`sites rm`,"server remove":`server rm`,uninstall:`server uninstall`,"traefik remove":`traefik rm`,traefik:`traefik status`,firewall:`firewall status`,"firewall remove":`firewall rm`,log:`history`,revert:`undo`,web:`ui`,info:`server info`,"sites domains":`domains`,"sites auth":`auth`,"sites open":`open`,"sites tunnel":`tunnel`,upgrade:`update`},Wo=new Map(Ho.map(e=>[e.name,e]));function Go(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=Wo.get(Uo[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function Ko(){let e=[[`Servers`,[`setup`,`server add`,`server list`,`server info`,`server config`,`server domain`,`server use`,`server rm`,`server uninstall`,`ssh`,`keygen`]],[`Traefik`,[`traefik setup`,`traefik status`,`traefik dashboard`,`traefik logs`,`traefik restart`,`traefik rm`]],[`Firewall`,[`firewall status`,`firewall enable`,`firewall disable`,`firewall allow`,`firewall deny`,`firewall update`,`firewall rm`]],[`Sites`,[`deploy`,`sites list`,`sites info`,`domains`,`auth`,`open`,`tunnel`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`Dashboard`,[`ui`]]],t=[`${C.bold(`landfall`)} ${C.dim(Vo)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${C.bold(`Usage:`)} lf <command> [options] ${C.dim(`lf <command> --help for details`)}`,` lf ui ${C.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,C.bold(n));for(let e of r){let n=Wo.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
819
+ `)[0]}`)}}t.push(``,C.bold(`Quick start`),` lf setup vps --check ${C.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${C.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${C.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${C.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${C.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${C.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${C.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${C.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${C.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${C.dim(`# what landfall changed, and revert the last of it`)}`,``,C.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
820
+ `))}async function qo(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return Ko(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(Vo),0;let{cmd:t,rest:n}=Go(e);if(!t)return console.error(`${C.red(`error:`)} unknown command "${e.join(` `)}"\n`),Ko(),2;let{values:r,positionals:i,rest:a}=me(t,n);if(r.help)return console.log(he(t)),0;j(!!r.yes),gt.cmd=ge(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function Jo(e){try{return await qo(e)}catch(e){return e instanceof O?(console.error(`${C.red(`error:`)} ${e.message}`),/--force\b/.test(e.message)&&oe({force:e.message})):console.error(e),1}}Jo(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{H as A,ye as C,z as D,ve as E,C as M,pe as N,V as O,G as S,Te as T,Ge as _,uo as a,U as b,Ua as c,za as d,Yn as f,at as g,ht as h,po as i,L as j,Be as k,Ba as l,Nn as m,fo as n,mo as o,jn as p,go as r,ho as s,Vo as t,Ra as u,q as v,we as w,W as x,K as y};