landfall 0.3.2 → 0.4.0

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{isIP as te}from"node:net";import{parse as g}from"yaml";import{randomBytes as _}from"node:crypto";import v from"bcryptjs";const y=process.stdout.isTTY&&!process.env.NO_COLOR,b=e=>t=>y?`\x1b[${e}m${t}\x1b[0m`:t,x={bold:b(`1`),dim:b(`2`),red:b(`31`),green:b(`32`),yellow:b(`33`),blue:b(`34`),cyan:b(`36`)},S=e=>console.error(`${x.cyan(`==>`)} ${x.bold(e)}`),C=e=>console.error(`${x.green(`✓`)} ${e}`),w=e=>console.error(` ${e}`),T=e=>console.error(`${x.yellow(`!`)} ${e}`);var E=class extends Error{};const D=e=>{throw new E(e)};let O=!1;const k=e=>O=e,ne=()=>!O&&!!process.stdin.isTTY;async function re(e,t){if(O||!process.stdin.isTTY){if(t!==void 0)return t;D(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${x.blue(`?`)} ${e}${t?x.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function ie(e){return O||!process.stdin.isTTY?!1:j(e,!1)}async function A(e,t){O||(process.stdin.isTTY||D(`${e} Pass -y to confirm (no terminal to ask).`),t?await re(`${e} Type ${x.bold(t)} to confirm:`)!==t&&D(`cancelled`):await j(e,!1)||D(`cancelled`))}async function j(e,t=!0){return O?!0:process.stdin.isTTY?(await re(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`):t}function M(e){if(!e.length)return;let t=e[0].map((t,n)=>Math.max(...e.map(e=>a(e[n]??``))));for(let n of e)console.log(n.map((e,n)=>e+` `.repeat(t[n]-a(e))).join(` `).trimEnd())}const N=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 ae(e){(O||!process.stdin.isTTY)&&D(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${x.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
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{isIP as g}from"node:net";import{parse as _}from"yaml";import{randomBytes as v}from"node:crypto";import y from"bcryptjs";const te=process.stdout.isTTY&&!process.env.NO_COLOR,b=e=>t=>te?`\x1b[${e}m${t}\x1b[0m`:t,x={bold:b(`1`),dim:b(`2`),red:b(`31`),green:b(`32`),yellow:b(`33`),blue:b(`34`),cyan:b(`36`)},S=e=>console.error(`${x.cyan(`==>`)} ${x.bold(e)}`),C=e=>console.error(`${x.green(`✓`)} ${e}`),w=e=>console.error(` ${e}`),T=e=>console.error(`${x.yellow(`!`)} ${e}`);var E=class extends Error{};const D=e=>{throw new E(e)};let O=!1;const k=e=>O=e,ne=()=>!O&&!!process.stdin.isTTY;async function re(e,t){if(O||!process.stdin.isTTY){if(t!==void 0)return t;D(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${x.blue(`?`)} ${e}${t?x.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function ie(e){return O||!process.stdin.isTTY?!1:j(e,!1)}async function A(e,t){O||(process.stdin.isTTY||D(`${e} Pass -y to confirm (no terminal to ask).`),t?await re(`${e} Type ${x.bold(t)} to confirm:`)!==t&&D(`cancelled`):await j(e,!1)||D(`cancelled`))}async function j(e,t=!0){return O?!0:process.stdin.isTTY?(await re(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`):t}function M(e){if(!e.length)return;let t=e[0].map((t,n)=>Math.max(...e.map(e=>a(e[n]??``))));for(let n of e)console.log(n.map((e,n)=>e+` `.repeat(t[n]-a(e))).join(` `).trimEnd())}const N=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 ae(e){(O||!process.stdin.isTTY)&&D(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${x.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
3
3
  `)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new E(`cancelled`));o===``||o===`\b`?n=n.slice(0,-1):n+=o}};t.on(`data`,i)})}finally{t.setRawMode(!1),t.pause(),process.stderr.write(`
4
4
  `)}}function oe(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const P=e=>e,F={type:`string`,short:`s`,desc:`server name or ssh alias`},I={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}};function se(t,n){let r=n.indexOf(`--`),i=t.passthrough&&r>=0?n.slice(0,r):n,a=t.passthrough&&r>=0?n.slice(r+1):[],o={};for(let[e,{desc:n,...r}]of Object.entries({...I,...t.options}))o[e]=r;try{let{values:t,positionals:n}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});return{values:t,positionals:n,rest:a}}catch(e){return D(`${e.message}\n\n${ce(t)}`)}}function ce(e){let t=[`${x.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,x.bold(`Options:`));let n=Object.entries({...e.options,yes:I.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?x.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?x.dim(` (repeatable)`):``)+r]}),r=Math.max(...n.map(e=>e[0].length));for(let[e,i]of n)t.push(` ${e.padEnd(r)} ${i}`);return t.join(`
5
- `)}function le(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(` `)}let ue=!1;const de=e=>ue=e,L=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,fe=(e=Ye(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],pe=e=>e.user?`${e.user}@${e.host}`:e.host;function me(e){let t=[...fe(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,J(e.key),`-o`,`IdentitiesOnly=yes`),t}let he;function ge(){return he||(he=p(process.platform===`darwin`?`/tmp`:l(),`landfall-${u().uid}`),o.mkdirSync(he,{recursive:!0,mode:448})),he}function _e(e,t={}){let n=[...me(e),`-o`,`ServerAliveInterval=15`];return(t.batch||ue)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${ge()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,pe(e)),n}function ve(e){return new Promise(t=>{let r=[...me(e),`-o`,`ControlPath=${ge()}/%C`,`-O`,`exit`,pe(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function ye(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`,ue?`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 be(e,t=[]){let{code:n}=await ye(`ssh`,[..._e(e,{tty:process.stdin.isTTY}),...t],{});return n}const xe={run:process.env.LANDFALL_RUN||`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,actor:`${u().username}@${c().split(`.`)[0]}`,cmd:``};async function R(e,t,n={}){let r={LANDFALL_RUN:xe.run,LANDFALL_ACTOR:xe.actor,LANDFALL_CMD:xe.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${L(typeof t==`boolean`?+!!t:t)}`).join(`
6
- `),a=await ye(`ssh`,[..._e(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${i}\n${n.oldLayout?``:Ge}${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
7
- `).at(-1);D(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function z(e){let{code:t}=await ye(`ssh`,[..._e(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}const Se=()=>[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,Ue,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`];let Ce;async function we(){if(!Ce){let{stdout:e}=await ye(`tar`,[`--version`],{capture:!0});Ce=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return Ce}async function Te(e,t,r,i=Se()){o.existsSync(t)||D(`directory not found: ${t}`);let a=[...await we(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${L(r)} && tar -xzof - -C ${L(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[..._e(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 B=(e,t,n=`644`)=>`printf %s ${L(Buffer.from(t).toString(`base64`))} | base64 -d > ${L(e)} && chmod ${n} ${L(e)}`,V=`/opt/landfall`,H=`${V}/sites`,U=`${V}/traefik`,Ee=`${U}/dynamic`,De=`traefik:v3`,W=`${V}/proxy.env`,G=`${V}/journal.log`,Oe=`/etc/ssh/sshd_config.d/00-landfall.conf`,ke={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},Ae=`"$(sed -n 's/^container=//p' ${W} 2>/dev/null | grep . || echo traefik)"`;function je(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 Me=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`,K=String.raw`
5
+ `)}function le(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 ue=e=>process.env[`LANDFALL_${e}`],L=process.env.XDG_CONFIG_HOME??p(s(),`.config`),de=ue(`CONFIG_DIR`)??p(L,`landfall`),fe=p(de,`config.json`),R=e=>e.startsWith(`~/`)?p(s(),e.slice(2)):e,z=e=>{let t=s();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function B(){try{let e=JSON.parse(o.readFileSync(fe,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:D(`cannot read ${fe}: ${e.message}`)}}function pe(e){o.mkdirSync(f(fe),{recursive:!0,mode:448}),o.writeFileSync(fe,JSON.stringify(e,null,2)+`
6
+ `,{mode:384})}function V(e){let t=B();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?D("no server given. Use --server <ssh-alias> or `lf server add`"):D(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}let n=t.servers[e]??{host:e};return{name:e,server:n}}const H=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,me=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return H.test(t)||D(`not a domain: ${e}`),t},he=/^[a-z0-9][a-z0-9._\-/:@]*$/i,ge=e=>he.test(e)?e:D(`not an image: ${e}`);function _e(e,t,n,r=2**53-1){let i=typeof e==`number`?e:/^\s*\d+\s*$/.test(String(e))?Number(e):NaN;return(!Number.isInteger(i)||i<n||i>r)&&D(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const ve=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,ye=e=>e.domains?.[0];function be(e,t){let n=ye(t);return e===`@`?n??D("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const xe=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),Se=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 Ce(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!H.test(n))continue;let e=Se(n);xe.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 we=`.landfall.json`,Te=e=>p(e,we);function Ee(e){try{return JSON.parse(o.readFileSync(Te(e),`utf8`))}catch{return}}function De(e,t){o.writeFileSync(p(e,we),JSON.stringify(t,null,2)+`
7
+ `)}const U=`/opt/landfall`,W=`${U}/sites`,G=`${U}/traefik`,Oe=`${G}/dynamic`,ke=`traefik:v3`,K=`${U}/proxy.env`,q=`${U}/journal.log`,Ae=`/etc/ssh/sshd_config.d/00-landfall.conf`,je={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},Me=`"$(sed -n 's/^container=//p' ${K} 2>/dev/null | grep . || echo traefik)"`;function Ne(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 Pe=e=>`# landfall routes its sites through this existing traefik; delete the file to let landfall run its own\ncontainer=${e.container}\nnetwork=${e.network}\nentrypoint=${e.entrypoint}\nresolver=${e.resolver}\n`,J=String.raw`
8
8
  log() { printf '\033[36m ->\033[0m %s\n' "$*" >&2; }
9
9
  SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
10
10
  # Files a container wrote may be root's.
@@ -65,19 +65,19 @@ _clean() { printf %s "$1" | tr '\t\n' ' '; }
65
65
  _journal() { # _journal <do|undo> <what> <undo shell>
66
66
  line=$(printf '%s\t' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$LANDFALL_RUN" "$(_clean "$LANDFALL_ACTOR")" "$(id -un)" "$(_clean "$LANDFALL_CMD")" "$1" "$(_clean "$2")")
67
67
  line="$line$(printf %s "$3" | base64 | tr -d '\n')"
68
- if [ -w ${G} ] || { [ ! -e ${G} ] && [ -w ${V} ]; }; then
69
- printf '%s\n' "$line" >> ${G}
68
+ if [ -w ${q} ] || { [ ! -e ${q} ] && [ -w ${U} ]; }; then
69
+ printf '%s\n' "$line" >> ${q}
70
70
  elif [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then false
71
71
  else
72
- { $SUDO install -d ${V} && printf '%s\n' "$line" | $SUDO tee -a ${G} >/dev/null; } 2>/dev/null
73
- fi || { log "warning: could not write ${G}"; return 0; }
72
+ { $SUDO install -d ${U} && printf '%s\n' "$line" | $SUDO tee -a ${q} >/dev/null; } 2>/dev/null
73
+ fi || { log "warning: could not write ${q}"; return 0; }
74
74
  # A root (or sudo) first entry would leave the file root-owned, and deploy users could no longer
75
- # append; it belongs to whoever owns ${V}.
76
- [ -O ${G} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${V})" ${G} 2>/dev/null || true
75
+ # append; it belongs to whoever owns ${U}.
76
+ [ -O ${q} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${U})" ${q} 2>/dev/null || true
77
77
  }
78
78
  # record <what> [undo shell]: journal a change; no undo shell = not reversible.
79
79
  record() { _journal do "$1" "$(shift; printf %s "$*")"; }
80
- # Run as $SUDO only when <dir> is not writable: deploy users own ${V} and need no sudo.
80
+ # Run as $SUDO only when <dir> is not writable: deploy users own ${U} and need no sudo.
81
81
  _as() { if [ -w "$1" ]; then shift; "$@"; else shift; $SUDO "$@"; fi; }
82
82
  # saved <file>: back the file up, print the shell that puts it back (or removes it if it was new).
83
83
  saved() {
@@ -117,10 +117,10 @@ 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
- `,Ne=`/usr/local/lib/docker/cli-plugins/docker-compose`,Pe=String.raw`
120
+ `,Fe=String.raw`
121
121
  compose_install() {
122
122
  arch=$(uname -m); case "$arch" in armv7l) arch=armv7 ;; armv6l) arch=armv6 ;; esac
123
- f=${Ne}
123
+ f=${`/usr/local/lib/docker/cli-plugins/docker-compose`}
124
124
  $SUDO mkdir -p "$(dirname $f)" || return 1
125
125
  $SUDO curl -fsSL -o $f.new "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$arch" || { $SUDO rm -f $f.new; return 1; }
126
126
  $SUDO chmod 755 $f.new
@@ -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
- `,Fe=`This server is run by landfall (https://www.npmjs.com/package/landfall).
131
+ `,Ie=`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
- ${U}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
135
- ${H}/<site>/ one Compose project per site
134
+ ${G}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
135
+ ${W}/<site>/ one Compose project per site
136
136
  .env the site's environment (lf env), mode 600
137
137
  site.json landfall's notes: domains, releases
138
138
  current -> releases/<id> the live release
139
139
  releases/<id>/compose.yaml the site's compose file as deployed
140
140
  releases/<id>/compose.override.yaml landfall's: Traefik labels, proxy network, project name
141
- ${G} every change landfall made, with the shell that undoes it
141
+ ${q} every change landfall made, with the shell that undoes it
142
142
 
143
143
  Run a site by hand:
144
- cd ${H}/<site>/current && docker compose ps (logs, restart, up -d, down …)
144
+ cd ${W}/<site>/current && docker compose ps (logs, restart, up -d, down …)
145
145
  Roll back by hand:
146
- cd ${H}/<site> && ln -sfn releases/<id> current && cd -P current && docker compose up -d
146
+ cd ${W}/<site> && ln -sfn releases/<id> current && cd -P current && docker compose up -d
147
147
 
148
148
  Named volumes (databases, uploads) belong to the project, not the release: they survive
149
149
  deploys and rollbacks. "lf sites rm" keeps them; "lf sites rm --volumes" deletes them.
150
- `,Ie=String.raw`
151
- ${Pe}
150
+ `,Le=String.raw`
151
+ ${Fe}
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 ${V}"
214
- new_sites=$([ -d ${H} ] || echo 1)
215
- new_traefik=$([ -d ${U} ] || echo 1)
216
- $SUDO install -d -o "$OWNER" -g "$OWNER" ${V} ${H} ${U}
217
- $SUDO chown "$OWNER:$OWNER" ${G} 2>/dev/null || true
218
- $SUDO tee ${V}/README >/dev/null <<'LANDFALL_README'
219
- ${Fe}LANDFALL_README
220
- # ${V} itself stays: it holds the journal. Each dir is only removed if setup created it.
221
- [ -z "$new_sites" ] || record "created ${H}" "_as ${V} rmdir ${H}"
222
- [ -z "$new_traefik" ] || record "created ${U}" "_as ${V} rm -rf ${U}"
213
+ log "preparing ${U}"
214
+ new_sites=$([ -d ${W} ] || echo 1)
215
+ new_traefik=$([ -d ${G} ] || echo 1)
216
+ $SUDO install -d -o "$OWNER" -g "$OWNER" ${U} ${W} ${G}
217
+ $SUDO chown "$OWNER:$OWNER" ${q} 2>/dev/null || true
218
+ $SUDO tee ${U}/README >/dev/null <<'LANDFALL_README'
219
+ ${Ie}LANDFALL_README
220
+ # ${U} itself stays: it holds the journal. Each dir is only removed if setup created it.
221
+ [ -z "$new_sites" ] || record "created ${W}" "_as ${U} rmdir ${W}"
222
+ [ -z "$new_traefik" ] || record "created ${G}" "_as ${U} rm -rf ${G}"
223
223
 
224
224
  DOCKER="$SUDO docker" ensure_network ${`web`}
225
225
 
@@ -238,10 +238,10 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
238
238
  if [ "$($SUDO sshd -T 2>/dev/null | awk '$1 == "permitrootlogin" {print $2}')" = no ]; then root_login=no; fi
239
239
  # 00- so it wins over cloud-init's 50-cloud-init.conf: sshd keeps the first value it reads.
240
240
  want=$(printf '%s\n' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' "PermitRootLogin $root_login")
241
- if [ "$($SUDO cat ${Oe} 2>/dev/null || true)" != "$want" ]; then
241
+ if [ "$($SUDO cat ${Ae} 2>/dev/null || true)" != "$want" ]; then
242
242
  $SUDO install -d /etc/ssh/sshd_config.d
243
- undo=$(saved ${Oe})
244
- printf '%s\n' "$want" | $SUDO tee ${Oe} >/dev/null
243
+ undo=$(saved ${Ae})
244
+ printf '%s\n' "$want" | $SUDO tee ${Ae} >/dev/null
245
245
  if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
246
246
  undo="$undo; $(saved /etc/ssh/sshd_config)"
247
247
  printf 'Include /etc/ssh/sshd_config.d/*.conf\n%s' "$($SUDO cat /etc/ssh/sshd_config)" | $SUDO tee /etc/ssh/sshd_config >/dev/null
@@ -256,7 +256,7 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
256
256
  fi
257
257
  fi
258
258
  log "bootstrap done"
259
- `,Le=String.raw`
259
+ `,Re=String.raw`
260
260
  set +e
261
261
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
262
262
  kv() { printf '%s=%s
@@ -285,15 +285,15 @@ if [ -n "$DEPLOY_USER" ] && id "$DEPLOY_USER" >/dev/null 2>&1; then
285
285
  else
286
286
  kv user 0
287
287
  fi
288
- kv root_dir "$(yn test -d ${H})"
289
- kv sites "$(ls ${H} 2>/dev/null | wc -l | tr -d ' ')"
288
+ kv root_dir "$(yn test -d ${W})"
289
+ kv sites "$(ls ${W} 2>/dev/null | wc -l | tr -d ' ')"
290
290
  kv network "$(yn S docker network inspect ${`web`})"
291
- kv proxy "$(grep -v '^#' ${W} 2>/dev/null | tr '\n' ' ')"
291
+ kv proxy "$(grep -v '^#' ${K} 2>/dev/null | tr '\n' ' ')"
292
292
  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
293
- kv hardened "$(yn test -f ${Oe})"
293
+ kv hardened "$(yn test -f ${Ae})"
294
294
  kv password_auth "$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" {print $2}')"
295
295
  kv password_users "$(S awk -F: '$2 ~ /^\$/ {printf "%s ", $1}' /etc/shadow 2>/dev/null)"
296
- f=${U}/docker-compose.yml
296
+ f=${G}/docker-compose.yml
297
297
  if [ ! -f "$f" ]; then kv traefik_compose none
298
298
  elif grep -q "managed by landfall" "$f"; then kv traefik_compose landfall
299
299
  else kv traefik_compose foreign; fi
@@ -320,14 +320,14 @@ else
320
320
  S netstat -ltunp 2>/dev/null | awk '$1 ~ /^tcp/ {print "listen=tcp " $4 " " $7} $1 ~ /^udp/ {print "listen=udp " $4 " " $6}'
321
321
  fi
322
322
  exit 0
323
- `,Re=e=>e.replace(/\$/g,`$$$$`),ze=e=>JSON.stringify(e);function Be(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.staging&&t.push(`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`);let n=[];return e.dashboard&&(t.push(`--api.dashboard=true`),n.push(`traefik.enable=true`,`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=${Re(e.dashboard.htpasswd)}`)),`# managed by landfall — regenerate with \`lf traefik setup\`
323
+ `,ze=e=>e.replace(/\$/g,`$$$$`),Be=e=>JSON.stringify(e);function Ve(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.staging&&t.push(`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`);let n=[];return e.dashboard&&(t.push(`--api.dashboard=true`),n.push(`traefik.enable=true`,`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=${ze(e.dashboard.htpasswd)}`)),`# managed by landfall — regenerate with \`lf traefik setup\`
324
324
  services:
325
325
  traefik:
326
326
  image: ${e.image}
327
327
  container_name: traefik
328
328
  restart: unless-stopped
329
329
  command:
330
- ${t.map(e=>` - ${ze(e)}`).join(`
330
+ ${t.map(e=>` - ${Be(e)}`).join(`
331
331
  `)}
332
332
  ${e.hostNetwork?` network_mode: host
333
333
  `:` ports:
@@ -339,56 +339,20 @@ ${e.hostNetwork?` network_mode: host
339
339
  - ./dynamic:/etc/traefik/dynamic:ro
340
340
  ${e.hostNetwork?``:` networks:
341
341
  - web
342
- `}${n.length?` labels:\n${n.map(e=>` - ${ze(e)}`).join(`
342
+ `}${n.length?` labels:\n${n.map(e=>` - ${Be(e)}`).join(`
343
343
  `)}\n`:``}${e.hostNetwork?``:`networks:
344
344
  web:
345
345
  external: true
346
- `}`}function Ve(e){return process.env[`SRV_${e}`]}function He(e,t){let n=p(e,`srv`);!o.existsSync(t)&&o.existsSync(p(n,`config.json`))&&o.renameSync(n,t)}const Ue=`srv.json`,We=e=>p(s(),`.ssh`,`srv_${e}`),q=`/opt/srv`,Ge=`[ ! -d ${q} ] || [ -e ${V} ] || { echo "this server still uses ${q} from when landfall was called srv; run 'lf setup' for it once to move it" >&2; exit 3; }\n`,Ke=`moved ${q} to ${V} (landfall was called srv); older changes can no longer be undone`,qe=String.raw`
347
- if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then
348
- echo "moving ${q} needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
349
- exit 1
350
- fi
351
- [ ! -e ${V} ] || { echo "both ${q} and ${V} exist; merge them by hand, then remove ${q}" >&2; exit 1; }
352
- log "moving ${q} to ${V}"
353
- $SUDO mv ${q} ${V}
354
- # Only the older journal steps restored these backups; the .env copies hold secrets.
355
- $SUDO find ${V} /etc/ssh -name '.*.srv-*' -type f -delete 2>/dev/null || true
356
- $SUDO rm -f ${Ne}.srv-*
357
- old_conf=/etc/ssh/sshd_config.d/00-srv.conf
358
- [ ! -f $old_conf ] || $SUDO mv $old_conf ${Oe}
359
- # Rewrite a file in place, keeping its owner (deploy users must still write it).
360
- rewrite() { o=$($SUDO stat -c %u:%g "$1"); $SUDO sed -i -E "$2" "$1"; $SUDO chown "$o" "$1"; }
361
- dc version >/dev/null 2>&1 || true
362
- sdc() { $SUDO $DC "$@"; }
363
- [ ! -f ${W} ] || rewrite ${W} 's/^# srv routes/# landfall routes/; s/let srv run/let landfall run/'
364
- f=${U}/docker-compose.yml
365
- if [ -f $f ] && grep -q "managed by srv" $f; then
366
- rewrite $f 's/managed by srv/managed by landfall/'
367
- log "restarting traefik from ${U}"
368
- (cd ${U} && sdc up -d --force-recreate --quiet-pull)
369
- fi
370
- for d in ${H}/*; do
371
- n=$(basename "$d"); f="$d/docker-compose.yml"
372
- [ -f "$f" ] && grep -q "managed by srv" "$f" || continue
373
- rewrite "$f" 's/managed by srv/managed by landfall/; s/^name: srv-/name: landfall-/; s/container_name: srv-/container_name: landfall-/; s/(routers|services|middlewares)\.srv-/\1.landfall-/g; s/middlewares=srv-/middlewares=landfall-/; s/"srv\.(managed|site)=/"landfall.\1=/'
374
- [ ! -f "$d/nginx.conf" ] || rewrite "$d/nginx.conf" 's/managed by srv/managed by landfall/'
375
- # A stopped site stays stopped.
376
- if [ "$($SUDO docker inspect -f '{{.State.Running}}' "srv-$n" 2>/dev/null)" = true ]; then act="up -d"; else act=create; fi
377
- log "moving srv-$n to landfall-$n"
378
- if (cd "$d" && sdc $act --quiet-pull >/dev/null); then $SUDO docker rm -f "srv-$n" >/dev/null 2>&1 || true
379
- else log "landfall-$n did not start, srv-$n keeps running; redeploy $n"; fi
380
- done
381
- record "${Ke}"
382
- `;async function Je(e,t){let{stdout:n}=await R(e,`[ ! -d ${q} ] || echo old`,{capture:!0,oldLayout:!0});return n.trim()!==`old`||(T(`${q} is from when landfall was called srv: its sites, traefik and journal move to ${V}`),w(x.dim(`sites keep running; traefik restarts once; changes made before the move can no longer be undone`)),t.check||!await j(`move /opt/srv to /opt/landfall?`)?!1:(await R(e,K+qe,{oldLayout:!0}),C(`moved to ${V}`),!0))}const Ye=e=>process.env[`LANDFALL_${e}`]??Ve(e),Xe=process.env.XDG_CONFIG_HOME??p(s(),`.config`),Ze=Ye(`CONFIG_DIR`)??p(Xe,`landfall`),Qe=p(Ze,`config.json`);Ye(`CONFIG_DIR`)||He(Xe,Ze);const J=e=>e.startsWith(`~/`)?p(s(),e.slice(2)):e,Y=e=>{let t=s();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function X(){try{let e=JSON.parse(o.readFileSync(Qe,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:D(`cannot read ${Qe}: ${e.message}`)}}function $e(e){o.mkdirSync(f(Qe),{recursive:!0,mode:448}),o.writeFileSync(Qe,JSON.stringify(e,null,2)+`
383
- `,{mode:384})}function Z(e){let t=X();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?D("no server given. Use --server <ssh-alias> or `lf server add`"):D(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}let n=t.servers[e]??{host:e};return{name:e,server:n}}const et=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,tt=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return et.test(t)||D(`not a domain: ${e}`),t},nt=/^[a-z0-9][a-z0-9._\-/:@]*$/i,rt=e=>nt.test(e)?e:D(`not an image: ${e}`);function it(e,t,n,r=2**53-1){let i=typeof e==`number`?e:/^\s*\d+\s*$/.test(String(e))?Number(e):NaN;return(!Number.isInteger(i)||i<n||i>r)&&D(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const at=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,ot=e=>e.domains?.[0];function st(e,t){let n=ot(t);return e===`@`?n??D("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const ct=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),lt=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 ut(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!et.test(n))continue;let e=lt(n);ct.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 dt=`.landfall.json`,ft=e=>{let t=p(e,dt);return o.existsSync(t)||!o.existsSync(p(e,`srv.json`))?t:p(e,Ue)};function pt(e){try{return JSON.parse(o.readFileSync(ft(e),`utf8`))}catch{return}}function mt(e,t){o.writeFileSync(p(e,dt),JSON.stringify(t,null,2)+`
384
- `),o.rmSync(p(e,Ue),{force:!0})}const ht=Ye(`SSH_CONFIG`)??p(s(),`.ssh`,`config`),gt=/* @__PURE__ */ new Map;function Q(e){let n=[...me(e),`-G`,pe(e)],r=gt.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return D(`ssh cannot resolve ${pe(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>J(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return gt.set(n.join(`\0`),s),s}function _t(e){return(e.key?[J(e.key)]:Q(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function vt(e=ht,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(`
385
- `)){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=J(e),i=r.startsWith(`/`)?r:p(s(),`.ssh`,r);for(let e of yt(i))n.push(...vt(e,t))}}return[...new Set(n)]}function yt(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 bt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],xt=(e,t)=>RegExp(`# >>> (?:landfall|srv):${kt(e)}\\n[\\s\\S]*?# <<< (?:landfall|srv):${kt(e)}${t}`),St=(e,t)=>e===`# >>> landfall:${t}`||e===`# >>> srv:${t}`;function Ct(e){let[t,n]=bt(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(`
386
- `)}function wt(e,t=ht){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`),i=Ct(e),a=xt(e.alias,`\\n?`),s=a.test(r)?r.replace(a,i+`
387
- `):`${i}\n\n${r}`;return o.mkdirSync(p(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),gt.clear(),{created:n,block:i}}function Tt(e,t=ht,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
388
- `);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])&&!(/* @__PURE__ */ RegExp(`^# <<< (landfall|srv):`)).test(r[n]);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:St(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=J(t);for(let t of yt(r.startsWith(`/`)?r:p(s(),`.ssh`,r))){let r=Tt(e,t,n);if(r)return r}}}}function Et(e,t){let n=Tt(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
389
- `),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(J(n))||J(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(`
390
- `)),gt.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function Dt(e,t=ht){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=xt(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const Ot=(e,t=ht)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
391
- `).some(t=>St(t,e)),kt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),At=String.raw`
346
+ `}`}let He=!1;const Ue=e=>He=e,Y=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,We=(e=ue(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],Ge=e=>e.user?`${e.user}@${e.host}`:e.host;function Ke(e){let t=[...We(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,R(e.key),`-o`,`IdentitiesOnly=yes`),t}let qe;function Je(){return qe||(qe=p(process.platform===`darwin`?`/tmp`:l(),`landfall-${u().uid}`),o.mkdirSync(qe,{recursive:!0,mode:448})),qe}function Ye(e,t={}){let n=[...Ke(e),`-o`,`ServerAliveInterval=15`];return(t.batch||He)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${Je()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,Ge(e)),n}function Xe(e){return new Promise(t=>{let r=[...Ke(e),`-o`,`ControlPath=${Je()}/%C`,`-O`,`exit`,Ge(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function Ze(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`,He?`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 Qe(e,t=[]){let{code:n}=await Ze(`ssh`,[...Ye(e,{tty:process.stdin.isTTY}),...t],{});return n}const $e={run:process.env.LANDFALL_RUN||`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,actor:`${u().username}@${c().split(`.`)[0]}`,cmd:``};async function X(e,t,n={}){let r={LANDFALL_RUN:$e.run,LANDFALL_ACTOR:$e.actor,LANDFALL_CMD:$e.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${Y(typeof t==`boolean`?+!!t:t)}`).join(`
347
+ `),a=await Ze(`ssh`,[...Ye(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(`
348
+ `).at(-1);D(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function et(e){let{code:t}=await Ze(`ssh`,[...Ye(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}async function tt(e){let{code:t}=await Ze(`ssh`,[...Ye(e,{batch:!0}),`test -d ${U}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const nt=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`];let rt;async function it(){if(!rt){let{stdout:e}=await Ze(`tar`,[`--version`],{capture:!0});rt=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return rt}async function at(e,t,r,i=nt){o.existsSync(t)||D(`directory not found: ${t}`);let a=[...await it(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${Y(r)} && tar -xzof - -C ${Y(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[...Ye(e),s],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(o.stdin),i.on(`error`,r),o.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(o)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const Z=(e,t,n=`644`)=>`printf %s ${Y(Buffer.from(t).toString(`base64`))} | base64 -d > ${Y(e)} && chmod ${n} ${Y(e)}`,ot=ue(`SSH_CONFIG`)??p(s(),`.ssh`,`config`),st=/* @__PURE__ */ new Map;function Q(e){let n=[...Ke(e),`-G`,Ge(e)],r=st.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return D(`ssh cannot resolve ${Ge(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>R(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return st.set(n.join(`\0`),s),s}function ct(e){return(e.key?[R(e.key)]:Q(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function lt(e=ot,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(`
349
+ `)){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=R(e),i=r.startsWith(`/`)?r:p(s(),`.ssh`,r);for(let e of ut(i))n.push(...lt(e,t))}}return[...new Set(n)]}function ut(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 dt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],ft=(e,t)=>RegExp(`# >>> landfall:${bt(e)}\\n[\\s\\S]*?# <<< landfall:${bt(e)}${t}`),pt=(e,t)=>e===`# >>> landfall:${t}`;function mt(e){let[t,n]=dt(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(`
350
+ `)}function ht(e,t=ot){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`),i=mt(e),a=ft(e.alias,`\\n?`),s=a.test(r)?r.replace(a,i+`
351
+ `):`${i}\n\n${r}`;return o.mkdirSync(p(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),st.clear(),{created:n,block:i}}function gt(e,t=ot,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
352
+ `);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:pt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=R(t);for(let t of ut(r.startsWith(`/`)?r:p(s(),`.ssh`,r))){let r=gt(e,t,n);if(r)return r}}}}function _t(e,t){let n=gt(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
353
+ `),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(R(n))||R(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(`
354
+ `)),st.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function vt(e,t=ot){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=ft(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const yt=(e,t=ot)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
355
+ `).some(t=>pt(t,e)),bt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),xt=String.raw`
392
356
  set +e
393
357
  kv() { printf '%s=%s\n' "$1" "$2"; }
394
358
  PRETTY_NAME=unknown; ID=; VERSION_ID=; ID_LIKE=; . /etc/os-release 2>/dev/null
@@ -410,10 +374,10 @@ kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\
410
374
  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)"
411
375
  kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
412
376
  exit 0
413
- `;function jt(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
414
- `)){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:Pt(n.id??``,n.version??``,n.pkg||`none`,t)}}async function Mt(e){let{stdout:t}=await R(e,K+At,{capture:!0});return jt(t)}const Nt={"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 Pt(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=Nt[`${e} ${t}`]??Nt[`${e} ${a}`]??Nt[`${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 Ft=/^(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])\.)/,It=e=>te(e)===4&&!Ft.test(e);async function Lt(e,t){let n=Q(e).hostname,r=te(n)?[n]:await ee.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const Rt=e=>e.find(It)??e[0],zt=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function Bt(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 Vt=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]}`},Ht=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`},Ut=e=>e.v!==2,Wt=e=>e.project??e.name,Gt=e=>Ut(e)?void 0:e.releases.find(t=>t.id===e.current),Kt=e=>!!(e.port||e.own),qt=e=>!!e.port&&!e.domains&&!e.own,Jt=e=>e?.services.find(qt),Yt=e=>e.adopted?void 0:Jt(Gt(e)),Xt=e=>`landfall-${e}`,Zt=e=>e.adopted?`adopted`:Ut(e)?`old`:`managed`,Qt=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[Zt(e)],$t=e=>/^up/i.test(e),en=e=>/^exited \(0\)/i.test(e);function tn(e){let t=Gt(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...Jt(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$/;function cn(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=>!et.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&&(Kt(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(Kt)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(qt);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 ln=e=>JSON.stringify(e),un=e=>e.replace(/\$/g,`$$$$`),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=ke){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${ln(Wt(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${ln(a.name)}:`),a.build&&r.push(` image: ${ln(nn(e.name,a.name,t.id))}`);let o={};Kt(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${ln(e)}: {}`),` ${dn}: {}`),a.own?o[`traefik.docker.network`]=n.network: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])=>` ${ln(e)}: ${ln(un(t))}`))}return i&&r.push(`networks:`,` ${dn}:`,` name: ${ln(n.network)}`,` external: true`),r.join(`
377
+ `;function St(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
378
+ `)){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:Tt(n.id??``,n.version??``,n.pkg||`none`,t)}}async function Ct(e){let{stdout:t}=await X(e,J+xt,{capture:!0});return St(t)}const wt={"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 Tt(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=wt[`${e} ${t}`]??wt[`${e} ${a}`]??wt[`${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 Et=/^(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])\.)/,Dt=e=>g(e)===4&&!Et.test(e);async function Ot(e,t){let n=Q(e).hostname,r=g(n)?[n]:await ee.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const kt=e=>e.find(Dt)??e[0],At=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function jt(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 Mt=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]}`},Nt=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`},Pt=e=>e.v!==2,Ft=e=>e.project??e.name,It=e=>Pt(e)?void 0:e.releases.find(t=>t.id===e.current),Lt=e=>!!(e.port||e.own),Rt=e=>!!e.port&&!e.domains&&!e.own,zt=e=>e?.services.find(Rt),Bt=e=>e.adopted?void 0:zt(It(e)),Vt=e=>`landfall-${e}`,Ht=e=>e.adopted?`adopted`:Pt(e)?`old`:`managed`,Ut=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[Ht(e)],Wt=e=>/^up/i.test(e),Gt=e=>/^exited \(0\)/i.test(e);function Kt(e){let t=It(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...zt(t)?e.domains:[],...n])]}const qt=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,Jt=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],Yt=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],Xt=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),Zt=/^traefik\.http\.routers\.[^.]+\.rule$/;function Qt(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])=>Zt.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])=>Xt(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=>!H.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&&(Lt(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(Lt)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(Rt);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 $t=e=>JSON.stringify(e),en=e=>e.replace(/\$/g,`$$$$`),tn=`landfall-proxy`;function nn(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 rn(e,t,n=je){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${$t(Ft(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${$t(a.name)}:`),a.build&&r.push(` image: ${$t(qt(e.name,a.name,t.id))}`);let o={};Lt(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${$t(e)}: {}`),` ${tn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=nn(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])=>` ${$t(e)}: ${$t(en(t))}`))}return i&&r.push(`networks:`,` ${tn}:`,` name: ${$t(n.network)}`,` external: true`),r.join(`
415
379
  `)+`
416
- `}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:
380
+ `}function an(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:
417
381
  image: nginx:alpine
418
382
  restart: unless-stopped
419
383
  volumes:
@@ -422,14 +386,14 @@ exit 0
422
386
  x-landfall:
423
387
  port: 80
424
388
  `:`${n} app:
425
- ${e===`image`?`image: ${ln(t.image)}`:`build: .`}
389
+ ${e===`image`?`image: ${$t(t.image)}`:`build: .`}
426
390
  restart: unless-stopped
427
391
  env_file:
428
392
  - path: .env
429
393
  required: false
430
394
  x-landfall:
431
395
  port: ${t.port??3e3}
432
- `}function hn(e){return`# generated by landfall
396
+ `}function on(e){return`# generated by landfall
433
397
  server {
434
398
  listen 80;
435
399
  server_name _;
@@ -453,33 +417,33 @@ server {
453
417
  error_page 404 /404.html;
454
418
  location = /404.html { internal; }
455
419
  }
456
- `}const gn=[`dist`,`build`,`out`,`public`,`_site`,`site`];function _n(e){return o.existsSync(p(e,`index.html`))?`.`:gn.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 rn.find(t=>o.existsSync(p(e,t)))}function xn(e){let t=pt(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)),a=t?.build;if(!a&&i(`package.json`))try{JSON.parse(o.readFileSync(p(e,`package.json`),`utf8`)).scripts?.build&&(a=i(`bun.lock`)||i(`bun.lockb`)?`bun run build`:i(`pnpm-lock.yaml`)?`pnpm build`:i(`yarn.lock`)?`yarn build`:`npm run build`)}catch{}if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??vn(p(e,`Dockerfile`))??3e3,project:t};let s=t?.dir??_n(e);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:a,project:t}}const Sn=p(Ze,`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(Ze,{recursive:!0,mode:448}),o.writeFileSync(Sn,JSON.stringify(n,null,2)+`
457
- `,{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=pt(i.path);e?.name===t&&mt(i.path,{...e,domains:n})}r.length&&o.writeFileSync(Sn,JSON.stringify(r,null,2)+`
458
- `,{mode:384})}const $=e=>`${H}/${e}`;function En(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||D(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const Dn=`
420
+ `}const sn=[`dist`,`build`,`out`,`public`,`_site`,`site`];function cn(e){return o.existsSync(p(e,`index.html`))?`.`:sn.find(t=>o.existsSync(p(e,t,`index.html`)))??`.`}function ln(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const un=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function dn(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return Jt.find(t=>o.existsSync(p(e,t)))}function fn(e){let t=Ee(e),n=t?.name??un(d(e)),r=dn(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)),a=t?.build;if(!a&&i(`package.json`))try{JSON.parse(o.readFileSync(p(e,`package.json`),`utf8`)).scripts?.build&&(a=i(`bun.lock`)||i(`bun.lockb`)?`bun run build`:i(`pnpm-lock.yaml`)?`pnpm build`:i(`yarn.lock`)?`yarn build`:`npm run build`)}catch{}if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??ln(p(e,`Dockerfile`))??3e3,project:t};let s=t?.dir??cn(e);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:a,project:t}}const pn=p(de,`projects.json`);function mn(){try{return JSON.parse(o.readFileSync(pn,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function hn(e){let t=h(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...mn().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(de,{recursive:!0,mode:448}),o.writeFileSync(pn,JSON.stringify(n,null,2)+`
421
+ `,{mode:384})}function gn(e,t,n){let r=mn();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=Ee(i.path);e?.name===t&&De(i.path,{...e,domains:n})}r.length&&o.writeFileSync(pn,JSON.stringify(r,null,2)+`
422
+ `,{mode:384})}const $=e=>`${W}/${e}`;function _n(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||D(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const vn=`
459
423
  --8<--landfall--8<--
460
- `;async function On(e,t){let n=$(t),{stdout:r}=await R(e,[...[`${n}/site.json`,`${n}/.env`,W].map(e=>`cat ${L(e)} 2>/dev/null || true`),`[ ! -d ${L(n)} ] || echo 1`].join(`; printf %s ${L(Dn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(Dn),c;if(i.trim())try{c=JSON.parse(i)}catch{D(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:je(o)??ke,exists:s.trim()===`1`}}async function kn(e,t,n={}){let r=await On(e,t);r.meta||D(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return Ut(i)&&D(`${t} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`),n.deployed&&i.adopted&&D(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const An=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),jn=(e,t,n)=>be(e,[`docker logs --tail ${L(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]);function Mn(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[`srv.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&&$t(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function Nn(e,t){if(!t.length)return`not running`;let n=Gt(e),r=new Set(n?.services.filter(Kt).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!$t(e.status))??t.find(e=>!r.has(e.service)&&!$t(e.status)&&!en(e.status))??i[0]??t[0]).status}const Pn=e=>Ut(e)?Xt(e.name):Wt(e);async function Fn(e){let{stdout:t}=await R(e,`printf 'READY\\t%s\\n' "$([ -d ${H} ] && command -v docker >/dev/null 2>&1 && echo 1)"
461
- P=${Ae}
424
+ `;async function yn(e,t){let n=$(t),{stdout:r}=await X(e,[...[`${n}/site.json`,`${n}/.env`,K].map(e=>`cat ${Y(e)} 2>/dev/null || true`),`[ ! -d ${Y(n)} ] || echo 1`].join(`; printf %s ${Y(vn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(vn),c;if(i.trim())try{c=JSON.parse(i)}catch{D(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:Ne(o)??je,exists:s.trim()===`1`}}async function bn(e,t,n={}){let r=await yn(e,t);r.meta||D(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return Pt(i)&&D(`${t} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`),n.deployed&&i.adopted&&D(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const xn=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),Sn=(e,t,n)=>Qe(e,[`docker logs --tail ${Y(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]);function Cn(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])=>Zt.test(e)).flatMap(([,e])=>Xt(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&&Wt(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function wn(e,t){if(!t.length)return`not running`;let n=It(e),r=new Set(n?.services.filter(Lt).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!Wt(e.status))??t.find(e=>!r.has(e.service)&&!Wt(e.status)&&!Gt(e.status))??i[0]??t[0]).status}const Tn=e=>Pt(e)?Vt(e.name):Ft(e);async function En(e){let{stdout:t}=await X(e,`printf 'READY\\t%s\\n' "$([ -d ${W} ] && command -v docker >/dev/null 2>&1 && echo 1)"
425
+ P=${Me}
462
426
  printf 'TRAEFIK\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P"
463
- for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
427
+ for f in ${W}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
464
428
  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
465
429
  ids=$(docker ps -aq 2>/dev/null || true)
466
430
  # inspect, unlike ps, does not expand \\t: hence {{"\\t"}}.
467
431
  [ -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`,sites:[],others:[]};for(let s of t.split(`
468
- `)){let[t,c=``,l=``,u=``,d=``]=s.split(` `);if(t===`READY`&&(o.ready=c===`1`),t===`TRAEFIK`&&(o.traefik=c,o.proxy=l||`traefik`),t===`SITE`)try{i.push(JSON.parse(s.slice(t.length+1)))}catch{T(`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=Mn(a,n,o.proxy,new Set(i.map(Pn))),o.sites=i.map(e=>{let t=(r.get(Pn(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:Nn(e,t),services:t,domains:tn(e),main:Yt(e)?.name,state:Zt(e),kind:Qt(e)}}),o}async function In(e,t,n,r={}){let i=`${$(t)}/releases/${n}`,a=[...rn,...an].filter(e=>e!==`compose.yaml`),{stdout:o}=await R(e,[K,`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 ${L(i)} && cd -P ${L(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>B(e,t)),...r.file&&r.file!==`compose.yaml`?[`mv -f ${L(r.file)} compose.yaml`]:[],`rm -f ${a.map(L).join(` `)}`,...r.env===void 0?[]:[B(`.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 ${L(Dn)}; printf '%s\\n' "$cfg"`].join(`
469
- `),{capture:!0}),[s,c=``]=o.split(Dn),l;try{l=g(c)}catch(e){D(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:s.trim(),config:l}}async function Ln(e,t){let n=$(t.name);await R(e,[K,`[ -r ${L(t.adopted)} ] && [ -x ${L(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${L(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${L(n)} && cd ${L(n)}`,`ln -s ${L(t.adopted)} current`,B(`site.json`,JSON.stringify(t,null,2)+`
470
- `),`record ${L(`adopted ${t.project} as site ${t.name}`)} ${L(`rm -rf ${n}`)}`].join(`
471
- `))}async function Rn(e,t,n){let r=$(t);await R(e,`cd / && rm -rf ${L(`${r}/releases/${n}`)}; [ -f ${L(r+`/site.json`)} ] || { rmdir ${L(r+`/releases`)} 2>/dev/null; rm -f ${L(r+`/.env`)}; rmdir ${L(r)} 2>/dev/null; } || true`)}const zn=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; }`];function Bn(e,t,n={}){let r=$(e.name),i=Gt(e),a=[`mkdir -p ${L(r)} && cd ${L(r)}`,B(`site.json`,JSON.stringify(e,null,2)+`
472
- `),n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:B(`.env`,n.env,`600`),`(cd -P ${L(`releases/`+e.current)} && ${B(`compose.override.yaml`,pn(e,i,t))}) || exit 1`];return n.build&&i.services.some(e=>e.build)&&a.push(`log "building ${e.name}"`,`(cd -P ${L(`releases/`+e.current)} && dc build -q) || exit 1`),n.pull&&a.push(`log "pulling images of ${e.name}"`,`(cd -P ${L(`releases/`+e.current)} && dc pull -q --ignore-buildable) || exit 1`),a.push(`ln -sfn ${L(`releases/`+e.current)} current.new && mv -Tf current.new current`,...zn(e.name)),a}const Vn=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>nn(e.name,n.name,t.id)),Hn=(e,t)=>[`rmf ${L(`${$(e.name)}/releases/${t.id}`)}`,...Vn(e,t).map(e=>`docker image rm ${L(e)} >/dev/null 2>&1 || true`)];function Un(e){let t=$(e.name),n=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${L(t)}`,`log "removing the old container of ${e.name}"`,`dc -p ${L(Xt(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${L(Xt(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.map(e=>`rm -rf ${L(`releases/`+e.id)}`),...n.map(e=>`docker image rm ${L(e)} >/dev/null 2>&1 || true`)]}async function Wn(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=$(t.name),l=[K];if(r.takeover){l.push(`record ${L(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...Bn(t,n,r),...Un(r.takeover)),await R(e,l.join(`
473
- `));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${L(c+`/.env`)})`),!r.prev)u=`[ ! -d ${L(c)} ] || {\n${qn(t,{keepDir:r.existed}).join(`
432
+ `)){let[t,c=``,l=``,u=``,d=``]=s.split(` `);if(t===`READY`&&(o.ready=c===`1`),t===`TRAEFIK`&&(o.traefik=c,o.proxy=l||`traefik`),t===`SITE`)try{i.push(JSON.parse(s.slice(t.length+1)))}catch{T(`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=Cn(a,n,o.proxy,new Set(i.map(Tn))),o.sites=i.map(e=>{let t=(r.get(Tn(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:wn(e,t),services:t,domains:Kt(e),main:Bt(e)?.name,state:Ht(e),kind:Ut(e)}}),o}async function Dn(e,t,n,r={}){let i=`${$(t)}/releases/${n}`,a=[...Jt,...Yt].filter(e=>e!==`compose.yaml`),{stdout:o}=await X(e,[J,`v=$(dc version --short 2>/dev/null | sed 's/^v//'); compose_ok "$v" || { echo "sites need docker compose 2.24 or newer, the server has \${v:-none}: run lf update --compose" >&2; exit 1; }`,`mkdir -p ${Y(i)} && cd -P ${Y(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>Z(e,t)),...r.file&&r.file!==`compose.yaml`?[`mv -f ${Y(r.file)} compose.yaml`]:[],`rm -f ${a.map(Y).join(` `)}`,...r.env===void 0?[]:[Z(`.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 ${Y(vn)}; printf '%s\\n' "$cfg"`].join(`
433
+ `),{capture:!0}),[s,c=``]=o.split(vn),l;try{l=_(c)}catch(e){D(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:s.trim(),config:l}}async function On(e,t){let n=$(t.name);await X(e,[J,`[ -r ${Y(t.adopted)} ] && [ -x ${Y(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${Y(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${Y(n)} && cd ${Y(n)}`,`ln -s ${Y(t.adopted)} current`,Z(`site.json`,JSON.stringify(t,null,2)+`
434
+ `),`record ${Y(`adopted ${t.project} as site ${t.name}`)} ${Y(`rm -rf ${n}`)}`].join(`
435
+ `))}async function kn(e,t,n){let r=$(t);await X(e,`cd / && rm -rf ${Y(`${r}/releases/${n}`)}; [ -f ${Y(r+`/site.json`)} ] || { rmdir ${Y(r+`/releases`)} 2>/dev/null; rm -f ${Y(r+`/.env`)}; rmdir ${Y(r)} 2>/dev/null; } || true`)}const An=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; }`];function jn(e,t,n={}){let r=$(e.name),i=It(e),a=[`mkdir -p ${Y(r)} && cd ${Y(r)}`,Z(`site.json`,JSON.stringify(e,null,2)+`
436
+ `),n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:Z(`.env`,n.env,`600`),`(cd -P ${Y(`releases/`+e.current)} && ${Z(`compose.override.yaml`,rn(e,i,t))}) || exit 1`];return n.build&&i.services.some(e=>e.build)&&a.push(`log "building ${e.name}"`,`(cd -P ${Y(`releases/`+e.current)} && dc build -q) || exit 1`),n.pull&&a.push(`log "pulling images of ${e.name}"`,`(cd -P ${Y(`releases/`+e.current)} && dc pull -q --ignore-buildable) || exit 1`),a.push(`ln -sfn ${Y(`releases/`+e.current)} current.new && mv -Tf current.new current`,...An(e.name)),a}const Mn=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>qt(e.name,n.name,t.id)),Nn=(e,t)=>[`rmf ${Y(`${$(e.name)}/releases/${t.id}`)}`,...Mn(e,t).map(e=>`docker image rm ${Y(e)} >/dev/null 2>&1 || true`)];function Pn(e){let t=$(e.name),n=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${Y(t)}`,`log "removing the old container of ${e.name}"`,`dc -p ${Y(Vt(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${Y(Vt(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.map(e=>`rm -rf ${Y(`releases/`+e.id)}`),...n.map(e=>`docker image rm ${Y(e)} >/dev/null 2>&1 || true`)]}async function Fn(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=xn(t),o=a.filter((e,n)=>n<i||e.id===t.current||e.id===r.prev?.current),s=a.filter(e=>!o.includes(e));t.releases=o;let c=$(t.name),l=[J];if(r.takeover){l.push(`record ${Y(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...jn(t,n,r),...Pn(r.takeover)),await X(e,l.join(`
437
+ `));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${Y(c+`/.env`)})`),!r.prev)u=`[ ! -d ${Y(c)} ] || {\n${Rn(t,{keepDir:r.existed}).join(`
474
438
  `)}\n`,d=`
475
- }`;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 ${L(c)} ] || { log ${L(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
476
- `+[...Bn(i,n),...a.flatMap(e=>Hn(t,e))].join(`
477
- `)}l.push(`record ${L(r.what)} ${L(u)}${f?`"$u"`:``}${L(d)}`),l.push(...Bn(t,n,r));for(let e of s)l.push(...Hn(t,e));await R(e,l.join(`
478
- `))}async function Gn(e,t,n,r=``){await R(e,`${K}\ncd ${L($(t))}\n${r}\ncd -P current && dc ${n}`)}const Kn=(e,t,n)=>be(e,[`cd -P ${L($(t)+`/current`)} && docker compose logs --tail ${L(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?L(n.service):``}`]);function qn(e,t={}){let n=$(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>Vn(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(L).join(` `);return[`cd ${L(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${L(Pn(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(e=>`docker image rm ${L(e)} >/dev/null 2>&1 || true`),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${L(n)}`]]}async function Jn(e,t,n={}){let r=`removed site ${t.name} (${tn(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await R(e,[K,...qn(t,n),`record ${L(r)}`].join(`
479
- `))}function Yn(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
439
+ }`;else{let e=r.prev,i={...e,releases:e.releases.filter(e=>o.some(t=>t.id===e.id))},a=o.filter(t=>!e.releases.some(e=>e.id===t.id));u=`[ -d ${Y(c)} ] || { log ${Y(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
440
+ `+[...jn(i,n),...a.flatMap(e=>Nn(t,e))].join(`
441
+ `)}l.push(`record ${Y(r.what)} ${Y(u)}${f?`"$u"`:``}${Y(d)}`),l.push(...jn(t,n,r));for(let e of s)l.push(...Nn(t,e));await X(e,l.join(`
442
+ `))}async function In(e,t,n,r=``){await X(e,`${J}\ncd ${Y($(t))}\n${r}\ncd -P current && dc ${n}`)}const Ln=(e,t,n)=>Qe(e,[`cd -P ${Y($(t)+`/current`)} && docker compose logs --tail ${Y(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?Y(n.service):``}`]);function Rn(e,t={}){let n=$(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>Mn(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(Y).join(` `);return[`cd ${Y(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${Y(Tn(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(e=>`docker image rm ${Y(e)} >/dev/null 2>&1 || true`),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${Y(n)}`]]}async function zn(e,t,n={}){let r=`removed site ${t.name} (${Kt(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await X(e,[J,...Rn(t,n),`record ${Y(r)}`].join(`
443
+ `))}function Bn(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
480
444
  `)){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(`
481
445
  `)+`
482
- `:``}function Xn(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&D(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function Zn(e,t){let{stdout:n}=await R(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 Q(e).user!==`root`&&await z(r)?r:D(`${t} needs root or passwordless sudo; ${Q(e).user} has neither and root's key login does not work`)}const Qn=String.raw`
446
+ `:``}function Vn(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&D(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function Hn(e,t){let{stdout:n}=await X(e,`{ [ "$(id -u)" = 0 ] || sudo -n true 2>/dev/null; } && echo yes || true`,{capture:!0});if(n.trim()===`yes`)return e;let r={...e,user:`root`};return Q(e).user!==`root`&&await et(r)?r:D(`${t} needs root or passwordless sudo; ${Q(e).user} has neither and root's key login does not work`)}const Un=String.raw`
483
447
  set +e
484
448
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
485
449
  m=$(pkg_mgr); echo "mgr=$m"
@@ -499,8 +463,8 @@ case "$m" in
499
463
  esac
500
464
  [ -f /var/run/reboot-required ] && echo reboot=1
501
465
  exit 0
502
- `;function $n(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
503
- `)){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 er(e,t=!0){let{stdout:n}=await R(e,K+Qn,{capture:!0,env:{REFRESH:t}});return $n(n)}const tr=String.raw`
466
+ `;function Wn(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
467
+ `)){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 Gn(e,t=!0){let{stdout:n}=await X(e,J+Un,{capture:!0,env:{REFRESH:t}});return Wn(n)}const Kn=String.raw`
504
468
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
505
469
  keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
506
470
  case "$(pkg_mgr)" in
@@ -520,8 +484,8 @@ if [ "$REBOOT" = 1 ] && { [ -f /var/run/reboot-required ] || { command -v needs-
520
484
  # Detached, so this ssh session can end cleanly first.
521
485
  (sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
522
486
  fi
523
- `;async function nr(e,t){await R(e,K+tr,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function rr(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 ir(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 ar(e){let t=rr(e);return t.digest?t.digest:(await ir(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function or(e){let t=rr(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await ir(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 sr(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 cr=/^(v?)(\d+(?:\.\d+){0,2})$/;function lr(e,t){let n=cr.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=cr.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&sr(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const ur=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,dr=/* @__PURE__ */ new Map;function fr(e){return dr.has(e)||dr.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)),dr.get(e)}const pr=()=>fr(`moby/moby`),mr=()=>fr(`docker/compose`);async function hr(e){let t=String.raw`
524
- ${K}
487
+ `;async function qn(e,t){await X(e,J+Kn,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function Jn(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 Yn(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 Xn(e){let t=Jn(e);return t.digest?t.digest:(await Yn(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function Zn(e){let t=Jn(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await Yn(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 Qn(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 $n=/^(v?)(\d+(?:\.\d+){0,2})$/;function er(e,t){let n=$n.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=$n.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&Qn(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const tr=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,nr=/* @__PURE__ */ new Map;function rr(e){return nr.has(e)||nr.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)),nr.get(e)}const ir=()=>rr(`moby/moby`),ar=()=>rr(`docker/compose`);async function or(e){let t=String.raw`
488
+ ${J}
525
489
  set +e
526
490
  printf 'docker=%s
527
491
  ' "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
@@ -531,11 +495,11 @@ printf 'path=%s
531
495
  ' "$(docker info --format '{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}' 2>/dev/null)"
532
496
  printf 'ce=%s
533
497
  ' "$(pkg_has docker-ce && echo 1)"
534
- exit 0`,[{stdout:n},r,i]=await Promise.all([R(e,t,{capture:!0}),pr(),mr()]),a=Object.fromEntries(n.split(`
535
- `).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 gr(e){let t=(e,t)=>!!e&&!!t&&sr(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 _r(e){await R(e,K+Pe+`compose_install || { log "could not download docker compose"; exit 1; }
536
- `)}const vr=String.raw`
498
+ exit 0`,[{stdout:n},r,i]=await Promise.all([X(e,t,{capture:!0}),ir(),ar()]),a=Object.fromEntries(n.split(`
499
+ `).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 sr(e){let t=(e,t)=>!!e&&!!t&&Qn(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 cr(e){await X(e,J+Fe+`compose_install || { log "could not download docker compose"; exit 1; }
500
+ `)}const lr=String.raw`
537
501
  set +e
538
- P=${Ae}
502
+ P=${Me}
539
503
  img() { # img <kind> <name> <container> <dir> [service]
540
504
  svc=; [ $# -lt 5 ] || svc=$5
541
505
  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; }
@@ -544,11 +508,11 @@ $x
544
508
  EOF
545
509
  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)"
546
510
  }
547
- if [ "$P" = traefik ] && grep -q "managed by landfall" ${U}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${U}
511
+ if [ "$P" = traefik ] && grep -q "managed by landfall" ${G}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${G}
548
512
  else img proxy "$P" "$P" ""; fi
549
513
  # All containers at once: <name> <service> <project>, the last two empty outside Compose.
550
514
  PS=$(docker ps -a --format '{{.Names}} {{.Label "com.docker.compose.service"}} {{.Label "com.docker.compose.project"}}' 2>/dev/null)
551
- for f in ${H}/*/site.json; do
515
+ for f in ${W}/*/site.json; do
552
516
  [ -f "$f" ] || continue
553
517
  d=$(dirname "$f"); n=$(basename "$d")
554
518
  # Sites of landfall before Compose sites: one container, its compose file next to site.json.
@@ -557,14 +521,14 @@ for f in ${H}/*/site.json; do
557
521
  printf '%s\n' "$PS" | while read -r c svc pr; do [ "$pr" != "$p" ] || img site "$n" "$c" "$d/current" "$svc"; done
558
522
  done
559
523
  exit 0
560
- `;async function yr(e){let{stdout:t}=await R(e,vr,{capture:!0}),n=t.split(`
561
- `).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(!cr.test(rr(e).tag))return;i.has(t)||i.set(t,or(e));let n=lr(rr(e).tag,await i.get(t));return n&&ur(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,ar(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>rr(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 br(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[K];for(let[e,t]of n){r.push(`log ${L(`pulling ${e}`)}`,`docker pull -q ${L(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${L(e)})`);for(let n of t){let t=n.service?` ${L(n.service)}`:``,i=`docker tag $old ${L(e)} && cd -P ${L(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${L(n.container)})`,`if [ "$old" = "$new" ]; then log ${L(`${n.name} already runs the newest ${e}`)}; else`,` record ${L(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${L(`recreating ${n.container}`)}`,` (cd -P ${L(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${L(n.container)} || exit 1`,`fi`)}}await R(e,r.join(`
562
- `))}function xr(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 Sr(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
563
- `)){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(xr(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function Cr(e,t={}){let{stdout:n}=await R(e,K+Le,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return Sr(n)}const wr=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),Tr=e=>e.includes(`:`),Er=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,Dr=[68,546,5353];function Or(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=Fr(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 kr(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 Ar=(e,t)=>{let n=kr(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},jr=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[Er(e),t(e)])).values()];function Mr(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=Nr(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 Nr(e,t){let n=e.values,r=e.rules.map(Or),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&Dr.includes(e.port)||Ar(r,e),a=e=>!wr(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[Tr(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...jr(o,e=>`${Er(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:jr(l,e=>`${Er(e)} (docker${Tr(e.addr)?`, IPv6`:``})`)],kept:c?[]:jr(s.filter(e=>!l.includes(e)),Er)}}function Pr(e){let t=e.values,n=e.rules.map(Or),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[Tr(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(wr(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=kr(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`&&Dr.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=`${Er(e)} ${t?`docker`:e.proc}`,r={port:Er(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 Fr=(e=``)=>e.split(/\s+/).filter(Boolean);function Ir(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&&sr(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 Lr(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(Ir(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,V,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(Mr(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=Fr(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(Br(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function Rr(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 zr=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function Br(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=je(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${zr(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${U}/docker-compose.yml was not written by landfall`);let[i,a]=Fr(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=Rr(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=Fr(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=Rr(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${zr(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 Vr(e){let t={done:x.green(`✓`),todo:x.cyan(`→`),conflict:x.yellow(`!`),off:x.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?x.dim(`disabled`):r.status===`conflict`?x.yellow(r.detail):x.dim(r.detail);w(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function Hr(e,t){return`${e}:${(await v.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}function Ur(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)}}const Wr=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function Gr(e,t,n){let r=Ur(t,n);rt(r.image);let{email:i,staging:a}=r;S(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?st(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){et.test(c)||D(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=n.dashboard?n.dashboardUser:o?.user??n.dashboardUser;s=n.dashboardPassword??_(12).toString(`base64url`),o={host:c,user:e,htpasswd:await Hr(e,s)}}let l=Be({...r,dashboard:o});await R(t,`${K}
524
+ `;async function ur(e){let{stdout:t}=await X(e,lr,{capture:!0}),n=t.split(`
525
+ `).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(!$n.test(Jn(e).tag))return;i.has(t)||i.set(t,Zn(e));let n=er(Jn(e).tag,await i.get(t));return n&&tr(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(qt(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,Xn(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>Jn(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 dr(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[J];for(let[e,t]of n){r.push(`log ${Y(`pulling ${e}`)}`,`docker pull -q ${Y(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${Y(e)})`);for(let n of t){let t=n.service?` ${Y(n.service)}`:``,i=`docker tag $old ${Y(e)} && cd -P ${Y(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${Y(n.container)})`,`if [ "$old" = "$new" ]; then log ${Y(`${n.name} already runs the newest ${e}`)}; else`,` record ${Y(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${Y(`recreating ${n.container}`)}`,` (cd -P ${Y(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${Y(n.container)} || exit 1`,`fi`)}}await X(e,r.join(`
526
+ `))}function fr(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 pr(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
527
+ `)){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(fr(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function mr(e,t={}){let{stdout:n}=await X(e,J+Re,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return pr(n)}const hr=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),gr=e=>e.includes(`:`),_r=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,vr=[68,546,5353];function yr(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=Er(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 br(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 xr=(e,t)=>{let n=br(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Sr=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[_r(e),t(e)])).values()];function Cr(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=wr(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 wr(e,t){let n=e.values,r=e.rules.map(yr),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&vr.includes(e.port)||xr(r,e),a=e=>!hr(e.addr)&&!i(e),o=e.listen.filter(e=>e.proc!==`docker-proxy`&&a(e)),s=[...e.published,...e.listen.filter(e=>e.proc===`docker-proxy`)].filter(a),c=n.docker_fw===`nftables`,l=s.filter(e=>n[gr(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Sr(o,e=>`${_r(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Sr(l,e=>`${_r(e)} (docker${gr(e.addr)?`, IPv6`:``})`)],kept:c?[]:Sr(s.filter(e=>!l.includes(e)),_r)}}function Tr(e){let t=e.values,n=e.rules.map(yr),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[gr(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(hr(e.addr))return{state:`local`,why:`localhost only`};if(!r)return{state:`open`,why:`open, firewall off`};if(a&&i(e))return{state:`open`,why:`open, docker skips ufw`};let o=br(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`&&vr.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=`${_r(e)} ${t?`docker`:e.proc}`,r={port:_r(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 Er=(e=``)=>e.split(/\s+/).filter(Boolean);function Dr(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&&Qn(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 Or(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(Dr(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,U,o?`done`:`todo`,o?`${s} site${s===1?``:`s`} (never touched by setup)`:`create dir and network`)),t.deployUser){let e=`deploy user ${t.deployUser}`;if(n.user!==`1`)i.push(r(`user`,e,`todo`,`create`));else{let t=[n.user_docker===`1`?``:`docker group`,n.user_key===`1`?``:`ssh key`].filter(Boolean);i.push(r(`user`,e,t.length?`todo`:`done`,t.length?`exists, add ${t.join(` + `)}`:``))}}if(t.firewall?n.ufw===`missing`?i.push(r(`firewall`,`firewall`,`done`,`ufw not available, skipped`)):n.ufw===`active`?i.push(r(`firewall`,`firewall`,`done`,`ufw active (landfall only adds ssh/80/443)`)):i.push(Cr(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=Er(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(jr(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function kr(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 Ar=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function jr(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=Ne(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${Ar(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${G}/docker-compose.yml was not written by landfall`);let[i,a]=Er(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=kr(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=Er(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=kr(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${Ar(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 Mr(e){let t={done:x.green(`✓`),todo:x.cyan(`→`),conflict:x.yellow(`!`),off:x.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?x.dim(`disabled`):r.status===`conflict`?x.yellow(r.detail):x.dim(r.detail);w(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function Nr(e,t){return`${e}:${(await y.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}function Pr(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)}}const Fr=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function Ir(e,t,n){let r=Pr(t,n);ge(r.image);let{email:i,staging:a}=r;S(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?be(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){H.test(c)||D(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=n.dashboard?n.dashboardUser:o?.user??n.dashboardUser;s=n.dashboardPassword??v(12).toString(`base64url`),o={host:c,user:e,htpasswd:await Nr(e,s)}}let l=Ve({...r,dashboard:o});await X(t,`${J}
564
528
  ensure_network web
565
- [ -w ${U} ] || { $SUDO install -d -o "$(id -un)" ${U}; }
566
- mkdir -p ${U}/letsencrypt ${Ee} && cd ${U}
567
- ${B(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
529
+ [ -w ${G} ] || { $SUDO install -d -o "$(id -un)" ${G}; }
530
+ mkdir -p ${G}/letsencrypt ${Oe} && cd ${G}
531
+ ${Z(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
568
532
  tls:
569
533
  options:
570
534
  default:
@@ -572,11 +536,11 @@ tls:
572
536
  sniStrict: true
573
537
  `)}
574
538
  if [ -e docker-compose.yml ]; then
575
- ${B(`.docker-compose.yml.new`,l)}
539
+ ${Z(`.docker-compose.yml.new`,l)}
576
540
  # .landfall.sha256: what landfall wrote last. A different file was edited by hand.
577
541
  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
578
542
  diff -u docker-compose.yml .docker-compose.yml.new >&2; rm -f .docker-compose.yml.new
579
- echo "${U}/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
543
+ echo "${G}/docker-compose.yml was edited by hand; the diff above is what landfall would change. Keep your edits: change dynamic/ or the flags instead; overwrite them: lf traefik setup --force" >&2
580
544
  exit 1
581
545
  fi
582
546
  # Same config again: nothing to journal.
@@ -584,28 +548,40 @@ if [ -e docker-compose.yml ]; then
584
548
  else
585
549
  u=$(saved docker-compose.yml)
586
550
  mv .docker-compose.yml.new docker-compose.yml
587
- record ${L(`reconfigured traefik (${Wr(r)})`)} "cd ${U} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
551
+ record ${Y(`reconfigured traefik (${Fr(r)})`)} "cd ${G} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
588
552
  fi
589
553
  else
590
- ${B(`docker-compose.yml`,l)}
554
+ ${Z(`docker-compose.yml`,l)}
591
555
  # Certificates in letsencrypt/ stay, a later install reuses them.
592
- record ${L(`installed traefik (${Wr(r)})`)} "cd ${U} && dc down --remove-orphans && rm -f docker-compose.yml .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
556
+ record ${Y(`installed traefik (${Fr(r)})`)} "cd ${G} && dc down --remove-orphans && rm -f docker-compose.yml .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
593
557
  fi
594
558
  log "starting traefik"
595
559
  dc up -d --remove-orphans --quiet-pull
596
560
  sleep 2 # traefik exits shortly after start on a bad config
597
561
  ensure_running traefik
598
- sha256sum docker-compose.yml > .landfall.sha256`);let u=X();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,dashboard:o}),$e(u)),C(`traefik running (${Wr(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&w(x.dim(`host network: ufw now decides who reaches 80/443 (landfall's firewall allows both)`)),o&&(C(`dashboard: https://${o.host}`),w(s?`user ${x.bold(o.user)} password ${x.bold(s)}`:x.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function Kr(e,t){S(`using traefik ${x.bold(t.container)} for lf sites`),await R(e,`${K}
562
+ sha256sum docker-compose.yml > .landfall.sha256`);let u=B();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,dashboard:o}),pe(u)),C(`traefik running (${Fr(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&w(x.dim(`host network: ufw now decides who reaches 80/443 (landfall's firewall allows both)`)),o&&(C(`dashboard: https://${o.host}`),w(s?`user ${x.bold(o.user)} password ${x.bold(s)}`:x.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function Lr(e,t){S(`using traefik ${x.bold(t.container)} for lf sites`),await X(e,`${J}
599
563
  docker network inspect ${t.network} >/dev/null 2>&1 || { echo "docker network ${t.network} does not exist" >&2; exit 1; }
600
- [ -w ${V} ] || $SUDO install -d -o "$(id -un)" ${V}
601
- u=$(saved ${W})
602
- ${B(W,Me(t))}
603
- record ${L(`sites use traefik ${t.container}`)} "$u"`),C(`sites join network ${x.bold(t.network)} with entrypoint ${x.bold(t.entrypoint)} and resolver ${x.bold(t.resolver)}`),w(x.dim(`redeploy existing sites to switch them over; delete ${W} to let landfall run its own traefik again`))}const qr={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},Jr=P({name:`traefik setup`,summary:`Install or reconfigure Traefik (reverse proxy + automatic HTTPS) on a server. Safe to re-run; refuses if ports 80/443 belong to something else.`,options:{server:F,email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},image:{type:`string`,desc:`traefik image, remembered (default: ${De})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},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:`admin`},"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=Br(await Cr(r));i.status===`conflict`&&D(`not touching ${n}: ${i.detail}`),i.proxy&&D(`${n} already has its own traefik: ${i.detail}. landfall does not reconfigure it (lf traefik use)`),await Gr(n,r,{email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`],force:t.force})}}),Yr=P({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
564
+ [ -w ${U} ] || $SUDO install -d -o "$(id -un)" ${U}
565
+ u=$(saved ${K})
566
+ ${Z(K,Pe(t))}
567
+ record ${Y(`sites use traefik ${t.container}`)} "$u"`),C(`sites join network ${x.bold(t.network)} with entrypoint ${x.bold(t.entrypoint)} and resolver ${x.bold(t.resolver)}`),w(x.dim(`redeploy existing sites to switch them over; delete ${K} to let landfall run its own traefik again`))}async function Rr(e){let{stdout:t}=await X(e,`[ ! -f ${K} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${K})"
568
+ f=${G}/docker-compose.yml
569
+ [ ! -f $f ] || { grep -q "managed by landfall" $f && echo compose ours || echo compose foreign; }
570
+ for d in ${W}/*/; do [ -d "$d" ] && printf 'site\\t%s\\n' "$(basename "$d")"; done; true`,{capture:!0}),n={sites:[]};for(let e of t.split(`
571
+ `)){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 zr(e,t={}){S(`removing traefik`),await X(e,`${J}
572
+ cd ${G}
573
+ log "stopping traefik"
574
+ dc down --remove-orphans
575
+ u="$(saved ${G}/docker-compose.yml); $(saved ${G}/.landfall.sha256); $(saved ${Oe}/landfall.yml)"
576
+ rm -f docker-compose.yml .landfall.sha256 ${Oe}/landfall.yml
577
+ ${t.certs?`rmf letsencrypt`:``}
578
+ # Certificates removed with --certs come back from Let's Encrypt on the next start.
579
+ record ${Y(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${G} && dc up -d --remove-orphans"`)}const Br={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},Vr=P({name:`traefik setup`,summary:`Install or reconfigure Traefik (reverse proxy + automatic HTTPS) on a server. Safe to re-run; refuses if ports 80/443 belong to something else.`,options:{server:F,email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},image:{type:`string`,desc:`traefik image, remembered (default: ${ke})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},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:`admin`},"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}=V(t.server),i=jr(await mr(r));i.status===`conflict`&&D(`not touching ${n}: ${i.detail}`),i.proxy&&D(`${n} already has its own traefik: ${i.detail}. landfall does not reconfigure it (lf traefik use)`),await Ir(n,r,{email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`],force:t.force})}}),Hr=P({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
604
580
 
605
581
  landfall reads network, entrypoint and cert resolver from the container's flags;
606
- pass them when it is configured by a file. That Traefik is never changed.`,options:{server:F,network:{type:`string`,desc:`docker network traefik watches (providers.docker.network)`},entrypoint:{type:`string`,desc:`HTTPS entrypoint (the one on :443)`},resolver:{type:`string`,desc:`certificate resolver`}},async run([e],t){let{name:n,server:r}=Z(t.server),i=Rr((await Cr(r)).values.foreign_proxy),a=e??i?.name;a||D(`no traefik container publishes port 443 on ${n}; name it: lf traefik use <container>`);let o=i?.name===a?i:void 0,s={container:a,network:t.network??o?.proxy?.network,entrypoint:t.entrypoint??o?.proxy?.entrypoint,resolver:t.resolver??o?.proxy?.resolver},c=[`network`,`entrypoint`,`resolver`].filter(e=>!s[e]);c.length&&D(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await Kr(r,s)}}),Xr=P({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:F},async run(e,t){let{server:n}=Z(t.server);await R(n,`P=${Ae}
582
+ pass them when it is configured by a file. That Traefik is never changed.`,options:{server:F,network:{type:`string`,desc:`docker network traefik watches (providers.docker.network)`},entrypoint:{type:`string`,desc:`HTTPS entrypoint (the one on :443)`},resolver:{type:`string`,desc:`certificate resolver`}},async run([e],t){let{name:n,server:r}=V(t.server),i=kr((await mr(r)).values.foreign_proxy),a=e??i?.name;a||D(`no traefik container publishes port 443 on ${n}; name it: lf traefik use <container>`);let o=i?.name===a?i:void 0,s={container:a,network:t.network??o?.proxy?.network,entrypoint:t.entrypoint??o?.proxy?.entrypoint,resolver:t.resolver??o?.proxy?.resolver},c=[`network`,`entrypoint`,`resolver`].filter(e=>!s[e]);c.length&&D(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await Lr(r,s)}}),Ur=P({name:`traefik rm`,summary:"Stop landfall's Traefik and remove its config. Sites stay but are unreachable until `lf traefik setup`.\n\nCertificates in letsencrypt/ stay for a later install unless --certs. Undo with `lf undo`.\nWith a Traefik of your own (lf traefik use), landfall only stops routing sites through it.",options:{server:F,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=V(t.server),i=await Rr(r);if(i.proxy)return await j(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||D(`cancelled`),await X(r,`${J}\nu=$(saved ${K})\n_as ${U} rm -f ${K}\nrecord ${Y(`sites stop using traefik ${i.proxy}`)} "$u"`),C(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),w(x.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&D(`${G}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return C(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await A(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await zr(r,{certs:t.certs}),C(`traefik removed${t.certs?``:x.dim(` (certificates stay for a later install; --certs deletes them)`)}`),w(x.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),Wr=P({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:F},async run(e,t){let{server:n}=V(t.server);await X(n,`P=${Me}
607
583
  if ! docker inspect "$P" >/dev/null 2>&1; then echo "traefik is not installed (lf traefik setup)"; exit 0; fi
608
- [ -f ${W} ] && echo "your own traefik, landfall only routes sites through it (${W})"
584
+ [ -f ${K} ] && echo "your own traefik, landfall only routes sites through it (${K})"
609
585
  docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
610
586
  echo
611
587
  echo "routes:"
@@ -629,23 +605,46 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
629
605
  printf " %s %s (%s)\\n", n, x, s
630
606
  }
631
607
  }' || true
632
- done | sort`)}}),Zr=P({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:F,...qr},async run(e,t){return jn(Z(t.server).server,Ae,t)}}),Qr=P({name:`traefik restart`,summary:`Restart traefik.`,options:{server:F},async run(e,t){let{server:n}=Z(t.server);await R(n,`${K}\ndocker restart ${Ae} >/dev/null\nrecord "restarted traefik"`),C(`traefik restarted`)}});function $r(e,t){let n=pt(process.cwd()),r=e??n?.name;return r||D(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...Z(t??n?.server)}}const ei=e=>$t(e)?x.green(e):en(e)?x.dim(e):x.red(e),ti=P({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:F,json:{type:`boolean`,desc:`machine-readable output`},others:{type:`boolean`,desc:`with --json: {sites, others}, others = containers Traefik routes that landfall did not deploy`}},async run(e,t){let{name:n,server:r}=Z(t.server??pt(process.cwd())?.server),{sites:i,others:a}=await Fn(r);if(t.json)return console.log(JSON.stringify(t.others?{sites:i,others:a}:i,null,2));if(a.length&&(console.log(x.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),M([[x.dim(`NAME`),x.dim(`DOMAINS`),x.dim(`FOLDER`),x.dim(`STATUS`)],...a.map(e=>[e.name,e.domains.join(`, `),e.dir??x.dim(e.containers.join(`, `)),ei(e.status)])]),i.length&&console.log()),!i.length)return console.log(`no landfall sites on ${n} yet — ${x.bold(`lf deploy --domain example.com`)}`);M([[x.dim(`NAME`),x.dim(`KIND`),x.dim(`DOMAINS`),x.dim(`SERVICES`),x.dim(`RELEASE`),x.dim(`UPDATED`),x.dim(`STATUS`)],...i.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[x.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||x.dim(`-`),e.current||x.dim(`-`),N(e.updatedAt),ei(t)])])}}),ni=P({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:F},async run([e],t){let{site:n,server:r}=$r(e,t.server),i=(await Fn(r)).sites.find(e=>e.meta.name===n);i||D(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${x.bold(a.name)} ${i.kind} ${ei(i.status)}`);for(let e of i.domains)console.log(` ${x.cyan(`https://`+e)}`);if(console.log(x.dim(` ${a.adopted??$(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${x.bold(`Services`)}`),M(i.services.map(e=>[` ${e.service}`,x.dim(e.container),ei(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${x.bold(`Releases`)}`);for(let e of An(a)){let t=e.id===a.current?x.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${x.dim(N(e.at))} ${x.dim(e.services.map(e=>e.name).join(`, `))}${n.length?x.dim(` → ${n.join(`, `)}`):``}`)}}}}),ri=P({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:F,...qr},async run([e,t],n){let{site:r,server:i}=$r(e,n.server);return Kn(i,r,{...n,service:t})}}),ii=(e,t,n,r,i)=>P({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:F},async run([a,o],s){let{site:c,server:l}=$r(a,s.server);await kn(l,c);let u=o?` ${L(o)}`:``,d=`record ${L(`${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} ${L(`cd -P ${$(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await Gn(l,c,t+u,p),C(`${o?`${c} ${o}`:c} ${n}`)}}),ai=ii(`restart`,`restart`,`restarted`),oi=ii(`stop`,`stop`,`stopped`,`start`,!0),si=ii(`start`,`up -d --wait`,`started`,`stop`,!1),ci=P({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:F,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=$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&&D(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||D(t.to?`no release ${t.to}`:`no release older than ${i.current}`);let l=structuredClone(i);i.current=c.id,i.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Wn(r,i,a,{keep:i.releases.length,prev:l,what:`rollback ${n} from ${l.current} to ${c.id}`}),C(`${n} rolled back to ${x.bold(c.id)}`)}}),li=P({name:`adopt`,args:`<project>`,summary:"Make a Compose project landfall did not deploy a site: `lf sites`, logs, restart,\nstop and start then work for it. It keeps running from its folder, with its own files and\nTraefik labels; landfall does not deploy, roll back or change its domains. `lf sites rm`\nstops it and forgets it again. The projects you can adopt are listed under `lf sites`.",options:{server:F,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||D("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=Z(t.server),i=await Fn(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>Wt(t.meta)===e);D(t?`${e} is already the site ${t.meta.name}`:`no Compose project "${e}" with Traefik routes on ${r}. See \`lf sites\``)}let o=a;o.dir||D(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=En(t.name??yn(e));i.sites.some(e=>e.meta.name===s)&&D(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...rn,...an].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&D(`${e} was started with ${l.join(`, `)}; rename it to compose.yaml in ${o.dir} (or adopt it after moving it there)`);let u=(/* @__PURE__ */ new Date()).toISOString();await Ln(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),C(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(x.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),ui=P({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
608
+ done | sort`)}}),Gr=P({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:F,...Br},async run(e,t){return Sn(V(t.server).server,Me,t)}}),Kr=P({name:`traefik restart`,summary:`Restart traefik.`,options:{server:F},async run(e,t){let{server:n}=V(t.server);await X(n,`${J}\ndocker restart ${Me} >/dev/null\nrecord "restarted traefik"`),C(`traefik restarted`)}});function qr(e,t){let n=Ee(process.cwd()),r=e??n?.name;return r||D(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...V(t??n?.server)}}const Jr=e=>Wt(e)?x.green(e):Gt(e)?x.dim(e):x.red(e),Yr=P({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:F,json:{type:`boolean`,desc:`machine-readable output`},others:{type:`boolean`,desc:`with --json: {sites, others}, others = containers Traefik routes that landfall did not deploy`}},async run(e,t){let{name:n,server:r}=V(t.server??Ee(process.cwd())?.server),{sites:i,others:a}=await En(r);if(t.json)return console.log(JSON.stringify(t.others?{sites:i,others:a}:i,null,2));if(a.length&&(console.log(x.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),M([[x.dim(`NAME`),x.dim(`DOMAINS`),x.dim(`FOLDER`),x.dim(`STATUS`)],...a.map(e=>[e.name,e.domains.join(`, `),e.dir??x.dim(e.containers.join(`, `)),Jr(e.status)])]),i.length&&console.log()),!i.length)return console.log(`no landfall sites on ${n} yet — ${x.bold(`lf deploy --domain example.com`)}`);M([[x.dim(`NAME`),x.dim(`KIND`),x.dim(`DOMAINS`),x.dim(`SERVICES`),x.dim(`RELEASE`),x.dim(`UPDATED`),x.dim(`STATUS`)],...i.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[x.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||x.dim(`-`),e.current||x.dim(`-`),N(e.updatedAt),Jr(t)])])}}),Xr=P({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:F},async run([e],t){let{site:n,server:r}=qr(e,t.server),i=(await En(r)).sites.find(e=>e.meta.name===n);i||D(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${x.bold(a.name)} ${i.kind} ${Jr(i.status)}`);for(let e of i.domains)console.log(` ${x.cyan(`https://`+e)}`);if(console.log(x.dim(` ${a.adopted??$(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${x.bold(`Services`)}`),M(i.services.map(e=>[` ${e.service}`,x.dim(e.container),Jr(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${x.bold(`Releases`)}`);for(let e of xn(a)){let t=e.id===a.current?x.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${x.dim(N(e.at))} ${x.dim(e.services.map(e=>e.name).join(`, `))}${n.length?x.dim(` → ${n.join(`, `)}`):``}`)}}}}),Zr=P({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:F,...Br},async run([e,t],n){let{site:r,server:i}=qr(e,n.server);return Ln(i,r,{...n,service:t})}}),Qr=(e,t,n,r,i)=>P({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:F},async run([a,o],s){let{site:c,server:l}=qr(a,s.server);await bn(l,c);let u=o?` ${Y(o)}`:``,d=`record ${Y(`${e} ${c}${o?` `+o:``}`)}`,f=`[ -n "$(cd -P current && dc ps -q --status running${u} 2>/dev/null)" ]`,p=r?`if ${i?``:`! `}${f}; then ${d} ${Y(`cd -P ${$(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await In(l,c,t+u,p),C(`${o?`${c} ${o}`:c} ${n}`)}}),$r=Qr(`restart`,`restart`,`restarted`),ei=Qr(`stop`,`stop`,`stopped`,`start`,!0),ti=Qr(`start`,`up -d --wait`,`started`,`stop`,!1),ni=P({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:F,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=qr(e,t.server),{meta:i,proxy:a}=await bn(r,n,{deployed:`roll back`}),o=xn(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&D(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||D(t.to?`no release ${t.to}`:`no release older than ${i.current}`);let l=structuredClone(i);i.current=c.id,i.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Fn(r,i,a,{keep:i.releases.length,prev:l,what:`rollback ${n} from ${l.current} to ${c.id}`}),C(`${n} rolled back to ${x.bold(c.id)}`)}}),ri=P({name:`adopt`,args:`<project>`,summary:"Make a Compose project landfall did not deploy a site: `lf sites`, logs, restart,\nstop and start then work for it. It keeps running from its folder, with its own files and\nTraefik labels; landfall does not deploy, roll back or change its domains. `lf sites rm`\nstops it and forgets it again. The projects you can adopt are listed under `lf sites`.",options:{server:F,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||D("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=V(t.server),i=await En(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>Ft(t.meta)===e);D(t?`${e} is already the site ${t.meta.name}`:`no Compose project "${e}" with Traefik routes on ${r}. See \`lf sites\``)}let o=a;o.dir||D(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=_n(t.name??un(e));i.sites.some(e=>e.meta.name===s)&&D(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...Jt,...Yt].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&D(`${e} was started with ${l.join(`, `)}; rename it to compose.yaml in ${o.dir} (or adopt it after moving it there)`);let u=(/* @__PURE__ */ new Date()).toISOString();await On(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),C(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(x.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),ii=P({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
633
609
 
634
610
  Named volumes (databases, uploads) stay, so deploying the site again finds its data;
635
- --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:F,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||D(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=$r(e,t.server),{meta:a}=await On(r,n);a||D(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=tn(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await A(o.adopted?`Stop ${n} (${s}) on ${i} and forget it${c}? ${o.adopted} stays.`:`Delete ${n} (${s})${c} from ${i}? This cannot be undone.`,n),await Jn(r,o,{volumes:t.volumes}),C(`removed ${n}${t.volumes?` and its volumes`:x.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),di=P({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:F,unset:{type:`string`,short:`u`,multiple:!0,desc:`remove KEY`}},async run(e,t){let n=e.filter(e=>e.includes(`=`)),[r]=e.filter(e=>!e.includes(`=`)),{site:i,server:a}=$r(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||x.dim(`(empty)
636
- `));let s=Yn(o,Xn(n),t.unset??[]),c=[...n.map(e=>e.slice(0,e.indexOf(`=`))),...(t.unset??[]).map(e=>`-${e}`)];await Gn(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
637
- record ${L(`env ${i} ${c.join(` `)}`)} "cd ${$(i)} && $u && cd -P current && dc up -d"
638
- ${B(`.env`,s,`600`)}`),C(`updated env of ${i}`)}});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 pi(e,t,n,r,i,a){let o=await Fn(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&T(`${t.join(`, `)} ${t.length>1?`are`:`is`} also routed to ${e.name}${e.dir?` (${e.dir})`:``}, which landfall did not deploy: Traefik sends visitors to only one of them. Remove the labels there to hand ${t.length>1?`them`:`it`} over.`)}let s=o.sites.map(e=>e.meta).filter(e=>e.name!==n&&tn(e).some(e=>r.includes(e)));for(let e of s){let t=tn(e).filter(e=>r.includes(e)),i=Yt(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=Ut(e)?`redeploy it first (lf deploy) so it can hand them over`:e.adopted?`its compose files in ${e.adopted} route ${i.length>1?`them`:`it`}`:`its compose.yaml routes ${i.length>1?`them`:`it`} (x-landfall.domains)`;D(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&D(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`),t.length===e.domains.length&&D(`${t.join(`, `)} ${t.length>1?`are`:`is`} all ${e.name} serves: give it another domain first (lf domains ${e.name} --add <domain>) or remove it (lf sites rm ${e.name})`),await A(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?`)}return async()=>{for(let a of s){let o=structuredClone(a);a.domains=a.domains.filter(e=>!r.includes(e)),a.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Wn(e,a,i,{keep:a.releases.length,prev:o,what:`moved ${o.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`}),Tn(t,a.name,a.domains),w(x.dim(`${a.name} keeps ${a.domains.join(`, `)}`))}}}const mi=P({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
611
+ --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:F,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||D(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=qr(e,t.server),{meta:a}=await yn(r,n);a||D(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=Kt(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await A(o.adopted?`Stop ${n} (${s}) on ${i} and forget it${c}? ${o.adopted} stays.`:`Delete ${n} (${s})${c} from ${i}? This cannot be undone.`,n),await zn(r,o,{volumes:t.volumes}),C(`removed ${n}${t.volumes?` and its volumes`:x.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),ai=P({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:F,unset:{type:`string`,short:`u`,multiple:!0,desc:`remove KEY`}},async run(e,t){let n=e.filter(e=>e.includes(`=`)),[r]=e.filter(e=>!e.includes(`=`)),{site:i,server:a}=qr(r,t.server),{env:o}=await bn(a,i,{deployed:`manage the .env of`});if(!n.length&&!t.unset?.length)return void process.stdout.write(o||x.dim(`(empty)
612
+ `));let s=Bn(o,Vn(n),t.unset??[]),c=[...n.map(e=>e.slice(0,e.indexOf(`=`))),...(t.unset??[]).map(e=>`-${e}`)];await In(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
613
+ record ${Y(`env ${i} ${c.join(` `)}`)} "cd ${$(i)} && $u && cd -P current && dc up -d"
614
+ ${Z(`.env`,s,`600`)}`),C(`updated env of ${i}`)}});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 si(e,t,n,r,i,a){let o=await En(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&T(`${t.join(`, `)} ${t.length>1?`are`:`is`} also routed to ${e.name}${e.dir?` (${e.dir})`:``}, which landfall did not deploy: Traefik sends visitors to only one of them. Remove the labels there to hand ${t.length>1?`them`:`it`} over.`)}let s=o.sites.map(e=>e.meta).filter(e=>e.name!==n&&Kt(e).some(e=>r.includes(e)));for(let e of s){let t=Kt(e).filter(e=>r.includes(e)),i=Bt(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=Pt(e)?`redeploy it first (lf deploy) so it can hand them over`:e.adopted?`its compose files in ${e.adopted} route ${i.length>1?`them`:`it`}`:`its compose.yaml routes ${i.length>1?`them`:`it`} (x-landfall.domains)`;D(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&D(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`);let o=t.length===e.domains.length?` ${e.name} keeps running with no domain.`:``;await A(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?${o}`)}return async()=>{for(let a of s){let o=structuredClone(a);a.domains=a.domains.filter(e=>!r.includes(e)),a.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Fn(e,a,i,{keep:a.releases.length,prev:o,what:`moved ${o.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`}),gn(t,a.name,a.domains),w(x.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}}const ci=P({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
639
615
 
640
616
  Without flags it lists and checks; --add/--rm/--set change the site (undoable)
641
- and update .landfall.json of remembered projects.`,options:{server:F,add:{type:`string`,short:`a`,multiple:!0,desc:"add a domain (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},rm:{type:`string`,multiple:!0,desc:`remove a domain`},set:{type:`string`,multiple:!0,desc:`replace all domains`},json:{type:`boolean`,desc:`machine-readable output (listing)`}},async run([e],t){let{site:n,name:r,server:i}=$r(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=tn(o),r=await Lt(i),[a,s]=await Promise.all([Bt(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?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`),n=e.https.status?(e.https.status<400?x.green:e.https.status===401?x.cyan:x.yellow)(`https ${e.https.status}`):x.red(`https ${e.https.error}`);console.log(`${x.cyan(`https://${e.domain}`)} ${t} ${n}`)}c.some(e=>!e.ok)&&console.log(x.dim(`\npoint an A record at ${r.join(` or `)} for each domain`));return}Yt(o)||D(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>st(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=>!et.test(e));u.length&&D(`not a domain: ${u.join(`, `)}`),l.length||D(`a site needs at least one domain`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&T(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return C(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));f.length&&await A(`Remove ${f.join(`, `)} from ${n}? ${f.length>1?`They stop`:`It stops`} serving it.`);let p=await pi(i,r,n,l.filter(e=>!o.domains.includes(e)),s),m=structuredClone(o);o.domains=l,o.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await p(),await Wn(i,o,s,{keep:o.releases.length,prev:m,what:`domains of ${n}: ${l.join(`, `)}`}),Tn(r,n,l);let h=pt(process.cwd());h?.name===n&&mt(process.cwd(),{...h,domains:l}),C(`${n} serves ${l.map(e=>x.cyan(`https://${e}`)).join(`, `)}`);let ee=await Lt(i);for(let e of await Bt(l.filter(e=>!m.domains.includes(e)),ee))e.ok||T(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),hi=P({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
617
+ and update .landfall.json of remembered projects.`,options:{server:F,add:{type:`string`,short:`a`,multiple:!0,desc:"add a domain (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},rm:{type:`string`,multiple:!0,desc:`remove a domain`},set:{type:`string`,multiple:!0,desc:`replace all domains`},json:{type:`boolean`,desc:`machine-readable output (listing)`}},async run([e],t){let{site:n,name:r,server:i}=qr(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await bn(i,n,a?{deployed:`change the domains of`}:{});if(!a){let e=Kt(o),r=await Ot(i),[a,s]=await Promise.all([jt(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?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`),n=e.https.status?(e.https.status<400?x.green:e.https.status===401?x.cyan:x.yellow)(`https ${e.https.status}`):x.red(`https ${e.https.error}`);console.log(`${x.cyan(`https://${e.domain}`)} ${t} ${n}`)}c.some(e=>!e.ok)&&console.log(x.dim(`\npoint an A record at ${r.join(` or `)} for each domain`));return}Bt(o)||D(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>be(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=>!H.test(e));u.length&&D(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&T(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return C(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));if(f.length){let e=l.length?``:` ${n} keeps running, unreachable until it gets a domain again.`;await A(`Remove ${f.join(`, `)} from ${n}? ${f.length>1?`They stop`:`It stops`} serving it.${e}`)}let p=await si(i,r,n,l.filter(e=>!o.domains.includes(e)),s),m=structuredClone(o);o.domains=l,o.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await p(),await Fn(i,o,s,{keep:o.releases.length,prev:m,what:`domains of ${n}: ${l.join(`, `)}`}),gn(r,n,l);let h=Ee(process.cwd());h?.name===n&&De(process.cwd(),{...h,domains:l}),l.length?C(`${n} serves ${l.map(e=>x.cyan(`https://${e}`)).join(`, `)}`):C(`${n} serves no domain now ${x.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let ee=await Ot(i);for(let e of await jt(l.filter(e=>!m.domains.includes(e)),ee))e.ok||T(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),li=P({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
642
618
 
643
619
  lf auth my-site --user anna add or change a login (asks the password)
644
620
  lf auth my-site --rm-user anna remove a login
645
621
  lf auth my-site --off remove all logins: the site is public again
646
- lf auth my-site show who can log in`,options:{server:F,user:{type:`string`,short:`u`,desc:`login to add or change`},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}=$r(e,t.server),{meta:i,proxy:a}=await kn(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(e=>e.slice(0,e.indexOf(`:`)));if(!t.user&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${x.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>x.bold(e)).join(`, `)}`);return}let s=t.off?[]:[...i.auth??[]],c=new Set(t[`rm-user`]??[]);for(let e of c)o.includes(e)||T(`no login ${e} on ${n}`);if(t.user){/^[A-Za-z0-9._@-]{1,64}$/.test(t.user)||D(`user may only have letters, digits and . _ @ -`);let e=t.password??await ae(`Password for ${t.user}:`);e.length<8&&D(`use a password of at least 8 characters`),c.add(t.user),s=s.filter(e=>!c.has(e.slice(0,e.indexOf(`:`)))),s.push(await Hr(t.user,e))}else s=s.filter(e=>!c.has(e.slice(0,e.indexOf(`:`))));if(s.join(`
622
+ lf auth my-site show who can log in`,options:{server:F,user:{type:`string`,short:`u`,desc:`login to add or change`},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}=qr(e,t.server),{meta:i,proxy:a}=await bn(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(e=>e.slice(0,e.indexOf(`:`)));if(!t.user&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${x.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>x.bold(e)).join(`, `)}`);return}let s=t.off?[]:[...i.auth??[]],c=new Set(t[`rm-user`]??[]);for(let e of c)o.includes(e)||T(`no login ${e} on ${n}`);if(t.user){/^[A-Za-z0-9._@-]{1,64}$/.test(t.user)||D(`user may only have letters, digits and . _ @ -`);let e=t.password??await ae(`Password for ${t.user}:`);e.length<8&&D(`use a password of at least 8 characters`),c.add(t.user),s=s.filter(e=>!c.has(e.slice(0,e.indexOf(`:`)))),s.push(await Nr(t.user,e))}else s=s.filter(e=>!c.has(e.slice(0,e.indexOf(`:`))));if(s.join(`
647
623
  `)===(i.auth??[]).join(`
648
- `))return C(`nothing to change`);let l=s.map(e=>e.slice(0,e.indexOf(`:`))),u=o.filter(e=>!l.includes(e));l.length?u.length&&await A(`Remove the login${u.length>1?`s`:``} ${u.join(`, `)} from ${n}?`):await A(`Remove every login from ${n}? Anyone can open it then.`);let d=structuredClone(i);i.auth=s.length?s:void 0,i.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Wn(r,i,a,{keep:i.releases.length,prev:d,what:l.length?`login for ${n}: ${l.join(`, `)}`:`removed the login of ${n}`}),C(l.length?`${n} asks for a login (${l.join(`, `)})`:`${n} is public again`)}}),gi=P({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:F},async run([e],t){let{site:n,server:r}=$r(e,t.server),{meta:i}=await kn(r,n),a=tn(i)[0];a||D(`${n} serves no domain`);let o=`https://${a}`;console.log(x.cyan(o)),oe(o)}}),_i={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}},vi=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function yi(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(vi(new Date(n)));)n+=1e3;return vi(new Date(n))}const bi=P({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
624
+ `))return C(`nothing to change`);let l=s.map(e=>e.slice(0,e.indexOf(`:`))),u=o.filter(e=>!l.includes(e));l.length?u.length&&await A(`Remove the login${u.length>1?`s`:``} ${u.join(`, `)} from ${n}?`):await A(`Remove every login from ${n}? Anyone can open it then.`);let d=structuredClone(i);i.auth=s.length?s:void 0,i.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Fn(r,i,a,{keep:i.releases.length,prev:d,what:l.length?`login for ${n}: ${l.join(`, `)}`:`removed the login of ${n}`}),C(l.length?`${n} asks for a login (${l.join(`, `)})`:`${n} is public again`)}}),ui=P({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:F},async run([e],t){let{site:n,server:r}=qr(e,t.server),{meta:i}=await bn(r,n),a=Kt(i)[0];a||D(`${n} serves no domain`);let o=`https://${a}`;console.log(x.cyan(o)),oe(o)}}),di=async e=>{let t=``;for await(let n of e)t+=n;return t};let fi;function pi(){if(fi)return fi;let e=o.mkdtempSync(p(l(),`landfall-`));return fi=p(e,`askpass`),o.writeFileSync(fi,`#!/bin/sh
625
+ printf '%s\\n' "$LANDFALL_PASSWORD"
626
+ `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),fi}const mi=e=>({SSH_ASKPASS:pi(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function hi(e){if(e){let e=(await di(process.stdin)).replace(/\r?\n$/,``);return e||D(`--password-stdin: nothing on stdin`),e}return ue(`PASSWORD`)||void 0}const gi=e=>p(s(),`.ssh`,`landfall_${e}`);function _i(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&&D(`ssh-keygen failed`),{path:t,created:!0})}async function vi(e,t,n){let i=Q(t),a=t.key?R(t.key):void 0;if(a&&!o.existsSync(a)&&D(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&D(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await et(t);if(!s&&!t.key&&o.existsSync(gi(e))&&await et({...t,key:gi(e)})&&(t.key=a=gi(e),s=!0),s)C(`key login works`),a??=ct(t);else if(n.check){T("no key login yet — `lf setup` would install a key first");return}let c=!1;if(!a&&!n.check){let t=_i(e);a=t.path,c=t.created,c&&C(`generated key ${z(a)}`)}let l=t.key&&R(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await et(t))){S(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...mi(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...Ke(t),Ge(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await et(t)||D(`could not log in with the key — check host, user and password`),C(`key installed`)}return a}const yi=e=>{for(let t of e)console.log(x.dim(` `)+t)};function bi(e,t,n,r){let i=Q(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=ht({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});C(`${r.created?`created`:`updated`} ${z(ot)} with ${x.bold(`Host ${e}`)} — try \`ssh ${e}\``),yi(r.block.split(`
627
+ `)),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!==ct({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?_t(e,n):void 0;s?(C(`added the key to your ${x.bold(`Host ${e}`)} in ${z(s.file)} ${x.dim(`(backup: ${z(s.backup)})`)}`),yi([...s.removed.map(e=>x.red(`- ${e}`)),x.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=z(n)),r.ownAlias&&w(x.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const xi=P({name:`keygen`,args:`<name>`,summary:`Create an ed25519 key pair at ~/.ssh/landfall_<name> (skips if it exists) and print the public key.`,async run([e]){e||D(`usage: lf keygen <name>`);let{path:t,created:n}=_i(e);C(`${n?`created`:`key exists:`} ${z(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Si(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?T(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await ie(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):T(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Ci(e,t){S(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await re(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(w(x.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await j(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&(t.email=await re(`Let's Encrypt e-mail (optional)`,``)||void 0);let n=ye(e);t.dashboard??=await re(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function wi(e,t){let n=Object.fromEntries(Object.entries(Ti.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return Ti.run([e],{...n,...t},[])}const Ti=P({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
628
+
629
+ Turns a fresh box into a deploy target (safe to re-run):
630
+ 1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
631
+ 2. install Docker, firewall (ufw: ssh/80/443), fail2ban, unattended-upgrades
632
+ 3. create a deploy user with docker access, disable SSH password login
633
+ 4. write \`Host <name>\` to ~/.ssh/config so \`ssh <name>\` just works
634
+ 5. install Traefik with automatic Let's Encrypt certificates
635
+
636
+ Works on existing ssh aliases too: \`lf setup vps\`.
637
+
638
+ Before changing anything, setup inspects the server and only does what is
639
+ missing. Steps that would break services already running there are skipped:
640
+ - firewall, if enabling ufw would block public TCP/UDP ports in use
641
+ (host services, or container ports when Docker's NAT doesn't skip ufw)
642
+ - ssh hardening, if other users still log in with a password
643
+ - traefik, if ports 80/443 or the name are taken by something else;
644
+ a Traefik already running there is used instead (lf traefik use)
645
+ Use --check to only see that report, --force to apply firewall/hardening anyway.
646
+ In a terminal, a first Traefik install asks for image, host network, e-mail and
647
+ 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: ${ke})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},check:{type:`boolean`,desc:`only report what is set up and what setup would change`},force:{type:`boolean`,desc:`apply firewall/ssh hardening despite conflicts`},"key-only":{type:`boolean`,desc:`only install the ssh key, change nothing else`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin instead of asking (also: $LANDFALL_PASSWORD)`}},async run([e],t){e||D(`usage: lf setup <name> [--host <ip>]`);let n=B(),r=n.servers[e],i=lt().includes(e)&&!yt(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`]&&Q(a).user===t[`deploy-user`]&&await et({...a,user:`root`})&&(a.user=`root`);let s=Q(a);S(`setting up ${x.bold(e)} ${x.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>S(`[${++c}/5] ${e}`);l(`ssh key`);let u=await vi(e,a,{check:t.check,password:await hi(t[`password-stdin`])});if(!u||t[`key-only`])return;let d=u?o.readFileSync(`${u}.pub`,`utf8`).trim():``,f=t[`deploy-user`]===s.user?``:t[`deploy-user`];l(`checking what is already set up`);let p={PUBKEY:d,DEPLOY_USER:f},[m,h,ee]=await Promise.all([mr(a,p),ir(),ar()]);Object.assign(m.values,{docker_latest:h??``,compose_latest:ee??``});let g=Number(m.values.ssh_port)||s.port,_=Or(m,{sshPort:g,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),v=_.find(e=>e.part===`traefik`),y={email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],dashboard:t.dashboard,dashboardUser:`admin`},te=v&&!v.proxy&&v.status!==`conflict`&&v.status!==`off`;te&&(v.detail+=x.dim(` · ${Fr(Pr(r??a,y))}`));let b=Tt(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(w(`os: ${m.values.os??`unknown`}${m.values.pkg?x.dim(` · ${m.values.pkg}`):``}`),b.note&&(b.level===`ok`?w:T)(b.note),Mr(_),t.check||!u)return;let E=await Si(_,t.force),O=te&&v.status===`todo`&&ne(),k=t.domain?me(t.domain):r&&ye(r);if(k===void 0&&O){let e=await re(`main domain for sites, e.g. example.com (optional)`,``);k=e.trim()?me(e):void 0}O&&(await Ci({...r??a,domains:ve(r?.domains,k)},y),w(`traefik: ${Fr(Pr(r??a,y))}${y.dashboard?`, dashboard ${y.dashboard}`:``}`));let ie=_.filter(e=>e.status!==`done`&&E.has(e.part)).length;if(ie&&!await j(`apply the ${ie} change${ie===1?``:`s`} above?`))return;let A=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];_.some(e=>A.includes(e.part)&&e.status!==`done`&&E.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await X(a,J+Le,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:g,HARDEN:E.has(`harden`),FIREWALL:E.has(`firewall`)}}),C(`server bootstrapped`)):(l(`bootstrap`),C(`already bootstrapped, nothing to change`));let M={...a};if(f){let e={...a,user:f};await et(e)?(C(`deploy user ${x.bold(f)} ready`),M=e):T(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let N=bi(e,M,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});k&&(N.domains=ve(N.domains,k)),n.servers[e]=N,n.default??=e,pe(n),l(`traefik`),v?.proxy?E.has(`traefik`)&&v.status===`todo`?await Lr(N,v.proxy):C(v.detail):E.has(`traefik`)?await Ir(e,N,y):w(x.dim(`skipped`)),k?(console.log(`\n${x.green(`Done.`)} Point ${x.bold(k)} and ${x.bold(`*.${k}`)} (A records) at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${x.dim(`# https://blog.${k}`)}`),console.log(` lf server domain ${e} ${x.dim(`# check the DNS`)}\n`)):(console.log(`\n${x.green(`Done.`)} Point a domain's A record at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),Ei={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function Di(e,t){if(!t)return e;let n=t.slice(0,t.indexOf(`:`));return[...(e??[]).filter(e=>e.slice(0,e.indexOf(`:`))!==n),t]}const Oi=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function ki(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(Oi(new Date(n)));)n+=1e3;return Oi(new Date(n))}const Ai=P({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
649
648
 
650
649
  Kinds:
651
650
  compose the folder has a compose.yaml: its services run on the server as one
@@ -656,16 +655,19 @@ Kinds:
656
655
 
657
656
  Every site is a Compose project on the server (/opt/landfall/sites/<name>/current),
658
657
  so \`docker compose\` works there without landfall. Settings are saved to
659
- ./.landfall.json, so later deploys are just \`lf deploy\`.`,options:{server:F,name:{type:`string`,short:`n`,desc:`site name (default: folder name)`},domain:{type:`string`,short:`d`,multiple:!0,desc:"domain to serve (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},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}},async run([e],t){let n=process.cwd(),i=pt(n)??{};i.name&&w(x.dim(`using ${dt}`));let{name:a,server:s}=Z(t.server??i.server),c=En(t.name??i.name??yn(d(n))),l=it(t.keep,`--keep`,1),u=(t.domain?.length?t.domain:i.domains??[]).map(e=>st(e,s)).filter(e=>!et.test(e));u.length&&D(`not a domain: ${u.join(`, `)}`);let f=t.build??i.build;if(f){S(`building locally: ${f}`);let e=r(f,{shell:!0,stdio:`inherit`});e.status!==0&&D(`build failed (exit ${e.status})`)}let ee=t.static??i.static,te=t.image??(ee?void 0:i.image),g=te&&rt(te),_=h(n,e??i.dir??`.`),v=g||ee?void 0:t.file??bn(_,i);v&&!o.existsSync(p(_,v))&&D(`no ${v} in ${m(n,_)||`.`}`);let y=v||g?_:h(n,e??i.dir??_n(n)),b=v?`compose`:g?`image`:!ee&&o.existsSync(p(y,`Dockerfile`))?`dockerfile`:`static`;b===`static`&&!o.existsSync(p(y,`index.html`))&&T(`no index.html in ${m(n,y)||`.`}`);let E=_i[b].port?it(t.port??i.port??(b===`dockerfile`?vn(p(y,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,O=b===`static`&&!!(t.spa??i.spa);S(`deploying ${x.bold(c)} (${b}) to ${x.bold(a)}`);let k=await On(s,c),{env:ne,proxy:ie,exists:A}=k,j=k.meta&&Ut(k.meta)?k.meta:void 0,M=j?void 0:k.meta;M?.adopted&&D(`${c} is adopted (${M.adopted}): deploy it there with docker compose, or pick another --name`),j?T(`${c} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):M&&M.kind!==b&&w(x.dim(`site was ${M.kind}, now ${b}`));let N;(t.env?.length||t[`env-file`])&&(N=ne,t[`env-file`]&&(N=Yn(N,Xn(xi(o.readFileSync(t[`env-file`],`utf8`))))),t.env?.length&&(N=Yn(N,Xn(t.env))));let ae=yi((M?.releases??[]).map(e=>e.id)),oe=`${$(c)}/releases/${ae}`,P=b===`compose`?{}:{"compose.yaml":mn(b,{port:E,image:g}),...b===`static`&&{"nginx.conf":hn(O)}},F,I,se;try{let e=_i[b].files;e!==void 0&&(S(`uploading ${m(n,y)||`.`}`),await Te(s,y,oe+e));let r=await In(s,c,ae,{file:v,generated:P,env:N}),o=cn(r.config,r.dir,e=>st(e,s));for(let e of o.warnings)T(e);o.errors.length&&D(`${v??`compose.yaml`} cannot be deployed:\n${o.errors.map(e=>` - ${e}`).join(`
660
- `)}`),F={id:ae,at:(/* @__PURE__ */ new Date()).toISOString(),services:o.services};let l=o.services.flatMap(e=>e.domains??[]);I=[];let u=!1;if(Jt(F)){if(u=!t.domain?.length&&!!i.domains?.length,I=[t.domain,i.domains,M?.domains,j?.domains].find(e=>e?.length)??[],!I.length){let e=ot(s),t=e?void 0:Rt(await Lt(s));I=(await re(`Domain(s) to serve (comma separated):`,e?`${c}.${e}`:t?zt(c,t):void 0)).split(/[\s,]+/).filter(Boolean)}I=[...new Set(I.map(e=>st(e,s)))];let e=I.filter(e=>!et.test(e)),n=I.filter(e=>l.includes(e));I.length||D(`at least one --domain is required`),e.length&&D(`not a domain: ${e.join(`, `)}`),n.length&&D(`${n.join(`, `)} is already routed by x-landfall.domains`)}else t.domain?.length&&T(`-d ignored: every routed service of ${c} names its own x-landfall.domains`);se=await pi(s,a,c,[...I,...l],ie,u?`${dt} lists it; deploy with -d to move it`:void 0)}catch(e){throw await Rn(s,c,ae).catch(()=>{}),e}let ce=(/* @__PURE__ */ new Date()).toISOString(),le={v:2,name:c,kind:b,domains:I,auth:M?.auth??j?.auth,current:ae,releases:[...M?.releases??[],F],createdAt:M?.createdAt??j?.createdAt??ce,updatedAt:ce};await se(),await Wn(s,le,ie,{env:N,build:!0,pull:!0,keep:l,prev:M,takeover:j,existed:!M&&!j&&A,what:`deploy ${c} release ${ae}${M?``:` (new site, ${tn(le).join(`, `)})`}`});let ue=tn(le);if(t.save){let e={name:c,server:a,domains:I};if(b===`image`)Object.assign(e,{image:g,port:E});else{let r=m(n,y)||`.`;b===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=v)):r!==_n(n)&&(e.dir=r),b===`dockerfile`&&(e.port=E),O&&(e.spa=!0),ee&&b===`static`&&(e.static=!0)}f&&(e.build=f),mt(n,e),wn({path:n,name:c,server:a,domains:ue,kind:b})}C(`release ${x.bold(ae)} live`);for(let e of ue)console.log(` ${x.cyan(`https://${e}`)}`);ue[0]&&await Si(ue[0])}});function xi(e){return e.split(`
661
- `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function Si(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?C(`https://${e} responded ${t.status}`):T(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);T(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const Ci=P({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
658
+ ./.landfall.json, so later deploys are just \`lf deploy\`.
659
+
660
+ A server landfall has not set up yet is set up first (\`lf setup\`), asking
661
+ before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:F,name:{type:`string`,short:`n`,desc:`site name (default: folder name)`},domain:{type:`string`,short:`d`,multiple:!0,desc:"domain to serve (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},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)`},"auth-password":{type:`string`,desc:`with --auth-user: its password (asked when missing)`}},async run([e],t){let n=process.cwd(),i=Ee(n)??{};i.name&&w(x.dim(`using ${we}`));let{name:a,server:s}=V(t.server??i.server);t.host&&!t.server&&!i.server&&!B().default&&D(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await tt(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&&!(ne()&&await j(`${a} ${e}. Set it up now (lf setup)?`))&&D(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await wi(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=V(a),await tt(s)!==`ready`&&D(`${a} is still not set up`)}let l=_n(t.name??i.name??un(d(n))),u=_e(t.keep,`--keep`,1),f=(t.domain?.length?t.domain:i.domains??[]).map(e=>be(e,s)).filter(e=>!H.test(e));f.length&&D(`not a domain: ${f.join(`, `)}`);let ee;if(t[`auth-user`]){let e=t[`auth-user`];/^[A-Za-z0-9._@-]{1,64}$/.test(e)||D(`--auth-user may only have letters, digits and . _ @ -`);let n=t[`auth-password`]??await ae(`Password for ${e}:`);n.length<8&&D(`use a password of at least 8 characters`),ee=await Nr(e,n)}else t[`auth-password`]&&D(`--auth-password needs --auth-user`);let g=t.build??i.build;if(g){S(`building locally: ${g}`);let e=r(g,{shell:!0,stdio:`inherit`});e.status!==0&&D(`build failed (exit ${e.status})`)}let _=t.static??i.static,v=t.image??(_?void 0:i.image),y=v&&ge(v),te=h(n,e??i.dir??`.`),b=y||_?void 0:t.file??dn(te,i);b&&!o.existsSync(p(te,b))&&D(`no ${b} in ${m(n,te)||`.`}`);let E=b||y?te:h(n,e??i.dir??cn(n)),O=b?`compose`:y?`image`:!_&&o.existsSync(p(E,`Dockerfile`))?`dockerfile`:`static`;O===`static`&&!o.existsSync(p(E,`index.html`))&&T(`no index.html in ${m(n,E)||`.`}`);let k=Ei[O].port?_e(t.port??i.port??(O===`dockerfile`?ln(p(E,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,ie=O===`static`&&!!(t.spa??i.spa);S(`deploying ${x.bold(l)} (${O}) to ${x.bold(a)}`);let A=await yn(s,l),{env:M,proxy:N,exists:oe}=A,P=A.meta&&Pt(A.meta)?A.meta:void 0,F=P?void 0:A.meta;F?.adopted&&D(`${l} is adopted (${F.adopted}): deploy it there with docker compose, or pick another --name`),P?T(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):F&&F.kind!==O&&w(x.dim(`site was ${F.kind}, now ${O}`));let I;(t.env?.length||t[`env-file`])&&(I=M,t[`env-file`]&&(I=Bn(I,Vn(ji(o.readFileSync(t[`env-file`],`utf8`))))),t.env?.length&&(I=Bn(I,Vn(t.env))));let se=ki((F?.releases??[]).map(e=>e.id)),ce=`${$(l)}/releases/${se}`,le=O===`compose`?{}:{"compose.yaml":an(O,{port:k,image:y}),...O===`static`&&{"nginx.conf":on(ie)}},ue,L,de;try{let e=Ei[O].files;e!==void 0&&(S(`uploading ${m(n,E)||`.`}`),await at(s,E,ce+e));let r=await Dn(s,l,se,{file:b,generated:le,env:I}),o=Qt(r.config,r.dir,e=>be(e,s));for(let e of o.warnings)T(e);o.errors.length&&D(`${b??`compose.yaml`} cannot be deployed:\n${o.errors.map(e=>` - ${e}`).join(`
662
+ `)}`),ue={id:se,at:(/* @__PURE__ */ new Date()).toISOString(),services:o.services};let c=o.services.flatMap(e=>e.domains??[]);L=[];let u=!1;if(zt(ue)){u=!t.domain?.length&&!!i.domains?.length,L=[t.domain,i.domains,F?.domains,P?.domains].find(e=>e?.length)??[];let e=!L.length&&!!F&&!!Bt(F)&&!F.domains.length;if(!L.length&&!e){let e=ye(s),t=e?void 0:kt(await Ot(s));L=(await re(`Domain(s) to serve (comma separated):`,e?`${l}.${e}`:t?At(l,t):void 0)).split(/[\s,]+/).filter(Boolean)}L=[...new Set(L.map(e=>be(e,s)))];let n=L.filter(e=>!H.test(e)),r=L.filter(e=>c.includes(e));!L.length&&!e&&D(`at least one --domain is required`),e&&w(x.dim(`${l} serves no domain (lf domains ${l} --add <domain>, or deploy with -d)`)),n.length&&D(`not a domain: ${n.join(`, `)}`),r.length&&D(`${r.join(`, `)} is already routed by x-landfall.domains`)}else t.domain?.length&&T(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);de=await si(s,a,l,[...L,...c],N,u?`${we} lists it; deploy with -d to move it`:void 0)}catch(e){throw await kn(s,l,se).catch(()=>{}),e}let fe=(/* @__PURE__ */ new Date()).toISOString(),R={v:2,name:l,kind:O,domains:L,auth:Di(F?.auth??P?.auth,ee),current:se,releases:[...F?.releases??[],ue],createdAt:F?.createdAt??P?.createdAt??fe,updatedAt:fe};await de(),await Fn(s,R,N,{env:I,build:!0,pull:!0,keep:u,prev:F,takeover:P,existed:!F&&!P&&oe,what:`deploy ${l} release ${se}${F?``:` (new site, ${Kt(R).join(`, `)})`}`});let z=Kt(R);if(t.save){let e={name:l,server:a,domains:L};if(O===`image`)Object.assign(e,{image:y,port:k});else{let r=m(n,E)||`.`;O===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=b)):r!==cn(n)&&(e.dir=r),O===`dockerfile`&&(e.port=k),ie&&(e.spa=!0),_&&O===`static`&&(e.static=!0)}g&&(e.build=g),De(n,e),hn({path:n,name:l,server:a,domains:z,kind:O})}C(`release ${x.bold(se)} live`);for(let e of z)console.log(` ${x.cyan(`https://${e}`)}`);z[0]&&await Mi(z[0])}});function ji(e){return e.split(`
663
+ `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function Mi(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?C(`https://${e} responded ${t.status}`):T(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);T(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const Ni=P({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
662
664
 
663
665
  compose.yaml what runs on the server; x-landfall routes a service through Traefik
664
666
  compose.override.yaml local only (docker compose merges it, lf deploy leaves it out):
665
667
  publishes the port on localhost
666
668
 
667
669
  \`docker compose up\` then runs the site locally, \`lf deploy\` on a server. Existing
668
- 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()||D(`${e} is not a folder`);let r=xn(n);r.kind===`compose`&&D(`${r.compose} exists already: add x-landfall: { port: <port> } to the service Traefik should serve`);let i={"compose.yaml":``,"compose.override.yaml":``},a=Object.keys(i).filter(e=>o.existsSync(p(n,e)));a.length&&D(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:it(t.port??r.port??3e3,`--port`,1,65535),c=r.kind===`static`?8080:s,l=!!t.postgres,u;if(r.kind===`static`){let e=r.dir??`dist`;r.dir||w(x.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
670
+ 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()||D(`${e} is not a folder`);let r=fn(n);r.kind===`compose`&&D(`${r.compose} exists already: add x-landfall: { port: <port> } to the service Traefik should serve`);let i={"compose.yaml":``,"compose.override.yaml":``},a=Object.keys(i).filter(e=>o.existsSync(p(n,e)));a.length&&D(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:_e(t.port??r.port??3e3,`--port`,1,65535),c=r.kind===`static`?8080:s,l=!!t.postgres,u;if(r.kind===`static`){let e=r.dir??`dist`;r.dir||w(x.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
669
671
  image: nginx:alpine
670
672
  restart: unless-stopped
671
673
  volumes:
@@ -710,10 +712,25 @@ services:
710
712
  ${d}:
711
713
  ports:
712
714
  - "127.0.0.1:${c}:${s}"
713
- `;for(let[e,t]of Object.entries(i))o.writeFileSync(p(n,e),t);let f=m(process.cwd(),n)||`.`;C(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(p(n,`.env`))&&w(`locally: put POSTGRES_PASSWORD=… in ${p(f,`.env`)} (keep it out of git)`),w(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),w(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),wi=async e=>{let t=``;for await(let n of e)t+=n;return t};let Ti;function Ei(){if(Ti)return Ti;let e=o.mkdtempSync(p(l(),`landfall-`));return Ti=p(e,`askpass`),o.writeFileSync(Ti,`#!/bin/sh
714
- printf '%s\\n' "$LANDFALL_PASSWORD"
715
- `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Ti}const Di=e=>({SSH_ASKPASS:Ei(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Oi(e){if(e){let e=(await wi(process.stdin)).replace(/\r?\n$/,``);return e||D(`--password-stdin: nothing on stdin`),e}return Ye(`PASSWORD`)||void 0}const ki=e=>{let t=p(s(),`.ssh`,`landfall_${e}`),n=We(e);return o.existsSync(t)||!o.existsSync(n)?t:n};function Ai(e){let t=ki(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&&D(`ssh-keygen failed`),{path:t,created:!0})}async function ji(e,t,n){let i=Q(t),a=t.key?J(t.key):void 0;if(a&&!o.existsSync(a)&&D(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&D(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await z(t);if(!s&&!t.key&&o.existsSync(ki(e))&&await z({...t,key:ki(e)})&&(t.key=a=ki(e),s=!0),s)C(`key login works`),a??=_t(t);else if(n.check){T("no key login yet — `lf setup` would install a key first");return}let c=!1;if(!a&&!n.check){let t=Ai(e);a=t.path,c=t.created,c&&C(`generated key ${Y(a)}`)}let l=t.key&&J(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await z(t))){S(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Di(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...me(t),pe(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await z(t)||D(`could not log in with the key — check host, user and password`),C(`key installed`)}return a}const Mi=e=>{for(let t of e)console.log(x.dim(` `)+t)};function Ni(e,t,n,r){let i=Q(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=wt({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});C(`${r.created?`created`:`updated`} ${Y(ht)} with ${x.bold(`Host ${e}`)} — try \`ssh ${e}\``),Mi(r.block.split(`
716
- `)),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!==_t({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Et(e,n):void 0;s?(C(`added the key to your ${x.bold(`Host ${e}`)} in ${Y(s.file)} ${x.dim(`(backup: ${Y(s.backup)})`)}`),Mi([...s.removed.map(e=>x.red(`- ${e}`)),x.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=Y(n)),r.ownAlias&&w(x.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Pi=P({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
715
+ `;for(let[e,t]of Object.entries(i))o.writeFileSync(p(n,e),t);let f=m(process.cwd(),n)||`.`;C(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(p(n,`.env`))&&w(`locally: put POSTGRES_PASSWORD=… in ${p(f,`.env`)} (keep it out of git)`),w(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),w(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),Pi=e=>e.steps.filter(e=>e.undo&&!e.undone);function Fi(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
716
+ `).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 Ii(e){let{stdout:t}=await X(e,`cat ${q} 2>/dev/null || sudo -n cat ${q} 2>/dev/null || true`,{capture:!0});return Fi(t)}function Li(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||D(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||D(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function Ri(e,t){let n=Q(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await X(e,`sudo -n true 2>/dev/null && echo yes || true`,{capture:!0});if(r.trim()===`yes`)return{login:e,sudo:!0};let i={...e,user:`root`};return await et(i)?{login:i}:D(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const zi=String.raw`
717
+ undo_step() { # undo_step <line> <undo shell, base64>
718
+ set +e
719
+ ( set -e; eval "$(printf %s "$2" | base64 -d)" )
720
+ rc=$?
721
+ set -e
722
+ [ "$rc" = 0 ] || { echo "could not undo journal line $1 (exit $rc); later steps are undone, the rest is kept" >&2; exit 1; }
723
+ _journal undo "$1" ""
724
+ }`;async function Bi(e,t){let n=t.filter(e=>e.undo&&!e.undone).sort((e,t)=>t.line-e.line),r=[e];for(let t=0;t<n.length;){let i=t;for(;i<n.length&&n[i].user===n[t].user;)i++;let a=n.slice(t,i),o=a.map(e=>`undo_step ${e.line} ${Y(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await Ri(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(Xe)),await X(s,[J,zi,...o].join(`
725
+ `),{sudo:c}),r.push(s),t=i}return n.length}async function Vi(e){let t=e,n=await mr(t);if(n.values.sudo!==`1`){let r={...e,user:`root`};Q(e).user!==`root`&&await et(r)||D(`the firewall needs root or passwordless sudo; ${Q(e).user} has no sudo and root's key login does not work`),t=r,n=await mr(t)}let r=n.values,i=Number(r.ssh_port)||Q(e).port;return{login:t,ufw:r.ufw===`active`?`active`:r.ufw===`missing`?`missing`:`inactive`,policy:r.ufw_in||`DROP`,sshPort:i,rules:n.rules.map(yr),ports:Tr(n),blocked:wr(n,[i]).blocked,probe:n}}const Hi=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,Ui=/^[0-9a-fA-F.:]+(\/\d+)?$/;function Wi(e){Hi.test(e.port)||D(`not a port: ${e.port} (e.g. 5432, 51820/udp, 6000:6007/tcp)`);let[t,n]=e.port.split(`/`);t.split(/[,:]/).some(e=>Number(e)<1||Number(e)>65535)&&D(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&D(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!Ui.test(e.from)&&D(`not an address or network: ${e.from} (e.g. 203.0.113.7 or 10.0.0.0/8)`);let r=e.from?[e.action,`from`,e.from,`to`,`any`,`port`,t,...n?[`proto`,n]:[]]:[e.action,e.port];return e.comment?[...r,`comment`,e.comment]:r}const Gi=e=>`ufw_add ${e.map(Y).join(` `)}`,Ki=e=>e[0]===`deny`?`${Gi([`prepend`,...e])} 2>/dev/null || ${Gi(e)}`:Gi(e),qi=e=>`$SUDO ufw --force delete ${e.spec} >/dev/null
726
+ record ${Y(`ufw delete ${e.spec}`)} ${Y(`$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`)}`,Ji=e=>yr(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),Yi=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function Xi(e,t,n){if(e.ufw!==`active`)return;let r=br(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 ie(`${i}. Do it anyway?`)||D(`${i} (--force to do it anyway)`)}function Zi(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||D(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const Qi={server:F,from:{type:`string`,desc:`only from this address or network (e.g. 10.0.0.0/8)`},comment:{type:`string`,desc:`note stored with the rule`}};function $i(e){return P({name:`firewall ${e}`,args:`<port[/proto]>`,summary:e===`allow`?"Open a port in ufw, e.g. `lf firewall allow 5432 --from 10.0.0.0/8` or `lf firewall allow 51820/udp`.":"Block a port in ufw, e.g. `lf firewall deny 5432`. Goes before the other rules, so it wins over an allow.",options:{...Qi,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||D(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=Wi({action:e,port:t,from:n.from,comment:n.comment}),i=await Vi(V(n.server).server);e===`deny`&&await Xi(i,[Ji(r),...i.rules],n.force),await X(i.login,`${J}\n${Yi}\n${Ki(r)}`),C(`${r.join(` `)}${i.ufw===`active`?``:x.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const ea=$i(`allow`),ta=$i(`deny`),na=P({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:F,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=V(t.server),i=await Vi(r);if(t.json){let{ufw:e,policy:t,sshPort:n,rules:r,ports:a,blocked:o}=i;console.log(JSON.stringify({ufw:e,policy:t,sshPort:n,rules:r.map((e,t)=>({n:t+1,...e})),ports:a,blocked:o},null,2));return}let a=i.ufw===`active`?x.green(`on`):i.ufw===`missing`?x.yellow(`not installed`):x.yellow(`off`);S(`firewall on ${n}: ${a}${i.ufw===`active`?x.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${x.bold(`Rules`)}`),i.rules.length||w(x.dim(`none`)),M(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?x.green(e.action):x.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${x.bold(`Listening ports`)}`);let o={open:x.green,limited:x.yellow,blocked:x.red,local:x.dim};M(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?T(`enabling would block ${i.blocked.join(`, `)}: allow them first`):w(x.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),ra=P({name:`firewall enable`,summary:`Turn ufw on (installs it if missing). Always allows the ssh port first; refuses if it would block a port in use.`,options:{server:F,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=V(t.server),i=await Vi(r);if(i.ufw===`active`)return C(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await ie(`${e}. Turn it on anyway?`)||D(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await Xi({...i,ufw:`active`},[...i.rules,Ji([`allow`,`${i.sshPort}/tcp`])],t.force),await X(i.login,`${J}
727
+ command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
728
+ ${Gi([`allow`,`${i.sshPort}/tcp`])}
729
+ ufw_enable`),C(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),ia=P({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:F},async run(e,t){let{name:n,server:r}=V(t.server),i=await Vi(r);if(i.ufw!==`active`)return C(`firewall on ${n} is already off`);await j(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||D(`cancelled`),await X(i.login,`${J}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),C(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),aa=P({name:`firewall rm`,args:`<number|rule>`,summary:"Remove a rule by its number in `lf firewall`, or by its text (e.g. 'allow 5432').",options:{server:F,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||D(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=V(t.server),i=await Vi(r),{rule:a,n:o}=Zi(i,e);await Xi(i,i.rules.filter(e=>e!==a),t.force),await A(`Remove rule ${o} (${a.spec}) on ${n}?`),await X(i.login,`${J}\n${Yi}\n${qi(a)}`),C(`removed rule ${o}: ${a.spec}`)}}),oa=P({name:`firewall update`,args:`<number|rule>`,summary:`Change a rule: what you pass replaces that part, the rest stays. If ufw rejects the new rule, the old one is restored.`,options:{...Qi,port:{type:`string`,desc:`port[/proto], e.g. 5432 or 51820/udp`},from:{type:`string`,desc:`only from this address or network; 'any' for everyone`},action:{type:`string`,desc:`allow, deny or limit`},force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||D(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await Vi(V(t.server).server),{rule:r,n:i}=Zi(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||D(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||D(`rule ${i} (${r.spec}) names no port; pass --port`);let s=Wi({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 Xi(n,a===`deny`?[Ji(s),...c]:[...c,Ji(s)],t.force),await X(n.login,`${J}
730
+ ${Yi}
731
+ ${qi(r)}
732
+ ${Ki(s)} || { $SUDO ufw ${r.spec} >/dev/null; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),C(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}});function sa(e,t){let n=x.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${x.dim(`↩ `+e.what)}`:e.undone?`${n} ${x.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${x.cyan(`↺`)} ${e.what}`:`${n} ${x.dim(`· `+e.what+` (not reversible)`)}`}const ca=e=>e.trim().replace(/\\(.)/g,`$1`),la=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function ua(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await Vi(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(`
733
+ `)){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=yr(ca(e[1])),n=i.findIndex(e=>la(e,t));n>=0&&i.splice(n,1)}else n?i=n[1]?[yr(ca(n[2])),...i]:[...i,yr(ca(n[2]))]:/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await Xi({...r,ufw:a},i,n)}const da=e=>`${x.bold(`#${e.n}`.padEnd(5))} ${x.dim(N(e.time).padEnd(9))} ${x.bold(e.cmd||`?`)} ${x.dim(`${e.actor} · ${e.id}`)}`,fa=P({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:F,n:{type:`string`,short:`n`,desc:`runs to show`,default:`20`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=V(t.server),i=(await Ii(r)).reverse().slice(0,Number(t.n)||20);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return w(`nothing in ${q} on ${n} yet`);for(let e of i)console.log(da(e)),e.steps.forEach((e,t)=>console.log(sa(e,t)));let a=i.find(e=>Pi(e).length);a&&console.log(x.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),pa=P({name:`undo`,args:`[run|run.step]`,summary:"Revert what landfall did: the latest run, run <n> from `lf history`, one step <n>.<m>, or --all.",options:{server:F,all:{type:`boolean`,desc:`everything landfall did that can be reverted`},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){let{name:n,server:r}=V(t.server),i=await Ii(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let t=e?Li(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>Pi(e.run).length);if(!t)return C(`nothing to undo on ${n}`);({steps:a}=t),o=`#${t.run.n} ${t.run.cmd} (${N(t.run.time)})`;let r=i.filter(e=>e.n>t.run.n&&Pi(e).length);r.length&&T(`later runs build on it and stay: ${r.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return C(`nothing left to undo in ${o}`);S(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${x.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${x.dim(`· ${e.what} stays (not reversible)`)}`);await ua(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await A(l,n):await j(l,!1)||D(`cancelled`),await Bi(r,s),C(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&w(x.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),ma=P({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
717
734
 
718
735
  Without --host, <name> is used as an ssh alias from ~/.ssh/config,
719
736
  so \`lf server add vps\` works whenever \`ssh vps\` works.
@@ -721,12 +738,19 @@ so \`lf server add vps\` works whenever \`ssh vps\` works.
721
738
  With --host lf logs in with --key, or installs its own key
722
739
  (~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
723
740
  to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
724
- The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||D(`usage: lf server add <name> [--host ...]`);let n=t.domain&&tt(t.domain),r=X(),i=r.servers[e],a=vt().includes(e)&&!Ot(e);t.host&&a&&D(`${e} is already a Host in your ssh config; pick another name or drop --host`);let o={host:t.host??i?.host??e,user:t.user??(t.host?`root`:i?.user),port:t.port?Number(t.port):i?.port,key:t.key??i?.key},s=await Oi(t[`password-stdin`]),c=await ji(e,o,{check:!s&&!ne(),password:s}),l=c?Ni(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=at(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),$e(r),C(`added ${x.bold(e)}${r.default===e?x.dim(` (default)`):``}`),w(c?x.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${x.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),Fi=P({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`}},async run(e,t){let n=X(),r=[[x.dim(`NAME`),x.dim(`TARGET`),x.dim(`SOURCE`)]];for(let[e,t]of Object.entries(n.servers)){let i=`${t.user?t.user+`@`:``}${t.host}${t.port?`:`+t.port:``}`;r.push([e+(n.default===e?x.green(` *`):``),i,`landfall`])}if(t.all)for(let e of vt())n.servers[e]||r.push([e,e,Ot(e)?`ssh (landfall)`:`ssh`]);r.length===1?console.log(`no servers yet — ${x.bold(`lf server add <ssh-alias>`)} or ${x.bold(`lf setup <name> --host <ip>`)}`):(M(r),t.all||console.log(x.dim(`
725
- Any ~/.ssh/config alias works too (--all to list them).`)))}}),Ii=P({name:`server rm`,args:`<name>`,summary:`Forget a server (nothing is changed on the server itself).`,options:{"ssh-config":{type:`boolean`,desc:`also remove the landfall-managed Host block from ~/.ssh/config`}},async run([e],t){e||D(`usage: lf server rm <name>`);let n=X();!n.servers[e]&&!Ot(e)&&D(`unknown server "${e}"`),await A(`Forget server ${e}? Nothing on the server changes.`),delete n.servers[e],n.default===e&&delete n.default,$e(n),t[`ssh-config`]&&Dt(e)&&C(`removed Host ${e} from ~/.ssh/config`),C(`removed ${e}`)}}),Li=P({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||D(`usage: lf server use <name>`);let t=X();t.servers[e]??={host:e},t.default=e,$e(t),C(`default server: ${x.bold(e)}`)}}),Ri=P({name:`ssh`,args:`[name] [-- command...]`,summary:`Open a shell on a server, or run a command there.`,passthrough:!0,async run([e],t,n){let{server:r}=Z(e);return be(r,n)}}),zi=P({name:`server info`,args:`[name]`,summary:`Show OS, hardware, load, memory, disk, addresses and containers of a server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=Z(e),i=await Mt(r);if(t.json)return void console.log(JSON.stringify({name:n,...i,ips:await Lt(r,i)},null,2));let a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(x.bold(n)+x.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:x.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Ht(i.uptime)}${i.reboot?x.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${Vt(o)} / ${Vt(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${Vt(i.disk.used)} / ${Vt(i.disk.total)}${a(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`docker`,i.docker||`not installed or no access (run lf setup)`]];for(let[e,t]of s)console.log(`${x.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:T)(i.support.note),i.docker&&(console.log(),await R(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function Bi(e){let t=X(),n=t.servers[e],r=n??{host:e},i=Q(r),a=Tt(r.host),s=Ur(r,{});return{name:e,isDefault:t.default===e,configFile:Y(Qe),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],traefik:{...s,summary:Wr(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:Y(e),exists:o.existsSync(J(e))})),block:a?{file:Y(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
726
- `)}:null}}}const Vi=P({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
741
+ The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||D(`usage: lf server add <name> [--host ...]`);let n=t.domain&&me(t.domain),r=B(),i=r.servers[e],a=lt().includes(e)&&!yt(e);t.host&&a&&D(`${e} is already a Host in your ssh config; pick another name or drop --host`);let o={host:t.host??i?.host??e,user:t.user??(t.host?`root`:i?.user),port:t.port?Number(t.port):i?.port,key:t.key??i?.key},s=await hi(t[`password-stdin`]),c=await vi(e,o,{check:!s&&!ne(),password:s}),l=c?bi(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=ve(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),pe(r),C(`added ${x.bold(e)}${r.default===e?x.dim(` (default)`):``}`),w(c?x.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${x.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),ha=P({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`}},async run(e,t){let n=B(),r=[[x.dim(`NAME`),x.dim(`TARGET`),x.dim(`SOURCE`)]];for(let[e,t]of Object.entries(n.servers)){let i=`${t.user?t.user+`@`:``}${t.host}${t.port?`:`+t.port:``}`;r.push([e+(n.default===e?x.green(` *`):``),i,`landfall`])}if(t.all)for(let e of lt())n.servers[e]||r.push([e,e,yt(e)?`ssh (landfall)`:`ssh`]);r.length===1?console.log(`no servers yet — ${x.bold(`lf server add <ssh-alias>`)} or ${x.bold(`lf setup <name> --host <ip>`)}`):(M(r),t.all||console.log(x.dim(`
742
+ Any ~/.ssh/config alias works too (--all to list them).`)))}}),ga=P({name:`server rm`,args:`<name>`,summary:`Forget a server (nothing is changed on the server itself).`,options:{"ssh-config":{type:`boolean`,desc:`also remove the landfall-managed Host block from ~/.ssh/config`}},async run([e],t){e||D(`usage: lf server rm <name>`);let n=B();!n.servers[e]&&!yt(e)&&D(`unknown server "${e}"`),await A(`Forget server ${e}? Nothing on the server changes.`),delete n.servers[e],n.default===e&&delete n.default,pe(n),t[`ssh-config`]&&vt(e)&&C(`removed Host ${e} from ~/.ssh/config`),C(`removed ${e}`)}}),_a=/^(setup|traefik setup|traefik use)( |$)/,va=P({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${U} and what setup changed.
743
+
744
+ Deletes every site landfall deployed (named volumes stay unless --volumes) and forgets
745
+ adopted ones, which keep running. Stops landfall's Traefik (a Traefik of your own is left
746
+ alone), reverts what \`lf setup\` changed (deploy user, ufw, ssh hardening, docker network)
747
+ and deletes ${U}, its journal included. Then forgets the server locally.
748
+ Docker, packages, landfall's key for root and rules from \`lf firewall\` stay.`,options:{volumes:{type:`boolean`,desc:`also delete the sites' named volumes (their data)`},"keep-setup":{type:`boolean`,desc:`keep the deploy user, ufw and ssh hardening; only remove sites, traefik and `+U},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||D(`usage: lf server uninstall <name>`);let{name:n,server:r}=V(e),i=await Ri(r,`root`),[a,s,c]=await Promise.all([En(r),Rr(r),Ii(r)]);s.compose===`foreign`&&D(`${G}/docker-compose.yml was not written by landfall: move it out of ${U} first`);let l=t[`keep-setup`]?[]:c.filter(e=>_a.test(e.cmd)).flatMap(Pi).filter(e=>!e.undo.includes(U));S(`uninstall landfall from ${x.bold(n)}`);for(let e of a.sites){let n=e.meta.adopted?`forget ${e.meta.name} (keeps running from ${e.meta.adopted})`:`delete ${e.meta.name} (${e.domains.join(`, `)||`no domains`})`;console.error(` ${x.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${x.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${x.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${x.cyan(`↺`)} ${e.what}`);console.error(` ${x.red(`-`)} delete ${U}, its journal included`),console.error(` ${x.red(`-`)} forget ${n} locally`),console.error(` ${x.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await ua(r,l,t.force),await A(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[J];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${Y($(e.name))}`]:Rn(e,{volumes:t.volumes});u.push(`log ${Y(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${Y(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${G} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await X(r,u.join(`
749
+ `)),l.length&&(S(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await Bi(r,l)),await X(i.login,`rm -rf ${U}`,{sudo:i.sudo}),C(`landfall removed from ${n}`);let d=B();delete d.servers[n],d.default===n&&delete d.default,pe(d),vt(n)&&C(`removed Host ${n} from ~/.ssh/config`),C(`forgot ${n}`),o.existsSync(gi(n))&&w(x.dim(`${z(gi(n))} stays: root on ${n} still accepts it`))}}),ya=P({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||D(`usage: lf server use <name>`);let t=B();t.servers[e]??={host:e},t.default=e,pe(t),C(`default server: ${x.bold(e)}`)}}),ba=P({name:`ssh`,args:`[name] [-- command...]`,summary:`Open a shell on a server, or run a command there.`,passthrough:!0,async run([e],t,n){let{server:r}=V(e);return Qe(r,n)}}),xa=P({name:`server info`,args:`[name]`,summary:`Show OS, hardware, load, memory, disk, addresses and containers of a server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=V(e),i=await Ct(r);if(t.json)return void console.log(JSON.stringify({name:n,...i,ips:await Ot(r,i)},null,2));let a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(x.bold(n)+x.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:x.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Nt(i.uptime)}${i.reboot?x.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${Mt(o)} / ${Mt(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${Mt(i.disk.used)} / ${Mt(i.disk.total)}${a(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`docker`,i.docker||`not installed or no access (run lf setup)`]];for(let[e,t]of s)console.log(`${x.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:T)(i.support.note),i.docker&&(console.log(),await X(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function Sa(e){let t=B(),n=t.servers[e],r=n??{host:e},i=Q(r),a=gt(r.host),s=Pr(r,{});return{name:e,isDefault:t.default===e,configFile:z(fe),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],traefik:{...s,summary:Fr(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:z(e),exists:o.existsSync(R(e))})),block:a?{file:z(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
750
+ `)}:null}}}const Ca=P({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
727
751
  the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
728
- 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=Bi(n);if(t.json)return void console.log(JSON.stringify(r,null,2));let i=(e,t)=>console.log(` ${x.dim(e.padEnd(10))} ${t}`);if(console.log(`${x.bold(n)}${r.isDefault?x.dim(` (default)`):``}`),console.log(`\n${x.bold(`landfall`)} ${x.dim(r.configFile)}`),!r.saved)i(`entry`,x.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(r.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&i(e,String(t));r.domains.length||i(`domain`,x.dim(`none (lf server domain ${n} example.com)`)),r.domains.forEach((e,t)=>i(t?``:r.domains.length>1?`domains`:`domain`,t?e:`${e} ${x.dim(`(primary: new sites get <site>.${e})`)}`)),i(`traefik`,`${r.traefik.summary}${r.traefik.email?`, ${r.traefik.email}`:``} ${x.dim(`(when landfall installs it)`)}`),i(`dashboard`,r.traefik.dashboard?`https://${r.traefik.dashboard}`:x.dim(`off`)),console.log(`\n${x.bold(`ssh`)} ${x.dim(`ssh -G ${r.saved?.host??n}`)}`),i(`login`,`${r.ssh.user}@${r.ssh.hostname}:${r.ssh.port}`);for(let e of r.ssh.keys)i(`key`,e.exists?e.file:`${e.file} ${x.dim(`(missing)`)}`);if(r.ssh.block){console.log(`\n${x.bold(`ssh config`)} ${x.dim(`${r.ssh.block.file}:${r.ssh.block.line}${r.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of r.ssh.block.text.split(`
729
- `))console.log(` ${e}`)}else console.log(`\n${x.bold(`ssh config`)} ${x.dim(`no Host block; ssh uses its defaults`)}`)}});async function Hi(e,t){let n=await Lt(e),[r,i]=await Bt([t,`landfall-${_(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function Ui(e){let{stdout:t}=await R(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 Wi=async e=>ut(await Ui(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),Gi=P({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
752
+ 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}=V(e),r=Sa(n);if(t.json)return void console.log(JSON.stringify(r,null,2));let i=(e,t)=>console.log(` ${x.dim(e.padEnd(10))} ${t}`);if(console.log(`${x.bold(n)}${r.isDefault?x.dim(` (default)`):``}`),console.log(`\n${x.bold(`landfall`)} ${x.dim(r.configFile)}`),!r.saved)i(`entry`,x.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(r.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&i(e,String(t));r.domains.length||i(`domain`,x.dim(`none (lf server domain ${n} example.com)`)),r.domains.forEach((e,t)=>i(t?``:r.domains.length>1?`domains`:`domain`,t?e:`${e} ${x.dim(`(primary: new sites get <site>.${e})`)}`)),i(`traefik`,`${r.traefik.summary}${r.traefik.email?`, ${r.traefik.email}`:``} ${x.dim(`(when landfall installs it)`)}`),i(`dashboard`,r.traefik.dashboard?`https://${r.traefik.dashboard}`:x.dim(`off`)),console.log(`\n${x.bold(`ssh`)} ${x.dim(`ssh -G ${r.saved?.host??n}`)}`),i(`login`,`${r.ssh.user}@${r.ssh.hostname}:${r.ssh.port}`);for(let e of r.ssh.keys)i(`key`,e.exists?e.file:`${e.file} ${x.dim(`(missing)`)}`);if(r.ssh.block){console.log(`\n${x.bold(`ssh config`)} ${x.dim(`${r.ssh.block.file}:${r.ssh.block.line}${r.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of r.ssh.block.text.split(`
753
+ `))console.log(` ${e}`)}else console.log(`\n${x.bold(`ssh config`)} ${x.dim(`no Host block; ssh uses its defaults`)}`)}});async function wa(e,t){let n=await Ot(e),[r,i]=await jt([t,`landfall-${v(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function Ta(e){let{stdout:t}=await X(e,`docker ps -q | xargs -r docker inspect -f '{{range $k, $v := .Config.Labels}}{{println $v}}{{end}}' 2>/dev/null || true`,{capture:!0});return[...t.matchAll(/Host\(`([^`]+)`\)/g)].map(e=>e[1])}const Ea=async e=>Ce(await Ta(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),Da=P({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
730
754
 
731
755
  The first main domain is the primary: \`lf deploy\` serves a new site at
732
756
  <site>.<primary>, \`--domain blog\` means blog.<primary>, and
@@ -737,52 +761,14 @@ More main domains are listed and DNS-checked; use them with full names
737
761
 
738
762
  Domains given as arguments replace the list (the first becomes primary).
739
763
  Without any, it lists them and, if there are none, suggests the base domains
740
- 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=X(),r=e.length>0&&!(e=>!!n.servers[e]||vt().includes(e))(e[0])&&et.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(tt),i=t.clear?[]:a.length?a.map(tt):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(tt)])].filter(e=>!r.includes(e)),i=at(i,t.primary&&tt(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&T(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await A(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,$e(n),s.domains=e.domains,C(i.length?`main domains of ${x.bold(o)}: ${i.map((e,t)=>t?e:x.bold(e)).join(`, `)}${i.length>1?x.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let c=s.domains??[];if(!c.length){let e=await Wi(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:c,suggest:e}));w(`${o} has no main domain yet`);for(let t of e)w(`${t.hosts} routed hosts use ${x.bold(t.domain)} — ${x.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||w(`set one: ${x.bold(`lf server domain ${o} example.com`)}`);return}let l=await Promise.all(c.map(e=>Hi(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:c.map((e,t)=>({domain:e,primary:t===0,...l[t]}))},null,2));let u=Math.max(...c.map(e=>e.length))+2,d=[];c.forEach((e,t)=>{let n=l[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?x.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(x.dim(`
764
+ 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=B(),r=e.length>0&&!(e=>!!n.servers[e]||lt().includes(e))(e[0])&&H.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=V(i);if(a.length||t.add||t.rm||t.primary||t.clear){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(me),i=t.clear?[]:a.length?a.map(me):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(me)])].filter(e=>!r.includes(e)),i=ve(i,t.primary&&me(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&T(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await A(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,pe(n),s.domains=e.domains,C(i.length?`main domains of ${x.bold(o)}: ${i.map((e,t)=>t?e:x.bold(e)).join(`, `)}${i.length>1?x.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let c=s.domains??[];if(!c.length){let e=await Ea(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:c,suggest:e}));w(`${o} has no main domain yet`);for(let t of e)w(`${t.hosts} routed hosts use ${x.bold(t.domain)} — ${x.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||w(`set one: ${x.bold(`lf server domain ${o} example.com`)}`);return}let l=await Promise.all(c.map(e=>wa(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:c.map((e,t)=>({domain:e,primary:t===0,...l[t]}))},null,2));let u=Math.max(...c.map(e=>e.length))+2,d=[];c.forEach((e,t)=>{let n=l[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?x.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(x.dim(`
741
765
  add these DNS records at your DNS provider:
742
766
  `)+d.join(`
743
- `));let f=ot(s);w(x.dim(`sites: lf deploy -d blog → blog.${f} · dashboard: lf traefik setup --dashboard traefik`))}});async function Ki(e){let t=e,n=await Cr(t);if(n.values.sudo!==`1`){let r={...e,user:`root`};Q(e).user!==`root`&&await z(r)||D(`the firewall needs root or passwordless sudo; ${Q(e).user} has no sudo and root's key login does not work`),t=r,n=await Cr(t)}let r=n.values,i=Number(r.ssh_port)||Q(e).port;return{login:t,ufw:r.ufw===`active`?`active`:r.ufw===`missing`?`missing`:`inactive`,policy:r.ufw_in||`DROP`,sshPort:i,rules:n.rules.map(Or),ports:Pr(n),blocked:Nr(n,[i]).blocked,probe:n}}const qi=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,Ji=/^[0-9a-fA-F.:]+(\/\d+)?$/;function Yi(e){qi.test(e.port)||D(`not a port: ${e.port} (e.g. 5432, 51820/udp, 6000:6007/tcp)`);let[t,n]=e.port.split(`/`);t.split(/[,:]/).some(e=>Number(e)<1||Number(e)>65535)&&D(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&D(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!Ji.test(e.from)&&D(`not an address or network: ${e.from} (e.g. 203.0.113.7 or 10.0.0.0/8)`);let r=e.from?[e.action,`from`,e.from,`to`,`any`,`port`,t,...n?[`proto`,n]:[]]:[e.action,e.port];return e.comment?[...r,`comment`,e.comment]:r}const Xi=e=>`ufw_add ${e.map(L).join(` `)}`,Zi=e=>e[0]===`deny`?`${Xi([`prepend`,...e])} 2>/dev/null || ${Xi(e)}`:Xi(e),Qi=e=>`$SUDO ufw --force delete ${e.spec} >/dev/null
744
- record ${L(`ufw delete ${e.spec}`)} ${L(`$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`)}`,$i=e=>Or(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),ea=`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=kr(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 ie(`${i}. Do it anyway?`)||D(`${i} (--force to do it anyway)`)}function na(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||D(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const ra={server:F,from:{type:`string`,desc:`only from this address or network (e.g. 10.0.0.0/8)`},comment:{type:`string`,desc:`note stored with the rule`}};function ia(e){return P({name:`firewall ${e}`,args:`<port[/proto]>`,summary:e===`allow`?"Open a port in ufw, e.g. `lf firewall allow 5432 --from 10.0.0.0/8` or `lf firewall allow 51820/udp`.":"Block a port in ufw, e.g. `lf firewall deny 5432`. Goes before the other rules, so it wins over an allow.",options:{...ra,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||D(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=Yi({action:e,port:t,from:n.from,comment:n.comment}),i=await Ki(Z(n.server).server);e===`deny`&&await ta(i,[$i(r),...i.rules],n.force),await R(i.login,`${K}\n${ea}\n${Zi(r)}`),C(`${r.join(` `)}${i.ufw===`active`?``:x.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const aa=ia(`allow`),oa=ia(`deny`),sa=P({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:F,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=Z(t.server),i=await Ki(r);if(t.json){let{ufw:e,policy:t,sshPort:n,rules:r,ports:a,blocked:o}=i;console.log(JSON.stringify({ufw:e,policy:t,sshPort:n,rules:r.map((e,t)=>({n:t+1,...e})),ports:a,blocked:o},null,2));return}let a=i.ufw===`active`?x.green(`on`):i.ufw===`missing`?x.yellow(`not installed`):x.yellow(`off`);S(`firewall on ${n}: ${a}${i.ufw===`active`?x.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${x.bold(`Rules`)}`),i.rules.length||w(x.dim(`none`)),M(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?x.green(e.action):x.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${x.bold(`Listening ports`)}`);let o={open:x.green,limited:x.yellow,blocked:x.red,local:x.dim};M(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?T(`enabling would block ${i.blocked.join(`, `)}: allow them first`):w(x.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),ca=P({name:`firewall enable`,summary:`Turn ufw on (installs it if missing). Always allows the ssh port first; refuses if it would block a port in use.`,options:{server:F,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=Z(t.server),i=await Ki(r);if(i.ufw===`active`)return C(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await ie(`${e}. Turn it on anyway?`)||D(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await ta({...i,ufw:`active`},[...i.rules,$i([`allow`,`${i.sshPort}/tcp`])],t.force),await R(i.login,`${K}
745
- command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
746
- ${Xi([`allow`,`${i.sshPort}/tcp`])}
747
- ufw_enable`),C(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),la=P({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:F},async run(e,t){let{name:n,server:r}=Z(t.server),i=await Ki(r);if(i.ufw!==`active`)return C(`firewall on ${n} is already off`);await j(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||D(`cancelled`),await R(i.login,`${K}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),C(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),ua=P({name:`firewall rm`,args:`<number|rule>`,summary:"Remove a rule by its number in `lf firewall`, or by its text (e.g. 'allow 5432').",options:{server:F,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||D(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=Z(t.server),i=await Ki(r),{rule:a,n:o}=na(i,e);await ta(i,i.rules.filter(e=>e!==a),t.force),await A(`Remove rule ${o} (${a.spec}) on ${n}?`),await R(i.login,`${K}\n${ea}\n${Qi(a)}`),C(`removed rule ${o}: ${a.spec}`)}}),da=P({name:`firewall update`,args:`<number|rule>`,summary:`Change a rule: what you pass replaces that part, the rest stays. If ufw rejects the new rule, the old one is restored.`,options:{...ra,port:{type:`string`,desc:`port[/proto], e.g. 5432 or 51820/udp`},from:{type:`string`,desc:`only from this address or network; 'any' for everyone`},action:{type:`string`,desc:`allow, deny or limit`},force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||D(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await Ki(Z(t.server).server),{rule:r,n:i}=na(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||D(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||D(`rule ${i} (${r.spec}) names no port; pass --port`);let s=Yi({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`?[$i(s),...c]:[...c,$i(s)],t.force),await R(n.login,`${K}
748
- ${ea}
749
- ${Qi(r)}
750
- ${Zi(s)} || { $SUDO ufw ${r.spec} >/dev/null; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),C(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}}),fa=e=>e.steps.filter(e=>e.undo&&!e.undone);function pa(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
751
- `).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 if(u===Ke)for(let e of n.values())delete e.undo;else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}async function ma(e){let{stdout:t}=await R(e,`cat ${G} 2>/dev/null || sudo -n cat ${G} 2>/dev/null || true`,{capture:!0});return pa(t)}function ha(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||D(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||D(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function ga(e,t){let n=Q(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await R(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 z(i)?{login:i}:D(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const _a=String.raw`
752
- undo_step() { # undo_step <line> <undo shell, base64>
753
- set +e
754
- ( set -e; eval "$(printf %s "$2" | base64 -d)" )
755
- rc=$?
756
- set -e
757
- [ "$rc" = 0 ] || { echo "could not undo journal line $1 (exit $rc); later steps are undone, the rest is kept" >&2; exit 1; }
758
- _journal undo "$1" ""
759
- }`;async function va(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} ${L(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await ga(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(ve)),await R(s,[K,_a,...o].join(`
760
- `),{sudo:c}),r.push(s),t=i}return n.length}function ya(e,t){let n=x.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${x.dim(`↩ `+e.what)}`:e.undone?`${n} ${x.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${x.cyan(`↺`)} ${e.what}`:`${n} ${x.dim(`· `+e.what+` (not reversible)`)}`}const ba=e=>e.trim().replace(/\\(.)/g,`$1`),xa=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function Sa(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await Ki(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(`
761
- `)){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=Or(ba(e[1])),n=i.findIndex(e=>xa(e,t));n>=0&&i.splice(n,1)}else n?i=n[1]?[Or(ba(n[2])),...i]:[...i,Or(ba(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 Ca=e=>`${x.bold(`#${e.n}`.padEnd(5))} ${x.dim(N(e.time).padEnd(9))} ${x.bold(e.cmd||`?`)} ${x.dim(`${e.actor} · ${e.id}`)}`,wa=P({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:F,n:{type:`string`,short:`n`,desc:`runs to show`,default:`20`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=Z(t.server),i=(await ma(r)).reverse().slice(0,Number(t.n)||20);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return w(`nothing in ${G} on ${n} yet`);for(let e of i)console.log(Ca(e)),e.steps.forEach((e,t)=>console.log(ya(e,t)));let a=i.find(e=>fa(e).length);a&&console.log(x.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),Ta=P({name:`undo`,args:`[run|run.step]`,summary:"Revert what landfall did: the latest run, run <n> from `lf history`, one step <n>.<m>, or --all.",options:{server:F,all:{type:`boolean`,desc:`everything landfall did that can be reverted`},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){let{name:n,server:r}=Z(t.server),i=await ma(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let t=e?ha(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>fa(e.run).length);if(!t)return C(`nothing to undo on ${n}`);({steps:a}=t),o=`#${t.run.n} ${t.run.cmd} (${N(t.run.time)})`;let r=i.filter(e=>e.n>t.run.n&&fa(e).length);r.length&&T(`later runs build on it and stay: ${r.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return C(`nothing left to undo in ${o}`);S(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${x.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${x.dim(`· ${e.what} stays (not reversible)`)}`);await Sa(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await A(l,n):await j(l,!1)||D(`cancelled`),await va(r,s),C(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&w(x.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),Ea=P({name:`keygen`,args:`<name>`,summary:`Create an ed25519 key pair at ~/.ssh/landfall_<name> (skips if it exists) and print the public key.`,async run([e]){e||D(`usage: lf keygen <name>`);let{path:t,created:n}=Ai(e);C(`${n?`created`:`key exists:`} ${Y(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Da(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?T(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await ie(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):T(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Oa(e,t){S(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await re(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(w(x.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await j(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&(t.email=await re(`Let's Encrypt e-mail (optional)`,``)||void 0);let n=ot(e);t.dashboard??=await re(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}const ka=P({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
762
-
763
- Turns a fresh box into a deploy target (safe to re-run):
764
- 1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
765
- 2. install Docker, firewall (ufw: ssh/80/443), fail2ban, unattended-upgrades
766
- 3. create a deploy user with docker access, disable SSH password login
767
- 4. write \`Host <name>\` to ~/.ssh/config so \`ssh <name>\` just works
768
- 5. install Traefik with automatic Let's Encrypt certificates
769
-
770
- Works on existing ssh aliases too: \`lf setup vps\`.
771
-
772
- Before changing anything, setup inspects the server and only does what is
773
- missing. Steps that would break services already running there are skipped:
774
- - firewall, if enabling ufw would block public TCP/UDP ports in use
775
- (host services, or container ports when Docker's NAT doesn't skip ufw)
776
- - ssh hardening, if other users still log in with a password
777
- - traefik, if ports 80/443 or the name are taken by something else;
778
- a Traefik already running there is used instead (lf traefik use)
779
- Use --check to only see that report, --force to apply firewall/hardening anyway.
780
- In a terminal, a first Traefik install asks for image, host network, e-mail and
781
- 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: ${De})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},check:{type:`boolean`,desc:`only report what is set up and what setup would change`},force:{type:`boolean`,desc:`apply firewall/ssh hardening despite conflicts`},"key-only":{type:`boolean`,desc:`only install the ssh key, change nothing else`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin instead of asking (also: $LANDFALL_PASSWORD)`}},async run([e],t){e||D(`usage: lf setup <name> [--host <ip>]`);let n=X(),r=n.servers[e],i=vt().includes(e)&&!Ot(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`]&&Q(a).user===t[`deploy-user`]&&await z({...a,user:`root`})&&(a.user=`root`);let s=Q(a);S(`setting up ${x.bold(e)} ${x.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>S(`[${++c}/5] ${e}`);l(`ssh key`);let u=await ji(e,a,{check:t.check,password:await Oi(t[`password-stdin`])});if(!u||t[`key-only`])return;let d=u?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};if(!await Je(a,t))return;let[m,h,ee]=await Promise.all([Cr(a,p),pr(),mr()]);Object.assign(m.values,{docker_latest:h??``,compose_latest:ee??``});let te=Number(m.values.ssh_port)||s.port,g=Lr(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`],dashboard:t.dashboard,dashboardUser:`admin`},y=_&&!_.proxy&&_.status!==`conflict`&&_.status!==`off`;y&&(_.detail+=x.dim(` · ${Wr(Ur(r??a,v))}`));let b=Pt(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(w(`os: ${m.values.os??`unknown`}${m.values.pkg?x.dim(` · ${m.values.pkg}`):``}`),b.note&&(b.level===`ok`?w:T)(b.note),Vr(g),t.check||!u)return;let E=await Da(g,t.force),O=y&&_.status===`todo`&&ne(),k=t.domain?tt(t.domain):r&&ot(r);if(k===void 0&&O){let e=await re(`main domain for sites, e.g. example.com (optional)`,``);k=e.trim()?tt(e):void 0}O&&(await Oa({...r??a,domains:at(r?.domains,k)},v),w(`traefik: ${Wr(Ur(r??a,v))}${v.dashboard?`, dashboard ${v.dashboard}`:``}`));let ie=g.filter(e=>e.status!==`done`&&E.has(e.part)).length;if(ie&&!await j(`apply the ${ie} change${ie===1?``:`s`} above?`))return;let A=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];g.some(e=>A.includes(e.part)&&e.status!==`done`&&E.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await R(a,K+Ie,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:te,HARDEN:E.has(`harden`),FIREWALL:E.has(`firewall`)}}),C(`server bootstrapped`)):(l(`bootstrap`),C(`already bootstrapped, nothing to change`));let M={...a};if(f){let e={...a,user:f};await z(e)?(C(`deploy user ${x.bold(f)} ready`),M=e):T(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let N=Ni(e,M,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});k&&(N.domains=at(N.domains,k)),n.servers[e]=N,n.default??=e,$e(n),l(`traefik`),_?.proxy?E.has(`traefik`)&&_.status===`todo`?await Kr(N,_.proxy):C(_.detail):E.has(`traefik`)?await Gr(e,N,v):w(x.dim(`skipped`)),k?(console.log(`\n${x.green(`Done.`)} Point ${x.bold(k)} and ${x.bold(`*.${k}`)} (A records) at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${x.dim(`# https://blog.${k}`)}`),console.log(` lf server domain ${e} ${x.dim(`# check the DNS`)}\n`)):(console.log(`\n${x.green(`Done.`)} Point a domain's A record at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),Aa=P({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
782
- 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-Cj4TXzYj.mjs`);return n({port:Number(t.port)||4777,open:t.open})}}),ja={current:x.green,update:x.yellow,unknown:x.dim,pinned:x.dim,rebuild:x.dim,foreign:x.dim,missing:x.red};function Ma(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return w(`packages: no supported package manager`);let r=e.updates.length?`${x.bold(String(e.updates.length))} package updates${n?`, ${x.yellow(`${n} security`)}`:``}`:x.green(`packages up to date`);console.log(`${r}${e.reboot?x.yellow(` · reboot required`):``}${e.refreshed?``:x.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);M(i.map(e=>[` `+e.name,x.dim(e.from??``),`→ `+e.to,e.security?x.yellow(`security`):``])),i.length<e.updates.length&&console.log(x.dim(` … ${e.updates.length-i.length} more (--all)`))}function Na(e){e.length&&(console.log(`\n${x.bold(`Images`)}`),M(e.map(e=>[` `+e.name,x.dim(e.ref||`-`),ja[e.state](e.state===`update`?`newer image`:e.state),x.dim(e.note??``)])))}function Pa(e){let t=gr(e);console.log(`\n${x.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||x.red(`missing`)}`:x.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+x.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(x.dim(` newest releases`))}const Fa=P({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:F,refresh:{type:`boolean`,desc:`refresh package lists first (apt update; --no-refresh to skip)`,default:!0},all:{type:`boolean`,desc:`list every package`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{server:n}=Z(t.server),r=await er(n,t.refresh),i=await yr(n),a=await hr(n);if(t.json)return void console.log(JSON.stringify({packages:r,images:i,engine:{...a,...gr(a)}},null,2));Ma(r,t.all),Pa(a),Na(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(x.dim(`\napply with: lf update${o}`)),gr(a).composeUpdate&&console.log(x.dim(`newest compose: lf update --compose${o}`))}}),Ia=P({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
767
+ `));let f=ye(s);w(x.dim(`sites: lf deploy -d blog → blog.${f} · dashboard: lf traefik setup --dashboard traefik`))}}),Oa=P({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
768
+ 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-BrAinE0R.mjs`);return n({port:Number(t.port)||4777,open:t.open})}}),ka={current:x.green,update:x.yellow,unknown:x.dim,pinned:x.dim,rebuild:x.dim,foreign:x.dim,missing:x.red};function Aa(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return w(`packages: no supported package manager`);let r=e.updates.length?`${x.bold(String(e.updates.length))} package updates${n?`, ${x.yellow(`${n} security`)}`:``}`:x.green(`packages up to date`);console.log(`${r}${e.reboot?x.yellow(` · reboot required`):``}${e.refreshed?``:x.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);M(i.map(e=>[` `+e.name,x.dim(e.from??``),`→ `+e.to,e.security?x.yellow(`security`):``])),i.length<e.updates.length&&console.log(x.dim(` … ${e.updates.length-i.length} more (--all)`))}function ja(e){e.length&&(console.log(`\n${x.bold(`Images`)}`),M(e.map(e=>[` `+e.name,x.dim(e.ref||`-`),ka[e.state](e.state===`update`?`newer image`:e.state),x.dim(e.note??``)])))}function Ma(e){let t=sr(e);console.log(`\n${x.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||x.red(`missing`)}`:x.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+x.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(x.dim(` newest releases`))}const Na=P({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:F,refresh:{type:`boolean`,desc:`refresh package lists first (apt update; --no-refresh to skip)`,default:!0},all:{type:`boolean`,desc:`list every package`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{server:n}=V(t.server),r=await Gn(n,t.refresh),i=await ur(n),a=await or(n);if(t.json)return void console.log(JSON.stringify({packages:r,images:i,engine:{...a,...sr(a)}},null,2));Aa(r,t.all),Ma(a),ja(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(x.dim(`\napply with: lf update${o}`)),sr(a).composeUpdate&&console.log(x.dim(`newest compose: lf update --compose${o}`))}}),Pa=P({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
783
769
 
784
770
  Without --packages/--images both are updated. Package upgrades cannot be undone;
785
771
  image updates can (\`lf undo\` puts the old image back). --compose replaces the
786
- compose plugin setup installed with the newest release (undoable too).`,options:{server:F,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf)`},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)&&D(`--compose goes alone`);let i=await Zn(r,`updating docker compose`),a=await hr(i),o=gr(a);if(!o.composeUpdate)return void(o.composeNote?w(o.composeNote):C(`compose ${a.compose} is the newest`));await j(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(S(`downloading docker compose`),await _r(i),C(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images;e.length&&t.packages&&D(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await Zn(r,`upgrading packages`),i=await er(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?T(`no supported package manager, skipping packages`):a.length?(Ma({...i,updates:a},!1),t.security&&i.mgr===`apk`&&D(`apk has no security-only upgrades; drop --security`),await j(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(S(`upgrading packages on ${n}`),await nr(e,{only:t.security&&i.mgr===`apt-get`?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),C(`packages upgraded`),t.reboot&&w(`rebooting if required; give it a minute`))):C(t.security?`no security updates`:`packages up to date`)}if(o){let t=await yr(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&D(`no site or service ${r.join(`, `)} on ${n}`)}let i=e.length?t.filter(t=>e.includes(t.name)):t;for(let e of i.filter(e=>e.state!==`update`&&e.state!==`current`&&e.state!==`unknown`))w(x.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(Na(a),await j(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(S(`updating images`),await br(r,a),C(`images updated (lf undo puts the old ones back)`))):C(`images up to date`)}}});var La=`0.3.2`;const Ra=[ka,Ci,bi,ti,ni,ri,di,ci,ai,oi,si,ui,li,mi,hi,gi,Fa,Ia,Pi,Fi,zi,Vi,Gi,Li,Ii,Ri,Jr,Yr,Xr,Zr,Qr,sa,ca,la,aa,oa,da,ua,wa,Ta,Ea,Aa],za={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`,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`,upgrade:`update`},Ba=new Map(Ra.map(e=>[e.name,e]));function Va(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=Ba.get(za[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function Ha(){let e=[[`Servers`,[`setup`,`server add`,`server list`,`server info`,`server config`,`server domain`,`server use`,`server rm`,`ssh`,`keygen`]],[`Traefik`,[`traefik setup`,`traefik status`,`traefik logs`,`traefik restart`]],[`Firewall`,[`firewall status`,`firewall enable`,`firewall disable`,`firewall allow`,`firewall deny`,`firewall update`,`firewall rm`]],[`Sites`,[`deploy`,`sites list`,`sites info`,`domains`,`auth`,`open`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`Dashboard`,[`ui`]]],t=[`${x.bold(`landfall`)} ${x.dim(La)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${x.bold(`Usage:`)} lf <command> [options] ${x.dim(`lf <command> --help for details`)}`,` lf ui ${x.dim(`web dashboard in your browser`)}`,` lf tui ${x.dim(`the dashboard in the terminal`)}`];for(let[n,r]of e){t.push(``,x.bold(n));for(let e of r){let n=Ba.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
772
+ compose plugin setup installed with the newest release (undoable too).`,options:{server:F,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf)`},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}=V(t.server);if(t.compose){(e.length||t.packages||t.images)&&D(`--compose goes alone`);let i=await Hn(r,`updating docker compose`),a=await or(i),o=sr(a);if(!o.composeUpdate)return void(o.composeNote?w(o.composeNote):C(`compose ${a.compose} is the newest`));await j(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(S(`downloading docker compose`),await cr(i),C(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images;e.length&&t.packages&&D(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await Hn(r,`upgrading packages`),i=await Gn(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?T(`no supported package manager, skipping packages`):a.length?(Aa({...i,updates:a},!1),t.security&&i.mgr===`apk`&&D(`apk has no security-only upgrades; drop --security`),await j(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(S(`upgrading packages on ${n}`),await qn(e,{only:t.security&&i.mgr===`apt-get`?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),C(`packages upgraded`),t.reboot&&w(`rebooting if required; give it a minute`))):C(t.security?`no security updates`:`packages up to date`)}if(o){let t=await ur(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&D(`no site or service ${r.join(`, `)} on ${n}`)}let i=e.length?t.filter(t=>e.includes(t.name)):t;for(let e of i.filter(e=>e.state!==`update`&&e.state!==`current`&&e.state!==`unknown`))w(x.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(ja(a),await j(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(S(`updating images`),await dr(r,a),C(`images updated (lf undo puts the old ones back)`))):C(`images up to date`)}}});var Fa=`0.4.0`;const Ia=[Ti,Ni,Ai,Yr,Xr,Zr,ai,ni,$r,ei,ti,ii,ri,ci,li,ui,Na,Pa,ma,ha,xa,Ca,Da,ya,ga,va,ba,Vr,Hr,Wr,Gr,Kr,Ur,na,ra,ia,ea,ta,oa,aa,fa,pa,xi,Oa],La={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`,upgrade:`update`},Ra=new Map(Ia.map(e=>[e.name,e]));function za(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=Ra.get(La[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function Ba(){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 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`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`Dashboard`,[`ui`]]],t=[`${x.bold(`landfall`)} ${x.dim(Fa)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${x.bold(`Usage:`)} lf <command> [options] ${x.dim(`lf <command> --help for details`)}`,` lf ui ${x.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,x.bold(n));for(let e of r){let n=Ra.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
787
773
  `)[0]}`)}}t.push(``,x.bold(`Quick start`),` lf setup vps --check ${x.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${x.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${x.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${x.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${x.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${x.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${x.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${x.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${x.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${x.dim(`# what landfall changed, and revert the last of it`)}`,``,x.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
788
- `))}async function Ua(e){if(e[0]===`tui`&&e.length===1){process.stdin.isTTY&&process.stdout.isTTY||D("the terminal dashboard needs a terminal; `lf ui` opens the web one"),process.env.NODE_ENV??=`production`;let{dashboard:e}=await import(`./tui-0-Gcinq3.mjs`);return e(Wa)}if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return Ha(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(La),0;let{cmd:t,rest:n}=Va(e);if(!t)return console.error(`${x.red(`error:`)} unknown command "${e.join(` `)}"\n`),Ha(),2;let{values:r,positionals:i,rest:a}=se(t,n);if(r.help)return console.log(ce(t)),0;k(!!r.yes),xe.cmd=le(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function Wa(e){try{return await Ua(e)}catch(e){return e instanceof E?console.error(`${x.red(`error:`)} ${e.message}`):console.error(e),1}}Wa(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{J as A,U as B,Rt as C,Qe as D,vt as E,De as F,N as H,G as I,W as L,pt as M,Z as N,et as O,$e as P,V as R,Mt as S,Q as T,x as U,de as V,oe as W,xn as _,Hi as a,$t as b,Di as c,hr as d,yr as f,$ as g,Fn as h,Ki as i,X as j,Ze as k,fi as l,An as m,fa as n,Bi as o,er as p,ma as r,Wi as s,La as t,gr as u,Cn as v,Lt as w,Bt as x,en as y,H as z};
774
+ `))}async function Va(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return Ba(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(Fa),0;let{cmd:t,rest:n}=za(e);if(!t)return console.error(`${x.red(`error:`)} unknown command "${e.join(` `)}"\n`),Ba(),2;let{values:r,positionals:i,rest:a}=se(t,n);if(r.help)return console.log(ce(t)),0;k(!!r.yes),$e.cmd=le(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function Ha(e){try{return await Va(e)}catch(e){return e instanceof E?console.error(`${x.red(`error:`)} ${e.message}`):console.error(e),1}}Ha(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{fe as A,Ue as C,U as D,K as E,Ee as F,V as I,pe as L,de as M,R as N,W as O,B as P,x as R,lt as S,q as T,jt as _,Vi as a,Ot as b,mi as c,or as d,ur as f,mn as g,fn as h,Ea as i,H as j,G as k,oi as l,En as m,wa as n,Pi as o,Gn as p,Sa as r,Ii as s,Fa as t,sr as u,Ct as v,ke as w,Q as x,kt as y,oe as z};