landfall 0.4.3 → 0.4.4

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{homedir as s,hostname as c,tmpdir as l,userInfo as u}from"node:os";import{basename as d,dirname as f,join as p,relative as m,resolve as h}from"node:path";import{promises as ee}from"node:dns";import te,{isIP as g}from"node:net";import{parse as _}from"yaml";import{randomBytes as v}from"node:crypto";import ne from"bcryptjs";const re=process.stdout.isTTY&&!process.env.NO_COLOR,y=e=>t=>re?`\x1b[${e}m${t}\x1b[0m`:t,b={bold:y(`1`),dim:y(`2`),red:y(`31`),green:y(`32`),yellow:y(`33`),blue:y(`34`),cyan:y(`36`)},x=e=>console.error(`${b.cyan(`==>`)} ${b.bold(e)}`),S=e=>console.error(`${b.green(`✓`)} ${e}`),C=e=>console.error(` ${e}`),w=e=>console.error(`${b.yellow(`!`)} ${e}`);var T=class extends Error{};const E=e=>{throw new T(e)};let D=!1;const O=e=>D=e,ie=()=>!D&&!!process.stdin.isTTY;async function ae(e,t){if(D||!process.stdin.isTTY){if(t!==void 0)return t;E(`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(`${b.blue(`?`)} ${e}${t?b.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function oe(e){return D||!process.stdin.isTTY?!1:A(e,!1)}async function k(e,t){D||(process.stdin.isTTY||E(`${e} Pass -y to confirm (no terminal to ask).`),t?await ae(`${e} Type ${b.bold(t)} to confirm:`)!==t&&E(`cancelled`):await A(e,!1)||E(`cancelled`))}async function A(e,t=!0){return D?!0:process.stdin.isTTY?(await ae(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`):t}function j(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 se=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 ce(e){(D||!process.stdin.isTTY)&&E(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${b.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===`
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{homedir as s,hostname as c,tmpdir as l,userInfo as u}from"node:os";import{basename as d,dirname as f,join as p,relative as m,resolve as ee}from"node:path";import{promises as te}from"node:dns";import ne,{isIP as h}from"node:net";import{parse as g}from"yaml";import{randomBytes as _}from"node:crypto";import re from"bcryptjs";import ie from"node:http";import v from"node:https";const ae=process.stdout.isTTY&&!process.env.NO_COLOR,y=e=>t=>ae?`\x1b[${e}m${t}\x1b[0m`:t,b={bold:y(`1`),dim:y(`2`),red:y(`31`),green:y(`32`),yellow:y(`33`),blue:y(`34`),cyan:y(`36`)},x=e=>console.error(`${b.cyan(`==>`)} ${b.bold(e)}`),S=e=>console.error(`${b.green(`✓`)} ${e}`),C=e=>console.error(` ${e}`),w=e=>console.error(`${b.yellow(`!`)} ${e}`);var T=class extends Error{};const E=e=>{throw new T(e)};let D=!1;const oe=e=>D=e,se=()=>!D&&!!process.stdin.isTTY;async function ce(e,t){if(D||!process.stdin.isTTY){if(t!==void 0)return t;E(`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(`${b.blue(`?`)} ${e}${t?b.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function O(e){return D||!process.stdin.isTTY?!1:A(e,!1)}async function k(e,t){D||(process.stdin.isTTY||E(`${e} Pass -y to confirm (no terminal to ask).`),t?await ce(`${e} Type ${b.bold(t)} to confirm:`)!==t&&E(`cancelled`):await A(e,!1)||E(`cancelled`))}async function A(e,t=!0){return D?!0:process.stdin.isTTY?(await ce(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`):t}function le(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 ue=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 de(e){(D||!process.stdin.isTTY)&&E(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${b.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
3
  `)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new T(`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 le(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const M=e=>e,N={type:`string`,short:`s`,desc:`server name or ssh alias`},P={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}};function ue(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({...P,...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 E(`${e.message}\n\n${de(t)}`)}}function de(e){let t=[`${b.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,b.bold(`Options:`));let n=Object.entries({...e.options,yes:P.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?b.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?b.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 fe(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 pe=e=>process.env[`LANDFALL_${e}`],me=process.env.XDG_CONFIG_HOME??p(s(),`.config`),F=pe(`CONFIG_DIR`)??p(me,`landfall`),he=p(F,`config.json`),I=e=>e.startsWith(`~/`)?p(s(),e.slice(2)):e,L=e=>{let t=s();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function R(){try{let e=JSON.parse(o.readFileSync(he,`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:{}}:E(`cannot read ${he}: ${e.message}`)}}function z(e){o.mkdirSync(f(he),{recursive:!0,mode:448}),o.writeFileSync(he,JSON.stringify(e,null,2)+`
6
- `,{mode:384})}function ge(e){let t=R();delete t.servers[e],t.default===e&&delete t.default,z(t)}function _e(e){let t=R();t.servers[e]??={host:e},t.default=e,z(t)}function B(e){let t=R();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?E("no server given. Use --server <ssh-alias> or `lf server add`"):E(`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,ve=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return V.test(t)||E(`not a domain: ${e}`),t},ye=/^[a-z0-9][a-z0-9._\-/:@]*$/i,be=e=>ye.test(e)?e:E(`not an image: ${e}`);function xe(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)&&E(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const Se=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,Ce=e=>e.domains?.[0];function we(e,t){let n=Ce(t);return e===`@`?n??E("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const Te=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),Ee=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 De(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!V.test(n))continue;let e=Ee(n);Te.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 Oe=`.landfall.json`,ke=e=>p(e,Oe);function Ae(e){try{return JSON.parse(o.readFileSync(ke(e),`utf8`))}catch{return}}function je(e,t){o.writeFileSync(p(e,Oe),JSON.stringify(t,null,2)+`
7
- `)}const H=`/opt/landfall`,U=`${H}/sites`,W=`${H}/traefik`,Me=`${W}/dynamic`,Ne=8080,Pe=`dashboard-tunnel`,Fe=`traefik:v3`,G=`${H}/proxy.env`,K=`${H}/journal.log`,Ie=`/etc/ssh/sshd_config.d/00-landfall.conf`,Le={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},Re=`"$(sed -n 's/^container=//p' ${G} 2>/dev/null | grep . || echo traefik)"`;function ze(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 Be=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`
4
+ `)}}function fe(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const j=e=>e,M={type:`string`,short:`s`,desc:`server name or ssh alias`},N={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}};function pe(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({...N,...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 E(`${e.message}\n\n${me(t)}`)}}function me(e){let t=[`${b.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,b.bold(`Options:`));let n=Object.entries({...e.options,yes:N.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?b.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?b.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 he(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 P=e=>process.env[`LANDFALL_${e}`],ge=process.env.XDG_CONFIG_HOME??p(s(),`.config`),_e=P(`CONFIG_DIR`)??p(ge,`landfall`),F=p(_e,`config.json`),I=e=>e.startsWith(`~/`)?p(s(),e.slice(2)):e,L=e=>{let t=s();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function R(){try{let e=JSON.parse(o.readFileSync(F,`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:{}}:E(`cannot read ${F}: ${e.message}`)}}function ve(e){o.mkdirSync(f(F),{recursive:!0,mode:448}),o.writeFileSync(F,JSON.stringify(e,null,2)+`
6
+ `,{mode:384})}function ye(e){let t=R();delete t.servers[e],t.default===e&&delete t.default,ve(t)}function be(e){let t=R();t.servers[e]??={host:e},t.default=e,ve(t)}function z(e){let t=R();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?E("no server given. Use --server <ssh-alias> or `lf server add`"):E(`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 xe=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,Se=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return xe.test(t)||E(`not a domain: ${e}`),t},Ce=/^[a-z0-9][a-z0-9._\-/:@]*$/i,we=e=>Ce.test(e)?e:E(`not an image: ${e}`);function Te(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)&&E(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const Ee=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,De=e=>e.domains?.[0];function Oe(e,t){let n=De(t);return e===`@`?n??E("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const ke=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),Ae=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 je(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!xe.test(n))continue;let e=Ae(n);ke.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 Me=`.landfall.json`,Ne=e=>p(e,Me);function Pe(e){try{return JSON.parse(o.readFileSync(Ne(e),`utf8`))}catch{return}}function Fe(e,t){o.writeFileSync(p(e,Me),JSON.stringify(t,null,2)+`
7
+ `)}const B=`/opt/landfall`,V=`${B}/sites`,H=`${B}/traefik`,Ie=`${H}/dynamic`,Le=8080,Re=`dashboard-tunnel`,ze=`traefik:v3`,U=`${B}/proxy.env`,W=`${B}/journal.log`,Be=`/etc/ssh/sshd_config.d/00-landfall.conf`,Ve={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},He=`"$(sed -n 's/^container=//p' ${U} 2>/dev/null | grep . || echo traefik)"`;function Ue(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 We=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`,G=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 ${W} ] || { [ ! -e ${W} ] && [ -w ${B} ]; }; then
69
+ printf '%s\n' "$line" >> ${W}
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 ${B} && printf '%s\n' "$line" | $SUDO tee -a ${W} >/dev/null; } 2>/dev/null
73
+ fi || { log "warning: could not write ${W}"; 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 ${B}.
76
+ [ -O ${W} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${B})" ${W} 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 ${B} 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() {
@@ -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
- `,Ve=String.raw`
120
+ `,Ge=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
- `,He=`This server is run by landfall (https://www.npmjs.com/package/landfall).
131
+ `,Ke=`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
+ ${H}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
135
+ ${V}/<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
+ ${W} 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 ${V}/<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 ${V}/<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
- `,Ue=String.raw`
151
- ${Ve}
150
+ `,qe=String.raw`
151
+ ${Ge}
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
- ${He}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 ${B}"
214
+ new_sites=$([ -d ${V} ] || echo 1)
215
+ new_traefik=$([ -d ${H} ] || echo 1)
216
+ $SUDO install -d -o "$OWNER" -g "$OWNER" ${B} ${V} ${H}
217
+ $SUDO chown "$OWNER:$OWNER" ${W} 2>/dev/null || true
218
+ $SUDO tee ${B}/README >/dev/null <<'LANDFALL_README'
219
+ ${Ke}LANDFALL_README
220
+ # ${B} itself stays: it holds the journal. Each dir is only removed if setup created it.
221
+ [ -z "$new_sites" ] || record "created ${V}" "_as ${B} rmdir ${V}"
222
+ [ -z "$new_traefik" ] || record "created ${H}" "_as ${B} rm -rf ${H}"
223
223
 
224
224
  DOCKER="$SUDO docker" ensure_network ${`web`}
225
225
 
@@ -239,10 +239,10 @@ 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 ${Ie} 2>/dev/null || true)" != "$want" ]; then
242
+ if [ "$($SUDO cat ${Be} 2>/dev/null || true)" != "$want" ]; then
243
243
  $SUDO install -d /etc/ssh/sshd_config.d
244
- undo=$(saved ${Ie})
245
- printf '%s\n' "$want" | $SUDO tee ${Ie} >/dev/null
244
+ undo=$(saved ${Be})
245
+ printf '%s\n' "$want" | $SUDO tee ${Be} >/dev/null
246
246
  if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
247
247
  undo="$undo; $(saved /etc/ssh/sshd_config)"
248
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
@@ -257,7 +257,7 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
257
257
  fi
258
258
  fi
259
259
  log "bootstrap done"
260
- `,We=String.raw`
260
+ `,Je=String.raw`
261
261
  set +e
262
262
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
263
263
  kv() { printf '%s=%s
@@ -286,15 +286,15 @@ if [ -n "$DEPLOY_USER" ] && id "$DEPLOY_USER" >/dev/null 2>&1; then
286
286
  else
287
287
  kv user 0
288
288
  fi
289
- kv root_dir "$(yn test -d ${U})"
290
- kv sites "$(ls ${U} 2>/dev/null | wc -l | tr -d ' ')"
289
+ kv root_dir "$(yn test -d ${V})"
290
+ kv sites "$(ls ${V} 2>/dev/null | wc -l | tr -d ' ')"
291
291
  kv network "$(yn S docker network inspect ${`web`})"
292
- kv proxy "$(grep -v '^#' ${G} 2>/dev/null | tr '\n' ' ')"
292
+ kv proxy "$(grep -v '^#' ${U} 2>/dev/null | tr '\n' ' ')"
293
293
  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 ${Ie})"
294
+ kv hardened "$(yn test -f ${Be})"
295
295
  kv password_auth "$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" {print $2}')"
296
296
  kv password_users "$(S awk -F: '$2 ~ /^\$/ {printf "%s ", $1}' /etc/shadow 2>/dev/null)"
297
- f=${W}/docker-compose.yml
297
+ f=${H}/docker-compose.yml
298
298
  if [ ! -f "$f" ]; then kv traefik_compose none
299
299
  elif grep -q "managed by landfall" "$f"; then kv traefik_compose landfall
300
300
  else kv traefik_compose foreign; fi
@@ -321,14 +321,14 @@ else
321
321
  S netstat -ltunp 2>/dev/null | awk '$1 ~ /^tcp/ {print "listen=tcp " $4 " " $7} $1 ~ /^udp/ {print "listen=udp " $4 " " $6}'
322
322
  fi
323
323
  exit 0
324
- `,Ge=e=>e.replace(/\$/g,`$$$$`),Ke=e=>JSON.stringify(e);function qe(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`:``}:${Ne}`),n.push(`traefik.enable=true`,`traefik.http.routers.${Pe}.rule=PathPrefix(\`/\`)`,`traefik.http.routers.${Pe}.entrypoints=tunnel`,`traefik.http.routers.${Pe}.service=api@internal`,`traefik.http.routers.${Pe}.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=${Ge(e.dashboard.htpasswd)}`),`# managed by landfall — regenerate with \`lf traefik setup\`
324
+ `,Ye=e=>e.replace(/\$/g,`$$$$`),Xe=e=>JSON.stringify(e);function Ze(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`:``}:${Le}`),n.push(`traefik.enable=true`,`traefik.http.routers.${Re}.rule=PathPrefix(\`/\`)`,`traefik.http.routers.${Re}.entrypoints=tunnel`,`traefik.http.routers.${Re}.service=api@internal`,`traefik.http.routers.${Re}.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=${Ye(e.dashboard.htpasswd)}`),`# managed by landfall — regenerate with \`lf traefik setup\`
325
325
  services:
326
326
  traefik:
327
327
  image: ${e.image}
328
328
  container_name: traefik
329
329
  restart: unless-stopped
330
330
  command:
331
- ${t.map(e=>` - ${Ke(e)}`).join(`
331
+ ${t.map(e=>` - ${Xe(e)}`).join(`
332
332
  `)}
333
333
  ${e.hostNetwork?` network_mode: host
334
334
  `:` ports:
@@ -341,20 +341,20 @@ ${e.http3?` - "443:443/udp"
341
341
  - ./dynamic:/etc/traefik/dynamic:ro
342
342
  ${e.hostNetwork?``:` networks:
343
343
  - web
344
- `}${n.length?` labels:\n${n.map(e=>` - ${Ke(e)}`).join(`
344
+ `}${n.length?` labels:\n${n.map(e=>` - ${Xe(e)}`).join(`
345
345
  `)}\n`:``}${e.hostNetwork?``:`networks:
346
346
  web:
347
347
  external: true
348
- `}`}let Je=!1;const Ye=e=>Je=e,J=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,Xe=(e=pe(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],Ze=e=>e.user?`${e.user}@${e.host}`:e.host;function Qe(e){let t=[...Xe(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,I(e.key),`-o`,`IdentitiesOnly=yes`),t}let $e;function et(){return $e||($e=p(process.platform===`darwin`?`/tmp`:l(),`landfall-${u().uid}`),o.mkdirSync($e,{recursive:!0,mode:448})),$e}function tt(e,t={}){let n=[...Qe(e),`-o`,`ServerAliveInterval=15`];return(t.batch||Je)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${et()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,Ze(e)),n}function nt(e){return new Promise(t=>{let r=[...Qe(e),`-o`,`ControlPath=${et()}/%C`,`-O`,`exit`,Ze(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function rt(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`,Je?`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 it(e,t=[]){let{code:n}=await rt(`ssh`,[...tt(e,{tty:process.stdin.isTTY}),...t],{});return n}const at=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,ot={run:process.env.LANDFALL_RUN||at(),actor:`${u().username}@${c().split(`.`)[0]}`,cmd:``};async function Y(e,t,n={}){let r={LANDFALL_RUN:ot.run,LANDFALL_ACTOR:ot.actor,LANDFALL_CMD:ot.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 rt(`ssh`,[...tt(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);E(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function st(e){let{code:t}=await rt(`ssh`,[...tt(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}async function ct(e){let{code:t}=await rt(`ssh`,[...tt(e,{batch:!0}),`test -d ${H}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const lt=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`];let ut;async function dt(){if(!ut){let{stdout:e}=await rt(`tar`,[`--version`],{capture:!0});ut=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return ut}async function ft(e,t,r,i=lt){o.existsSync(t)||E(`directory not found: ${t}`);let a=[...await dt(),`-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`,[...tt(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)}`,pt=pe(`SSH_CONFIG`)??p(s(),`.ssh`,`config`),mt=/* @__PURE__ */ new Map;function Z(e){let n=[...Qe(e),`-G`,Ze(e)],r=mt.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return E(`ssh cannot resolve ${Ze(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>I(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return mt.set(n.join(`\0`),s),s}function ht(e){return(e.key?[I(e.key)]:Z(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function gt(e=pt,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=I(e),i=r.startsWith(`/`)?r:p(s(),`.ssh`,r);for(let e of _t(i))n.push(...gt(e,t))}}return[...new Set(n)]}function _t(e){if(!/[*?[]/.test(e))return[e];let t=f(e);return o.existsSync(t)?o.globSync(d(e),{cwd:t}).map(e=>p(t,e)):[]}const vt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],yt=(e,t)=>RegExp(`# >>> landfall:${Dt(e)}\\n[\\s\\S]*?# <<< landfall:${Dt(e)}${t}`),bt=(e,t)=>e===`# >>> landfall:${t}`;function xt(e){let[t,n]=vt(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 St(e,t=pt){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`),i=xt(e),a=yt(e.alias,`\\n?`),s=a.test(r)?r.replace(a,i+`
353
- `):`${i}\n\n${r}`;return o.mkdirSync(p(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),mt.clear(),{created:n,block:i}}function Ct(e,t=pt,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:bt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=I(t);for(let t of _t(r.startsWith(`/`)?r:p(s(),`.ssh`,r))){let r=Ct(e,t,n);if(r)return r}}}}function wt(e,t){let n=Ct(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
348
+ `}`}let Qe=!1;const $e=e=>Qe=e,K=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,et=(e=P(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],tt=e=>e.user?`${e.user}@${e.host}`:e.host;function nt(e){let t=[...et(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,I(e.key),`-o`,`IdentitiesOnly=yes`),t}let rt;function it(){return rt||(rt=p(process.platform===`darwin`?`/tmp`:l(),`landfall-${u().uid}`),o.mkdirSync(rt,{recursive:!0,mode:448})),rt}function at(e,t={}){let n=[...nt(e),`-o`,`ServerAliveInterval=15`];return(t.batch||Qe)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${it()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,tt(e)),n}function ot(e){return new Promise(t=>{let r=[...nt(e),`-o`,`ControlPath=${it()}/%C`,`-O`,`exit`,tt(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function st(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`,Qe?`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 ct(e,t=[]){let{code:n}=await st(`ssh`,[...at(e,{tty:process.stdin.isTTY}),...t],{});return n}const lt=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,ut={run:process.env.LANDFALL_RUN||lt(),actor:`${u().username}@${c().split(`.`)[0]}`,cmd:``};async function q(e,t,n={}){let r={LANDFALL_RUN:ut.run,LANDFALL_ACTOR:ut.actor,LANDFALL_CMD:ut.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${K(typeof t==`boolean`?+!!t:t)}`).join(`
349
+ `),a=await st(`ssh`,[...at(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);E(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function J(e){let{code:t}=await st(`ssh`,[...at(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}async function dt(e){let{code:t}=await st(`ssh`,[...at(e,{batch:!0}),`test -d ${B}`],{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 st(`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)||E(`directory not found: ${t}`);let a=[...await mt(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${K(r)} && tar -xzof - -C ${K(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[...at(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 Y=(e,t,n=`644`)=>`printf %s ${K(Buffer.from(t).toString(`base64`))} | base64 -d > ${K(e)} && chmod ${n} ${K(e)}`,gt=P(`SSH_CONFIG`)??p(s(),`.ssh`,`config`),_t=/* @__PURE__ */ new Map;function X(e){let n=[...nt(e),`-G`,tt(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 E(`ssh cannot resolve ${tt(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>I(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?[I(e.key)]:X(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=I(e),i=r.startsWith(`/`)?r:p(s(),`.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=f(e);return o.existsSync(t)?o.globSync(d(e),{cwd:t}).map(e=>p(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(p(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=I(t);for(let t of bt(r.startsWith(`/`)?r:p(s(),`.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
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(I(n))||I(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
- `)),mt.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function Tt(e,t=pt){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=yt(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const Et=(e,t=pt)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
357
- `).some(t=>bt(t,e)),Dt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Ot=String.raw`
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`
358
358
  set +e
359
359
  kv() { printf '%s=%s\n' "$1" "$2"; }
360
360
  PRETTY_NAME=unknown; ID=; VERSION_ID=; ID_LIKE=; . /etc/os-release 2>/dev/null
@@ -376,10 +376,10 @@ kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\
376
376
  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
377
  kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
378
378
  exit 0
379
- `;function kt(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:Mt(n.id??``,n.version??``,n.pkg||`none`,t)}}async function At(e){let{stdout:t}=await Y(e,q+Ot,{capture:!0});return kt(t)}const jt={"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 Mt(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=jt[`${e} ${t}`]??jt[`${e} ${a}`]??jt[`${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 Nt=/^(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])\.)/,Pt=e=>g(e)===4&&!Nt.test(e);async function Ft(e,t){let n=Z(e).hostname,r=g(n)?[n]:await ee.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const It=e=>e.find(Pt)??e[0],Lt=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function Rt(e,t){return Promise.all(e.map(async e=>{let n=await ee.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const zt=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]}`},Bt=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`},Vt=e=>e.v!==2,Ht=e=>e.project??e.name,Ut=e=>Vt(e)?void 0:e.releases.find(t=>t.id===e.current),Wt=e=>!!(e.port||e.own),Gt=e=>!!e.port&&!e.domains&&!e.own,Kt=e=>e?.services.find(Gt),qt=e=>e.adopted?void 0:Kt(Ut(e)),Jt=e=>`landfall-${e}`,Yt=e=>e.adopted?`adopted`:Vt(e)?`old`:`managed`,Xt=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[Yt(e)],Zt=e=>/^up/i.test(e),Qt=e=>/^exited \(0\)/i.test(e);function $t(e){let t=Ut(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...Kt(t)?e.domains:[],...n])]}const en=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,tn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],nn=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],rn=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),an=/^traefik\.http\.routers\.[^.]+\.rule$/,on=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function sn(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])=>an.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])=>rn(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&&(Wt(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(Wt)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(Gt);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 cn=Ke,ln=`landfall-proxy`;function un(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 dn(e,t,n=Le){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${cn(Ht(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${cn(a.name)}:`),a.build&&r.push(` image: ${cn(en(e.name,a.name,t.id))}`);let o={};Wt(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${cn(e)}: {}`),` ${ln}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=un(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])=>` ${cn(e)}: ${cn(Ge(t))}`))}return i&&r.push(`networks:`,` ${ln}:`,` name: ${cn(n.network)}`,` external: true`),r.join(`
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 q(e,G+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=>h(e)===4&&!It.test(e);async function Rt(e,t){let n=X(e).hostname,r=h(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 Z(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 nn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,rn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],an=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],on=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),sn=/^traefik\.http\.routers\.[^.]+\.rule$/,cn=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function ln(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])=>sn.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])=>on(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=>!xe.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 un=Xe,dn=`landfall-proxy`;function fn(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 pn(e,t,n=Ve){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${un(Gt(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${un(a.name)}:`),a.build&&r.push(` image: ${un(nn(e.name,a.name,t.id))}`);let o={};qt(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${un(e)}: {}`),` ${dn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=fn(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])=>` ${un(e)}: ${un(Ye(t))}`))}return i&&r.push(`networks:`,` ${dn}:`,` name: ${un(n.network)}`,` external: true`),r.join(`
381
381
  `)+`
382
- `}function fn(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:
382
+ `}function mn(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
383
  image: nginx:alpine
384
384
  restart: unless-stopped
385
385
  volumes:
@@ -388,14 +388,14 @@ exit 0
388
388
  x-landfall:
389
389
  port: 80
390
390
  `:`${n} app:
391
- ${e===`image`?`image: ${cn(t.image)}`:`build: .`}
391
+ ${e===`image`?`image: ${un(t.image)}`:`build: .`}
392
392
  restart: unless-stopped
393
393
  env_file:
394
394
  - path: .env
395
395
  required: false
396
396
  x-landfall:
397
397
  port: ${t.port??3e3}
398
- `}function pn(e){return`# generated by landfall
398
+ `}function hn(e){return`# generated by landfall
399
399
  server {
400
400
  listen 80;
401
401
  server_name _;
@@ -419,33 +419,33 @@ server {
419
419
  error_page 404 /404.html;
420
420
  location = /404.html { internal; }
421
421
  }
422
- `}const mn=[`dist`,`build`,`out`,`public`,`_site`,`site`],hn=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``);function gn(e){let t=t=>o.existsSync(p(e,t)),n=t=>{try{return o.readFileSync(p(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')`,hn(r(i(`next.config`)),`distDir`)??`out`);if(o(`nuxt`))return s(`Nuxt (nuxt generate)`,`.output/public`);if(o(`astro`))return s(`Astro`,hn(r(i(`astro.config`)),`outDir`)??`dist`);if(o(`@sveltejs/kit`))return s(`SvelteKit (adapter-static)`,hn(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`,hn(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`,hn(n(`vue.config.js`),`outputDir`)??`dist`):o(`vite`)?s(`Vite`,hn(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:hn(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function _n(e,t=gn(e)){return t.dir?t.dir:o.existsSync(p(e,`index.html`))?`.`:mn.find(t=>o.existsSync(p(e,t,`index.html`)))??`.`}function vn(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const yn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function bn(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return tn.find(t=>o.existsSync(p(e,t)))}function xn(e){let t=Ae(e),n=t?.name??yn(d(e)),r=bn(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(p(e,t));if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??vn(p(e,`Dockerfile`))??3e3,project:t};let a=gn(e),s=t?.dir??_n(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 Sn=p(F,`projects.json`);function Cn(){try{return JSON.parse(o.readFileSync(Sn,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function wn(e){let t=h(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...Cn().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(F,{recursive:!0,mode:448}),o.writeFileSync(Sn,JSON.stringify(n,null,2)+`
423
- `,{mode:384})}function Tn(e,t,n){let r=Cn();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=Ae(i.path);e?.name===t&&je(i.path,{...e,domains:n})}r.length&&o.writeFileSync(Sn,JSON.stringify(r,null,2)+`
424
- `,{mode:384})}const Q=e=>`${U}/${e}`;function En(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||E(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const Dn=`
422
+ `}const gn=[`dist`,`build`,`out`,`public`,`_site`,`site`],_n=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``);function vn(e){let t=t=>o.existsSync(p(e,t)),n=t=>{try{return o.readFileSync(p(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')`,_n(r(i(`next.config`)),`distDir`)??`out`);if(o(`nuxt`))return s(`Nuxt (nuxt generate)`,`.output/public`);if(o(`astro`))return s(`Astro`,_n(r(i(`astro.config`)),`outDir`)??`dist`);if(o(`@sveltejs/kit`))return s(`SvelteKit (adapter-static)`,_n(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`,_n(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`,_n(n(`vue.config.js`),`outputDir`)??`dist`):o(`vite`)?s(`Vite`,_n(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:_n(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function yn(e,t=vn(e)){return t.dir?t.dir:o.existsSync(p(e,`index.html`))?`.`:gn.find(t=>o.existsSync(p(e,t,`index.html`)))??`.`}function bn(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const xn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function Sn(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return rn.find(t=>o.existsSync(p(e,t)))}function Cn(e){let t=Pe(e),n=t?.name??xn(d(e)),r=Sn(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(p(e,t));if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??bn(p(e,`Dockerfile`))??3e3,project:t};let a=vn(e),s=t?.dir??yn(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 wn=p(_e,`projects.json`);function Tn(){try{return JSON.parse(o.readFileSync(wn,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function En(e){let t=ee(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...Tn().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(_e,{recursive:!0,mode:448}),o.writeFileSync(wn,JSON.stringify(n,null,2)+`
423
+ `,{mode:384})}function Dn(e,t,n){let r=Tn();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=Pe(i.path);e?.name===t&&Fe(i.path,{...e,domains:n})}r.length&&o.writeFileSync(wn,JSON.stringify(r,null,2)+`
424
+ `,{mode:384})}const Q=e=>`${V}/${e}`;function On(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||E(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const kn=`
425
425
  --8<--landfall--8<--
426
- `;async function On(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(Dn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(Dn),c;if(i.trim())try{c=JSON.parse(i)}catch{E(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:ze(o)??Le,exists:s.trim()===`1`}}async function kn(e,t,n={}){let r=await On(e,t);r.meta||E(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return Vt(i)&&E(`${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&&E(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const An=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),jn=(e,t,n)=>it(e,[`docker logs --tail ${J(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]);function Mn(e){return Object.entries(e).filter(([e,t])=>/^traefik\.http\.routers\.[^.]+\.service$/.test(e)&&t===`api@internal`).flatMap(([t])=>rn(e[t.replace(/service$/,`rule`)]??``))}const Nn=e=>e[`traefik.http.routers.${Pe}.service`]===`api@internal`;function Pn(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])=>an.test(e)).flatMap(([,e])=>rn(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&&Zt(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function Fn(e,t){if(!t.length)return`not running`;let n=Ut(e),r=new Set(n?.services.filter(Wt).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!Zt(e.status))??t.find(e=>!r.has(e.service)&&!Zt(e.status)&&!Qt(e.status))??i[0]??t[0]).status}const In=e=>Vt(e)?Jt(e.name):Ht(e);async function Ln(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=${Re}
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
426
+ `;async function An(e,t){let n=Q(t),{stdout:r}=await q(e,[...[`${n}/site.json`,`${n}/.env`,U].map(e=>`cat ${K(e)} 2>/dev/null || true`),`[ ! -d ${K(n)} ] || echo 1`].join(`; printf %s ${K(kn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(kn),c;if(i.trim())try{c=JSON.parse(i)}catch{E(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:Ue(o)??Ve,exists:s.trim()===`1`}}async function jn(e,t,n={}){let r=await An(e,t);r.meta||E(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return Wt(i)&&E(`${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&&E(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const Mn=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),Nn=(e,t,n)=>ct(e,[`docker logs --tail ${K(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]);function Pn(e){return Fn(e).flatMap(e=>e.hosts)}function Fn(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:on(e[`traefik.http.routers.${t}.rule`]??``)}))}const In=e=>e[`traefik.http.routers.${Re}.service`]===`api@internal`;function Ln(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])=>sn.test(e)).flatMap(([,e])=>on(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 Rn(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 zn=e=>Wt(e)?Zt(e.name):Gt(e);async function Bn(e){let{stdout:t}=await q(e,`printf 'READY\\t%s\\n' "$([ -d ${V} ] && command -v docker >/dev/null 2>&1 && echo 1)"
427
+ P=${He}
428
+ printf 'TRAEFIK\\t%s\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P" "$([ -f ${U} ] && echo 1)"
429
+ for f in ${V}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
430
430
  docker ps -a --format 'CTR\\t{{.Names}}\\t{{.Status}}\\t{{.Label "com.docker.compose.project"}}\\t{{.Label "com.docker.compose.service"}}' 2>/dev/null || true
431
431
  ids=$(docker ps -aq 2>/dev/null || true)
432
432
  # inspect, unlike ps, does not expand \\t: hence {{"\\t"}}.
433
433
  [ -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}};for(let s of t.split(`
434
- `)){let[t,c=``,l=``,u=``,d=``]=s.split(` `);if(t===`READY`&&(o.ready=c===`1`),t===`TRAEFIK`&&(o.traefik=c,o.proxy=l||`traefik`,o.adopted=u===`1`),t===`SITE`)try{i.push(JSON.parse(s.slice(t.length+1)))}catch{w(`skipped a corrupt site.json on ${e.host}: ${s.slice(5,80)}`)}if(t===`CTR`&&(n.set(c,l),u&&r.set(u,[...r.get(u)??[],{service:d||c,container:c,status:l}])),t===`LABELS`)try{a.push({name:c.replace(/^\//,``),labels:JSON.parse(l??`null`)??{}})}catch{}}return o.others=Pn(a,n,o.proxy,new Set(i.map(In))),o.dashboard={hosts:[...new Set(a.flatMap(e=>Mn(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&Nn(e.labels))},o.sites=i.map(e=>{let t=(r.get(In(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:Fn(e,t),services:t,domains:$t(e),main:qt(e)?.name,state:Yt(e),kind:Xt(e)}}),o}async function Rn(e,t,n,r={}){let i=`${Q(t)}/releases/${n}`,a=[...tn,...nn].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(Dn)}; printf '%s\\n' "$cfg"`].join(`
435
- `),{capture:!0}),[s,c=``]=o.split(Dn),l;try{l=_(c)}catch(e){E(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:s.trim(),config:l}}async function zn(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 Bn(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 Vn=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; }`],Hn=e=>`docker image rm ${J(e)} >/dev/null 2>&1 || true`;function Un(e,t,n={}){let r=Q(e.name),i=Ut(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`,dn(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`,...Vn(e.name)),o}const Wn=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>en(e.name,n.name,t.id)),Gn=(e,t)=>[`rmf ${J(`${Q(e.name)}/releases/${t.id}`)}`,...Wn(e,t).map(Hn)];function Kn(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(Jt(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${J(Jt(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(Hn)]}async function qn(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Jn(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function Jn(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=An(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)`)}`,...Un(t,n,r),...Kn(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${Zn(t,{keepDir:r.existed}).join(`
434
+ `)){let[t,c=``,l=``,u=``,d=``]=s.split(` `);if(t===`READY`&&(o.ready=c===`1`),t===`TRAEFIK`&&(o.traefik=c,o.proxy=l||`traefik`,o.adopted=u===`1`),t===`SITE`)try{i.push(JSON.parse(s.slice(t.length+1)))}catch{w(`skipped a corrupt site.json on ${e.host}: ${s.slice(5,80)}`)}if(t===`CTR`&&(n.set(c,l),u&&r.set(u,[...r.get(u)??[],{service:d||c,container:c,status:l}])),t===`LABELS`)try{a.push({name:c.replace(/^\//,``),labels:JSON.parse(l??`null`)??{}})}catch{}}return o.others=Ln(a,n,o.proxy,new Set(i.map(zn))),o.dashboard={hosts:[...new Set(a.flatMap(e=>Pn(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&(In(e.labels)||Pn(e.labels).length>0))},o.sites=i.map(e=>{let t=(r.get(zn(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:Rn(e,t),services:t,domains:Z(e),main:Xt(e)?.name,state:Qt(e),kind:$t(e)}}),o}async function Vn(e,t,n,r={}){let i=`${Q(t)}/releases/${n}`,a=[...rn,...an].filter(e=>e!==`compose.yaml`),{stdout:o}=await q(e,[G,`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 ${K(i)} && cd -P ${K(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>Y(e,t)),...r.file&&r.file!==`compose.yaml`?[`mv -f ${K(r.file)} compose.yaml`]:[],`rm -f ${a.map(K).join(` `)}`,...r.env===void 0?[]:[Y(`.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 ${K(kn)}; printf '%s\\n' "$cfg"`].join(`
435
+ `),{capture:!0}),[s,c=``]=o.split(kn),l;try{l=g(c)}catch(e){E(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:s.trim(),config:l}}async function Hn(e,t){let n=Q(t.name);await q(e,[G,`[ -r ${K(t.adopted)} ] && [ -x ${K(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${K(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${K(n)} && cd ${K(n)}`,`ln -s ${K(t.adopted)} current`,Y(`site.json`,JSON.stringify(t,null,2)+`
436
+ `),`record ${K(`adopted ${t.project} as site ${t.name}`)} ${K(`rm -rf ${n}`)}`].join(`
437
+ `))}async function Un(e,t,n){let r=Q(t);await q(e,`cd / && rm -rf ${K(`${r}/releases/${n}`)}; [ -f ${K(r+`/site.json`)} ] || { rmdir ${K(r+`/releases`)} 2>/dev/null; rm -f ${K(r+`/.env`)}; rmdir ${K(r)} 2>/dev/null; } || true`)}const Wn=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; }`],Gn=e=>`docker image rm ${K(e)} >/dev/null 2>&1 || true`;function Kn(e,t,n={}){let r=Q(e.name),i=Kt(e),a=K(`releases/`+e.current),o=[`mkdir -p ${K(r)} && cd ${K(r)}`,Y(`site.json`,JSON.stringify(e,null,2)+`
438
+ `),n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:Y(`.env`,n.env,`600`),`(cd -P ${a} && ${Y(`compose.override.yaml`,pn(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`,...Wn(e.name)),o}const qn=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>nn(e.name,n.name,t.id)),Jn=(e,t)=>[`rmf ${K(`${Q(e.name)}/releases/${t.id}`)}`,...qn(e,t).map(Gn)];function Yn(e){let t=Q(e.name),n=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${K(t)}`,`log "removing the old container of ${e.name}"`,`dc -p ${K(Zt(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${K(Zt(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.map(e=>`rm -rf ${K(`releases/`+e.id)}`),...n.map(Gn)]}async function Xn(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Zn(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function Zn(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=Mn(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=[G];if(r.takeover){l.push(`record ${K(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...Kn(t,n,r),...Yn(r.takeover)),await q(e,l.join(`
439
+ `));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${K(c+`/.env`)})`),!r.prev)u=`[ ! -d ${K(c)} ] || {\n${er(t,{keepDir:r.existed}).join(`
440
440
  `)}\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
- `+[...Un(i,n),...a.flatMap(e=>Gn(t,e))].join(`
443
- `)}l.push(`record ${J(r.what)} ${J(u)}${f?`"$u"`:``}${J(d)}`),l.push(...Un(t,n,r));for(let e of s)l.push(...Gn(t,e));await Y(e,l.join(`
444
- `))}async function Yn(e,t,n,r=``){await Y(e,`${q}\ncd ${J(Q(t))}\n${r}\ncd -P current && dc ${n}`)}const Xn=(e,t,n)=>it(e,[`cd -P ${J(Q(t)+`/current`)} && docker compose logs --tail ${J(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?J(n.service):``}`]);function Zn(e,t={}){let n=Q(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>Wn(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(In(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(Hn),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${J(n)}`]]}async function Qn(e,t,n={}){let r=`removed site ${t.name} (${$t(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await Y(e,[q,...Zn(t,n),`record ${J(r)}`].join(`
445
- `))}function $n(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
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 ${K(c)} ] || { log ${K(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
442
+ `+[...Kn(i,n),...a.flatMap(e=>Jn(t,e))].join(`
443
+ `)}l.push(`record ${K(r.what)} ${K(u)}${f?`"$u"`:``}${K(d)}`),l.push(...Kn(t,n,r));for(let e of s)l.push(...Jn(t,e));await q(e,l.join(`
444
+ `))}async function Qn(e,t,n,r=``){await q(e,`${G}\ncd ${K(Q(t))}\n${r}\ncd -P current && dc ${n}`)}const $n=(e,t,n)=>ct(e,[`cd -P ${K(Q(t)+`/current`)} && docker compose logs --tail ${K(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?K(n.service):``}`]);function er(e,t={}){let n=Q(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>qn(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(K).join(` `);return[`cd ${K(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${K(zn(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(Gn),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${K(n)}`]]}async function tr(e,t,n={}){let r=`removed site ${t.name} (${Z(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await q(e,[G,...er(t,n),`record ${K(r)}`].join(`
445
+ `))}function nr(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
446
446
  `)){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
447
  `)+`
448
- `:``}function er(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&E(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function tr(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 st(r)?r:E(`${t} needs root or passwordless sudo; ${Z(e).user} has neither and root's key login does not work`)}const nr=String.raw`
448
+ `:``}function rr(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&E(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function ir(e,t){let{stdout:n}=await q(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 X(e).user!==`root`&&await J(r)?r:E(`${t} needs root or passwordless sudo; ${X(e).user} has neither and root's key login does not work`)}const ar=String.raw`
449
449
  set +e
450
450
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
451
451
  m=$(pkg_mgr); echo "mgr=$m"
@@ -465,8 +465,8 @@ case "$m" in
465
465
  esac
466
466
  [ -f /var/run/reboot-required ] && echo reboot=1
467
467
  exit 0
468
- `;function rr(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 ir(e,t=!0){let{stdout:n}=await Y(e,q+nr,{capture:!0,env:{REFRESH:t}});return rr(n)}const ar=String.raw`
468
+ `;function or(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 sr(e,t=!0){let{stdout:n}=await q(e,G+ar,{capture:!0,env:{REFRESH:t}});return or(n)}const cr=String.raw`
470
470
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
471
471
  keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
472
472
  case "$(pkg_mgr)" in
@@ -486,8 +486,8 @@ if [ "$REBOOT" = 1 ] && { [ -f /var/run/reboot-required ] || { command -v needs-
486
486
  # Detached, so this ssh session can end cleanly first.
487
487
  (sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
488
488
  fi
489
- `;async function or(e,t){await Y(e,q+ar,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function sr(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 cr(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 lr(e){let t=sr(e);return t.digest?t.digest:(await cr(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function ur(e){let t=sr(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await cr(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 dr(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 fr=/^(v?)(\d+(?:\.\d+){0,2})$/;function pr(e,t){let n=fr.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=fr.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&dr(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const mr=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,hr=/* @__PURE__ */ new Map;function gr(e){return hr.has(e)||hr.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)),hr.get(e)}const _r=()=>gr(`moby/moby`),vr=()=>gr(`docker/compose`);async function yr(e){let t=String.raw`
490
- ${q}
489
+ `;async function lr(e,t){await q(e,G+cr,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function ur(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 dr(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 fr(e){let t=ur(e);return t.digest?t.digest:(await dr(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function pr(e){let t=ur(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await dr(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 mr(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 hr=/^(v?)(\d+(?:\.\d+){0,2})$/;function gr(e,t){let n=hr.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=hr.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&mr(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const _r=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,vr=/* @__PURE__ */ new Map;function yr(e){return vr.has(e)||vr.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)),vr.get(e)}const br=()=>yr(`moby/moby`),xr=()=>yr(`docker/compose`);async function Sr(e){let t=String.raw`
490
+ ${G}
491
491
  set +e
492
492
  printf 'docker=%s
493
493
  ' "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
@@ -497,11 +497,11 @@ printf 'path=%s
497
497
  ' "$(docker info --format '{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}' 2>/dev/null)"
498
498
  printf 'ce=%s
499
499
  ' "$(pkg_has docker-ce && echo 1)"
500
- exit 0`,[{stdout:n},r,i]=await Promise.all([Y(e,t,{capture:!0}),_r(),vr()]),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 br(e){let t=(e,t)=>!!e&&!!t&&dr(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 xr(e){await Y(e,q+Ve+`compose_install || { log "could not download docker compose"; exit 1; }
502
- `)}const Sr=String.raw`
500
+ exit 0`,[{stdout:n},r,i]=await Promise.all([q(e,t,{capture:!0}),br(),xr()]),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 Cr(e){let t=(e,t)=>!!e&&!!t&&mr(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 wr(e){await q(e,G+Ge+`compose_install || { log "could not download docker compose"; exit 1; }
502
+ `)}const Tr=String.raw`
503
503
  set +e
504
- P=${Re}
504
+ P=${He}
505
505
  img() { # img <kind> <name> <container> <dir> [service]
506
506
  svc=; [ $# -lt 5 ] || svc=$5
507
507
  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 +510,11 @@ $x
510
510
  EOF
511
511
  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
512
  }
513
- if [ "$P" = traefik ] && grep -q "managed by landfall" ${W}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${W}
513
+ if [ "$P" = traefik ] && grep -q "managed by landfall" ${H}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${H}
514
514
  else img proxy "$P" "$P" ""; fi
515
515
  # All containers at once: <name> <service> <project>, the last two empty outside Compose.
516
516
  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
517
+ for f in ${V}/*/site.json; do
518
518
  [ -f "$f" ] || continue
519
519
  d=$(dirname "$f"); n=$(basename "$d")
520
520
  # Sites of landfall before Compose sites: one container, its compose file next to site.json.
@@ -523,15 +523,15 @@ for f in ${U}/*/site.json; do
523
523
  printf '%s\n' "$PS" | while read -r c svc pr; do [ "$pr" != "$p" ] || img site "$n" "$c" "$d/current" "$svc"; done
524
524
  done
525
525
  exit 0
526
- `;async function Cr(e){let{stdout:t}=await Y(e,Sr,{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(!fr.test(sr(e).tag))return;i.has(t)||i.set(t,ur(e));let n=pr(sr(e).tag,await i.get(t));return n&&mr(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(en(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,lr(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>sr(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 wr(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 Tr(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 Er(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(Tr(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function Dr(e,t={}){let{stdout:n}=await Y(e,q+We,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return Er(n)}const Or=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),kr=e=>e.includes(`:`),Ar=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,jr=[68,546,5353];function Mr(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=zr(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 Nr(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 Pr=(e,t)=>{let n=Nr(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Fr=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[Ar(e),t(e)])).values()];function Ir(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=Lr(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 Lr(e,t){let n=e.values,r=e.rules.map(Mr),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&jr.includes(e.port)||Pr(r,e),a=e=>!Or(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[kr(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Fr(o,e=>`${Ar(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Fr(l,e=>`${Ar(e)} (docker${kr(e.addr)?`, IPv6`:``})`)],kept:c?[]:Fr(s.filter(e=>!l.includes(e)),Ar)}}function Rr(e){let t=e.values,n=e.rules.map(Mr),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[kr(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(Or(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=Nr(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`&&jr.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=`${Ar(e)} ${t?`docker`:e.proc}`,r={port:Ar(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 zr=(e=``)=>e.split(/\s+/).filter(Boolean);function Br(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&&dr(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 Vr(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(Br(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(Ir(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=zr(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(Wr(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function Hr(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 Ur=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function Wr(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=ze(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${Ur(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${W}/docker-compose.yml was not written by landfall`);let[i,a]=zr(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=Hr(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=zr(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=Hr(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${Ur(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 Gr(e){let t={done:b.green(`✓`),todo:b.cyan(`→`),conflict:b.yellow(`!`),off:b.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?b.dim(`disabled`):r.status===`conflict`?b.yellow(r.detail):b.dim(r.detail);C(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function Kr(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}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s]=r.split(` `).map(e=>JSON.parse(e)),c=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e));return{name:String(i).replace(/^\//,``),host:a===`host`,ips:c,labels:s??{}}}function qr(e,t){if(e.host)return`127.0.0.1`;let n=e.ips[t]??Object.values(e.ips)[0];return n||E(`${e.name} has no IP address to tunnel to`),n}async function Jr(e){let t=e=>new Promise(t=>{let n=te.createServer().once(`error`,()=>t(void 0));n.listen(e,`127.0.0.1`,()=>{let e=n.address().port;n.close(()=>t(e))})});return e&&await t(e)||await t(0)}const Yr=e=>new Promise(t=>{let n=te.connect(e,`127.0.0.1`);n.once(`connect`,()=>(n.destroy(),t(!0))),n.once(`error`,()=>t(!1))});async function Xr(e,t,r,i){let a=i.local===void 0?void 0:xe(i.local,`--local`,1,65535),o=await Jr(a??(r>=1024?r:8080));a&&o!==a&&E(`local port ${a} is taken; pick another with --local`);let s=[...Qe(e),`-o`,`ControlPath=none`,`-o`,`BatchMode=yes`,`-o`,`ExitOnForwardFailure=yes`,`-o`,`ServerAliveInterval=15`],c=n(`ssh`,[...s,`-N`,`-L`,`127.0.0.1:${o}:${t}:${r}`,Ze(e)],{stdio:[`ignore`,`inherit`,`inherit`]}),l=new Promise(e=>c.on(`close`,t=>e(t??1))),u=!1;for(let e of[`SIGINT`,`SIGTERM`])process.once(e,()=>(u=!0,c.kill()));for(let e=0;e<100&&c.exitCode===null&&!await Yr(o);e++)await new Promise(e=>setTimeout(e,150));c.exitCode===null&&(S(`${i.what}: ${b.cyan(`http://localhost:${o}${i.path??`/`}`)}`),i.note&&C(b.dim(i.note)),C(b.dim(`the tunnel stays open until Ctrl-C`)));let d=await l;!u&&d!==0&&E(`the tunnel closed (ssh exit ${d})`)}async function Zr(e,t){return`${e}:${(await ne.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const Qr=e=>e.slice(0,e.indexOf(`:`)),$r=`admin`;async function ei(e,t,n=`user`){/^[A-Za-z0-9._@-]{1,64}$/.test(e)||E(`${n} may only have letters, digits and . _ @ -`);let r=t??await ce(`Password for ${e}:`);return r.length<8&&E(`use a password of at least 8 characters`),Zr(e,r)}function ti(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 ni=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 ri(e,t,n){let r=ti(t,n);be(r.image);let{email:i,staging:a}=r;x(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?we(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){V.test(c)||E(`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??v(12).toString(`base64url`),o={host:c,user:e,htpasswd:await Zr(e,s)}}let l=qe({...r,dashboard:o});await Y(t,`${q}
526
+ `;async function Er(e){let{stdout:t}=await q(e,Tr,{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(!hr.test(ur(e).tag))return;i.has(t)||i.set(t,pr(e));let n=gr(ur(e).tag,await i.get(t));return n&&_r(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(nn(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,fr(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>ur(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 Dr(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[G];for(let[e,t]of n){r.push(`log ${K(`pulling ${e}`)}`,`docker pull -q ${K(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${K(e)})`);for(let n of t){let t=n.service?` ${K(n.service)}`:``,i=`docker tag $old ${K(e)} && cd -P ${K(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${K(n.container)})`,`if [ "$old" = "$new" ]; then log ${K(`${n.name} already runs the newest ${e}`)}; else`,` record ${K(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${K(`recreating ${n.container}`)}`,` (cd -P ${K(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${K(n.container)} || exit 1`,`fi`)}}await q(e,r.join(`
528
+ `))}function Or(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 kr(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(Or(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function Ar(e,t={}){let{stdout:n}=await q(e,G+Je,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return kr(n)}const jr=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),Mr=e=>e.includes(`:`),Nr=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,Pr=[68,546,5353];function Fr(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=Hr(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 Ir(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 Lr=(e,t)=>{let n=Ir(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Rr=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[Nr(e),t(e)])).values()];function zr(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=Br(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 Br(e,t){let n=e.values,r=e.rules.map(Fr),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&Pr.includes(e.port)||Lr(r,e),a=e=>!jr(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[Mr(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Rr(o,e=>`${Nr(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Rr(l,e=>`${Nr(e)} (docker${Mr(e.addr)?`, IPv6`:``})`)],kept:c?[]:Rr(s.filter(e=>!l.includes(e)),Nr)}}function Vr(e){let t=e.values,n=e.rules.map(Fr),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[Mr(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(jr(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=Ir(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`&&Pr.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=`${Nr(e)} ${t?`docker`:e.proc}`,r={port:Nr(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 Hr=(e=``)=>e.split(/\s+/).filter(Boolean);function Ur(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&&mr(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 Wr(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(Ur(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,B,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(zr(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=Hr(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(qr(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function Gr(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 Kr=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function qr(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=Ue(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${Kr(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${H}/docker-compose.yml was not written by landfall`);let[i,a]=Hr(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=Gr(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=Hr(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=Gr(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${Kr(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 Jr(e){let t={done:b.green(`✓`),todo:b.cyan(`→`),conflict:b.yellow(`!`),off:b.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?b.dim(`disabled`):r.status===`conflict`?b.yellow(r.detail):b.dim(r.detail);C(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function Yr(e,t){let{stdout:n}=await q(e,`id=$(docker ps -q ${t.map(e=>`--filter ${K(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}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s,c]=r.split(` `).map(e=>JSON.parse(e)),l=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e));return{name:String(i).replace(/^\//,``),host:a===`host`,ips:l,labels:s??{},args:c??[]}}function Xr(e,t){if(e.host)return`127.0.0.1`;let n=e.ips[t]??Object.values(e.ips)[0];return n||E(`${e.name} has no IP address to tunnel to`),n}function Zr(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}}async function Qr(e){let t=e=>new Promise(t=>{let n=ne.createServer().once(`error`,()=>t(void 0));n.listen(e,`127.0.0.1`,()=>{let e=n.address().port;n.close(()=>t(e))})});return e&&await t(e)||await t(0)}const $r=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 ei(e,t,n){let r=n.tls?v:ie,i=ie.createServer((e,i)=>{let a=r.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=>(i.writeHead(e.statusCode??502,e.headers),e.pipe(i)));a.on(`error`,e=>i.headersSent?i.destroy():i.writeHead(502).end(`${n.name}: ${e.message}\n`)),e.pipe(a)});return new Promise((t,n)=>i.once(`error`,n).listen(e,`127.0.0.1`,()=>t(i)))}async function ti(e,t,r,i){let a=i.local===void 0?void 0:Te(i.local,`--local`,1,65535),o=await Qr(a??(r>=1024?r:8080));a&&o!==a&&E(`local port ${a} is taken; pick another with --local`);let s=i.as?await Qr():o,c=[...nt(e),`-o`,`ControlPath=none`,`-o`,`BatchMode=yes`,`-o`,`ExitOnForwardFailure=yes`,`-o`,`ServerAliveInterval=15`],l=n(`ssh`,[...c,`-N`,`-L`,`127.0.0.1:${s}:${t}:${r}`,tt(e)],{stdio:[`ignore`,`inherit`,`inherit`]}),u=new Promise(e=>l.on(`close`,t=>e(t??1))),d=!1;for(let e of[`SIGINT`,`SIGTERM`])process.once(e,()=>(d=!0,l.kill()));for(let e=0;e<100&&l.exitCode===null&&!await $r(s);e++)await new Promise(e=>setTimeout(e,150));let f;l.exitCode===null&&(i.as&&(f=await ei(o,s,i.as)),S(`${i.what}: ${b.cyan(`http://localhost:${o}${i.path??`/`}`)}`),i.note&&C(b.dim(i.note)),C(b.dim(`the tunnel stays open until Ctrl-C`)));let p=await u;f?.close(),!d&&p!==0&&E(`the tunnel closed (ssh exit ${p})`)}async function ni(e,t){return`${e}:${(await re.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const ri=e=>e.slice(0,e.indexOf(`:`)),ii=`admin`;async function ai(e,t,n=`user`){/^[A-Za-z0-9._@-]{1,64}$/.test(e)||E(`${n} may only have letters, digits and . _ @ -`);let r=t??await de(`Password for ${e}:`);return r.length<8&&E(`use a password of at least 8 characters`),ni(e,r)}function oi(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 si=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 ci(e,t,n){let r=oi(t,n);we(r.image);let{email:i,staging:a}=r;x(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?Oe(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){xe.test(c)||E(`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??_(12).toString(`base64url`),o={host:c,user:e,htpasswd:await ni(e,s)}}let l=Ze({...r,dashboard:o});await q(t,`${G}
531
531
  ensure_network web
532
- [ -w ${W} ] || { $SUDO install -d -o "$(id -un)" ${W}; }
533
- mkdir -p ${W}/letsencrypt ${Me} && cd ${W}
534
- ${X(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
532
+ [ -w ${H} ] || { $SUDO install -d -o "$(id -un)" ${H}; }
533
+ mkdir -p ${H}/letsencrypt ${Ie} && cd ${H}
534
+ ${Y(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
535
535
  tls:
536
536
  options:
537
537
  default:
@@ -542,11 +542,11 @@ tls:
542
542
  gw=$(docker network inspect web -f '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>/dev/null | tr ' ' '\\n' | grep -m1 '[.]' || true)
543
543
  printf 'LANDFALL_GATEWAY=%s\\n' "\${gw:-127.0.0.1}" > .env
544
544
  if [ -e docker-compose.yml ]; then
545
- ${X(`.docker-compose.yml.new`,l)}
545
+ ${Y(`.docker-compose.yml.new`,l)}
546
546
  # .landfall.sha256: what landfall wrote last. A different file was edited by hand.
547
547
  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
548
  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
549
+ echo "${H}/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
550
  exit 1
551
551
  fi
552
552
  # Same config again: nothing to journal.
@@ -554,12 +554,12 @@ if [ -e docker-compose.yml ]; then
554
554
  else
555
555
  u=$(saved docker-compose.yml)
556
556
  mv .docker-compose.yml.new docker-compose.yml
557
- record ${J(`reconfigured traefik (${ni(r)})`)} "cd ${W} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
557
+ record ${K(`reconfigured traefik (${si(r)})`)} "cd ${H} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
558
558
  fi
559
559
  else
560
- ${X(`docker-compose.yml`,l)}
560
+ ${Y(`docker-compose.yml`,l)}
561
561
  # Certificates in letsencrypt/ stay, a later install reuses them.
562
- record ${J(`installed traefik (${ni(r)})`)} "cd ${W} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
562
+ record ${K(`installed traefik (${si(r)})`)} "cd ${H} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
563
563
  fi
564
564
  # Host mode: ufw decides who reaches 443/udp (published ports bypass it). Setup without ufw: nothing to open.
565
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
@@ -569,29 +569,29 @@ log "starting traefik"
569
569
  dc up -d --remove-orphans --quiet-pull
570
570
  sleep 2 # traefik exits shortly after start on a bad config
571
571
  ensure_running traefik
572
- sha256sum docker-compose.yml > .landfall.sha256`);let u=R();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}),z(u)),S(`traefik running (${ni(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&C(b.dim(`host network: ufw now decides who reaches 80/443 (landfall's firewall allows both)`)),o&&(S(`dashboard: https://${o.host}`),C(s?`user ${b.bold(o.user)} password ${b.bold(s)}`:b.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function ii(e,t){x(`using traefik ${b.bold(t.container)} for lf sites`),await Y(e,`${q}
572
+ sha256sum docker-compose.yml > .landfall.sha256`);let u=R();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)),S(`traefik running (${si(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&C(b.dim(`host network: ufw now decides who reaches 80/443 (landfall's firewall allows both)`)),o&&(S(`dashboard: https://${o.host}`),C(s?`user ${b.bold(o.user)} password ${b.bold(s)}`:b.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function li(e,t){x(`using traefik ${b.bold(t.container)} for lf sites`),await q(e,`${G}
573
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,Be(t))}
577
- record ${J(`sites use traefik ${t.container}`)} "$u"`),S(`sites join network ${b.bold(t.network)} with entrypoint ${b.bold(t.entrypoint)} and resolver ${b.bold(t.resolver)}`),C(b.dim(`redeploy existing sites to switch them over; delete ${G} to let landfall run its own traefik again`))}async function ai(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
574
+ [ -w ${B} ] || $SUDO install -d -o "$(id -un)" ${B}
575
+ u=$(saved ${U})
576
+ ${Y(U,We(t))}
577
+ record ${K(`sites use traefik ${t.container}`)} "$u"`),S(`sites join network ${b.bold(t.network)} with entrypoint ${b.bold(t.entrypoint)} and resolver ${b.bold(t.resolver)}`),C(b.dim(`redeploy existing sites to switch them over; delete ${U} to let landfall run its own traefik again`))}async function ui(e){let{stdout:t}=await q(e,`[ ! -f ${U} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${U})"
578
+ f=${H}/docker-compose.yml
579
579
  [ ! -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 oi(e,t={}){x(`removing traefik`),await Y(e,`${q}
582
- cd ${W}
580
+ for d in ${V}/*/; 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 di(e,t={}){x(`removing traefik`),await q(e,`${G}
582
+ cd ${H}
583
583
  log "stopping traefik"
584
584
  dc down --remove-orphans
585
- u="$(saved ${W}/docker-compose.yml); $(saved ${W}/.landfall.sha256); $(saved ${Me}/landfall.yml)"
586
- rm -f docker-compose.yml .landfall.sha256 ${Me}/landfall.yml
585
+ u="$(saved ${H}/docker-compose.yml); $(saved ${H}/.landfall.sha256); $(saved ${Ie}/landfall.yml)"
586
+ rm -f docker-compose.yml .landfall.sha256 ${Ie}/landfall.yml
587
587
  ${t.certs?`rmf letsencrypt`:``}
588
588
  # 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 si={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},ci=M({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:N,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: ${Fe})`},"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:$r},"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=Wr(await Dr(r));i.status===`conflict`&&E(`not touching ${n}: ${i.detail}`),i.proxy&&E(`${n} already has its own traefik: ${i.detail}. landfall does not reconfigure it (lf traefik use)`),await ri(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})}}),li=M({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
589
+ record ${K(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${H} && dc up -d --remove-orphans"`)}const fi={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},pi=j({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:M,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: ${ze})`},"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:ii},"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}=z(t.server),i=qr(await Ar(r));i.status===`conflict`&&E(`not touching ${n}: ${i.detail}`),i.proxy&&E(`${n} already has its own traefik: ${i.detail}. landfall does not reconfigure it (lf traefik use)`),await ci(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})}}),mi=j({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
590
590
 
591
591
  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:N,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=Hr((await Dr(r)).values.foreign_proxy),a=e??i?.name;a||E(`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&&E(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await ii(r,s)}}),ui=M({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:N,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 ai(r);if(i.proxy)return await A(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||E(`cancelled`),await Y(r,`${q}\nu=$(saved ${G})\n_as ${H} rm -f ${G}\nrecord ${J(`sites stop using traefik ${i.proxy}`)} "$u"`),S(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),C(b.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&E(`${W}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return S(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await k(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await oi(r,{certs:t.certs}),S(`traefik removed${t.certs?``:b.dim(` (certificates stay for a later install; --certs deletes them)`)}`),C(b.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),di=M({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:N},async run(e,t){let{server:n}=B(t.server);await Y(n,`P=${Re}
592
+ pass them when it is configured by a file. That Traefik is never changed.`,options:{server:M,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}=z(t.server),i=Gr((await Ar(r)).values.foreign_proxy),a=e??i?.name;a||E(`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&&E(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await li(r,s)}}),hi=j({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:M,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=z(t.server),i=await ui(r);if(i.proxy)return await A(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||E(`cancelled`),await q(r,`${G}\nu=$(saved ${U})\n_as ${B} rm -f ${U}\nrecord ${K(`sites stop using traefik ${i.proxy}`)} "$u"`),S(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),C(b.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&E(`${H}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return S(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await k(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await di(r,{certs:t.certs}),S(`traefik removed${t.certs?``:b.dim(` (certificates stay for a later install; --certs deletes them)`)}`),C(b.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),gi=j({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:M},async run(e,t){let{server:n}=z(t.server);await q(n,`P=${He}
593
593
  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})"
594
+ [ -f ${U} ] && echo "your own traefik, landfall only routes sites through it (${U})"
595
595
  docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
596
596
  echo
597
597
  echo "routes:"
@@ -615,34 +615,38 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
615
615
  printf " %s %s (%s)\\n", n, x, s
616
616
  }
617
617
  }' || true
618
- done | sort`)}}),fi=M({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:N,...si},async run(e,t){return jn(B(t.server).server,Re,t)}}),pi=M({name:`traefik restart`,summary:`Restart traefik.`,options:{server:N},async run(e,t){let{server:n}=B(t.server);await Y(n,`${q}\ndocker restart ${Re} >/dev/null\nrecord "restarted traefik"`),S(`traefik restarted`)}}),mi=M({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
618
+ done | sort`)}}),_i=j({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:M,...fi},async run(e,t){return Nn(z(t.server).server,He,t)}}),vi=j({name:`traefik restart`,summary:`Restart traefik.`,options:{server:M},async run(e,t){let{server:n}=z(t.server);await q(n,`${G}\ndocker restart ${He} >/dev/null\nrecord "restarted traefik"`),S(`traefik restarted`)}}),yi=j({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
619
619
 
620
620
  No domain or password needed: landfall's Traefik serves the dashboard to the server
621
621
  itself, and only there. To also serve it on a domain behind a password:
622
- lf traefik setup --dashboard traefik (traefik.<main domain>).`,options:{server:N,local:{type:`string`,short:`l`,desc:`local port (default: 8080, else a free one)`},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 ai(r),a=await Kr(r,[`name=^${i.proxy??`traefik`}$`]);a||E(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=Mn(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?xe(t.port,`--port`,1,65535):!i.proxy&&Nn(a.labels)?Ne:void 0;c||(s&&C(s),E(i.proxy?`${i.proxy} is your own traefik; 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`)),await Xr(r,qr(a,`web`),c,{local:t.local,path:`/dashboard/`,what:`traefik dashboard of ${n}`,note:s})}});function $(e,t){let n=Ae(process.cwd()),r=e??n?.name;return r||E(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...B(t??n?.server)}}const hi=e=>Zt(e)?b.green(e):Qt(e)?b.dim(e):b.red(e),gi=M({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:N,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??Ae(process.cwd())?.server),{sites:i,others:a}=await Ln(r);if(t.json)return console.log(JSON.stringify(t.others?{sites:i,others:a}:i,null,2));if(a.length&&(console.log(b.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),j([[b.dim(`NAME`),b.dim(`DOMAINS`),b.dim(`FOLDER`),b.dim(`STATUS`)],...a.map(e=>[e.name,e.domains.join(`, `),e.dir??b.dim(e.containers.join(`, `)),hi(e.status)])]),i.length&&console.log()),!i.length)return console.log(`no landfall sites on ${n} yet — ${b.bold(`lf deploy --domain example.com`)}`);j([[b.dim(`NAME`),b.dim(`KIND`),b.dim(`DOMAINS`),b.dim(`SERVICES`),b.dim(`RELEASE`),b.dim(`UPDATED`),b.dim(`STATUS`)],...i.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[b.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||b.dim(`-`),e.current||b.dim(`-`),se(e.updatedAt),hi(t)])])}}),_i=M({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:N},async run([e],t){let{site:n,server:r}=$(e,t.server),i=(await Ln(r)).sites.find(e=>e.meta.name===n);i||E(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${b.bold(a.name)} ${i.kind} ${hi(i.status)}`);for(let e of i.domains)console.log(` ${b.cyan(`https://`+e)}`);if(console.log(b.dim(` ${a.adopted??Q(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${b.bold(`Services`)}`),j(i.services.map(e=>[` ${e.service}`,b.dim(e.container),hi(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${b.bold(`Releases`)}`);for(let e of An(a)){let t=e.id===a.current?b.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${b.dim(se(e.at))} ${b.dim(e.services.map(e=>e.name).join(`, `))}${n.length?b.dim(` → ${n.join(`, `)}`):``}`)}}}}),vi=M({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:N,...si},async run([e,t],n){let{site:r,server:i}=$(e,n.server);return Xn(i,r,{...n,service:t})}}),yi=(e,t,n,r,i)=>M({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:N},async run([a,o],s){let{site:c,server:l}=$(a,s.server);await kn(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 Yn(l,c,t+u,p),S(`${o?`${c} ${o}`:c} ${n}`)}}),bi=yi(`restart`,`restart`,`restarted`),xi=yi(`stop`,`stop`,`stopped`,`start`,!0),Si=yi(`start`,`up -d --wait`,`started`,`stop`,!1),Ci=M({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:N,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 kn(r,n,{deployed:`roll back`}),o=An(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&E(`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||E(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)),S(`${n} rolled back to ${b.bold(c.id)}`)}}),wi=M({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:N,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||E("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=B(t.server),i=await Ln(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>Ht(t.meta)===e);E(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||E(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=En(t.name??yn(e));i.sites.some(e=>e.meta.name===s)&&E(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...tn,...nn].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&E(`${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}),S(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(b.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),Ti=M({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
622
+ lf traefik setup --dashboard traefik (traefik.<main domain>).
623
+
624
+ 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:M,local:{type:`string`,short:`l`,desc:`local port (default: 8080, else a free one)`},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}=z(t.server),i=await ui(r),a=await Yr(r,[`name=^${i.proxy??`traefik`}$`]);a||E(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=Pn(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?Te(t.port,`--port`,1,65535):!i.proxy&&In(a.labels)?Le:void 0,l=`traefik dashboard of ${n}`;if(c)return ti(r,Xr(a,`web`),c,{local:t.local,path:`/dashboard/`,what:l,note:s});let u=Fn(a.labels).flatMap(e=>e.hosts[0]?[{host:e.hosts[0],at:Zr(a,e.router)}]:[]).find(e=>e.at);u||E(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 ti(r,Xr(a,`web`),f.port,{local:t.local,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=Pe(process.cwd()),r=e??n?.name;return r||E(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...z(t??n?.server)}}const bi=e=>en(e)?b.green(e):tn(e)?b.dim(e):b.red(e),xi=j({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:M,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}=z(t.server??Pe(process.cwd())?.server),{sites:i,others:a}=await Bn(r);if(t.json)return console.log(JSON.stringify(t.others?{sites:i,others:a}:i,null,2));if(a.length&&(console.log(b.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),le([[b.dim(`NAME`),b.dim(`DOMAINS`),b.dim(`FOLDER`),b.dim(`STATUS`)],...a.map(e=>[e.name,e.domains.join(`, `),e.dir??b.dim(e.containers.join(`, `)),bi(e.status)])]),i.length&&console.log()),!i.length)return console.log(`no landfall sites on ${n} yet — ${b.bold(`lf deploy --domain example.com`)}`);le([[b.dim(`NAME`),b.dim(`KIND`),b.dim(`DOMAINS`),b.dim(`SERVICES`),b.dim(`RELEASE`),b.dim(`UPDATED`),b.dim(`STATUS`)],...i.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[b.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||b.dim(`-`),e.current||b.dim(`-`),ue(e.updatedAt),bi(t)])])}}),Si=j({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:M},async run([e],t){let{site:n,server:r}=$(e,t.server),i=(await Bn(r)).sites.find(e=>e.meta.name===n);i||E(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${b.bold(a.name)} ${i.kind} ${bi(i.status)}`);for(let e of i.domains)console.log(` ${b.cyan(`https://`+e)}`);if(console.log(b.dim(` ${a.adopted??Q(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${b.bold(`Services`)}`),le(i.services.map(e=>[` ${e.service}`,b.dim(e.container),bi(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${b.bold(`Releases`)}`);for(let e of Mn(a)){let t=e.id===a.current?b.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${b.dim(ue(e.at))} ${b.dim(e.services.map(e=>e.name).join(`, `))}${n.length?b.dim(` → ${n.join(`, `)}`):``}`)}}}}),Ci=j({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:M,...fi},async run([e,t],n){let{site:r,server:i}=$(e,n.server);return $n(i,r,{...n,service:t})}}),wi=(e,t,n,r,i)=>j({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:M},async run([a,o],s){let{site:c,server:l}=$(a,s.server);await jn(l,c);let u=o?` ${K(o)}`:``,d=`record ${K(`${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} ${K(`cd -P ${Q(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await Qn(l,c,t+u,p),S(`${o?`${c} ${o}`:c} ${n}`)}}),Ti=wi(`restart`,`restart`,`restarted`),Ei=wi(`stop`,`stop`,`stopped`,`start`,!0),Di=wi(`start`,`up -d --wait`,`started`,`stop`,!1),Oi=j({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:M,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 jn(r,n,{deployed:`roll back`}),o=Mn(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&E(`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||E(t.to?`no release ${t.to}`:`no release older than ${i.current}`),await Xn(r,i,a,`rollback ${n} from ${i.current} to ${c.id}`,e=>void(e.current=c.id)),S(`${n} rolled back to ${b.bold(c.id)}`)}}),ki=j({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:M,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||E("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=z(t.server),i=await Bn(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>Gt(t.meta)===e);E(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||E(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=On(t.name??xn(e));i.sites.some(e=>e.meta.name===s)&&E(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...rn,...an].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&E(`${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 Hn(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),S(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(b.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),Ai=j({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
623
626
 
624
627
  Named volumes (databases, uploads) stay, so deploying the site again finds its data;
625
- --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:N,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||E(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=$(e,t.server),{meta:a}=await On(r,n);a||E(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=$t(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await k(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 Qn(r,o,{volumes:t.volumes}),S(`removed ${n}${t.volumes?` and its volumes`:b.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),Ei=M({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:N,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 kn(a,i,{deployed:`manage the .env of`});if(!n.length&&!t.unset?.length)return void process.stdout.write(o||b.dim(`(empty)
626
- `));let s=$n(o,er(n),t.unset??[]),c=[...n.map(e=>e.slice(0,e.indexOf(`=`))),...(t.unset??[]).map(e=>`-${e}`)];await Yn(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
627
- record ${J(`env ${i} ${c.join(` `)}`)} "cd ${Q(i)} && $u && cd -P current && dc up -d"
628
- ${X(`.env`,s,`600`)}`),S(`updated env of ${i}`)}}),Di=M({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
628
+ --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:M,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||E(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=$(e,t.server),{meta:a}=await An(r,n);a||E(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=Z(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await k(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 tr(r,o,{volumes:t.volumes}),S(`removed ${n}${t.volumes?` and its volumes`:b.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),ji=j({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:M,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 jn(a,i,{deployed:`manage the .env of`});if(!n.length&&!t.unset?.length)return void process.stdout.write(o||b.dim(`(empty)
629
+ `));let s=nr(o,rr(n),t.unset??[]),c=[...n.map(e=>e.slice(0,e.indexOf(`=`))),...(t.unset??[]).map(e=>`-${e}`)];await Qn(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
630
+ record ${K(`env ${i} ${c.join(` `)}`)} "cd ${Q(i)} && $u && cd -P current && dc up -d"
631
+ ${Y(`.env`,s,`600`)}`),S(`updated env of ${i}`)}}),Mi=j({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
629
632
 
630
633
  Nothing changes on the server: no port is published and its domains stay as they are,
631
634
  so it also works for a site with no domain. Traefik is bypassed: no HTTPS and no login.
632
- The tunnel stays open until Ctrl-C.`,options:{server:N,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)`}},async run([e,t],n){let{site:r,server:i}=$(e,n.server),{meta:a,proxy:o}=await kn(i,r),s=Ut(a),c=t?s?.services.find(e=>e.name===t):qt(a)??s?.services.find(Wt);t&&s&&!c&&E(`${r} has no service ${t} (${s.services.map(e=>e.name).join(`, `)})`);let l=c?.name??t;l||E(`which service of ${r}? lf tunnel ${r} <service>`);let u=await Kr(i,[`label=com.docker.compose.project=${Ht(a)}`,`label=com.docker.compose.service=${l}`]);u||E(`${l} of ${r} is not running (lf start ${r})`);let d=n.port??c?.port??Object.entries(u.labels).find(([e])=>on.test(e))?.[1];d===void 0&&E(`which port of ${l}? pass --port`);let f=xe(d,`--port`,1,65535);await Xr(i,qr(u,o.network),f,{local:n.local,what:`${r}${t?` (${l})`:``}`,note:`straight to the container: no HTTPS, no login, and the app sees Host: localhost`})}});async function Oi(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 ki(e,t,n,r,i,a){let o=await Ln(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&w(`${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&&$t(e).some(e=>r.includes(e)));for(let e of s){let t=$t(e).filter(e=>r.includes(e)),i=qt(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=Vt(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)`;E(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&E(`${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 k(`${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)))),Tn(t,a.name,a.domains),C(b.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const Ai=M({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
635
+ 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:M,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)`}},async run([e,t],n){let{site:r,server:i}=$(e,n.server),{meta:a,proxy:o}=await An(i,r);if(!a)return Ni(i,r,t,o.network,n);Wt(a)&&E(`${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&&E(`${r} has no service ${t} (${s.services.map(e=>e.name).join(`, `)})`);let l=c?.name??t;l||E(`which service of ${r}? lf tunnel ${r} <service>`);let u=await Yr(i,[`label=com.docker.compose.project=${Gt(a)}`,`label=com.docker.compose.service=${l}`]);u||E(`${l} of ${r} is not running (lf start ${r})`),await Pi(i,u,o.network,n.port??c?.port,{local:n.local,what:`${r}${t?` (${l})`:``}`})}});async function Ni(e,t,n,r,i){let a=[`label=com.docker.compose.project=${t}`,...n?[`label=com.docker.compose.service=${n}`]:[]],o=await Yr(e,[...a,`label=traefik.enable=true`])??await Yr(e,a)??(n?void 0:await Yr(e,[`name=^/?${t}$`]));o||E(`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 Pi(e,o,r,i.port,{local:i.local,what:`${t}${s&&s!==t?` (${s})`:``}`})}async function Pi(e,t,n,r,i){let a=r??Object.entries(t.labels).find(([e])=>cn.test(e))?.[1];a===void 0&&E(`which port of ${t.name}? pass --port`),await ti(e,Xr(t,n),Te(String(a),`--port`,1,65535),{...i,note:`straight to the container: no HTTPS, no login, and the app sees Host: localhost`})}async function Fi(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 Ii(e,t,n,r,i,a){let o=await Bn(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&w(`${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&&Z(e).some(e=>r.includes(e)));for(let e of s){let t=Z(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)`;E(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&E(`${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 k(`${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 Xn(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)))),Dn(t,a.name,a.domains),C(b.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const Li=j({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
633
637
 
634
638
  Without flags it lists and checks; --add/--rm/--set change the site (undoable)
635
- and update .landfall.json of remembered projects.`,options:{server:N,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 kn(i,n,a?{deployed:`change the domains of`}:{});if(!a){let e=$t(o),r=await Ft(i),[a,s]=await Promise.all([Rt(e,r),Promise.all(e.map(Oi))]),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?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`),n=e.https.status?(e.https.status<400?b.green:e.https.status===401?b.cyan:b.yellow)(`https ${e.https.status}`):b.red(`https ${e.https.error}`);console.log(`${b.cyan(`https://${e.domain}`)} ${t} ${n}`)}c.some(e=>!e.ok)&&console.log(b.dim(`\npoint an A record at ${r.join(` or `)} for each domain`));return}qt(o)||E(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>we(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&&E(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&w(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return S(`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 k(`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 ki(i,r,n,p,s))(),await qn(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),Tn(r,n,l);let m=Ae(process.cwd());m?.name===n&&je(process.cwd(),{...m,domains:l}),l.length?S(`${n} serves ${l.map(e=>b.cyan(`https://${e}`)).join(`, `)}`):S(`${n} serves no domain now ${b.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let h=await Ft(i);for(let e of await Rt(p,h))e.ok||w(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${h.join(` or `)}: HTTPS waits for it`)}}),ji=M({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
639
+ and update .landfall.json of remembered projects.`,options:{server:M,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 jn(i,n,a?{deployed:`change the domains of`}:{});if(!a){let e=Z(o),r=await Rt(i),[a,s]=await Promise.all([Vt(e,r),Promise.all(e.map(Fi))]),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?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`),n=e.https.status?(e.https.status<400?b.green:e.https.status===401?b.cyan:b.yellow)(`https ${e.https.status}`):b.red(`https ${e.https.error}`);console.log(`${b.cyan(`https://${e.domain}`)} ${t} ${n}`)}c.some(e=>!e.ok)&&console.log(b.dim(`\npoint an A record at ${r.join(` or `)} for each domain`));return}Xt(o)||E(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>Oe(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=>!xe.test(e));u.length&&E(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&w(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return S(`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 k(`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 Ii(i,r,n,p,s))(),await Xn(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),Dn(r,n,l);let m=Pe(process.cwd());m?.name===n&&Fe(process.cwd(),{...m,domains:l}),l.length?S(`${n} serves ${l.map(e=>b.cyan(`https://${e}`)).join(`, `)}`):S(`${n} serves no domain now ${b.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let ee=await Rt(i);for(let e of await Vt(p,ee))e.ok||w(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),Ri=j({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
636
640
 
637
641
  lf auth my-site --user anna add or change a login (asks the password)
638
642
  lf auth my-site --rm-user anna remove a login
639
643
  lf auth my-site --off remove all logins: the site is public again
640
- lf auth my-site show who can log in`,options:{server:N,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 kn(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(Qr),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 ${b.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>b.bold(e)).join(`, `)}`);return}let c=t.off?[]:[...i.auth??[]],l=new Set(t[`rm-user`]??[]);for(let e of l)o.includes(e)||w(`no login ${e} on ${n}`);if(s&&l.add(s),c=c.filter(e=>!l.has(Qr(e))),s&&c.push(await ei(s,t.password)),c.join(`
644
+ lf auth my-site show who can log in`,options:{server:M,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 jn(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(ri),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 ${b.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>b.bold(e)).join(`, `)}`);return}let c=t.off?[]:[...i.auth??[]],l=new Set(t[`rm-user`]??[]);for(let e of l)o.includes(e)||w(`no login ${e} on ${n}`);if(s&&l.add(s),c=c.filter(e=>!l.has(ri(e))),s&&c.push(await ai(s,t.password)),c.join(`
641
645
  `)===(i.auth??[]).join(`
642
- `))return S(`nothing to change`);let u=c.map(Qr),d=o.filter(e=>!u.includes(e));u.length?d.length&&await k(`Remove the login${d.length>1?`s`:``} ${d.join(`, `)} from ${n}?`):await k(`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)),S(u.length?`${n} asks for a login (${u.join(`, `)})`:`${n} is public again`)}}),Mi=M({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:N},async run([e],t){let{site:n,server:r}=$(e,t.server),{meta:i}=await kn(r,n),a=$t(i)[0];a||E(`${n} serves no domain`);let o=`https://${a}`;console.log(b.cyan(o)),le(o)}}),Ni=async e=>{let t=``;for await(let n of e)t+=n;return t};let Pi;function Fi(){if(Pi)return Pi;let e=o.mkdtempSync(p(l(),`landfall-`));return Pi=p(e,`askpass`),o.writeFileSync(Pi,`#!/bin/sh
646
+ `))return S(`nothing to change`);let u=c.map(ri),d=o.filter(e=>!u.includes(e));u.length?d.length&&await k(`Remove the login${d.length>1?`s`:``} ${d.join(`, `)} from ${n}?`):await k(`Remove every login from ${n}? Anyone can open it then.`),await Xn(r,i,a,u.length?`login for ${n}: ${u.join(`, `)}`:`removed the login of ${n}`,e=>void(e.auth=c.length?c:void 0)),S(u.length?`${n} asks for a login (${u.join(`, `)})`:`${n} is public again`)}}),zi=j({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:M},async run([e],t){let{site:n,server:r}=$(e,t.server),{meta:i}=await jn(r,n),a=Z(i)[0];a||E(`${n} serves no domain`);let o=`https://${a}`;console.log(b.cyan(o)),fe(o)}}),Bi=async e=>{let t=``;for await(let n of e)t+=n;return t};let Vi;function Hi(){if(Vi)return Vi;let e=o.mkdtempSync(p(l(),`landfall-`));return Vi=p(e,`askpass`),o.writeFileSync(Vi,`#!/bin/sh
643
647
  printf '%s\\n' "$LANDFALL_PASSWORD"
644
- `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Pi}const Ii=e=>({SSH_ASKPASS:Fi(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Li(e){if(e){let e=(await Ni(process.stdin)).replace(/\r?\n$/,``);return e||E(`--password-stdin: nothing on stdin`),e}return pe(`PASSWORD`)||void 0}const Ri=e=>p(s(),`.ssh`,`landfall_${e}`);function zi(e){let t=Ri(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(p(s(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&E(`ssh-keygen failed`),{path:t,created:!0})}async function Bi(e,t,n){let i=Z(t),a=t.key?I(t.key):void 0;if(a&&!o.existsSync(a)&&E(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&E(`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 st(t);if(!s&&!t.key&&o.existsSync(Ri(e))&&await st({...t,key:Ri(e)})&&(t.key=a=Ri(e),s=!0),s)S(`key login works`),a??=ht(t);else if(n.check){w("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&&S(`generated key ${L(a)}`)}let l=t.key&&I(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await st(t))){x(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Ii(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...Qe(t),Ze(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await st(t)||E(`could not log in with the key — check host, user and password`),S(`key installed`)}return a}const Vi=e=>{for(let t of e)console.log(b.dim(` `)+t)};function Hi(e,t,n,r){let i=Z(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=St({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});S(`${r.created?`created`:`updated`} ${L(pt)} with ${b.bold(`Host ${e}`)} — try \`ssh ${e}\``),Vi(r.block.split(`
645
- `)),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!==ht({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?wt(e,n):void 0;s?(S(`added the key to your ${b.bold(`Host ${e}`)} in ${L(s.file)} ${b.dim(`(backup: ${L(s.backup)})`)}`),Vi([...s.removed.map(e=>b.red(`- ${e}`)),b.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=L(n)),r.ownAlias&&C(b.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Ui=M({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||E(`usage: lf keygen <name>`);let{path:t,created:n}=zi(e);S(`${n?`created`:`key exists:`} ${L(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Wi(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?w(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await oe(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):w(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Gi(e,t){x(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await ae(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(C(b.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await A(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&(t.email=await ae(`Let's Encrypt e-mail (optional)`,``)||void 0);let n=Ce(e);t.dashboard??=await ae(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function Ki(e,t){let n=Object.fromEntries(Object.entries(qi.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return qi.run([e],{...n,...t},[])}const qi=M({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
648
+ `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Vi}const Ui=e=>({SSH_ASKPASS:Hi(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Wi(e){if(e){let e=(await Bi(process.stdin)).replace(/\r?\n$/,``);return e||E(`--password-stdin: nothing on stdin`),e}return P(`PASSWORD`)||void 0}const Gi=e=>p(s(),`.ssh`,`landfall_${e}`);function Ki(e){let t=Gi(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(p(s(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&E(`ssh-keygen failed`),{path:t,created:!0})}async function qi(e,t,n){let i=X(t),a=t.key?I(t.key):void 0;if(a&&!o.existsSync(a)&&E(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&E(`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 J(t);if(!s&&!t.key&&o.existsSync(Gi(e))&&await J({...t,key:Gi(e)})&&(t.key=a=Gi(e),s=!0),s)S(`key login works`),a??=vt(t);else if(n.check){w("no key login yet — `lf setup` would install a key first");return}let c=!1;if(!a&&!n.check){let t=Ki(e);a=t.path,c=t.created,c&&S(`generated key ${L(a)}`)}let l=t.key&&I(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await J(t))){x(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Ui(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...nt(t),tt(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await J(t)||E(`could not log in with the key — check host, user and password`),S(`key installed`)}return a}const Ji=e=>{for(let t of e)console.log(b.dim(` `)+t)};function Yi(e,t,n,r){let i=X(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});S(`${r.created?`created`:`updated`} ${L(gt)} with ${b.bold(`Host ${e}`)} — try \`ssh ${e}\``),Ji(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?(S(`added the key to your ${b.bold(`Host ${e}`)} in ${L(s.file)} ${b.dim(`(backup: ${L(s.backup)})`)}`),Ji([...s.removed.map(e=>b.red(`- ${e}`)),b.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=L(n)),r.ownAlias&&C(b.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Xi=j({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||E(`usage: lf keygen <name>`);let{path:t,created:n}=Ki(e);S(`${n?`created`:`key exists:`} ${L(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Zi(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?w(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await O(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):w(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Qi(e,t){x(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await ce(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(C(b.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await A(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&(t.email=await ce(`Let's Encrypt e-mail (optional)`,``)||void 0);let n=De(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 $i(e,t){let n=Object.fromEntries(Object.entries(ea.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return ea.run([e],{...n,...t},[])}const ea=j({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
646
650
 
647
651
  Turns a fresh box into a deploy target (safe to re-run):
648
652
  1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
@@ -662,7 +666,7 @@ missing. Steps that would break services already running there are skipped:
662
666
  a Traefik already running there is used instead (lf traefik use)
663
667
  Use --check to only see that report, --force to apply firewall/hardening anyway.
664
668
  In a terminal, a first Traefik install asks for image, host network, e-mail and
665
- 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: ${Fe})`},"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||E(`usage: lf setup <name> [--host <ip>]`);let n=R(),r=n.servers[e],i=gt().includes(e)&&!Et(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 st({...a,user:`root`})&&(a.user=`root`);let s=Z(a);x(`setting up ${b.bold(e)} ${b.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>x(`[${++c}/5] ${e}`);l(`ssh key`);let u=await Bi(e,a,{check:t.check,password:await Li(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([Dr(a,p),_r(),vr()]);Object.assign(m.values,{docker_latest:h??``,compose_latest:ee??``});let te=Number(m.values.ssh_port)||s.port,g=Vr(m,{sshPort:te,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),_=g.find(e=>e.part===`traefik`),v={email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:$r},ne=_&&!_.proxy&&_.status!==`conflict`&&_.status!==`off`;ne&&(_.detail+=b.dim(` · ${ni(ti(r??a,v))}`));let re=Mt(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(C(`os: ${m.values.os??`unknown`}${m.values.pkg?b.dim(` · ${m.values.pkg}`):``}`),re.note&&(re.level===`ok`?C:w)(re.note),Gr(g),t.check)return;let y=await Wi(g,t.force),T=ne&&_.status===`todo`&&ie(),D=t.domain?ve(t.domain):r&&Ce(r);if(D===void 0&&T){let e=await ae(`main domain for sites, e.g. example.com (optional)`,``);D=e.trim()?ve(e):void 0}T&&(await Gi({...r??a,domains:Se(r?.domains,D)},v),C(`traefik: ${ni(ti(r??a,v))}${v.dashboard?`, dashboard ${v.dashboard}`:``}`));let O=g.filter(e=>e.status!==`done`&&y.has(e.part)).length;if(O&&!await A(`apply the ${O} change${O===1?``:`s`} above?`))return;let oe=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];g.some(e=>oe.includes(e.part)&&e.status!==`done`&&y.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await Y(a,q+Ue,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:te,HARDEN:y.has(`harden`),FIREWALL:y.has(`firewall`)}}),S(`server bootstrapped`)):(l(`bootstrap`),S(`already bootstrapped, nothing to change`));let k={...a};if(f){let e={...a,user:f};await st(e)?(S(`deploy user ${b.bold(f)} ready`),k=e):w(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let j=Hi(e,k,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});D&&(j.domains=Se(j.domains,D)),n.servers[e]=j,n.default??=e,z(n),l(`traefik`),_?.proxy?y.has(`traefik`)&&_.status===`todo`?await ii(j,_.proxy):S(_.detail):y.has(`traefik`)?await ri(e,j,v):C(b.dim(`skipped`)),D?(console.log(`\n${b.green(`Done.`)} Point ${b.bold(D)} and ${b.bold(`*.${D}`)} (A records) at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${b.dim(`# https://blog.${D}`)}`),console.log(` lf server domain ${e} ${b.dim(`# check the DNS`)}\n`)):(console.log(`\n${b.green(`Done.`)} Point a domain's A record at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),Ji={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function Yi(e,t){if(!t)return e;let n=Qr(t);return[...(e??[]).filter(e=>Qr(e)!==n),t]}const Xi=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function Zi(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(Xi(new Date(n)));)n+=1e3;return Xi(new Date(n))}const Qi=M({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
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: ${ze})`},"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||E(`usage: lf setup <name> [--host <ip>]`);let n=R(),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`]&&X(a).user===t[`deploy-user`]&&await J({...a,user:`root`})&&(a.user=`root`);let s=X(a);x(`setting up ${b.bold(e)} ${b.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>x(`[${++c}/5] ${e}`);l(`ssh key`);let u=await qi(e,a,{check:t.check,password:await Wi(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([Ar(a,p),br(),xr()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let ne=Number(m.values.ssh_port)||s.port,h=Wr(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,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:ii},re=g&&!g.proxy&&g.status!==`conflict`&&g.status!==`off`;re&&(g.detail+=b.dim(` · ${si(oi(r??a,_))}`));let ie=Ft(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(C(`os: ${m.values.os??`unknown`}${m.values.pkg?b.dim(` · ${m.values.pkg}`):``}`),ie.note&&(ie.level===`ok`?C:w)(ie.note),Jr(h),t.check)return;let v=await Zi(h,t.force),ae=re&&g.status===`todo`&&se(),y=t.domain?Se(t.domain):r&&De(r);if(y===void 0&&ae){let e=await ce(`main domain for sites, e.g. example.com (optional)`,``);y=e.trim()?Se(e):void 0}ae&&(await Qi({...r??a,domains:Ee(r?.domains,y)},_),C(`traefik: ${si(oi(r??a,_))}${_.dashboard?`, dashboard ${_.dashboard}`:``}`));let T=h.filter(e=>e.status!==`done`&&v.has(e.part)).length;if(T&&!await A(`apply the ${T} change${T===1?``:`s`} above?`))return;let D=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];h.some(e=>D.includes(e.part)&&e.status!==`done`&&v.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await q(a,G+qe,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:ne,HARDEN:v.has(`harden`),FIREWALL:v.has(`firewall`)}}),S(`server bootstrapped`)):(l(`bootstrap`),S(`already bootstrapped, nothing to change`));let oe={...a};if(f){let e={...a,user:f};await J(e)?(S(`deploy user ${b.bold(f)} ready`),oe=e):w(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let O=Yi(e,oe,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});y&&(O.domains=Ee(O.domains,y)),n.servers[e]=O,n.default??=e,ve(n),l(`traefik`),g?.proxy?v.has(`traefik`)&&g.status===`todo`?await li(O,g.proxy):S(g.detail):v.has(`traefik`)?await ci(e,O,_):C(b.dim(`skipped`)),y?(console.log(`\n${b.green(`Done.`)} Point ${b.bold(y)} and ${b.bold(`*.${y}`)} (A records) at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${b.dim(`# https://blog.${y}`)}`),console.log(` lf server domain ${e} ${b.dim(`# check the DNS`)}\n`)):(console.log(`\n${b.green(`Done.`)} Point a domain's A record at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),ta={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function na(e,t){if(!t)return e;let n=ri(t);return[...(e??[]).filter(e=>ri(e)!==n),t]}const ra=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function ia(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(ra(new Date(n)));)n+=1e3;return ra(new Date(n))}const aa=j({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
666
670
 
667
671
  Kinds:
668
672
  compose the folder has a compose.yaml: its services run on the server as one
@@ -676,16 +680,16 @@ so \`docker compose\` works there without landfall. Settings are saved to
676
680
  ./.landfall.json, so later deploys are just \`lf deploy\`.
677
681
 
678
682
  A server landfall has not set up yet is set up first (\`lf setup\`), asking
679
- before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:N,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"},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=Ae(n)??{};i.name&&C(b.dim(`using ${Oe}`));let{name:a,server:s}=B(t.server??i.server);t.host&&!t.server&&!i.server&&!R().default&&E(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await ct(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&&!(ie()&&await A(`${a} ${e}. Set it up now (lf setup)?`))&&E(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await Ki(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=B(a),await ct(s)!==`ready`&&E(`${a} is still not set up`)}let l=En(t.name??i.name??yn(d(n))),u=xe(t.keep,`--keep`,1),f=(t.domain?.length?t.domain:i.domains??[]).map(e=>we(e,s)).filter(e=>!V.test(e));f.length&&E(`not a domain: ${f.join(`, `)}`);let ee=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),te=ee?await ei(ee,t[`auth-password`],`--auth-user`):void 0,g=t.build??i.build;if(g){x(`building locally: ${g}`);let e=r(g,{shell:!0,stdio:`inherit`});e.status!==0&&E(`build failed (exit ${e.status})`)}let _=t.static??i.static,v=t.image??(_?void 0:i.image),ne=v&&be(v),re=h(n,e??i.dir??`.`),y=ne||_?void 0:t.file??bn(re,i);y&&!o.existsSync(p(re,y))&&E(`no ${y} in ${m(n,re)||`.`}`);let T=gn(n),D=y||ne?re:h(n,e??i.dir??_n(n,T)),O=y?`compose`:ne?`image`:!_&&o.existsSync(p(D,`Dockerfile`))?`dockerfile`:`static`,oe=O===`static`&&!e&&!i.dir?T.tool:void 0;oe&&C(b.dim(`publishing ${m(n,D)}: where ${oe} builds the site (another folder: lf deploy <dir>)`)),O===`static`&&!o.existsSync(p(D,`index.html`))&&w(`no index.html in ${m(n,D)||`.`}${oe&&!g?`: build first, or let deploy do it (--build "${T.build}")`:``}`);let k=Ji[O].port?xe(t.port??i.port??(O===`dockerfile`?vn(p(D,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,j=O===`static`&&!!(t.spa??i.spa);x(`deploying ${b.bold(l)} (${O}) to ${b.bold(a)}`);let se=await On(s,l),{env:ce,proxy:le,exists:M}=se,N=se.meta&&Vt(se.meta)?se.meta:void 0,P=N?void 0:se.meta;P?.adopted&&E(`${l} is adopted (${P.adopted}): deploy it there with docker compose, or pick another --name`),N?w(`${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&&C(b.dim(`site was ${P.kind}, now ${O}`));let ue;(t.env?.length||t[`env-file`])&&(ue=ce,t[`env-file`]&&(ue=$n(ue,er($i(o.readFileSync(t[`env-file`],`utf8`))))),t.env?.length&&(ue=$n(ue,er(t.env))));let de=Zi((P?.releases??[]).map(e=>e.id)),fe=`${Q(l)}/releases/${de}`,pe=O===`compose`?{}:{"compose.yaml":fn(O,{port:k,image:ne}),...O===`static`&&{"nginx.conf":pn(j)}},me,F,he;try{let e=Ji[O].files;e!==void 0&&(x(`uploading ${m(n,D)||`.`}`),await ft(s,D,fe+e));let r=await Rn(s,l,de,{file:y,generated:pe,env:ue}),o=sn(r.config,r.dir,e=>we(e,s));for(let e of o.warnings)w(e);o.errors.length&&E(`${y??`compose.yaml`} cannot be deployed:\n${o.errors.map(e=>` - ${e}`).join(`
680
- `)}`),me={id:de,at:(/* @__PURE__ */ new Date()).toISOString(),services:o.services};let c=o.services.flatMap(e=>e.domains??[]);F=[];let u=!1;if(Kt(me)){u=!t.domain?.length&&!!i.domains?.length,F=[t.domain,i.domains,P?.domains,N?.domains].find(e=>e?.length)??[];let e=!F.length&&!!P&&!!qt(P)&&!P.domains.length;if(!F.length&&!e){let e=Ce(s),t=e?void 0:It(await Ft(s));F=(await ae(`Domain(s) to serve (comma separated):`,e?`${l}.${e}`:t?Lt(l,t):void 0)).split(/[\s,]+/).filter(Boolean)}F=[...new Set(F.map(e=>we(e,s)))];let n=F.filter(e=>!V.test(e)),r=F.filter(e=>c.includes(e));!F.length&&!e&&E(`at least one --domain is required`),e&&C(b.dim(`${l} serves no domain (lf domains ${l} --add <domain>, or deploy with -d)`)),n.length&&E(`not a domain: ${n.join(`, `)}`),r.length&&E(`${r.join(`, `)} is already routed by x-landfall.domains`)}else t.domain?.length&&w(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);he=await ki(s,a,l,[...F,...c],le,u?`${Oe} lists it; deploy with -d to move it`:void 0)}catch(e){throw await Bn(s,l,de).catch(()=>{}),e}let I=(/* @__PURE__ */ new Date()).toISOString(),L={v:2,name:l,kind:O,domains:F,auth:Yi(P?.auth??N?.auth,te),current:de,releases:[...P?.releases??[],me],createdAt:P?.createdAt??N?.createdAt??I,updatedAt:I};await he(),await Jn(s,L,le,{env:ue,build:!0,pull:!0,keep:u,prev:P,takeover:N,existed:!P&&!N&&M,what:`deploy ${l} release ${de}${P?``:` (new site, ${$t(L).join(`, `)})`}`});let z=$t(L);if(t.save){let e={name:l,server:a,domains:F};if(O===`image`)Object.assign(e,{image:ne,port:k});else{let r=m(n,D)||`.`;O===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=y)):r!==_n(n,T)&&(e.dir=r),O===`dockerfile`&&(e.port=k),j&&(e.spa=!0),_&&O===`static`&&(e.static=!0)}g&&(e.build=g),je(n,e),wn({path:n,name:l,server:a,domains:z,kind:O})}S(`release ${b.bold(de)} live`);for(let e of z)console.log(` ${b.cyan(`https://${e}`)}`);z[0]&&await ea(z[0])}});function $i(e){return e.split(`
681
- `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function ea(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?S(`https://${e} responded ${t.status}`):w(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);w(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const ta=M({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
683
+ before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:M,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"},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=Pe(n)??{};i.name&&C(b.dim(`using ${Me}`));let{name:a,server:s}=z(t.server??i.server);t.host&&!t.server&&!i.server&&!R().default&&E(`--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&&!(se()&&await A(`${a} ${e}. Set it up now (lf setup)?`))&&E(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await $i(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=z(a),await dt(s)!==`ready`&&E(`${a} is still not set up`)}let l=On(t.name??i.name??xn(d(n))),u=Te(t.keep,`--keep`,1),f=(t.domain?.length?t.domain:i.domains??[]).map(e=>Oe(e,s)).filter(e=>!xe.test(e));f.length&&E(`not a domain: ${f.join(`, `)}`);let te=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),ne=te?await ai(te,t[`auth-password`],`--auth-user`):void 0,h=t.build??i.build;if(h){x(`building locally: ${h}`);let e=r(h,{shell:!0,stdio:`inherit`});e.status!==0&&E(`build failed (exit ${e.status})`)}let g=t.static??i.static,_=t.image??(g?void 0:i.image),re=_&&we(_),ie=ee(n,e??i.dir??`.`),v=re||g?void 0:t.file??Sn(ie,i);v&&!o.existsSync(p(ie,v))&&E(`no ${v} in ${m(n,ie)||`.`}`);let ae=vn(n),y=v||re?ie:ee(n,e??i.dir??yn(n,ae)),T=v?`compose`:re?`image`:!g&&o.existsSync(p(y,`Dockerfile`))?`dockerfile`:`static`,D=T===`static`&&!e&&!i.dir?ae.tool:void 0;D&&C(b.dim(`publishing ${m(n,y)}: where ${D} builds the site (another folder: lf deploy <dir>)`)),T===`static`&&!o.existsSync(p(y,`index.html`))&&w(`no index.html in ${m(n,y)||`.`}${D&&!h?`: build first, or let deploy do it (--build "${ae.build}")`:``}`);let oe=ta[T].port?Te(t.port??i.port??(T===`dockerfile`?bn(p(y,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,O=T===`static`&&!!(t.spa??i.spa);x(`deploying ${b.bold(l)} (${T}) to ${b.bold(a)}`);let k=await An(s,l),{env:le,proxy:ue,exists:de}=k,fe=k.meta&&Wt(k.meta)?k.meta:void 0,j=fe?void 0:k.meta;j?.adopted&&E(`${l} is adopted (${j.adopted}): deploy it there with docker compose, or pick another --name`),fe?w(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):j&&j.kind!==T&&C(b.dim(`site was ${j.kind}, now ${T}`));let M;(t.env?.length||t[`env-file`])&&(M=le,t[`env-file`]&&(M=nr(M,rr(oa(o.readFileSync(t[`env-file`],`utf8`))))),t.env?.length&&(M=nr(M,rr(t.env))));let N=ia((j?.releases??[]).map(e=>e.id)),pe=`${Q(l)}/releases/${N}`,me=T===`compose`?{}:{"compose.yaml":mn(T,{port:oe,image:re}),...T===`static`&&{"nginx.conf":hn(O)}},he,P,ge;try{let e=ta[T].files;e!==void 0&&(x(`uploading ${m(n,y)||`.`}`),await ht(s,y,pe+e));let r=await Vn(s,l,N,{file:v,generated:me,env:M}),o=ln(r.config,r.dir,e=>Oe(e,s));for(let e of o.warnings)w(e);o.errors.length&&E(`${v??`compose.yaml`} cannot be deployed:\n${o.errors.map(e=>` - ${e}`).join(`
684
+ `)}`),he={id:N,at:(/* @__PURE__ */ new Date()).toISOString(),services:o.services};let c=o.services.flatMap(e=>e.domains??[]);P=[];let u=!1;if(Yt(he)){u=!t.domain?.length&&!!i.domains?.length,P=[t.domain,i.domains,j?.domains,fe?.domains].find(e=>e?.length)??[];let e=!P.length&&!!j&&!!Xt(j)&&!j.domains.length;if(!P.length&&!e){let e=De(s),t=e?void 0:zt(await Rt(s));P=(await ce(`Domain(s) to serve (comma separated):`,e?`${l}.${e}`:t?Bt(l,t):void 0)).split(/[\s,]+/).filter(Boolean)}P=[...new Set(P.map(e=>Oe(e,s)))];let n=P.filter(e=>!xe.test(e)),r=P.filter(e=>c.includes(e));!P.length&&!e&&E(`at least one --domain is required`),e&&C(b.dim(`${l} serves no domain (lf domains ${l} --add <domain>, or deploy with -d)`)),n.length&&E(`not a domain: ${n.join(`, `)}`),r.length&&E(`${r.join(`, `)} is already routed by x-landfall.domains`)}else t.domain?.length&&w(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);ge=await Ii(s,a,l,[...P,...c],ue,u?`${Me} lists it; deploy with -d to move it`:void 0)}catch(e){throw await Un(s,l,N).catch(()=>{}),e}let _e=(/* @__PURE__ */ new Date()).toISOString(),F={v:2,name:l,kind:T,domains:P,auth:na(j?.auth??fe?.auth,ne),current:N,releases:[...j?.releases??[],he],createdAt:j?.createdAt??fe?.createdAt??_e,updatedAt:_e};await ge(),await Zn(s,F,ue,{env:M,build:!0,pull:!0,keep:u,prev:j,takeover:fe,existed:!j&&!fe&&de,what:`deploy ${l} release ${N}${j?``:` (new site, ${Z(F).join(`, `)})`}`});let I=Z(F);if(t.save){let e={name:l,server:a,domains:P};if(T===`image`)Object.assign(e,{image:re,port:oe});else{let r=m(n,y)||`.`;T===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=v)):r!==yn(n,ae)&&(e.dir=r),T===`dockerfile`&&(e.port=oe),O&&(e.spa=!0),g&&T===`static`&&(e.static=!0)}h&&(e.build=h),Fe(n,e),En({path:n,name:l,server:a,domains:I,kind:T})}S(`release ${b.bold(N)} live`);for(let e of I)console.log(` ${b.cyan(`https://${e}`)}`);I[0]&&await sa(I[0])}});function oa(e){return e.split(`
685
+ `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function sa(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?S(`https://${e} responded ${t.status}`):w(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);w(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const ca=j({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
682
686
 
683
687
  compose.yaml what runs on the server; x-landfall routes a service through Traefik
684
688
  compose.override.yaml local only (docker compose merges it, lf deploy leaves it out):
685
689
  publishes the port on localhost
686
690
 
687
691
  \`docker compose up\` then runs the site locally, \`lf deploy\` on a server. Existing
688
- 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=h(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||E(`${e} is not a folder`);let r=xn(n);r.kind===`compose`&&E(`${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(p(n,e)));a.length&&E(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:xe(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||C(b.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
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()||E(`${e} is not a folder`);let r=Cn(n);r.kind===`compose`&&E(`${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(p(n,e)));a.length&&E(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:Te(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||C(b.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
689
693
  image: nginx:alpine
690
694
  restart: unless-stopped
691
695
  volumes:
@@ -730,8 +734,8 @@ services:
730
734
  ${d}:
731
735
  ports:
732
736
  - "127.0.0.1:${c}:${s}"
733
- `;for(let[e,t]of Object.entries(i))o.writeFileSync(p(n,e),t);let f=m(process.cwd(),n)||`.`;S(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(p(n,`.env`))&&C(`locally: put POSTGRES_PASSWORD=… in ${p(f,`.env`)} (keep it out of git)`),C(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),C(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),na=e=>e.steps.filter(e=>e.undo&&!e.undone);function ra(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
734
- `).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 ia(e){let{stdout:t}=await Y(e,`cat ${K} 2>/dev/null || sudo -n cat ${K} 2>/dev/null || true`,{capture:!0});return ra(t)}function aa(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||E(`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||E(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function oa(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 st(i)?{login:i}:E(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const sa=String.raw`
737
+ `;for(let[e,t]of Object.entries(i))o.writeFileSync(p(n,e),t);let f=m(process.cwd(),n)||`.`;S(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(p(n,`.env`))&&C(`locally: put POSTGRES_PASSWORD=… in ${p(f,`.env`)} (keep it out of git)`),C(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),C(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),la=e=>e.steps.filter(e=>e.undo&&!e.undone);function ua(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 da(e){let{stdout:t}=await q(e,`cat ${W} 2>/dev/null || sudo -n cat ${W} 2>/dev/null || true`,{capture:!0});return ua(t)}function fa(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||E(`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||E(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function pa(e,t){let n=X(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await q(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 J(i)?{login:i}:E(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const ma=String.raw`
735
739
  undo_step() { # undo_step <line> <undo shell, base64>
736
740
  set +e
737
741
  ( set -e; eval "$(printf %s "$2" | base64 -d)" )
@@ -739,16 +743,16 @@ undo_step() { # undo_step <line> <undo shell, base64>
739
743
  set -e
740
744
  [ "$rc" = 0 ] || { echo "could not undo journal line $1 (exit $rc); later steps are undone, the rest is kept" >&2; exit 1; }
741
745
  _journal undo "$1" ""
742
- }`;async function ca(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 oa(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(nt)),await Y(s,[q,sa,...o].join(`
743
- `),{sudo:c}),r.push(s),t=i}return n.length}async function la(e){let t=e,n=await Dr(t);if(n.values.sudo!==`1`){let r={...e,user:`root`};Z(e).user!==`root`&&await st(r)||E(`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 Dr(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(Mr),ports:Rr(n),blocked:Lr(n,[i]).blocked,probe:n}}const ua=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,da=/^[0-9a-fA-F.:]+(\/\d+)?$/;function fa(e){ua.test(e.port)||E(`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)&&E(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&E(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!da.test(e.from)&&E(`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 pa=e=>`ufw_add ${e.map(J).join(` `)}`,ma=e=>e[0]===`deny`?`${pa([`prepend`,...e])} 2>/dev/null || ${pa(e)}`:pa(e),ha=e=>`$SUDO ufw --force delete ${e.spec} >/dev/null
744
- 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`)}`,ga=e=>Mr(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),_a=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function va(e,t,n){if(e.ufw!==`active`)return;let r=Nr(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 oe(`${i}. Do it anyway?`)||E(`${i} (--force to do it anyway)`)}function ya(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||E(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const ba={server:N,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 xa(e){return M({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:{...ba,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||E(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=fa({action:e,port:t,from:n.from,comment:n.comment}),i=await la(B(n.server).server);e===`deny`&&await va(i,[ga(r),...i.rules],n.force),await Y(i.login,`${q}\n${_a}\n${ma(r)}`),S(`${r.join(` `)}${i.ufw===`active`?``:b.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const Sa=xa(`allow`),Ca=xa(`deny`),wa=M({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:N,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=B(t.server),i=await la(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`?b.green(`on`):i.ufw===`missing`?b.yellow(`not installed`):b.yellow(`off`);x(`firewall on ${n}: ${a}${i.ufw===`active`?b.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${b.bold(`Rules`)}`),i.rules.length||C(b.dim(`none`)),j(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?b.green(e.action):b.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${b.bold(`Listening ports`)}`);let o={open:b.green,limited:b.yellow,blocked:b.red,local:b.dim};j(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?w(`enabling would block ${i.blocked.join(`, `)}: allow them first`):C(b.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),Ta=M({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:N,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 la(r);if(i.ufw===`active`)return S(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await oe(`${e}. Turn it on anyway?`)||E(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await va({...i,ufw:`active`},[...i.rules,ga([`allow`,`${i.sshPort}/tcp`])],t.force),await Y(i.login,`${q}
746
+ }`;async function ha(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} ${K(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await pa(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(ot)),await q(s,[G,ma,...o].join(`
747
+ `),{sudo:c}),r.push(s),t=i}return n.length}async function ga(e){let t=e,n=await Ar(t);if(n.values.sudo!==`1`){let r={...e,user:`root`};X(e).user!==`root`&&await J(r)||E(`the firewall needs root or passwordless sudo; ${X(e).user} has no sudo and root's key login does not work`),t=r,n=await Ar(t)}let r=n.values,i=Number(r.ssh_port)||X(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(Fr),ports:Vr(n),blocked:Br(n,[i]).blocked,probe:n}}const _a=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,va=/^[0-9a-fA-F.:]+(\/\d+)?$/;function ya(e){_a.test(e.port)||E(`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)&&E(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&E(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!va.test(e.from)&&E(`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 ba=e=>`ufw_add ${e.map(K).join(` `)}`,xa=e=>e[0]===`deny`?`${ba([`prepend`,...e])} 2>/dev/null || ${ba(e)}`:ba(e),Sa=e=>`$SUDO ufw --force delete ${e.spec} >/dev/null
748
+ record ${K(`ufw delete ${e.spec}`)} ${K(`$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`)}`,Ca=e=>Fr(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),wa=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function Ta(e,t,n){if(e.ufw!==`active`)return;let r=Ir(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 O(`${i}. Do it anyway?`)||E(`${i} (--force to do it anyway)`)}function Ea(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||E(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const Da={server:M,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 Oa(e){return j({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:{...Da,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||E(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=ya({action:e,port:t,from:n.from,comment:n.comment}),i=await ga(z(n.server).server);e===`deny`&&await Ta(i,[Ca(r),...i.rules],n.force),await q(i.login,`${G}\n${wa}\n${xa(r)}`),S(`${r.join(` `)}${i.ufw===`active`?``:b.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const ka=Oa(`allow`),Aa=Oa(`deny`),ja=j({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:M,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=z(t.server),i=await ga(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`?b.green(`on`):i.ufw===`missing`?b.yellow(`not installed`):b.yellow(`off`);x(`firewall on ${n}: ${a}${i.ufw===`active`?b.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${b.bold(`Rules`)}`),i.rules.length||C(b.dim(`none`)),le(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?b.green(e.action):b.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${b.bold(`Listening ports`)}`);let o={open:b.green,limited:b.yellow,blocked:b.red,local:b.dim};le(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?w(`enabling would block ${i.blocked.join(`, `)}: allow them first`):C(b.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),Ma=j({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:M,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=z(t.server),i=await ga(r);if(i.ufw===`active`)return S(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await O(`${e}. Turn it on anyway?`)||E(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await Ta({...i,ufw:`active`},[...i.rules,Ca([`allow`,`${i.sshPort}/tcp`])],t.force),await q(i.login,`${G}
745
749
  command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
746
- ${pa([`allow`,`${i.sshPort}/tcp`])}
747
- ufw_enable`),S(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),Ea=M({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:N},async run(e,t){let{name:n,server:r}=B(t.server),i=await la(r);if(i.ufw!==`active`)return S(`firewall on ${n} is already off`);await A(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||E(`cancelled`),await Y(i.login,`${q}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),S(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),Da=M({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:N,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||E(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=B(t.server),i=await la(r),{rule:a,n:o}=ya(i,e);await va(i,i.rules.filter(e=>e!==a),t.force),await k(`Remove rule ${o} (${a.spec}) on ${n}?`),await Y(i.login,`${q}\n${_a}\n${ha(a)}`),S(`removed rule ${o}: ${a.spec}`)}}),Oa=M({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:{...ba,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||E(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await la(B(t.server).server),{rule:r,n:i}=ya(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||E(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||E(`rule ${i} (${r.spec}) names no port; pass --port`);let s=fa({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 va(n,a===`deny`?[ga(s),...c]:[...c,ga(s)],t.force),await Y(n.login,`${q}
748
- ${_a}
749
- ${ha(r)}
750
- ${ma(s)} || { $SUDO ufw ${r.spec} >/dev/null; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),S(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}});function ka(e,t){let n=b.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${b.dim(`↩ `+e.what)}`:e.undone?`${n} ${b.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${b.cyan(`↺`)} ${e.what}`:`${n} ${b.dim(`· `+e.what+` (not reversible)`)}`}const Aa=e=>e.trim().replace(/\\(.)/g,`$1`),ja=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function Ma(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await la(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(`
751
- `)){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=Mr(Aa(e[1])),n=i.findIndex(e=>ja(e,t));n>=0&&i.splice(n,1)}else n?i=n[1]?[Mr(Aa(n[2])),...i]:[...i,Mr(Aa(n[2]))]:/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await va({...r,ufw:a},i,n)}const Na=e=>`${b.bold(`#${e.n}`.padEnd(5))} ${b.dim(se(e.time).padEnd(9))} ${b.bold(e.cmd||`?`)} ${b.dim(`${e.actor} · ${e.id}`)}`,Pa=M({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:N,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 ia(r)).reverse().slice(0,Number(t.n)||20);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return C(`nothing in ${K} on ${n} yet`);for(let e of i)console.log(Na(e)),e.steps.forEach((e,t)=>console.log(ka(e,t)));let a=i.find(e=>na(e).length);a&&console.log(b.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),Fa=M({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:N,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 ia(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let t=e?aa(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>na(e.run).length);if(!t)return S(`nothing to undo on ${n}`);({steps:a}=t),o=`#${t.run.n} ${t.run.cmd} (${se(t.run.time)})`;let r=i.filter(e=>e.n>t.run.n&&na(e).length);r.length&&w(`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 S(`nothing left to undo in ${o}`);x(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${b.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${b.dim(`· ${e.what} stays (not reversible)`)}`);await Ma(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await k(l,n):await A(l,!1)||E(`cancelled`),await ca(r,s),S(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&C(b.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),Ia=M({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
750
+ ${ba([`allow`,`${i.sshPort}/tcp`])}
751
+ ufw_enable`),S(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),Na=j({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:M},async run(e,t){let{name:n,server:r}=z(t.server),i=await ga(r);if(i.ufw!==`active`)return S(`firewall on ${n} is already off`);await A(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||E(`cancelled`),await q(i.login,`${G}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),S(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),Pa=j({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:M,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||E(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=z(t.server),i=await ga(r),{rule:a,n:o}=Ea(i,e);await Ta(i,i.rules.filter(e=>e!==a),t.force),await k(`Remove rule ${o} (${a.spec}) on ${n}?`),await q(i.login,`${G}\n${wa}\n${Sa(a)}`),S(`removed rule ${o}: ${a.spec}`)}}),Fa=j({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:{...Da,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||E(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await ga(z(t.server).server),{rule:r,n:i}=Ea(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||E(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||E(`rule ${i} (${r.spec}) names no port; pass --port`);let s=ya({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 Ta(n,a===`deny`?[Ca(s),...c]:[...c,Ca(s)],t.force),await q(n.login,`${G}
752
+ ${wa}
753
+ ${Sa(r)}
754
+ ${xa(s)} || { $SUDO ufw ${r.spec} >/dev/null; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),S(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}});function Ia(e,t){let n=b.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${b.dim(`↩ `+e.what)}`:e.undone?`${n} ${b.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${b.cyan(`↺`)} ${e.what}`:`${n} ${b.dim(`· `+e.what+` (not reversible)`)}`}const La=e=>e.trim().replace(/\\(.)/g,`$1`),Ra=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function za(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await ga(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=Fr(La(e[1])),n=i.findIndex(e=>Ra(e,t));n>=0&&i.splice(n,1)}else n?i=n[1]?[Fr(La(n[2])),...i]:[...i,Fr(La(n[2]))]:/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await Ta({...r,ufw:a},i,n)}const Ba=e=>`${b.bold(`#${e.n}`.padEnd(5))} ${b.dim(ue(e.time).padEnd(9))} ${b.bold(e.cmd||`?`)} ${b.dim(`${e.actor} · ${e.id}`)}`,Va=j({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:M,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}=z(t.server),i=(await da(r)).reverse().slice(0,Number(t.n)||20);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return C(`nothing in ${W} on ${n} yet`);for(let e of i)console.log(Ba(e)),e.steps.forEach((e,t)=>console.log(Ia(e,t)));let a=i.find(e=>la(e).length);a&&console.log(b.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),Ha=j({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:M,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}=z(t.server),i=await da(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let t=e?fa(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>la(e.run).length);if(!t)return S(`nothing to undo on ${n}`);({steps:a}=t),o=`#${t.run.n} ${t.run.cmd} (${ue(t.run.time)})`;let r=i.filter(e=>e.n>t.run.n&&la(e).length);r.length&&w(`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 S(`nothing left to undo in ${o}`);x(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${b.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${b.dim(`· ${e.what} stays (not reversible)`)}`);await za(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await k(l,n):await A(l,!1)||E(`cancelled`),await ha(r,s),S(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&C(b.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),Ua=j({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
752
756
 
753
757
  Without --host, <name> is used as an ssh alias from ~/.ssh/config,
754
758
  so \`lf server add vps\` works whenever \`ssh vps\` works.
@@ -756,19 +760,19 @@ so \`lf server add vps\` works whenever \`ssh vps\` works.
756
760
  With --host lf logs in with --key, or installs its own key
757
761
  (~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
758
762
  to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
759
- 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||E(`usage: lf server add <name> [--host ...]`);let n=t.domain&&ve(t.domain),r=R(),i=r.servers[e],a=gt().includes(e)&&!Et(e);t.host&&a&&E(`${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 Li(t[`password-stdin`]),c=await Bi(e,o,{check:!s&&!ie(),password:s}),l=c?Hi(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=Se(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),z(r),S(`added ${b.bold(e)}${r.default===e?b.dim(` (default)`):``}`),C(c?b.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${b.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),La=M({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=R(),r=[[b.dim(`NAME`),b.dim(`TARGET`),b.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?b.green(` *`):``),i,`landfall`])}if(t.all)for(let e of gt())n.servers[e]||r.push([e,e,Et(e)?`ssh (landfall)`:`ssh`]);r.length===1?console.log(`no servers yet — ${b.bold(`lf server add <ssh-alias>`)} or ${b.bold(`lf setup <name> --host <ip>`)}`):(j(r),t.all||console.log(b.dim(`
760
- Any ~/.ssh/config alias works too (--all to list them).`)))}}),Ra=M({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||E(`usage: lf server rm <name>`),!R().servers[e]&&!Et(e)&&E(`unknown server "${e}"`),await k(`Forget server ${e}? Nothing on the server changes.`),ge(e),t[`ssh-config`]&&Tt(e)&&S(`removed Host ${e} from ~/.ssh/config`),S(`removed ${e}`)}}),za=/^(setup|traefik setup|traefik use)( |$)/,Ba=M({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${H} and what setup changed.
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||E(`usage: lf server add <name> [--host ...]`);let n=t.domain&&Se(t.domain),r=R(),i=r.servers[e],a=yt().includes(e)&&!kt(e);t.host&&a&&E(`${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 Wi(t[`password-stdin`]),c=await qi(e,o,{check:!s&&!se(),password:s}),l=c?Yi(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=Ee(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),ve(r),S(`added ${b.bold(e)}${r.default===e?b.dim(` (default)`):``}`),C(c?b.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${b.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),Wa=j({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=R(),r=[[b.dim(`NAME`),b.dim(`TARGET`),b.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?b.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 — ${b.bold(`lf server add <ssh-alias>`)} or ${b.bold(`lf setup <name> --host <ip>`)}`):(le(r),t.all||console.log(b.dim(`
764
+ Any ~/.ssh/config alias works too (--all to list them).`)))}}),Ga=j({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||E(`usage: lf server rm <name>`),!R().servers[e]&&!kt(e)&&E(`unknown server "${e}"`),await k(`Forget server ${e}? Nothing on the server changes.`),ye(e),t[`ssh-config`]&&Ot(e)&&S(`removed Host ${e} from ~/.ssh/config`),S(`removed ${e}`)}}),Ka=/^(setup|traefik setup|traefik use)( |$)/,qa=j({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${B} and what setup changed.
761
765
 
762
766
  Deletes every site landfall deployed (named volumes stay unless --volumes) and forgets
763
767
  adopted ones, which keep running. Stops landfall's Traefik (a Traefik of your own is left
764
768
  alone), reverts what \`lf setup\` changed (deploy user, ufw, ssh hardening, docker network)
765
- and deletes ${H}, its journal included. Then forgets the server locally.
766
- 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||E(`usage: lf server uninstall <name>`);let{name:n,server:r}=B(e),i=await oa(r,`root`),[a,s,c]=await Promise.all([Ln(r),ai(r),ia(r)]);s.compose===`foreign`&&E(`${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(na).filter(e=>!e.undo.includes(H));x(`uninstall landfall from ${b.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(` ${b.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${b.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${b.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${b.cyan(`↺`)} ${e.what}`);console.error(` ${b.red(`-`)} delete ${H}, its journal included`),console.error(` ${b.red(`-`)} forget ${n} locally`),console.error(` ${b.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await Ma(r,l,t.force),await k(`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))}`]:Zn(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(`
767
- `)),l.length&&(x(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await ca(r,l)),await Y(i.login,`rm -rf ${H}`,{sudo:i.sudo}),S(`landfall removed from ${n}`),ge(n),Tt(n)&&S(`removed Host ${n} from ~/.ssh/config`),S(`forgot ${n}`),o.existsSync(Ri(n))&&C(b.dim(`${L(Ri(n))} stays: root on ${n} still accepts it`))}}),Va=M({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||E(`usage: lf server use <name>`),_e(e),S(`default server: ${b.bold(e)}`)}}),Ha=M({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 it(r,n)}}),Ua=M({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 At(r);if(t.json)return void console.log(JSON.stringify({name:n,...i,ips:await Ft(r,i)},null,2));let a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(b.bold(n)+b.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:b.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Bt(i.uptime)}${i.reboot?b.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${zt(o)} / ${zt(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${zt(i.disk.used)} / ${zt(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(`${b.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:w)(i.support.note),i.docker&&(console.log(),await Y(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function Wa(e){let t=R(),n=t.servers[e],r=n??{host:e},i=Z(r),a=Ct(r.host),s=ti(r,{});return{name:e,isDefault:t.default===e,configFile:L(he),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],traefik:{...s,summary:ni(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:L(e),exists:o.existsSync(I(e))})),block:a?{file:L(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
768
- `)}:null}}}const Ga=M({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
769
+ and deletes ${B}, 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 `+B},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||E(`usage: lf server uninstall <name>`);let{name:n,server:r}=z(e),i=await pa(r,`root`),[a,s,c]=await Promise.all([Bn(r),ui(r),da(r)]);s.compose===`foreign`&&E(`${H}/docker-compose.yml was not written by landfall: move it out of ${B} first`);let l=t[`keep-setup`]?[]:c.filter(e=>Ka.test(e.cmd)).flatMap(la).filter(e=>!e.undo.includes(B));x(`uninstall landfall from ${b.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(` ${b.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${b.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${b.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${b.cyan(`↺`)} ${e.what}`);console.error(` ${b.red(`-`)} delete ${B}, its journal included`),console.error(` ${b.red(`-`)} forget ${n} locally`),console.error(` ${b.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await za(r,l,t.force),await k(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[G];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${K(Q(e.name))}`]:er(e,{volumes:t.volumes});u.push(`log ${K(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${K(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${H} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await q(r,u.join(`
771
+ `)),l.length&&(x(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await ha(r,l)),await q(i.login,`rm -rf ${B}`,{sudo:i.sudo}),S(`landfall removed from ${n}`),ye(n),Ot(n)&&S(`removed Host ${n} from ~/.ssh/config`),S(`forgot ${n}`),o.existsSync(Gi(n))&&C(b.dim(`${L(Gi(n))} stays: root on ${n} still accepts it`))}}),Ja=j({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||E(`usage: lf server use <name>`),be(e),S(`default server: ${b.bold(e)}`)}}),Ya=j({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}=z(e);return ct(r,n)}}),Xa=j({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}=z(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(b.bold(n)+b.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:b.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Ut(i.uptime)}${i.reboot?b.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(`${b.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:w)(i.support.note),i.docker&&(console.log(),await q(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function Za(e){let t=R(),n=t.servers[e],r=n??{host:e},i=X(r),a=Et(r.host),s=oi(r,{});return{name:e,isDefault:t.default===e,configFile:L(F),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],traefik:{...s,summary:si(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:L(e),exists:o.existsSync(I(e))})),block:a?{file:L(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
772
+ `)}:null}}}const Qa=j({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
769
773
  the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
770
- 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=Wa(n);if(t.json)return void console.log(JSON.stringify(r,null,2));let i=(e,t)=>console.log(` ${b.dim(e.padEnd(10))} ${t}`);if(console.log(`${b.bold(n)}${r.isDefault?b.dim(` (default)`):``}`),console.log(`\n${b.bold(`landfall`)} ${b.dim(r.configFile)}`),!r.saved)i(`entry`,b.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`,b.dim(`none (lf server domain ${n} example.com)`)),r.domains.forEach((e,t)=>i(t?``:r.domains.length>1?`domains`:`domain`,t?e:`${e} ${b.dim(`(primary: new sites get <site>.${e})`)}`)),i(`traefik`,`${r.traefik.summary}${r.traefik.email?`, ${r.traefik.email}`:``} ${b.dim(`(when landfall installs it)`)}`),i(`dashboard`,r.traefik.dashboard?`https://${r.traefik.dashboard}`:b.dim(`off`)),console.log(`\n${b.bold(`ssh`)} ${b.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} ${b.dim(`(missing)`)}`);if(r.ssh.block){console.log(`\n${b.bold(`ssh config`)} ${b.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(`
771
- `))console.log(` ${e}`)}else console.log(`\n${b.bold(`ssh config`)} ${b.dim(`no Host block; ssh uses its defaults`)}`)}});async function Ka(e,t){let n=await Ft(e),[r,i]=await Rt([t,`landfall-${v(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function qa(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 Ja=async e=>De(await qa(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),Ya=M({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
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}=z(e),r=Za(n);if(t.json)return void console.log(JSON.stringify(r,null,2));let i=(e,t)=>console.log(` ${b.dim(e.padEnd(10))} ${t}`);if(console.log(`${b.bold(n)}${r.isDefault?b.dim(` (default)`):``}`),console.log(`\n${b.bold(`landfall`)} ${b.dim(r.configFile)}`),!r.saved)i(`entry`,b.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`,b.dim(`none (lf server domain ${n} example.com)`)),r.domains.forEach((e,t)=>i(t?``:r.domains.length>1?`domains`:`domain`,t?e:`${e} ${b.dim(`(primary: new sites get <site>.${e})`)}`)),i(`traefik`,`${r.traefik.summary}${r.traefik.email?`, ${r.traefik.email}`:``} ${b.dim(`(when landfall installs it)`)}`),i(`dashboard`,r.traefik.dashboard?`https://${r.traefik.dashboard}`:b.dim(`off`)),console.log(`\n${b.bold(`ssh`)} ${b.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} ${b.dim(`(missing)`)}`);if(r.ssh.block){console.log(`\n${b.bold(`ssh config`)} ${b.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${b.bold(`ssh config`)} ${b.dim(`no Host block; ssh uses its defaults`)}`)}});async function $a(e,t){let n=await Rt(e),[r,i]=await Vt([t,`landfall-${_(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function eo(e){let{stdout:t}=await q(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 to=async e=>je(await eo(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),no=j({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
772
776
 
773
777
  The first main domain is the primary: \`lf deploy\` serves a new site at
774
778
  <site>.<primary>, \`--domain blog\` means blog.<primary>, and
@@ -779,14 +783,14 @@ More main domains are listed and DNS-checked; use them with full names
779
783
 
780
784
  Domains given as arguments replace the list (the first becomes primary).
781
785
  Without any, it lists them and, if there are none, suggests the base domains
782
- 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=R(),r=e.length>0&&!(e=>!!n.servers[e]||gt().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(ve),i=t.clear?[]:a.length?a.map(ve):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(ve)])].filter(e=>!r.includes(e)),i=Se(i,t.primary&&ve(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&w(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await k(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,z(n),s.domains=e.domains,S(i.length?`main domains of ${b.bold(o)}: ${i.map((e,t)=>t?e:b.bold(e)).join(`, `)}${i.length>1?b.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let c=s.domains??[];if(!c.length){let e=await Ja(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:c,suggest:e}));C(`${o} has no main domain yet`);for(let t of e)C(`${t.hosts} routed hosts use ${b.bold(t.domain)} — ${b.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||C(`set one: ${b.bold(`lf server domain ${o} example.com`)}`);return}let l=await Promise.all(c.map(e=>Ka(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?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?b.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(b.dim(`
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=R(),r=e.length>0&&!(e=>!!n.servers[e]||yt().includes(e))(e[0])&&xe.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=z(i);if(a.length||t.add||t.rm||t.primary||t.clear){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(Se),i=t.clear?[]:a.length?a.map(Se):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(Se)])].filter(e=>!r.includes(e)),i=Ee(i,t.primary&&Se(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&w(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await k(`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,S(i.length?`main domains of ${b.bold(o)}: ${i.map((e,t)=>t?e:b.bold(e)).join(`, `)}${i.length>1?b.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let c=s.domains??[];if(!c.length){let e=await to(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:c,suggest:e}));C(`${o} has no main domain yet`);for(let t of e)C(`${t.hosts} routed hosts use ${b.bold(t.domain)} — ${b.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||C(`set one: ${b.bold(`lf server domain ${o} example.com`)}`);return}let l=await Promise.all(c.map(e=>$a(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?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?b.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(b.dim(`
783
787
  add these DNS records at your DNS provider:
784
788
  `)+d.join(`
785
- `));let f=Ce(s);C(b.dim(`sites: lf deploy -d blog → blog.${f} · dashboard: lf traefik setup --dashboard traefik`))}}),Xa=M({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
786
- 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`},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-BqVugweK.mjs`);return n({port:Number(t.port)||4777,open:t.open})}}),Za={current:b.green,update:b.yellow,unknown:b.dim,pinned:b.dim,rebuild:b.dim,foreign:b.dim,missing:b.red};function Qa(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return C(`packages: no supported package manager`);let r=e.updates.length?`${b.bold(String(e.updates.length))} package updates${n?`, ${b.yellow(`${n} security`)}`:``}`:b.green(`packages up to date`);console.log(`${r}${e.reboot?b.yellow(` · reboot required`):``}${e.refreshed?``:b.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);j(i.map(e=>[` `+e.name,b.dim(e.from??``),`→ `+e.to,e.security?b.yellow(`security`):``])),i.length<e.updates.length&&console.log(b.dim(` … ${e.updates.length-i.length} more (--all)`))}function $a(e){e.length&&(console.log(`\n${b.bold(`Images`)}`),j(e.map(e=>[` `+e.name,b.dim(e.ref||`-`),Za[e.state](e.state===`update`?`newer image`:e.state),b.dim(e.note??``)])))}function eo(e){let t=br(e);console.log(`\n${b.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||b.red(`missing`)}`:b.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+b.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(b.dim(` newest releases`))}const to=M({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:N,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 ir(n,t.refresh),i=await Cr(n),a=await yr(n);if(t.json)return void console.log(JSON.stringify({packages:r,images:i,engine:{...a,...br(a)}},null,2));Qa(r,t.all),eo(a),$a(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(b.dim(`\napply with: lf update${o}`)),br(a).composeUpdate&&console.log(b.dim(`newest compose: lf update --compose${o}`))}}),no=M({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
789
+ `));let f=De(s);C(b.dim(`sites: lf deploy -d blog → blog.${f} · dashboard: lf traefik setup --dashboard traefik`))}}),ro=j({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`},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-BqVugweK.mjs`);return n({port:Number(t.port)||4777,open:t.open})}}),io={current:b.green,update:b.yellow,unknown:b.dim,pinned:b.dim,rebuild:b.dim,foreign:b.dim,missing:b.red};function ao(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return C(`packages: no supported package manager`);let r=e.updates.length?`${b.bold(String(e.updates.length))} package updates${n?`, ${b.yellow(`${n} security`)}`:``}`:b.green(`packages up to date`);console.log(`${r}${e.reboot?b.yellow(` · reboot required`):``}${e.refreshed?``:b.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);le(i.map(e=>[` `+e.name,b.dim(e.from??``),`→ `+e.to,e.security?b.yellow(`security`):``])),i.length<e.updates.length&&console.log(b.dim(` … ${e.updates.length-i.length} more (--all)`))}function oo(e){e.length&&(console.log(`\n${b.bold(`Images`)}`),le(e.map(e=>[` `+e.name,b.dim(e.ref||`-`),io[e.state](e.state===`update`?`newer image`:e.state),b.dim(e.note??``)])))}function so(e){let t=Cr(e);console.log(`\n${b.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||b.red(`missing`)}`:b.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+b.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(b.dim(` newest releases`))}const co=j({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:M,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}=z(t.server),r=await sr(n,t.refresh),i=await Er(n),a=await Sr(n);if(t.json)return void console.log(JSON.stringify({packages:r,images:i,engine:{...a,...Cr(a)}},null,2));ao(r,t.all),so(a),oo(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(b.dim(`\napply with: lf update${o}`)),Cr(a).composeUpdate&&console.log(b.dim(`newest compose: lf update --compose${o}`))}}),lo=j({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
787
791
 
788
792
  Without --packages/--images both are updated. Package upgrades cannot be undone;
789
793
  image updates can (\`lf undo\` puts the old image back). --compose replaces the
790
- compose plugin setup installed with the newest release (undoable too).`,options:{server:N,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)&&E(`--compose goes alone`);let i=await tr(r,`updating docker compose`),a=await yr(i),o=br(a);if(!o.composeUpdate)return void(o.composeNote?C(o.composeNote):S(`compose ${a.compose} is the newest`));await A(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(x(`downloading docker compose`),await xr(i),S(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images;e.length&&t.packages&&E(`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 tr(r,`upgrading packages`),i=await ir(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?w(`no supported package manager, skipping packages`):a.length?(Qa({...i,updates:a},!1),t.security&&i.mgr===`apk`&&E(`apk has no security-only upgrades; drop --security`),await A(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(x(`upgrading packages on ${n}`),await or(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}),S(`packages upgraded`),t.reboot&&C(`rebooting if required; give it a minute`))):S(t.security?`no security updates`:`packages up to date`)}if(o){let t=await Cr(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&E(`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`))C(b.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?($a(a),await A(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(x(`updating images`),await wr(r,a),S(`images updated (lf undo puts the old ones back)`))):S(`images up to date`)}}});var ro=`0.4.3`;const io=[qi,ta,Qi,gi,_i,vi,Ei,Ci,bi,xi,Si,Ti,wi,Di,Ai,ji,Mi,to,no,Ia,La,Ua,Ga,Ya,Va,Ra,Ba,Ha,ci,li,di,fi,pi,ui,mi,wa,Ta,Ea,Sa,Ca,Oa,Da,Pa,Fa,Ui,Xa],ao={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`},oo=new Map(io.map(e=>[e.name,e]));function so(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=oo.get(ao[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function co(){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=[`${b.bold(`landfall`)} ${b.dim(ro)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${b.bold(`Usage:`)} lf <command> [options] ${b.dim(`lf <command> --help for details`)}`,` lf ui ${b.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,b.bold(n));for(let e of r){let n=oo.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
794
+ compose plugin setup installed with the newest release (undoable too).`,options:{server:M,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}=z(t.server);if(t.compose){(e.length||t.packages||t.images)&&E(`--compose goes alone`);let i=await ir(r,`updating docker compose`),a=await Sr(i),o=Cr(a);if(!o.composeUpdate)return void(o.composeNote?C(o.composeNote):S(`compose ${a.compose} is the newest`));await A(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(x(`downloading docker compose`),await wr(i),S(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images;e.length&&t.packages&&E(`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 ir(r,`upgrading packages`),i=await sr(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?w(`no supported package manager, skipping packages`):a.length?(ao({...i,updates:a},!1),t.security&&i.mgr===`apk`&&E(`apk has no security-only upgrades; drop --security`),await A(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(x(`upgrading packages on ${n}`),await lr(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}),S(`packages upgraded`),t.reboot&&C(`rebooting if required; give it a minute`))):S(t.security?`no security updates`:`packages up to date`)}if(o){let t=await Er(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&E(`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`))C(b.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(oo(a),await A(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(x(`updating images`),await Dr(r,a),S(`images updated (lf undo puts the old ones back)`))):S(`images up to date`)}}});var uo=`0.4.4`;const fo=[ea,ca,aa,xi,Si,Ci,ji,Oi,Ti,Ei,Di,Ai,ki,Mi,Li,Ri,zi,co,lo,Ua,Wa,Xa,Qa,no,Ja,Ga,qa,Ya,pi,mi,gi,_i,vi,hi,yi,ja,Ma,Na,ka,Aa,Fa,Pa,Va,Ha,Xi,ro],po={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`},mo=new Map(fo.map(e=>[e.name,e]));function ho(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=mo.get(po[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function go(){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=[`${b.bold(`landfall`)} ${b.dim(uo)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${b.bold(`Usage:`)} lf <command> [options] ${b.dim(`lf <command> --help for details`)}`,` lf ui ${b.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,b.bold(n));for(let e of r){let n=mo.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
791
795
  `)[0]}`)}}t.push(``,b.bold(`Quick start`),` lf setup vps --check ${b.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${b.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${b.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${b.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${b.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${b.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${b.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${b.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${b.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${b.dim(`# what landfall changed, and revert the last of it`)}`,``,b.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
792
- `))}async function lo(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return co(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(ro),0;let{cmd:t,rest:n}=so(e);if(!t)return console.error(`${b.red(`error:`)} unknown command "${e.join(` `)}"\n`),co(),2;let{values:r,positionals:i,rest:a}=ue(t,n);if(r.help)return console.log(de(t)),0;O(!!r.yes),ot.cmd=fe(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function uo(e){try{return await lo(e)}catch(e){return e instanceof T?console.error(`${b.red(`error:`)} ${e.message}`):console.error(e),1}}uo(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{W as A,le as B,at as C,G as D,K as E,R as F,Ae as I,B as L,V as M,F as N,H as O,I as P,_e as R,gt as S,Fe as T,Rt as _,la as a,Ft as b,Ii as c,yr as d,Cr as f,Cn as g,xn as h,Ja as i,he as j,U as k,Oi as l,Ln as m,Ka as n,na as o,ir as p,Wa as r,ia as s,ro as t,br as u,At as v,Ye as w,Z as x,It as y,b as z};
796
+ `))}async function _o(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return go(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(uo),0;let{cmd:t,rest:n}=ho(e);if(!t)return console.error(`${b.red(`error:`)} unknown command "${e.join(` `)}"\n`),go(),2;let{values:r,positionals:i,rest:a}=pe(t,n);if(r.help)return console.log(me(t)),0;oe(!!r.yes),ut.cmd=he(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function vo(e){try{return await _o(e)}catch(e){return e instanceof T?console.error(`${b.red(`error:`)} ${e.message}`):console.error(e),1}}vo(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{H as A,fe as B,lt as C,U as D,W as E,R as F,Pe as I,z as L,xe as M,_e as N,B as O,I as P,be as R,yt as S,ze as T,Vt as _,ga as a,Rt as b,Ui as c,Sr as d,Er as f,Tn as g,Cn as h,to as i,F as j,V as k,Fi as l,Bn as m,$a as n,la as o,sr as p,Za as r,da as s,uo as t,Cr as u,Nt as v,$e as w,X as x,zt as y,b as z};