landfall 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import{homedir as s,hostname as c,tmpdir as l,userInfo as u}from"node:os";import{basename as d,dirname as f,join as p,relative as m,resolve as ee}from"node:path";import{promises as te}from"node:dns";import{isIP as h}from"node:net";import{randomBytes as g}from"node:crypto";import _ from"bcryptjs";const v=process.stdout.isTTY&&!process.env.NO_COLOR,y=e=>t=>v?`\x1b[${e}m${t}\x1b[0m`:t,b={bold:y(`1`),dim:y(`2`),red:y(`31`),green:y(`32`),yellow:y(`33`),blue:y(`34`),cyan:y(`36`)},x=e=>console.error(`${b.cyan(`==>`)} ${b.bold(e)}`),S=e=>console.error(`${b.green(`✓`)} ${e}`),C=e=>console.error(` ${e}`),w=e=>console.error(`${b.yellow(`!`)} ${e}`);var T=class extends Error{};const E=e=>{throw new T(e)};let D=!1;const O=e=>D=e,ne=()=>!D&&!!process.stdin.isTTY;async function re(e,t){if(D||!process.stdin.isTTY){if(t!==void 0)return t;E(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${b.blue(`?`)} ${e}${t?b.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function k(e){return D||!process.stdin.isTTY?!1:j(e,!1)}async function A(e,t){D||(process.stdin.isTTY||E(`${e} Pass -y to confirm (no terminal to ask).`),t?await re(`${e} Type ${b.bold(t)} to confirm:`)!==t&&E(`cancelled`):await j(e,!1)||E(`cancelled`))}async function j(e,t=!0){return D?!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 P(e){(D||!process.stdin.isTTY)&&E(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${b.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
3
- `)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new T(`cancelled`));o===``||o===`\b`?n=n.slice(0,-1):n+=o}};t.on(`data`,i)})}finally{t.setRawMode(!1),t.pause(),process.stderr.write(`
4
- `)}}function ie(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const F=e=>e,I={type:`string`,short:`s`,desc:`server name or ssh alias`},ae={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}};function oe(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({...ae,...t.options}))o[e]=r;try{let{values:t,positionals:n}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});return{values:t,positionals:n,rest:a}}catch(e){return E(`${e.message}\n\n${se(t)}`)}}function se(e){let t=[`${b.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,b.bold(`Options:`));let n=Object.entries({...e.options,yes:ae.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?b.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?b.dim(` (repeatable)`):``)+r]}),r=Math.max(...n.map(e=>e[0].length));for(let[e,i]of n)t.push(` ${e.padEnd(r)} ${i}`);return t.join(`
5
- `)}function ce(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 le=!1;const ue=e=>le=e,L=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,de=(e=Xe(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],fe=e=>e.user?`${e.user}@${e.host}`:e.host;function pe(e){let t=[...de(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,Y(e.key),`-o`,`IdentitiesOnly=yes`),t}let me;function he(){return me||(me=p(process.platform===`darwin`?`/tmp`:l(),`landfall-${u().uid}`),o.mkdirSync(me,{recursive:!0,mode:448})),me}function ge(e,t={}){let n=[...pe(e),`-o`,`ServerAliveInterval=15`];return(t.batch||le)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${he()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,fe(e)),n}function _e(e){return new Promise(t=>{let r=[...pe(e),`-o`,`ControlPath=${he()}/%C`,`-O`,`exit`,fe(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function ve(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`,le?`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 ye(e,t=[]){let{code:n}=await ve(`ssh`,[...ge(e,{tty:process.stdin.isTTY}),...t],{});return n}const be={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:be.run,LANDFALL_ACTOR:be.actor,LANDFALL_CMD:be.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 ve(`ssh`,[...ge(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${i}\n${n.oldLayout?``:Ke}${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
7
- `).at(-1);E(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function z(e){let{code:t}=await ve(`ssh`,[...ge(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}const xe=()=>[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,We,`.env`,`.env.*`];let Se;async function Ce(){if(!Se){let{stdout:e}=await ve(`tar`,[`--version`],{capture:!0});Se=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return Se}async function we(e,t,r,i=xe()){o.existsSync(t)||E(`directory not found: ${t}`);let a=[...await Ce(),`-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`,[...ge(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`,Te=`${U}/dynamic`,Ee=`traefik:v3`,W=`${V}/proxy.env`,G=`${V}/journal.log`,De=`/etc/ssh/sshd_config.d/00-landfall.conf`,Oe={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},ke=`"$(sed -n 's/^container=//p' ${W} 2>/dev/null | grep . || echo traefik)"`;function Ae(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 je=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`
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 b=process.stdout.isTTY&&!process.env.NO_COLOR,x=e=>t=>b?`\x1b[${e}m${t}\x1b[0m`:t,S={bold:x(`1`),dim:x(`2`),red:x(`31`),green:x(`32`),yellow:x(`33`),blue:x(`34`),cyan:x(`36`)},C=e=>console.error(`${S.cyan(`==>`)} ${S.bold(e)}`),w=e=>console.error(`${S.green(`✓`)} ${e}`),T=e=>console.error(` ${e}`),E=e=>console.error(`${S.yellow(`!`)} ${e}`);var D=class extends Error{};const O=e=>{throw new D(e)};let k=!1;const A=e=>k=e,te=()=>!k&&!!process.stdin.isTTY;async function ne(e,t){if(k||!process.stdin.isTTY){if(t!==void 0)return t;O(`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(`${S.blue(`?`)} ${e}${t?S.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function re(e){return k||!process.stdin.isTTY?!1:M(e,!1)}async function j(e,t){k||(process.stdin.isTTY||O(`${e} Pass -y to confirm (no terminal to ask).`),t?await ne(`${e} Type ${S.bold(t)} to confirm:`)!==t&&O(`cancelled`):await M(e,!1)||O(`cancelled`))}async function M(e,t=!0){return k?!0:process.stdin.isTTY?(await ne(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`):t}function N(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 P=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 ie(e){(k||!process.stdin.isTTY)&&O(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${S.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
3
+ `)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new D(`cancelled`));o===``||o===`\b`?n=n.slice(0,-1):n+=o}};t.on(`data`,i)})}finally{t.setRawMode(!1),t.pause(),process.stderr.write(`
4
+ `)}}function ae(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const F=e=>e,I={type:`string`,short:`s`,desc:`server name or ssh alias`},oe={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({...oe,...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 O(`${e.message}\n\n${ce(t)}`)}}function ce(e){let t=[`${S.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,S.bold(`Options:`));let n=Object.entries({...e.options,yes:oe.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?S.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?S.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);O(`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)||O(`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`
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
  # The distro's package manager, and installing with it.
@@ -112,10 +112,10 @@ ufw_enable() {
112
112
  *inactive*) $SUDO ufw --force enable >/dev/null; record "enabled ufw" '$SUDO ufw disable >/dev/null' ;;
113
113
  esac
114
114
  }
115
- `,Me=`/usr/local/lib/docker/cli-plugins/docker-compose`,Ne=String.raw`
115
+ `,Ne=`/usr/local/lib/docker/cli-plugins/docker-compose`,Pe=String.raw`
116
116
  compose_install() {
117
117
  arch=$(uname -m); case "$arch" in armv7l) arch=armv7 ;; armv6l) arch=armv6 ;; esac
118
- f=${Me}
118
+ f=${Ne}
119
119
  $SUDO mkdir -p "$(dirname $f)" || return 1
120
120
  $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; }
121
121
  $SUDO chmod 755 $f.new
@@ -123,8 +123,27 @@ compose_install() {
123
123
  $SUDO mv $f.new $f
124
124
  record "installed docker compose $(docker compose version --short 2>/dev/null) to $f" "$undo"
125
125
  }
126
- `,Pe=String.raw`
127
- ${Ne}
126
+ `,Fe=`This server is run by landfall (https://www.npmjs.com/package/landfall).
127
+ Everything here works without it: it is plain Docker Compose.
128
+
129
+ ${U}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
130
+ ${H}/<site>/ one Compose project per site
131
+ .env the site's environment (lf env), mode 600
132
+ site.json landfall's notes: domains, releases
133
+ current -> releases/<id> the live release
134
+ releases/<id>/compose.yaml the site's compose file as deployed
135
+ releases/<id>/compose.override.yaml landfall's: Traefik labels, proxy network, project name
136
+ ${G} every change landfall made, with the shell that undoes it
137
+
138
+ Run a site by hand:
139
+ cd ${H}/<site>/current && docker compose ps (logs, restart, up -d, down …)
140
+ Roll back by hand:
141
+ cd ${H}/<site> && ln -sfn releases/<id> current && cd -P current && docker compose up -d
142
+
143
+ Named volumes (databases, uploads) belong to the project, not the release: they survive
144
+ deploys and rollbacks. "lf sites rm" keeps them; "lf sites rm --volumes" deletes them.
145
+ `,Ie=String.raw`
146
+ ${Pe}
128
147
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then
129
148
  echo "setup needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
130
149
  exit 1
@@ -151,10 +170,10 @@ if ! command -v docker >/dev/null 2>&1; then
151
170
  record "installed docker"
152
171
  fi
153
172
  $SUDO systemctl enable --now docker >/dev/null 2>&1 || $SUDO service docker start >/dev/null 2>&1 || true
154
- # Every site needs compose v2+. Distro docker packages (docker.io, moby) often lack it or ship v1:
155
- # Docker's own repo has the newest plugin, everyone else gets the official release binary.
173
+ # Sites need compose 2.24+ (optional env_file, up --wait). Distro docker packages (docker.io, moby)
174
+ # often lack it or ship older: Docker's own repo has the newest plugin, everyone else gets the release binary.
156
175
  v=$(docker compose version --short 2>/dev/null | sed 's/^v//' || true)
157
- case "$v" in ""|0.*|1.*)
176
+ case "$v" in ""|0.*|1.*|2.[0-9].*|2.1[0-9].*|2.2[0-3].*)
158
177
  log "installing the newest docker compose"
159
178
  if pkg_has docker-ce && pkg_install docker-compose-plugin; then record "installed docker-compose-plugin"
160
179
  else compose_install || log "could not install docker compose; install it, then re-run setup"; fi ;;
@@ -193,6 +212,8 @@ new_sites=$([ -d ${H} ] || echo 1)
193
212
  new_traefik=$([ -d ${U} ] || echo 1)
194
213
  $SUDO install -d -o "$OWNER" -g "$OWNER" ${V} ${H} ${U}
195
214
  $SUDO chown "$OWNER:$OWNER" ${G} 2>/dev/null || true
215
+ $SUDO tee ${V}/README >/dev/null <<'LANDFALL_README'
216
+ ${Fe}LANDFALL_README
196
217
  # ${V} itself stays: it holds the journal. Each dir is only removed if setup created it.
197
218
  [ -z "$new_sites" ] || record "created ${H}" "_as ${V} rmdir ${H}"
198
219
  [ -z "$new_traefik" ] || record "created ${U}" "_as ${V} rm -rf ${U}"
@@ -214,10 +235,10 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
214
235
  if [ "$($SUDO sshd -T 2>/dev/null | awk '$1 == "permitrootlogin" {print $2}')" = no ]; then root_login=no; fi
215
236
  # 00- so it wins over cloud-init's 50-cloud-init.conf: sshd keeps the first value it reads.
216
237
  want=$(printf '%s\n' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' "PermitRootLogin $root_login")
217
- if [ "$($SUDO cat ${De} 2>/dev/null || true)" != "$want" ]; then
238
+ if [ "$($SUDO cat ${Oe} 2>/dev/null || true)" != "$want" ]; then
218
239
  $SUDO install -d /etc/ssh/sshd_config.d
219
- undo=$(saved ${De})
220
- printf '%s\n' "$want" | $SUDO tee ${De} >/dev/null
240
+ undo=$(saved ${Oe})
241
+ printf '%s\n' "$want" | $SUDO tee ${Oe} >/dev/null
221
242
  if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
222
243
  undo="$undo; $(saved /etc/ssh/sshd_config)"
223
244
  printf 'Include /etc/ssh/sshd_config.d/*.conf\n%s' "$($SUDO cat /etc/ssh/sshd_config)" | $SUDO tee /etc/ssh/sshd_config >/dev/null
@@ -232,7 +253,7 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
232
253
  fi
233
254
  fi
234
255
  log "bootstrap done"
235
- `,Fe=String.raw`
256
+ `,Le=String.raw`
236
257
  set +e
237
258
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
238
259
  kv() { printf '%s=%s
@@ -266,7 +287,7 @@ kv sites "$(ls ${H} 2>/dev/null | wc -l | tr -d ' ')"
266
287
  kv network "$(yn S docker network inspect ${`web`})"
267
288
  kv proxy "$(grep -v '^#' ${W} 2>/dev/null | tr '\n' ' ')"
268
289
  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
269
- kv hardened "$(yn test -f ${De})"
290
+ kv hardened "$(yn test -f ${Oe})"
270
291
  kv password_auth "$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" {print $2}')"
271
292
  kv password_users "$(S awk -F: '$2 ~ /^\$/ {printf "%s ", $1}' /etc/shadow 2>/dev/null)"
272
293
  f=${U}/docker-compose.yml
@@ -296,14 +317,14 @@ else
296
317
  S netstat -ltunp 2>/dev/null | awk '$1 ~ /^tcp/ {print "listen=tcp " $4 " " $7} $1 ~ /^udp/ {print "listen=udp " $4 " " $6}'
297
318
  fi
298
319
  exit 0
299
- `,Ie=e=>e.replace(/\$/g,`$$$$`),Le=e=>JSON.stringify(e);function Re(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=${Ie(e.dashboard.htpasswd)}`)),`# managed by landfall — regenerate with \`lf traefik setup\`
320
+ `,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\`
300
321
  services:
301
322
  traefik:
302
323
  image: ${e.image}
303
324
  container_name: traefik
304
325
  restart: unless-stopped
305
326
  command:
306
- ${t.map(e=>` - ${Le(e)}`).join(`
327
+ ${t.map(e=>` - ${ze(e)}`).join(`
307
328
  `)}
308
329
  ${e.hostNetwork?` network_mode: host
309
330
  `:` ports:
@@ -315,71 +336,28 @@ ${e.hostNetwork?` network_mode: host
315
336
  - ./dynamic:/etc/traefik/dynamic:ro
316
337
  ${e.hostNetwork?``:` networks:
317
338
  - web
318
- `}${n.length?` labels:\n${n.map(e=>` - ${Le(e)}`).join(`
339
+ `}${n.length?` labels:\n${n.map(e=>` - ${ze(e)}`).join(`
319
340
  `)}\n`:``}${e.hostNetwork?``:`networks:
320
341
  web:
321
342
  external: true
322
- `}`}const q=e=>`landfall-${e}`;function ze(e,t,n,r=Oe,i=[]){let a=q(e),o=t.map(e=>`Host(\`${e}\`)`).join(` || `),s=i.length?[`traefik.http.middlewares.${a}-auth.basicauth.users=${i.join(`,`)}`,`traefik.http.routers.${a}.middlewares=${a}-auth`]:[];return[`traefik.enable=true`,`traefik.docker.network=${r.network}`,`traefik.http.routers.${a}.rule=${o}`,`traefik.http.routers.${a}.entrypoints=${r.entrypoint}`,`traefik.http.routers.${a}.tls.certresolver=${r.resolver}`,`traefik.http.services.${a}.loadbalancer.server.port=${n}`,...s,`landfall.managed=true`,`landfall.site=${e}`]}function Be(e,t=Oe){let n=e.releases.find(t=>t.id===e.current),r=ze(e.name,e.domains,e.port,t,e.auth).map(e=>` - ${Le(Ie(e))}`),i;return i=e.kind===`static`?` image: nginx:alpine
323
- volumes:
324
- - ./releases:/site/releases:ro
325
- - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro`:` image: ${Le(n?.image??``)}
326
- env_file:
327
- - .env`,`# managed by landfall — do not edit, changes are overwritten on deploy
328
- name: ${q(e.name)}
329
- services:
330
- app:
331
- ${i}
332
- container_name: ${q(e.name)}
333
- restart: unless-stopped
334
- networks:
335
- - ${t.network}
336
- labels:
337
- ${r.join(`
338
- `)}
339
- networks:
340
- ${t.network}:
341
- external: true
342
- `}function Ve(e,t){return`# managed by landfall
343
- server {
344
- listen 80;
345
- server_name _;
346
- root /site/releases/${e};
347
- index index.html;
348
- absolute_redirect off;
349
-
350
- gzip on;
351
- gzip_types text/plain text/css application/javascript application/json image/svg+xml application/xml;
352
-
353
- location ~* \\.(?:css|js|mjs|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif|ico)$ {
354
- expires 30d;
355
- add_header Cache-Control "public";
356
- try_files $uri =404;
357
- }
358
-
359
- location / {
360
- try_files $uri $uri/ $uri.html ${t?`/index.html`:`=404`};
361
- }
362
-
363
- error_page 404 /404.html;
364
- location = /404.html { internal; }
365
- }
366
- `}function He(e){return process.env[`SRV_${e}`]}function Ue(e,t){let n=p(e,`srv`);!o.existsSync(t)&&o.existsSync(p(n,`config.json`))&&o.renameSync(n,t)}const We=`srv.json`,Ge=e=>p(s(),`.ssh`,`srv_${e}`),J=`/opt/srv`,Ke=`[ ! -d ${J} ] || [ -e ${V} ] || { echo "this server still uses ${J} from when landfall was called srv; run 'lf setup' for it once to move it" >&2; exit 3; }\n`,qe=`moved ${J} to ${V} (landfall was called srv); older changes can no longer be undone`,Je=String.raw`
343
+ `}`}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`
367
344
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then
368
- echo "moving ${J} needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
345
+ echo "moving ${q} needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
369
346
  exit 1
370
347
  fi
371
- [ ! -e ${V} ] || { echo "both ${J} and ${V} exist; merge them by hand, then remove ${J}" >&2; exit 1; }
372
- log "moving ${J} to ${V}"
373
- $SUDO mv ${J} ${V}
348
+ [ ! -e ${V} ] || { echo "both ${q} and ${V} exist; merge them by hand, then remove ${q}" >&2; exit 1; }
349
+ log "moving ${q} to ${V}"
350
+ $SUDO mv ${q} ${V}
374
351
  # Only the older journal steps restored these backups; the .env copies hold secrets.
375
352
  $SUDO find ${V} /etc/ssh -name '.*.srv-*' -type f -delete 2>/dev/null || true
376
- $SUDO rm -f ${Me}.srv-*
353
+ $SUDO rm -f ${Ne}.srv-*
377
354
  old_conf=/etc/ssh/sshd_config.d/00-srv.conf
378
- [ ! -f $old_conf ] || $SUDO mv $old_conf ${De}
355
+ [ ! -f $old_conf ] || $SUDO mv $old_conf ${Oe}
379
356
  # Rewrite a file in place, keeping its owner (deploy users must still write it).
380
357
  rewrite() { o=$($SUDO stat -c %u:%g "$1"); $SUDO sed -i -E "$2" "$1"; $SUDO chown "$o" "$1"; }
381
358
  dc version >/dev/null 2>&1 || true
382
359
  sdc() { $SUDO $DC "$@"; }
360
+ [ ! -f ${W} ] || rewrite ${W} 's/^# srv routes/# landfall routes/; s/let srv run/let landfall run/'
383
361
  f=${U}/docker-compose.yml
384
362
  if [ -f $f ] && grep -q "managed by srv" $f; then
385
363
  rewrite $f 's/managed by srv/managed by landfall/'
@@ -397,17 +375,17 @@ for d in ${H}/*; do
397
375
  if (cd "$d" && sdc $act --quiet-pull >/dev/null); then $SUDO docker rm -f "srv-$n" >/dev/null 2>&1 || true
398
376
  else log "landfall-$n did not start, srv-$n keeps running; redeploy $n"; fi
399
377
  done
400
- record "${qe}"
401
- `;async function Ye(e,t){let{stdout:n}=await R(e,`[ ! -d ${J} ] || echo old`,{capture:!0,oldLayout:!0});return n.trim()!==`old`||(w(`${J} is from when landfall was called srv: its sites, traefik and journal move to ${V}`),C(b.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+Je,{oldLayout:!0}),S(`moved to ${V}`),!0))}const Xe=e=>process.env[`LANDFALL_${e}`]??He(e),Ze=process.env.XDG_CONFIG_HOME??p(s(),`.config`),Qe=Xe(`CONFIG_DIR`)??p(Ze,`landfall`),$e=p(Qe,`config.json`);Xe(`CONFIG_DIR`)||Ue(Ze,Qe);const Y=e=>e.startsWith(`~/`)?p(s(),e.slice(2)):e,et=e=>{let t=s();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function X(){try{let e=JSON.parse(o.readFileSync($e,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:E(`cannot read ${$e}: ${e.message}`)}}function tt(e){o.mkdirSync(f($e),{recursive:!0,mode:448}),o.writeFileSync($e,JSON.stringify(e,null,2)+`
402
- `,{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?E("no server given. Use --server <ssh-alias> or `lf server add`"):E(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}let n=t.servers[e]??{host:e};return{name:e,server:n}}const nt=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,rt=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return nt.test(t)||E(`not a domain: ${e}`),t},it=/^[a-z0-9][a-z0-9._\-/:@]*$/i,at=e=>it.test(e)?e:E(`not an image: ${e}`);function ot(e,t,n,r=2**53-1){let i=typeof e==`number`?e:/^\s*\d+\s*$/.test(String(e))?Number(e):NaN;return(!Number.isInteger(i)||i<n||i>r)&&E(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const st=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,ct=e=>e.domains?.[0];function lt(e,t){let n=ct(t);return e===`@`?n??E("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const ut=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),dt=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 ft(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!nt.test(n))continue;let e=dt(n);ut.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 pt=`.landfall.json`,mt=e=>{let t=p(e,pt);return o.existsSync(t)||!o.existsSync(p(e,`srv.json`))?t:p(e,We)};function ht(e){try{return JSON.parse(o.readFileSync(mt(e),`utf8`))}catch{return}}function gt(e,t){o.writeFileSync(p(e,pt),JSON.stringify(t,null,2)+`
403
- `),o.rmSync(p(e,We),{force:!0})}const _t=Xe(`SSH_CONFIG`)??p(s(),`.ssh`,`config`),vt=/* @__PURE__ */ new Map;function Q(e){let n=[...pe(e),`-G`,fe(e)],r=vt.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return E(`ssh cannot resolve ${fe(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>Y(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return vt.set(n.join(`\0`),s),s}function yt(e){return(e.key?[Y(e.key)]:Q(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function bt(e=_t,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(`
404
- `)){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=Y(e),i=r.startsWith(`/`)?r:p(s(),`.ssh`,r);for(let e of xt(i))n.push(...bt(e,t))}}return[...new Set(n)]}function xt(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 St=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],Ct=(e,t)=>RegExp(`# >>> (?:landfall|srv):${jt(e)}\\n[\\s\\S]*?# <<< (?:landfall|srv):${jt(e)}${t}`),wt=(e,t)=>e===`# >>> landfall:${t}`||e===`# >>> srv:${t}`;function Tt(e){let[t,n]=St(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(`
405
- `)}function Et(e,t=_t){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`),i=Tt(e),a=Ct(e.alias,`\\n?`),s=a.test(r)?r.replace(a,i+`
406
- `):`${i}\n\n${r}`;return o.mkdirSync(p(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),vt.clear(),{created:n,block:i}}function Dt(e,t=_t,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
407
- `);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:wt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=Y(t);for(let t of xt(r.startsWith(`/`)?r:p(s(),`.ssh`,r))){let r=Dt(e,t,n);if(r)return r}}}}function Ot(e,t){let n=Dt(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
408
- `),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(Y(n))||Y(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(`
409
- `)),vt.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function kt(e,t=_t){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=Ct(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const At=(e,t=_t)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
410
- `).some(t=>wt(t,e)),jt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Mt=String.raw`
378
+ record "${Ke}"
379
+ `;async function Je(e,t){let{stdout:n}=await R(e,`[ ! -d ${q} ] || echo old`,{capture:!0,oldLayout:!0});return n.trim()!==`old`||(E(`${q} is from when landfall was called srv: its sites, traefik and journal move to ${V}`),T(S.dim(`sites keep running; traefik restarts once; changes made before the move can no longer be undone`)),t.check||!await M(`move /opt/srv to /opt/landfall?`)?!1:(await R(e,K+qe,{oldLayout:!0}),w(`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:{}}:O(`cannot read ${Qe}: ${e.message}`)}}function $e(e){o.mkdirSync(f(Qe),{recursive:!0,mode:448}),o.writeFileSync(Qe,JSON.stringify(e,null,2)+`
380
+ `,{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?O("no server given. Use --server <ssh-alias> or `lf server add`"):O(`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)||O(`not a domain: ${e}`),t},nt=/^[a-z0-9][a-z0-9._\-/:@]*$/i,rt=e=>nt.test(e)?e:O(`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)&&O(`${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??O("`@` 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)+`
381
+ `),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 O(`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(`
382
+ `)){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(`
383
+ `)}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+`
384
+ `):`${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(`
385
+ `);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(`
386
+ `),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(`
387
+ `)),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(`
388
+ `).some(t=>St(t,e)),kt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),At=String.raw`
411
389
  set +e
412
390
  kv() { printf '%s=%s\n' "$1" "$2"; }
413
391
  PRETTY_NAME=unknown; ID=; VERSION_ID=; ID_LIKE=; . /etc/os-release 2>/dev/null
@@ -429,27 +407,76 @@ kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\
429
407
  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)"
430
408
  kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
431
409
  exit 0
432
- `;function Nt(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
433
- `)){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:It(n.id??``,n.version??``,n.pkg||`none`,t)}}async function Pt(e){let{stdout:t}=await R(e,K+Mt,{capture:!0});return Nt(t)}const Ft={"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 It(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=Ft[`${e} ${t}`]??Ft[`${e} ${a}`]??Ft[`${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 Lt=/^(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])\.)/,Rt=e=>h(e)===4&&!Lt.test(e);async function zt(e,t){let n=Q(e).hostname,r=h(n)?[n]:await te.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const Bt=e=>e.find(Rt)??e[0],Vt=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function Ht(e,t){return Promise.all(e.map(async e=>{let n=await te.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const Ut=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]}`},Wt=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`},Gt=[`dist`,`build`,`out`,`public`,`_site`,`site`];function Kt(e){return o.existsSync(p(e,`index.html`))?`.`:Gt.find(t=>o.existsSync(p(e,t,`index.html`)))??`.`}function qt(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const Jt=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function Yt(e){let t=ht(e),n=t?.name??Jt(d(e));if(t?.image&&!t.static)return{path:e,name:n,kind:`image`,port:t.port,project:t};let r=t=>o.existsSync(p(e,t)),i=t?.build;if(!i&&r(`package.json`))try{JSON.parse(o.readFileSync(p(e,`package.json`),`utf8`)).scripts?.build&&(i=r(`bun.lock`)||r(`bun.lockb`)?`bun run build`:r(`pnpm-lock.yaml`)?`pnpm build`:r(`yarn.lock`)?`yarn build`:`npm run build`)}catch{}if(!t?.static&&r(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??qt(p(e,`Dockerfile`))??3e3,project:t};let a=t?.dir??Kt(e);return{path:e,name:n,kind:`static`,dir:a!==`.`||r(`index.html`)?a:void 0,build:i,project:t}}const Xt=p(Qe,`projects.json`);function Zt(){try{return JSON.parse(o.readFileSync(Xt,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function Qt(e){let t=ee(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...Zt().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(Qe,{recursive:!0,mode:448}),o.writeFileSync(Xt,JSON.stringify(n,null,2)+`
434
- `,{mode:384})}function $t(e,t,n){let r=Zt();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=ht(i.path);e?.name===t&&gt(i.path,{...e,domains:n})}r.length&&o.writeFileSync(Xt,JSON.stringify(r,null,2)+`
435
- `,{mode:384})}const $=e=>`${H}/${e}`;function en(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||E(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const tn=`
410
+ `;function jt(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
411
+ `)){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=>g(e)===4&&!Ft.test(e);async function Lt(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 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(`
412
+ `)+`
413
+ `}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:
414
+ image: nginx:alpine
415
+ restart: unless-stopped
416
+ volumes:
417
+ - ./public:/usr/share/nginx/html:ro
418
+ - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
419
+ x-landfall:
420
+ port: 80
421
+ `:`${n} app:
422
+ ${e===`image`?`image: ${ln(t.image)}`:`build: .`}
423
+ restart: unless-stopped
424
+ env_file:
425
+ - path: .env
426
+ required: false
427
+ x-landfall:
428
+ port: ${t.port??3e3}
429
+ `}function hn(e){return`# generated by landfall
430
+ server {
431
+ listen 80;
432
+ server_name _;
433
+ root /usr/share/nginx/html;
434
+ index index.html;
435
+ absolute_redirect off;
436
+
437
+ gzip on;
438
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml application/xml;
439
+
440
+ location ~* \\.(?:css|js|mjs|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif|ico)$ {
441
+ expires 30d;
442
+ add_header Cache-Control "public";
443
+ try_files $uri =404;
444
+ }
445
+
446
+ location / {
447
+ try_files $uri $uri/ $uri.html ${e?`/index.html`:`=404`};
448
+ }
449
+
450
+ error_page 404 /404.html;
451
+ location = /404.html { internal; }
452
+ }
453
+ `}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)+`
454
+ `,{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)+`
455
+ `,{mode:384})}const $=e=>`${H}/${e}`;function En(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||O(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const Dn=`
436
456
  --8<--landfall--8<--
437
- `;async function nn(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(tn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(tn),c;if(i.trim())try{c=JSON.parse(i)}catch{E(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:Ae(o)??Oe,exists:s.trim()===`1`}}async function rn(e,t){let n=await nn(e,t);return n.meta||E(`no site "${t}" on ${e.host}. See \`lf sites list\``),n}const an=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),on=(e,t,n)=>ye(e,[`docker logs --tail ${L(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]);async function sn(e){let{stdout:t}=await R(e,`printf 'READY\\t%s\\n' "$([ -d ${H} ] && command -v docker >/dev/null 2>&1 && echo 1)"
438
- P=${ke}
457
+ `;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{O(`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||O(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return Ut(i)&&O(`${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&&O(`${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)"
458
+ P=${Ae}
439
459
  printf 'TRAEFIK\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P"
440
460
  for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
441
- docker ps -a --filter label=landfall.managed=true --format 'CTR\\t{{.Names}}\\t{{.Status}}' 2>/dev/null || true`,{capture:!0}),n=/* @__PURE__ */ new Map,r=[],i={ready:!1,traefik:``,proxy:`traefik`,sites:[]};for(let a of t.split(`
442
- `)){let[t,o=``,s]=a.split(` `);if(t===`READY`&&(i.ready=o===`1`),t===`TRAEFIK`&&(i.traefik=o,i.proxy=s||`traefik`),t===`SITE`)try{r.push(JSON.parse(a.slice(t.length+1)))}catch{w(`skipped a corrupt site.json on ${e.host}: ${a.slice(5,80)}`)}t===`CTR`&&n.set(o,s)}return i.sites=r.map(e=>({meta:e,status:n.get(q(e.name))??`not running`})),i}const cn=async e=>(await sn(e)).sites;function ln(e,t,n={}){let r=$(e.name),i=e.releases.find(t=>t.id===e.current),a=[`mkdir -p ${L(r)} && cd ${L(r)}`,B(`site.json`,JSON.stringify(e,null,2)+`
443
- `),B(`docker-compose.yml`,Be(e,t)),n.env===void 0?`touch .env && chmod 600 .env`:B(`.env`,n.env,`600`)];return e.kind===`static`&&a.push(B(`nginx.conf`,Ve(e.current,!!e.spa))),e.kind!==`image`&&a.push(`ln -sfn ${L(`releases/`+e.current)} current`),n.build&&e.kind===`dockerfile`&&a.push(`log "building image ${i.image}"`,`docker build -q -t ${L(i.image)} ${L(`releases/`+e.current)} >/dev/null`),n.pull&&e.kind===`image`&&a.push(`log "pulling ${i.image}"`,`dc pull -q`),a.push(`log "starting ${q(e.name)}"`,`{ dc up -d --remove-orphans --quiet-pull && ensure_running ${q(e.name)}; } || exit 1`),e.kind===`static`&&a.push(`docker exec ${q(e.name)} nginx -s reload >/dev/null 2>&1 || true`),a}const un=(e,t)=>[`rm -rf ${L(`${$(e.name)}/releases/${t.id}`)}`,...e.kind===`dockerfile`&&t.image?[`docker image rm ${L(t.image)} >/dev/null 2>&1 || true`]:[]];async function dn(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],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${pn(t,r.existed).join(`
461
+ 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
462
+ ids=$(docker ps -aq 2>/dev/null || true)
463
+ # inspect, unlike ps, does not expand \\t: hence {{"\\t"}}.
464
+ [ -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(`
465
+ `)){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{E(`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//'); case "$v" in 2.2[4-9]*|2.[3-9][0-9]*|[3-9]*) ;; *) echo "sites need docker compose 2.24 or newer, the server has \${v:-none}: run lf update --compose" >&2; exit 1 ;; esac`,`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(`
466
+ `),{capture:!0}),[s,c=``]=o.split(Dn),l;try{l=_(c)}catch(e){O(`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)+`
467
+ `),`record ${L(`adopted ${t.project} as site ${t.name}`)} ${L(`rm -rf ${n}`)}`].join(`
468
+ `))}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)+`
469
+ `),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)=>[`rm -rf ${L(`${$(e.name)}/releases/${t.id}`)} 2>/dev/null || $SUDO rm -rf ${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(`
470
+ `));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(`
444
471
  `)}\n`,d=`
445
472
  }`;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=`
446
- `+[...ln(i,n),...a.flatMap(e=>un(t,e))].join(`
447
- `)}l.push(`record ${L(r.what)} ${L(u)}${f?`"$u"`:``}${L(d)}`),l.push(...ln(t,n,r));for(let e of s)l.push(...un(t,e));await R(e,l.join(`
448
- `))}async function fn(e,t,n,r=``){await R(e,`${K}\ncd ${L($(t))}\n${r}\ndc ${n}`)}function pn(e,t=!1){let n=$(e.name),r=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[],i=[`site.json`,`docker-compose.yml`,`nginx.conf`,`current`,...e.releases.map(e=>`releases/${e.id}`)].map(L).join(` `);return[`cd ${L(n)}`,`dc down --remove-orphans >/dev/null 2>&1 || docker rm -f ${q(e.name)} >/dev/null 2>&1 || true`,...r.map(e=>`docker image rm ${L(e)} >/dev/null 2>&1 || true`),...t?[`rm -rf ${i} 2>/dev/null || $SUDO rm -rf ${i}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rm -rf ${L(n)} 2>/dev/null || $SUDO rm -rf ${L(n)}`]]}async function mn(e,t){await R(e,[K,...pn(t),`record ${L(`removed site ${t.name} (${t.domains.join(`, `)})`)}`].join(`
449
- `))}function hn(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
473
+ `+[...Bn(i,n),...a.flatMap(e=>Hn(t,e))].join(`
474
+ `)}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(`
475
+ `))}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?[`rm -rf ${a} 2>/dev/null || $SUDO rm -rf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rm -rf ${L(n)} 2>/dev/null || $SUDO rm -rf ${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(`
476
+ `))}function Yn(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
450
477
  `)){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(`
451
478
  `)+`
452
- `:``}function gn(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&E(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function _n(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:E(`${t} needs root or passwordless sudo; ${Q(e).user} has neither and root's key login does not work`)}const vn=String.raw`
479
+ `:``}function Xn(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&O(`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:O(`${t} needs root or passwordless sudo; ${Q(e).user} has neither and root's key login does not work`)}const Qn=String.raw`
453
480
  set +e
454
481
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
455
482
  m=$(pkg_mgr); echo "mgr=$m"
@@ -469,8 +496,8 @@ case "$m" in
469
496
  esac
470
497
  [ -f /var/run/reboot-required ] && echo reboot=1
471
498
  exit 0
472
- `;function yn(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
473
- `)){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 bn(e,t=!0){let{stdout:n}=await R(e,K+vn,{capture:!0,env:{REFRESH:t}});return yn(n)}const xn=String.raw`
499
+ `;function $n(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
500
+ `)){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`
474
501
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
475
502
  keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
476
503
  case "$(pkg_mgr)" in
@@ -490,7 +517,7 @@ if [ "$REBOOT" = 1 ] && { [ -f /var/run/reboot-required ] || { command -v needs-
490
517
  # Detached, so this ssh session can end cleanly first.
491
518
  (sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
492
519
  fi
493
- `;async function Sn(e,t){await R(e,K+xn,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function Cn(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 wn(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 Tn(e){let t=Cn(e);return t.digest?t.digest:(await wn(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function En(e){let t=Cn(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await wn(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 Dn(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 On=/^(v?)(\d+(?:\.\d+){0,2})$/;function kn(e,t){let n=On.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=On.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&Dn(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const An=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,jn=/* @__PURE__ */ new Map;function Mn(e){return jn.has(e)||jn.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)),jn.get(e)}const Nn=()=>Mn(`moby/moby`),Pn=()=>Mn(`docker/compose`);async function Fn(e){let t=String.raw`
520
+ `;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`
494
521
  ${K}
495
522
  set +e
496
523
  printf 'docker=%s
@@ -501,32 +528,39 @@ printf 'path=%s
501
528
  ' "$(docker info --format '{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}' 2>/dev/null)"
502
529
  printf 'ce=%s
503
530
  ' "$(pkg_has docker-ce && echo 1)"
504
- exit 0`,[{stdout:n},r,i]=await Promise.all([R(e,t,{capture:!0}),Nn(),Pn()]),a=Object.fromEntries(n.split(`
505
- `).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 In(e){let t=(e,t)=>!!e&&!!t&&Dn(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 Ln(e){await R(e,K+Ne+`compose_install || { log "could not download docker compose"; exit 1; }
506
- `)}const Rn=String.raw`
531
+ exit 0`,[{stdout:n},r,i]=await Promise.all([R(e,t,{capture:!0}),pr(),mr()]),a=Object.fromEntries(n.split(`
532
+ `).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; }
533
+ `)}const vr=String.raw`
507
534
  set +e
508
- P=${ke}
509
- img() { # img <kind> <name> <container> <dir>
510
- ref=$(docker inspect -f '{{.Config.Image}}' "$3" 2>/dev/null) || { printf 'IMG\t%s\t%s\t%s\t%s\t\t\n' "$1" "$2" "$3" "$4"; return 0; }
511
- id=$(docker inspect -f '{{.Image}}' "$3" 2>/dev/null)
512
- printf 'IMG\t%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$ref" "$(docker image inspect -f '{{range .RepoDigests}}{{.}} {{end}}' "$id" 2>/dev/null)"
535
+ P=${Ae}
536
+ img() { # img <kind> <name> <container> <dir> [service]
537
+ svc=; [ $# -lt 5 ] || svc=$5
538
+ 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; }
539
+ read -r ref id <<EOF
540
+ $x
541
+ EOF
542
+ 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)"
513
543
  }
514
544
  if [ "$P" = traefik ] && grep -q "managed by landfall" ${U}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${U}
515
545
  else img proxy "$P" "$P" ""; fi
546
+ # All containers at once: <name> <service> <project>, the last two empty outside Compose.
547
+ PS=$(docker ps -a --format '{{.Names}} {{.Label "com.docker.compose.service"}} {{.Label "com.docker.compose.project"}}' 2>/dev/null)
516
548
  for f in ${H}/*/site.json; do
517
549
  [ -f "$f" ] || continue
518
550
  d=$(dirname "$f"); n=$(basename "$d")
519
- if grep -q '"kind": *"dockerfile"' "$f"; then printf 'IMG\tbuilt\t%s\t${q(`%s`)}\t%s\t\t\n' "$n" "$n" "$d"; continue; fi
520
- img site "$n" "${q(`$n`)}" "$d"
551
+ # Sites of landfall before Compose sites: one container, its compose file next to site.json.
552
+ if ! grep -q '"v": *2' "$f"; then img site "$n" "landfall-$n" "$d"; continue; fi
553
+ p=$(sed -n 's/^ *"project": *"\([^"]*\)".*/\1/p' "$f"); [ -n "$p" ] || p=$n
554
+ printf '%s\n' "$PS" | while read -r c svc pr; do [ "$pr" != "$p" ] || img site "$n" "$c" "$d/current" "$svc"; done
521
555
  done
522
556
  exit 0
523
- `;async function zn(e){let{stdout:t}=await R(e,Rn,{capture:!0}),n=t.split(`
524
- `).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(!On.test(Cn(e).tag))return;i.has(t)||i.set(t,En(e));let n=kn(Cn(e).tag,await i.get(t));return n&&An(e,n)};return Promise.all(n.map(async([,e,t,n,i,o=``,s=``])=>{let c={name:t,container:n,dir:i,ref:o};if(e===`built`)return{...c,kind:`site`,state:`rebuild`,note:`built from its Dockerfile: redeploy to rebuild`};if(!o)return{...c,kind:e,state:`missing`,note:`no container`};if(e===`proxy`)return{...c,kind:`proxy`,state:`foreign`,note:`not landfall's traefik: update it where it was set up`};if(o.includes(`@`))return{...c,kind:e,state:`pinned`,note:`pinned to a digest`};r.has(o)||r.set(o,Tn(o));let[l,u]=await Promise.all([r.get(o),a(o)]),d=e,f=e=>Cn(e).tag.replace(/^v/,``).split(`.`)[0],p=d===`traefik`?`lf traefik setup --image ${u}`:`redeploy with it`,m=u?{newer:u,note:`newer version ${u} (${f(u)===f(o)?``:`new major, read its upgrade notes; `}${p})`}:{};return l?{...c,kind:d,state:s.split(` `).some(e=>e.endsWith(`@${l}`))?`current`:`update`,...m}:{...c,kind:d,state:`unknown`,note:`registry did not answer (private image?)`,...m}}))}async function Bn(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=`docker tag $old ${L(e)} && cd ${L(n.dir)} && dc up -d`;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}`)} "${t.replace(/"/g,`\\"`)}"`,` log ${L(`recreating ${n.container}`)}`,` (cd ${L(n.dir)} && dc up -d --quiet-pull) && ensure_running ${L(n.container)} || exit 1`,`fi`)}}await R(e,r.join(`
525
- `))}function Vn(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 Hn(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
526
- `)){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(Vn(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function Un(e,t={}){let{stdout:n}=await R(e,K+Fe,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return Hn(n)}const Wn=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),Gn=e=>e.includes(`:`),Kn=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,qn=[68,546,5353];function Jn(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=tr(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 Yn(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 Xn=(e,t)=>{let n=Yn(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Zn=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[Kn(e),t(e)])).values()];function Qn(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=$n(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 $n(e,t){let n=e.values,r=e.rules.map(Jn),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&qn.includes(e.port)||Xn(r,e),a=e=>!Wn(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[Gn(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Zn(o,e=>`${Kn(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Zn(l,e=>`${Kn(e)} (docker${Gn(e.addr)?`, IPv6`:``})`)],kept:c?[]:Zn(s.filter(e=>!l.includes(e)),Kn)}}function er(e){let t=e.values,n=e.rules.map(Jn),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[Gn(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(Wn(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=Yn(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`&&qn.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=`${Kn(e)} ${t?`docker`:e.proc}`,r={port:Kn(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 tr=(e=``)=>e.split(/\s+/).filter(Boolean);function nr(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&&Dn(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 rr(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(nr(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(Qn(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=tr(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(or(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function ir(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 or(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=Ae(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${ar(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${U}/docker-compose.yml was not written by landfall`);let[i,a]=tr(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=ir(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=tr(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=ir(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 sr(e){let t={done:b.green(`✓`),todo:b.cyan(`→`),conflict:b.yellow(`!`),off:b.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?b.dim(`disabled`):r.status===`conflict`?b.yellow(r.detail):b.dim(r.detail);C(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function cr(e,t){return`${e}:${(await _.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}function lr(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 ur=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function dr(e,t,n){let r=lr(t,n);at(r.image);let{email:i,staging:a}=r;x(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?lt(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){nt.test(c)||E(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=n.dashboard?n.dashboardUser:o?.user??n.dashboardUser;s=n.dashboardPassword??g(12).toString(`base64url`),o={host:c,user:e,htpasswd:await cr(e,s)}}let l=Re({...r,dashboard:o});await R(t,`${K}
557
+ `;async function yr(e){let{stdout:t}=await R(e,vr,{capture:!0}),n=t.split(`
558
+ `).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(`
559
+ `))}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(`
560
+ `)){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:S.green(`✓`),todo:S.cyan(`→`),conflict:S.yellow(`!`),off:S.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?S.dim(`disabled`):r.status===`conflict`?S.yellow(r.detail):S.dim(r.detail);T(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function Hr(e,t){return`${e}:${(await y.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;C(`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)||O(`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 Hr(e,s)}}let l=Be({...r,dashboard:o});await R(t,`${K}
527
561
  ensure_network web
528
562
  [ -w ${U} ] || { $SUDO install -d -o "$(id -un)" ${U}; }
529
- mkdir -p ${U}/letsencrypt ${Te} && cd ${U}
563
+ mkdir -p ${U}/letsencrypt ${Ee} && cd ${U}
530
564
  ${B(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
531
565
  tls:
532
566
  options:
@@ -536,30 +570,37 @@ tls:
536
570
  `)}
537
571
  if [ -e docker-compose.yml ]; then
538
572
  ${B(`.docker-compose.yml.new`,l)}
573
+ # .landfall.sha256: what landfall wrote last. A different file was edited by hand.
574
+ 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
575
+ diff -u docker-compose.yml .docker-compose.yml.new >&2; rm -f .docker-compose.yml.new
576
+ 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
577
+ exit 1
578
+ fi
539
579
  # Same config again: nothing to journal.
540
580
  if cmp -s .docker-compose.yml.new docker-compose.yml; then rm -f .docker-compose.yml.new
541
581
  else
542
582
  u=$(saved docker-compose.yml)
543
583
  mv .docker-compose.yml.new docker-compose.yml
544
- record ${L(`reconfigured traefik (${ur(r)})`)} "cd ${U} && $u && dc up -d --remove-orphans"
584
+ record ${L(`reconfigured traefik (${Wr(r)})`)} "cd ${U} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
545
585
  fi
546
586
  else
547
587
  ${B(`docker-compose.yml`,l)}
548
588
  # Certificates in letsencrypt/ stay, a later install reuses them.
549
- record ${L(`installed traefik (${ur(r)})`)} "cd ${U} && dc down --remove-orphans && rm -f docker-compose.yml dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
589
+ 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; }"
550
590
  fi
551
591
  log "starting traefik"
552
592
  dc up -d --remove-orphans --quiet-pull
553
593
  sleep 2 # traefik exits shortly after start on a bad config
554
- ensure_running traefik`);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}),tt(u)),S(`traefik running (${ur(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&C(b.dim(`host network: ufw now decides who reaches 80/443 (landfall's firewall allows both)`)),o&&(S(`dashboard: https://${o.host}`),C(s?`user ${b.bold(o.user)} password ${b.bold(s)}`:b.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function fr(e,t){x(`using traefik ${b.bold(t.container)} for lf sites`),await R(e,`${K}
594
+ ensure_running traefik
595
+ 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)),w(`traefik running (${Wr(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&T(S.dim(`host network: ufw now decides who reaches 80/443 (landfall's firewall allows both)`)),o&&(w(`dashboard: https://${o.host}`),T(s?`user ${S.bold(o.user)} password ${S.bold(s)}`:S.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function Kr(e,t){C(`using traefik ${S.bold(t.container)} for lf sites`),await R(e,`${K}
555
596
  docker network inspect ${t.network} >/dev/null 2>&1 || { echo "docker network ${t.network} does not exist" >&2; exit 1; }
556
597
  [ -w ${V} ] || $SUDO install -d -o "$(id -un)" ${V}
557
598
  u=$(saved ${W})
558
- ${B(W,je(t))}
559
- record ${L(`sites use traefik ${t.container}`)} "$u"`),S(`sites join network ${b.bold(t.network)} with entrypoint ${b.bold(t.entrypoint)} and resolver ${b.bold(t.resolver)}`),C(b.dim(`redeploy existing sites to switch them over; delete ${W} to let landfall run its own traefik again`))}const pr={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},mr=F({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:I,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: ${Ee})`},"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)`}},async run(e,t){let{name:n,server:r}=Z(t.server),i=or(await Un(r));i.status===`conflict`&&E(`not touching ${n}: ${i.detail}`),i.proxy&&E(`${n} already has its own traefik: ${i.detail}. landfall does not reconfigure it (lf traefik use)`),await dr(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`]})}}),hr=F({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
599
+ ${B(W,Me(t))}
600
+ record ${L(`sites use traefik ${t.container}`)} "$u"`),w(`sites join network ${S.bold(t.network)} with entrypoint ${S.bold(t.entrypoint)} and resolver ${S.bold(t.resolver)}`),T(S.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=F({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:I,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`&&O(`not touching ${n}: ${i.detail}`),i.proxy&&O(`${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=F({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
560
601
 
561
602
  landfall reads network, entrypoint and cert resolver from the container's flags;
562
- pass them when it is configured by a file. That Traefik is never changed.`,options:{server:I,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=ir((await Un(r)).values.foreign_proxy),a=e??i?.name;a||E(`no traefik container publishes port 443 on ${n}; name it: lf traefik use <container>`);let o=i?.name===a?i:void 0,s={container:a,network:t.network??o?.proxy?.network,entrypoint:t.entrypoint??o?.proxy?.entrypoint,resolver:t.resolver??o?.proxy?.resolver},c=[`network`,`entrypoint`,`resolver`].filter(e=>!s[e]);c.length&&E(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await fr(r,s)}}),gr=F({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:I},async run(e,t){let{server:n}=Z(t.server);await R(n,`P=${ke}
603
+ pass them when it is configured by a file. That Traefik is never changed.`,options:{server:I,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||O(`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&&O(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await Kr(r,s)}}),Xr=F({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:I},async run(e,t){let{server:n}=Z(t.server);await R(n,`P=${Ae}
563
604
  if ! docker inspect "$P" >/dev/null 2>&1; then echo "traefik is not installed (lf traefik setup)"; exit 0; fi
564
605
  [ -f ${W} ] && echo "your own traefik, landfall only routes sites through it (${W})"
565
606
  docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
@@ -585,44 +626,104 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
585
626
  printf " %s %s (%s)\\n", n, x, s
586
627
  }
587
628
  }' || true
588
- done | sort`)}}),_r=F({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:I,...pr},async run(e,t){return on(Z(t.server).server,ke,t)}}),vr=F({name:`traefik restart`,summary:`Restart traefik.`,options:{server:I},async run(e,t){let{server:n}=Z(t.server);await R(n,`${K}\ndocker restart ${ke} >/dev/null\nrecord "restarted traefik"`),S(`traefik restarted`)}});function yr(e,t){let n=ht(process.cwd()),r=e??n?.name;return r||E(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...Z(t??n?.server)}}const br=e=>e.startsWith(`Up`)?b.green(e):b.red(e),xr=F({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:I,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=Z(t.server??ht(process.cwd())?.server),i=await cn(r);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return console.log(`no sites on ${n} yet — ${b.bold(`lf deploy --domain example.com`)}`);M([[b.dim(`NAME`),b.dim(`KIND`),b.dim(`DOMAINS`),b.dim(`RELEASE`),b.dim(`UPDATED`),b.dim(`STATUS`)],...i.map(({meta:e,status:t})=>[b.bold(e.name),e.kind,e.domains.join(`, `),e.current,N(e.updatedAt),br(t)])])}}),Sr=F({name:`sites info`,args:`[name]`,summary:`Show a site's details and release history.`,options:{server:I},async run([e],t){let{site:n,server:r}=yr(e,t.server),{meta:i}=await rn(r,n),{stdout:a}=await R(r,`docker ps -a --filter name=^${q(n)}$ --format '{{.Status}}'`,{capture:!0});console.log(`${b.bold(i.name)} ${i.kind} ${br(a.trim()||`not running`)}`);for(let e of i.domains)console.log(` ${b.cyan(`https://`+e)}`);console.log(b.dim(` dir ${$(n)} port ${i.port}${i.spa?` spa`:``}`)),console.log(`\n${b.bold(`Releases`)}`);for(let e of an(i)){let t=e.id===i.current?b.green(`● `):` `;console.log(`${t}${e.id} ${b.dim(N(e.at))}${e.image?` `+e.image:``}`)}}}),Cr=F({name:`logs`,args:`[name]`,summary:`Show a site's container logs.`,options:{server:I,...pr},async run([e],t){let{site:n,server:r}=yr(e,t.server);return on(r,q(n),t)}}),wr=(e,t,n,r,i)=>F({name:e,args:`[name]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's container.`,options:{server:I},async run([a],o){let{site:s,server:c}=yr(a,o.server);await rn(c,s);let l=`record ${L(`${e} ${s}`)}`,u=`[ "$(docker inspect -f '{{.State.Running}}' ${q(s)} 2>/dev/null)" = true ]`;await fn(c,s,t,r?`if ${i?``:`! `}${u}; then ${l} ${L(`cd ${$(s)} && dc ${r}`)}; else ${l}; fi`:l),S(`${s} ${n}`)}}),Tr=wr(`restart`,`restart`,`restarted`),Er=wr(`stop`,`stop`,`stopped`,`up -d`,!0),Dr=wr(`start`,`up -d`,`started`,`stop`,!1),Or=F({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:I,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=yr(e,t.server),{meta:i,proxy:a}=await rn(r,n),o=an(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&E(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||E(t.to?`no release ${t.to}`:`no release older than ${i.current}`);let l=structuredClone(i);i.current=c.id,i.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await dn(r,i,a,{keep:i.releases.length,prev:l,what:`rollback ${n} from ${l.current} to ${c.id}`}),S(`${n} rolled back to ${b.bold(c.id)}`)}}),kr=F({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete all its files and releases from the server.`,options:{server:I},async run([e],t){e||E(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=yr(e,t.server),{meta:a}=await rn(r,n);await A(`Delete ${n} (${a.domains.join(`, `)}) from ${i}? This cannot be undone.`,n),await mn(r,a),S(`removed ${n}`)}}),Ar=F({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change environment variables of an image/dockerfile site; the container is recreated.`,options:{server:I,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}=yr(r,t.server),{meta:o,env:s}=await rn(a,i);if(o.kind===`static`&&E(`${i} is a static site; env vars only reach image and Dockerfile sites`),!n.length&&!t.unset?.length)return void process.stdout.write(s||b.dim(`(empty)
589
- `));let c=hn(s,gn(n),t.unset??[]),l=[...n.map(e=>e.slice(0,e.indexOf(`=`))),...(t.unset??[]).map(e=>`-${e}`)];await fn(a,i,`up -d --force-recreate --quiet-pull`,`u=$(saved .env)
590
- record ${L(`env ${i} ${l.join(` `)}`)} "cd ${$(i)} && $u && dc up -d --force-recreate"
591
- ${B(`.env`,c,`600`)}`),S(`updated env of ${i}`)}});async function jr(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 Mr(e,t,n,r,i,a){let o=(await cn(e)).map(e=>e.meta).filter(e=>e.name!==n&&e.domains.some(e=>r.includes(e)));for(let e of o){let t=e.domains.filter(e=>r.includes(e));a&&E(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`),t.length===e.domains.length&&E(`${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 o){let o=structuredClone(a);a.domains=a.domains.filter(e=>!r.includes(e)),a.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await dn(e,a,i,{keep:a.releases.length,prev:o,what:`moved ${o.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`}),$t(t,a.name,a.domains),C(b.dim(`${a.name} keeps ${a.domains.join(`, `)}`))}}}const Nr=F({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
629
+ done | sort`)}}),Zr=F({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:I,...qr},async run(e,t){return jn(Z(t.server).server,Ae,t)}}),Qr=F({name:`traefik restart`,summary:`Restart traefik.`,options:{server:I},async run(e,t){let{server:n}=Z(t.server);await R(n,`${K}\ndocker restart ${Ae} >/dev/null\nrecord "restarted traefik"`),w(`traefik restarted`)}});function $r(e,t){let n=pt(process.cwd()),r=e??n?.name;return r||O(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...Z(t??n?.server)}}const ei=e=>$t(e)?S.green(e):en(e)?S.dim(e):S.red(e),ti=F({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:I,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(S.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),N([[S.dim(`NAME`),S.dim(`DOMAINS`),S.dim(`FOLDER`),S.dim(`STATUS`)],...a.map(e=>[e.name,e.domains.join(`, `),e.dir??S.dim(e.containers.join(`, `)),ei(e.status)])]),i.length&&console.log()),!i.length)return console.log(`no landfall sites on ${n} yet — ${S.bold(`lf deploy --domain example.com`)}`);N([[S.dim(`NAME`),S.dim(`KIND`),S.dim(`DOMAINS`),S.dim(`SERVICES`),S.dim(`RELEASE`),S.dim(`UPDATED`),S.dim(`STATUS`)],...i.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[S.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||S.dim(`-`),e.current||S.dim(`-`),P(e.updatedAt),ei(t)])])}}),ni=F({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:I},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||O(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${S.bold(a.name)} ${i.kind} ${ei(i.status)}`);for(let e of i.domains)console.log(` ${S.cyan(`https://`+e)}`);if(console.log(S.dim(` ${a.adopted??$(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${S.bold(`Services`)}`),N(i.services.map(e=>[` ${e.service}`,S.dim(e.container),ei(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${S.bold(`Releases`)}`);for(let e of An(a)){let t=e.id===a.current?S.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${S.dim(P(e.at))} ${S.dim(e.services.map(e=>e.name).join(`, `))}${n.length?S.dim(` → ${n.join(`, `)}`):``}`)}}}}),ri=F({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:I,...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)=>F({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:I},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),w(`${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=F({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:I,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&&O(`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||O(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}`}),w(`${n} rolled back to ${S.bold(c.id)}`)}}),li=F({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:I,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||O("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);O(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||O(`${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)&&O(`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&&O(`${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}),w(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(S.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),ui=F({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
630
+
631
+ Named volumes (databases, uploads) stay, so deploying the site again finds its data;
632
+ --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:I,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||O(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=$r(e,t.server),{meta:a}=await On(r,n);a||O(`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 j(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}),w(`removed ${n}${t.volumes?` and its volumes`:S.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),di=F({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:I,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||S.dim(`(empty)
633
+ `));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)
634
+ record ${L(`env ${i} ${c.join(` `)}`)} "cd ${$(i)} && $u && cd -P current && dc up -d"
635
+ ${B(`.env`,s,`600`)}`),w(`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&&E(`${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)`;O(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&O(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`),t.length===e.domains.length&&O(`${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 j(`${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),T(S.dim(`${a.name} keeps ${a.domains.join(`, `)}`))}}}const mi=F({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
592
636
 
593
637
  Without flags it lists and checks; --add/--rm/--set change the site (undoable)
594
- and update .landfall.json of remembered projects.`,options:{server:I,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}=yr(e,t.server),{meta:a,proxy:o}=await rn(i,n);if(!t.add&&!t.rm&&!t.set){let e=await zt(i),[r,o]=await Promise.all([Ht(a.domains,e),Promise.all(a.domains.map(jr))]),s=r.map((e,t)=>({...e,https:o[t]}));if(t.json)return void console.log(JSON.stringify({site:n,serverIps:e,domains:s},null,2));for(let e of s){let t=e.ok?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`),n=e.https.status?(e.https.status<400?b.green:e.https.status===401?b.cyan:b.yellow)(`https ${e.https.status}`):b.red(`https ${e.https.error}`);console.log(`${b.cyan(`https://${e.domain}`)} ${t} ${n}`)}s.some(e=>!e.ok)&&console.log(b.dim(`\npoint an A record at ${e.join(` or `)} for each domain`));return}let s=e=>(e??[]).map(e=>lt(e.trim(),i)).filter(Boolean),c=t.set?s(t.set):[...a.domains];c=[.../* @__PURE__ */ new Set([...c,...s(t.add)])].filter(e=>!s(t.rm).includes(e));let l=c.filter(e=>!nt.test(e));l.length&&E(`not a domain: ${l.join(`, `)}`),c.length||E(`a site needs at least one domain`);let u=s(t.rm).filter(e=>!a.domains.includes(e));if(u.length&&w(`${u.join(`, `)} not on ${n}`),c.join()===a.domains.join())return S(`nothing to change`);let d=a.domains.filter(e=>!c.includes(e));d.length&&await A(`Remove ${d.join(`, `)} from ${n}? ${d.length>1?`They stop`:`It stops`} serving it.`);let f=await Mr(i,r,n,c.filter(e=>!a.domains.includes(e)),o),p=structuredClone(a);a.domains=c,a.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await f(),await dn(i,a,o,{keep:a.releases.length,prev:p,what:`domains of ${n}: ${c.join(`, `)}`}),$t(r,n,c);let m=ht(process.cwd());m?.name===n&&gt(process.cwd(),{...m,domains:c}),S(`${n} serves ${c.map(e=>b.cyan(`https://${e}`)).join(`, `)}`);let ee=await zt(i);for(let e of await Ht(c.filter(e=>!p.domains.includes(e)),ee))e.ok||w(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),Pr=F({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
638
+ and update .landfall.json of remembered projects.`,options:{server:I,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?S.green(`dns ok`):e.ips.length?S.yellow(`dns → ${e.ips.join(`, `)}`):S.red(`no dns`),n=e.https.status?(e.https.status<400?S.green:e.https.status===401?S.cyan:S.yellow)(`https ${e.https.status}`):S.red(`https ${e.https.error}`);console.log(`${S.cyan(`https://${e.domain}`)} ${t} ${n}`)}c.some(e=>!e.ok)&&console.log(S.dim(`\npoint an A record at ${r.join(` or `)} for each domain`));return}Yt(o)||O(`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&&O(`not a domain: ${u.join(`, `)}`),l.length||O(`a site needs at least one domain`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&E(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return w(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));f.length&&await j(`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}),w(`${n} serves ${l.map(e=>S.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||E(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),hi=F({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
595
639
 
596
640
  lf auth my-site --user anna add or change a login (asks the password)
597
641
  lf auth my-site --rm-user anna remove a login
598
642
  lf auth my-site --off remove all logins: the site is public again
599
- lf auth my-site show who can log in`,options:{server:I,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}=yr(e,t.server),{meta:i,proxy:a}=await rn(r,n),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 ${b.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>b.bold(e)).join(`, `)}`);return}let s=t.off?[]:[...i.auth??[]],c=new Set(t[`rm-user`]??[]);for(let e of c)o.includes(e)||w(`no login ${e} on ${n}`);if(t.user){/^[A-Za-z0-9._@-]{1,64}$/.test(t.user)||E(`user may only have letters, digits and . _ @ -`);let e=t.password??await P(`Password for ${t.user}:`);e.length<8&&E(`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 cr(t.user,e))}else s=s.filter(e=>!c.has(e.slice(0,e.indexOf(`:`))));if(s.join(`
643
+ lf auth my-site show who can log in`,options:{server:I,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 ${S.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>S.bold(e)).join(`, `)}`);return}let s=t.off?[]:[...i.auth??[]],c=new Set(t[`rm-user`]??[]);for(let e of c)o.includes(e)||E(`no login ${e} on ${n}`);if(t.user){/^[A-Za-z0-9._@-]{1,64}$/.test(t.user)||O(`user may only have letters, digits and . _ @ -`);let e=t.password??await ie(`Password for ${t.user}:`);e.length<8&&O(`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(`
600
644
  `)===(i.auth??[]).join(`
601
- `))return S(`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 dn(r,i,a,{keep:i.releases.length,prev:d,what:l.length?`login for ${n}: ${l.join(`, `)}`:`removed the login of ${n}`}),S(l.length?`${n} asks for a login (${l.join(`, `)})`:`${n} is public again`)}}),Fr=F({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:I},async run([e],t){let{site:n,server:r}=yr(e,t.server),{meta:i}=await rn(r,n),a=`https://${i.domains[0]}`;console.log(b.cyan(a)),ie(a)}}),Ir=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function Lr(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(Ir(new Date(n)));)n+=1e3;return Ir(new Date(n))}const Rr=F({name:`deploy`,args:`[dir]`,summary:`Deploy a static folder, Dockerfile or image with HTTPS.
645
+ `))return w(`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 j(`Remove the login${u.length>1?`s`:``} ${u.join(`, `)} from ${n}?`):await j(`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}`}),w(l.length?`${n} asks for a login (${l.join(`, `)})`:`${n} is public again`)}}),gi=F({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:I},async run([e],t){let{site:n,server:r}=$r(e,t.server),{meta:i}=await kn(r,n),a=tn(i)[0];a||O(`${n} serves no domain`);let o=`https://${a}`;console.log(S.cyan(o)),ae(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=F({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
602
646
 
603
647
  Kinds:
604
- static a folder of files (default; auto-detects dist/, build/, out/, public/)
648
+ compose the folder has a compose.yaml: its services run on the server as one
649
+ project; x-landfall: { port: 3000 } on a service routes it through Traefik
605
650
  dockerfile the folder has a Dockerfile: uploaded and built on the server
651
+ static a folder of files (auto-detects dist/, build/, out/, public/)
606
652
  image --image: run a prebuilt image (e.g. ghcr.io/me/app:1.2)
607
653
 
608
- Settings are saved to ./.landfall.json, so later deploys are just \`lf deploy\`.`,options:{server:I,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"},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 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=ht(n)??{};i.name&&C(b.dim(`using ${pt}`));let{name:a,server:s}=Z(t.server??i.server),c=en(t.name??i.name??Jt(d(n))),l=!t.domain?.length&&!!i.domains?.length,u=t.domain?.length?t.domain:i.domains??[];if(!u.length){let e=ct(s),t=e?void 0:Bt(await zt(s));u=(await re(`Domain(s) to serve (comma separated):`,e?`${c}.${e}`:t?Vt(c,t):void 0)).split(/[\s,]+/).filter(Boolean)}u=u.map(e=>lt(e,s)),u.length||E(`at least one --domain is required`);let f=u.filter(e=>!nt.test(e));f.length&&E(`not a domain: ${f.join(`, `)}`);let te=ot(t.keep,`--keep`,1),h=t.build??i.build;if(h){x(`building locally: ${h}`);let e=r(h,{shell:!0,stdio:`inherit`});e.status!==0&&E(`build failed (exit ${e.status})`)}let g=t.static??i.static,_=t.image??(g?void 0:i.image),v=_&&at(_),y=ee(n,e??i.dir??(v?`.`:Kt(n))),T=v?`image`:!g&&o.existsSync(p(y,`Dockerfile`))?`dockerfile`:`static`;T===`static`&&!o.existsSync(p(y,`index.html`))&&w(`no index.html in ${m(n,y)||`.`}`);let D=T===`static`?80:ot(t.port??i.port??(T===`dockerfile`?qt(p(y,`Dockerfile`)):void 0)??3e3,`--port`,1,65535);x(`deploying ${b.bold(c)} (${T}) to ${b.bold(a)}`);let{meta:O,env:ne,proxy:k,exists:A}=await nn(s,c);O&&O.kind!==T&&C(b.dim(`site was ${O.kind}, now ${T}`));let j=await Mr(s,a,c,u,k,l?`${pt} lists it; deploy with -d to move it`:void 0),M=Lr((O?.releases??[]).map(e=>e.id)),N={id:M,at:(/* @__PURE__ */ new Date()).toISOString()};T===`image`&&(N.image=v),T===`dockerfile`&&(N.image=`${q(c)}:${M}`),T!==`image`&&(x(`uploading ${m(n,y)||`.`}`),await we(s,y,`${$(c)}/releases/${M}`));let P;(t.env?.length||t[`env-file`])&&(P=ne,t[`env-file`]&&(P=hn(P,gn(zr(o.readFileSync(t[`env-file`],`utf8`))))),t.env?.length&&(P=hn(P,gn(t.env))));let ie=(/* @__PURE__ */ new Date()).toISOString(),F={name:c,kind:T,domains:u,port:D,spa:T===`static`?!!(t.spa??i.spa):void 0,auth:O?.auth,current:M,releases:[...O?.releases??[],N],createdAt:O?.createdAt??ie,updatedAt:ie};if(await j(),await dn(s,F,k,{env:P,build:T===`dockerfile`,pull:T===`image`,keep:te,prev:O,existed:!O&&A,what:`deploy ${c} release ${M}${O?``:` (new site, ${u.join(`, `)})`}`}),t.save){let e={name:c,server:a,domains:u};if(T===`image`)Object.assign(e,{image:v,port:D});else{let t=m(n,y)||`.`;t!==Kt(n)&&(e.dir=t),T===`dockerfile`&&(e.port=D),F.spa&&(e.spa=!0),g&&T===`static`&&(e.static=!0)}h&&(e.build=h),gt(n,e),Qt({path:n,name:c,server:a,domains:u,kind:T})}S(`release ${b.bold(M)} live`);for(let e of u)console.log(` ${b.cyan(`https://${e}`)}`);await Br(u[0])}});function zr(e){return e.split(`
609
- `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function Br(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?S(`https://${e} responded ${t.status}`):w(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);w(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const Vr=async e=>{let t=``;for await(let n of e)t+=n;return t};let Hr;function Ur(){if(Hr)return Hr;let e=o.mkdtempSync(p(l(),`landfall-`));return Hr=p(e,`askpass`),o.writeFileSync(Hr,`#!/bin/sh
654
+ Every site is a Compose project on the server (/opt/landfall/sites/<name>/current),
655
+ so \`docker compose\` works there without landfall. Settings are saved to
656
+ ./.landfall.json, so later deploys are just \`lf deploy\`.`,options:{server:I,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&&T(S.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.build??i.build;if(u){C(`building locally: ${u}`);let e=r(u,{shell:!0,stdio:`inherit`});e.status!==0&&O(`build failed (exit ${e.status})`)}let f=t.static??i.static,ee=t.image??(f?void 0:i.image),g=ee&&rt(ee),_=h(n,e??i.dir??`.`),v=g||f?void 0:t.file??bn(_,i);v&&!o.existsSync(p(_,v))&&O(`no ${v} in ${m(n,_)||`.`}`);let y=v||g?_:h(n,e??i.dir??_n(n)),b=v?`compose`:g?`image`:!f&&o.existsSync(p(y,`Dockerfile`))?`dockerfile`:`static`;b===`static`&&!o.existsSync(p(y,`index.html`))&&E(`no index.html in ${m(n,y)||`.`}`);let x=_i[b].port?it(t.port??i.port??(b===`dockerfile`?vn(p(y,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,D=b===`static`&&!!(t.spa??i.spa);C(`deploying ${S.bold(c)} (${b}) to ${S.bold(a)}`);let k=await On(s,c),{env:A,proxy:te,exists:re}=k,j=k.meta&&Ut(k.meta)?k.meta:void 0,M=j?void 0:k.meta;M?.adopted&&O(`${c} is adopted (${M.adopted}): deploy it there with docker compose, or pick another --name`),j?E(`${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&&T(S.dim(`site was ${M.kind}, now ${b}`));let N;(t.env?.length||t[`env-file`])&&(N=A,t[`env-file`]&&(N=Yn(N,Xn(xi(o.readFileSync(t[`env-file`],`utf8`))))),t.env?.length&&(N=Yn(N,Xn(t.env))));let P=yi((M?.releases??[]).map(e=>e.id)),ie=`${$(c)}/releases/${P}`,ae=b===`compose`?{}:{"compose.yaml":mn(b,{port:x,image:g}),...b===`static`&&{"nginx.conf":hn(D)}},F,I,oe;try{let e=_i[b].files;e!==void 0&&(C(`uploading ${m(n,y)||`.`}`),await Te(s,y,ie+e));let r=await In(s,c,P,{file:v,generated:ae,env:N}),o=cn(r.config,r.dir,e=>st(e,s));for(let e of o.warnings)E(e);o.errors.length&&O(`${v??`compose.yaml`} cannot be deployed:\n${o.errors.map(e=>` - ${e}`).join(`
657
+ `)}`),F={id:P,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 ne(`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||O(`at least one --domain is required`),e.length&&O(`not a domain: ${e.join(`, `)}`),n.length&&O(`${n.join(`, `)} is already routed by x-landfall.domains`)}else t.domain?.length&&E(`-d ignored: every routed service of ${c} names its own x-landfall.domains`);oe=await pi(s,a,c,[...I,...l],te,u?`${dt} lists it; deploy with -d to move it`:void 0)}catch(e){throw await Rn(s,c,P).catch(()=>{}),e}let se=(/* @__PURE__ */ new Date()).toISOString(),ce={v:2,name:c,kind:b,domains:I,auth:M?.auth??j?.auth,current:P,releases:[...M?.releases??[],F],createdAt:M?.createdAt??j?.createdAt??se,updatedAt:se};await oe(),await Wn(s,ce,te,{env:N,build:!0,pull:!0,keep:l,prev:M,takeover:j,existed:!M&&!j&&re,what:`deploy ${c} release ${P}${M?``:` (new site, ${tn(ce).join(`, `)})`}`});let le=tn(ce);if(t.save){let e={name:c,server:a,domains:I};if(b===`image`)Object.assign(e,{image:g,port:x});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=x),D&&(e.spa=!0),f&&b===`static`&&(e.static=!0)}u&&(e.build=u),mt(n,e),wn({path:n,name:c,server:a,domains:le,kind:b})}w(`release ${S.bold(P)} live`);for(let e of le)console.log(` ${S.cyan(`https://${e}`)}`);le[0]&&await Si(le[0])}});function xi(e){return e.split(`
658
+ `).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?w(`https://${e} responded ${t.status}`):E(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);E(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const Ci=F({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
659
+
660
+ compose.yaml what runs on the server; x-landfall routes a service through Traefik
661
+ compose.override.yaml local only (docker compose merges it, lf deploy leaves it out):
662
+ publishes the port on localhost
663
+
664
+ \`docker compose up\` then runs the site locally, \`lf deploy\` on a server. Existing
665
+ 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()||O(`${e} is not a folder`);let r=xn(n);r.kind===`compose`&&O(`${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&&O(`${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||T(S.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
666
+ image: nginx:alpine
667
+ restart: unless-stopped
668
+ volumes:
669
+ - ./${e}:/usr/share/nginx/html:ro
670
+ x-landfall:
671
+ port: 80
672
+ `}else u=` app:
673
+ ${r.kind===`image`?`image: ${r.project.image}`:`build: .`}
674
+ restart: unless-stopped
675
+ env_file:
676
+ - path: .env
677
+ required: false
678
+ ${l?` environment:
679
+ DATABASE_URL: postgres://app:\${POSTGRES_PASSWORD}@db:5432/app
680
+ depends_on:
681
+ db:
682
+ condition: service_healthy
683
+ `:``} x-landfall:
684
+ port: ${s}
685
+ `;let d=r.kind===`static`?`web`:`app`;i[`compose.yaml`]=`# Runs locally with \`docker compose up\` and on a server with \`lf deploy\`.
686
+ # x-landfall.port: the port Traefik sends the site's requests to (HTTPS on its domains).
687
+ services:
688
+ ${u}${l?` db:
689
+ image: postgres:17-alpine
690
+ restart: unless-stopped
691
+ environment:
692
+ POSTGRES_USER: app
693
+ POSTGRES_DB: app
694
+ POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
695
+ volumes:
696
+ - db:/var/lib/postgresql/data
697
+ healthcheck:
698
+ test: pg_isready -U app
699
+ interval: 5s
700
+ retries: 10
701
+
702
+ # Survives deploys and rollbacks; \`lf sites rm --volumes\` deletes it.
703
+ volumes:
704
+ db:
705
+ `:``}`,i[`compose.override.yaml`]=`# Local only: docker compose merges this file, lf deploy leaves it out.
706
+ services:
707
+ ${d}:
708
+ ports:
709
+ - "127.0.0.1:${c}:${s}"
710
+ `;for(let[e,t]of Object.entries(i))o.writeFileSync(p(n,e),t);let f=m(process.cwd(),n)||`.`;w(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(p(n,`.env`))&&T(`locally: put POSTGRES_PASSWORD=… in ${p(f,`.env`)} (keep it out of git)`),T(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),T(`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
610
711
  printf '%s\\n' "$LANDFALL_PASSWORD"
611
- `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Hr}const Wr=e=>({SSH_ASKPASS:Ur(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Gr(e){if(e){let e=(await Vr(process.stdin)).replace(/\r?\n$/,``);return e||E(`--password-stdin: nothing on stdin`),e}return Xe(`PASSWORD`)||void 0}const Kr=e=>{let t=p(s(),`.ssh`,`landfall_${e}`),n=Ge(e);return o.existsSync(t)||!o.existsSync(n)?t:n};function qr(e){let t=Kr(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(p(s(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&E(`ssh-keygen failed`),{path:t,created:!0})}async function Jr(e,t,n){let i=Q(t),a=t.key?Y(t.key):void 0;if(a&&!o.existsSync(a)&&E(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&E(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await z(t);if(!s&&!t.key&&o.existsSync(Kr(e))&&await z({...t,key:Kr(e)})&&(t.key=a=Kr(e),s=!0),s)S(`key login works`),a??=yt(t);else if(n.check){w("no key login yet — `lf setup` would install a key first");return}let c=!1;if(!a&&!n.check){let t=qr(e);a=t.path,c=t.created,c&&S(`generated key ${et(a)}`)}let l=t.key&&Y(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await z(t))){x(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Wr(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...pe(t),fe(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await z(t)||E(`could not log in with the key — check host, user and password`),S(`key installed`)}return a}const Yr=e=>{for(let t of e)console.log(b.dim(` `)+t)};function Xr(e,t,n,r){let i=Q(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Et({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});S(`${r.created?`created`:`updated`} ${et(_t)} with ${b.bold(`Host ${e}`)} — try \`ssh ${e}\``),Yr(r.block.split(`
612
- `)),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!==yt({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Ot(e,n):void 0;s?(S(`added the key to your ${b.bold(`Host ${e}`)} in ${et(s.file)} ${b.dim(`(backup: ${et(s.backup)})`)}`),Yr([...s.removed.map(e=>b.red(`- ${e}`)),b.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=et(n)),r.ownAlias&&C(b.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Zr=F({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
712
+ `,{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||O(`--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&&O(`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)&&O(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&O(`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)w(`key login works`),a??=_t(t);else if(n.check){E("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&&w(`generated key ${Y(a)}`)}let l=t.key&&J(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await z(t))){C(`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)||O(`could not log in with the key — check host, user and password`),w(`key installed`)}return a}const Mi=e=>{for(let t of e)console.log(S.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});w(`${r.created?`created`:`updated`} ${Y(ht)} with ${S.bold(`Host ${e}`)} — try \`ssh ${e}\``),Mi(r.block.split(`
713
+ `)),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?(w(`added the key to your ${S.bold(`Host ${e}`)} in ${Y(s.file)} ${S.dim(`(backup: ${Y(s.backup)})`)}`),Mi([...s.removed.map(e=>S.red(`- ${e}`)),S.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=Y(n)),r.ownAlias&&T(S.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Pi=F({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
613
714
 
614
715
  Without --host, <name> is used as an ssh alias from ~/.ssh/config,
615
- so \`lf server add het\` works whenever \`ssh het\` works.
716
+ so \`lf server add vps\` works whenever \`ssh vps\` works.
616
717
 
617
718
  With --host lf logs in with --key, or installs its own key
618
719
  (~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
619
720
  to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
620
- The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||E(`usage: lf server add <name> [--host ...]`);let n=t.domain&&rt(t.domain),r=X(),i=r.servers[e],a=bt().includes(e)&&!At(e);t.host&&a&&E(`${e} is already a Host in your ssh config; pick another name or drop --host`);let o={host:t.host??i?.host??e,user:t.user??(t.host?`root`:i?.user),port:t.port?Number(t.port):i?.port,key:t.key??i?.key},s=await Gr(t[`password-stdin`]),c=await Jr(e,o,{check:!s&&!ne(),password:s}),l=c?Xr(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=st(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),tt(r),S(`added ${b.bold(e)}${r.default===e?b.dim(` (default)`):``}`),C(c?b.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${b.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),Qr=F({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=[[b.dim(`NAME`),b.dim(`TARGET`),b.dim(`SOURCE`)]];for(let[e,t]of Object.entries(n.servers)){let i=`${t.user?t.user+`@`:``}${t.host}${t.port?`:`+t.port:``}`;r.push([e+(n.default===e?b.green(` *`):``),i,`landfall`])}if(t.all)for(let e of bt())n.servers[e]||r.push([e,e,At(e)?`ssh (landfall)`:`ssh`]);r.length===1?console.log(`no servers yet — ${b.bold(`lf server add <ssh-alias>`)} or ${b.bold(`lf setup <name> --host <ip>`)}`):(M(r),t.all||console.log(b.dim(`
621
- Any ~/.ssh/config alias works too (--all to list them).`)))}}),$r=F({name:`server rm`,args:`<name>`,summary:`Forget a server (nothing is changed on the server itself).`,options:{"ssh-config":{type:`boolean`,desc:`also remove the landfall-managed Host block from ~/.ssh/config`}},async run([e],t){e||E(`usage: lf server rm <name>`);let n=X();!n.servers[e]&&!At(e)&&E(`unknown server "${e}"`),await A(`Forget server ${e}? Nothing on the server changes.`),delete n.servers[e],n.default===e&&delete n.default,tt(n),t[`ssh-config`]&&kt(e)&&S(`removed Host ${e} from ~/.ssh/config`),S(`removed ${e}`)}}),ei=F({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||E(`usage: lf server use <name>`);let t=X();t.servers[e]??={host:e},t.default=e,tt(t),S(`default server: ${b.bold(e)}`)}}),ti=F({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 ye(r,n)}}),ni=F({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 Pt(r);if(t.json)return void console.log(JSON.stringify({name:n,...i,ips:await zt(r,i)},null,2));let a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(b.bold(n)+b.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:b.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Wt(i.uptime)}${i.reboot?b.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${Ut(o)} / ${Ut(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${Ut(i.disk.used)} / ${Ut(i.disk.total)}${a(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`docker`,i.docker||`not installed or no access (run lf setup)`]];for(let[e,t]of s)console.log(`${b.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:w)(i.support.note),i.docker&&(console.log(),await R(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function ri(e){let t=X(),n=t.servers[e],r=n??{host:e},i=Q(r),a=Dt(r.host),s=lr(r,{});return{name:e,isDefault:t.default===e,configFile:et($e),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],traefik:{...s,summary:ur(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:et(e),exists:o.existsSync(Y(e))})),block:a?{file:et(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
622
- `)}:null}}}const ii=F({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
721
+ 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||O(`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&&O(`${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&&!te(),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),w(`added ${S.bold(e)}${r.default===e?S.dim(` (default)`):``}`),T(c?S.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${S.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),Fi=F({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=[[S.dim(`NAME`),S.dim(`TARGET`),S.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?S.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 — ${S.bold(`lf server add <ssh-alias>`)} or ${S.bold(`lf setup <name> --host <ip>`)}`):(N(r),t.all||console.log(S.dim(`
722
+ Any ~/.ssh/config alias works too (--all to list them).`)))}}),Ii=F({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||O(`usage: lf server rm <name>`);let n=X();!n.servers[e]&&!Ot(e)&&O(`unknown server "${e}"`),await j(`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)&&w(`removed Host ${e} from ~/.ssh/config`),w(`removed ${e}`)}}),Li=F({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||O(`usage: lf server use <name>`);let t=X();t.servers[e]??={host:e},t.default=e,$e(t),w(`default server: ${S.bold(e)}`)}}),Ri=F({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=F({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(S.bold(n)+S.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:S.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Ht(i.uptime)}${i.reboot?S.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(`${S.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:E)(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(`
723
+ `)}:null}}}const Vi=F({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
623
724
  the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
624
- 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=ri(n);if(t.json)return void console.log(JSON.stringify(r,null,2));let i=(e,t)=>console.log(` ${b.dim(e.padEnd(10))} ${t}`);if(console.log(`${b.bold(n)}${r.isDefault?b.dim(` (default)`):``}`),console.log(`\n${b.bold(`landfall`)} ${b.dim(r.configFile)}`),!r.saved)i(`entry`,b.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(r.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&i(e,String(t));r.domains.length||i(`domain`,b.dim(`none (lf server domain ${n} example.com)`)),r.domains.forEach((e,t)=>i(t?``:r.domains.length>1?`domains`:`domain`,t?e:`${e} ${b.dim(`(primary: new sites get <site>.${e})`)}`)),i(`traefik`,`${r.traefik.summary}${r.traefik.email?`, ${r.traefik.email}`:``} ${b.dim(`(when landfall installs it)`)}`),i(`dashboard`,r.traefik.dashboard?`https://${r.traefik.dashboard}`:b.dim(`off`)),console.log(`\n${b.bold(`ssh`)} ${b.dim(`ssh -G ${r.saved?.host??n}`)}`),i(`login`,`${r.ssh.user}@${r.ssh.hostname}:${r.ssh.port}`);for(let e of r.ssh.keys)i(`key`,e.exists?e.file:`${e.file} ${b.dim(`(missing)`)}`);if(r.ssh.block){console.log(`\n${b.bold(`ssh config`)} ${b.dim(`${r.ssh.block.file}:${r.ssh.block.line}${r.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of r.ssh.block.text.split(`
625
- `))console.log(` ${e}`)}else console.log(`\n${b.bold(`ssh config`)} ${b.dim(`no Host block; ssh uses its defaults`)}`)}});async function ai(e,t){let n=await zt(e),[r,i]=await Ht([t,`landfall-${g(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function oi(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 si=async e=>ft(await oi(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),ci=F({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
725
+ 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(` ${S.dim(e.padEnd(10))} ${t}`);if(console.log(`${S.bold(n)}${r.isDefault?S.dim(` (default)`):``}`),console.log(`\n${S.bold(`landfall`)} ${S.dim(r.configFile)}`),!r.saved)i(`entry`,S.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`,S.dim(`none (lf server domain ${n} example.com)`)),r.domains.forEach((e,t)=>i(t?``:r.domains.length>1?`domains`:`domain`,t?e:`${e} ${S.dim(`(primary: new sites get <site>.${e})`)}`)),i(`traefik`,`${r.traefik.summary}${r.traefik.email?`, ${r.traefik.email}`:``} ${S.dim(`(when landfall installs it)`)}`),i(`dashboard`,r.traefik.dashboard?`https://${r.traefik.dashboard}`:S.dim(`off`)),console.log(`\n${S.bold(`ssh`)} ${S.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} ${S.dim(`(missing)`)}`);if(r.ssh.block){console.log(`\n${S.bold(`ssh config`)} ${S.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(`
726
+ `))console.log(` ${e}`)}else console.log(`\n${S.bold(`ssh config`)} ${S.dim(`no Host block; ssh uses its defaults`)}`)}});async function Hi(e,t){let n=await Lt(e),[r,i]=await Bt([t,`landfall-${v(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=F({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
626
727
 
627
728
  The first main domain is the primary: \`lf deploy\` serves a new site at
628
729
  <site>.<primary>, \`--domain blog\` means blog.<primary>, and
@@ -633,18 +734,18 @@ More main domains are listed and DNS-checked; use them with full names
633
734
 
634
735
  Domains given as arguments replace the list (the first becomes primary).
635
736
  Without any, it lists them and, if there are none, suggests the base domains
636
- 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]||bt().includes(e))(e[0])&&nt.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(rt),i=t.clear?[]:a.length?a.map(rt):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(rt)])].filter(e=>!r.includes(e)),i=st(i,t.primary&&rt(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&w(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await 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,tt(n),s.domains=e.domains,S(i.length?`main domains of ${b.bold(o)}: ${i.map((e,t)=>t?e:b.bold(e)).join(`, `)}${i.length>1?b.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let c=s.domains??[];if(!c.length){let e=await si(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:c,suggest:e}));C(`${o} has no main domain yet`);for(let t of e)C(`${t.hosts} routed hosts use ${b.bold(t.domain)} — ${b.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||C(`set one: ${b.bold(`lf server domain ${o} example.com`)}`);return}let l=await Promise.all(c.map(e=>ai(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:c.map((e,t)=>({domain:e,primary:t===0,...l[t]}))},null,2));let u=Math.max(...c.map(e=>e.length))+2,d=[];c.forEach((e,t)=>{let n=l[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?b.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(b.dim(`
737
+ 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&&E(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await j(`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,w(i.length?`main domains of ${S.bold(o)}: ${i.map((e,t)=>t?e:S.bold(e)).join(`, `)}${i.length>1?S.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}));T(`${o} has no main domain yet`);for(let t of e)T(`${t.hosts} routed hosts use ${S.bold(t.domain)} — ${S.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||T(`set one: ${S.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?S.green(`dns ok`):e.ips.length?S.yellow(`dns → ${e.ips.join(`, `)}`):S.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?S.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(S.dim(`
637
738
  add these DNS records at your DNS provider:
638
739
  `)+d.join(`
639
- `));let f=ct(s);C(b.dim(`sites: lf deploy -d blog → blog.${f} · dashboard: lf traefik setup --dashboard traefik`))}});async function li(e){let t=e,n=await Un(t);if(n.values.sudo!==`1`){let r={...e,user:`root`};Q(e).user!==`root`&&await z(r)||E(`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 Un(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(Jn),ports:er(n),blocked:$n(n,[i]).blocked,probe:n}}const ui=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,di=/^[0-9a-fA-F.:]+(\/\d+)?$/;function fi(e){ui.test(e.port)||E(`not a port: ${e.port} (e.g. 5432, 51820/udp, 6000:6007/tcp)`);let[t,n]=e.port.split(`/`);t.split(/[,:]/).some(e=>Number(e)<1||Number(e)>65535)&&E(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&E(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!di.test(e.from)&&E(`not an address or network: ${e.from} (e.g. 203.0.113.7 or 10.0.0.0/8)`);let r=e.from?[e.action,`from`,e.from,`to`,`any`,`port`,t,...n?[`proto`,n]:[]]:[e.action,e.port];return e.comment?[...r,`comment`,e.comment]:r}const pi=e=>`ufw_add ${e.map(L).join(` `)}`,mi=e=>e[0]===`deny`?`${pi([`prepend`,...e])} 2>/dev/null || ${pi(e)}`:pi(e),hi=e=>`$SUDO ufw --force delete ${e.spec} >/dev/null
640
- 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`)}`,gi=e=>Jn(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),_i=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function vi(e,t,n){if(e.ufw!==`active`)return;let r=Yn(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 k(`${i}. Do it anyway?`)||E(`${i} (--force to do it anyway)`)}function yi(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||E(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const bi={server:I,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 xi(e){return F({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:{...bi,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||E(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=fi({action:e,port:t,from:n.from,comment:n.comment}),i=await li(Z(n.server).server);e===`deny`&&await vi(i,[gi(r),...i.rules],n.force),await R(i.login,`${K}\n${_i}\n${mi(r)}`),S(`${r.join(` `)}${i.ufw===`active`?``:b.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const Si=xi(`allow`),Ci=xi(`deny`),wi=F({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:I,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=Z(t.server),i=await li(r);if(t.json){let{ufw:e,policy:t,sshPort:n,rules:r,ports:a,blocked:o}=i;console.log(JSON.stringify({ufw:e,policy:t,sshPort:n,rules:r.map((e,t)=>({n:t+1,...e})),ports:a,blocked:o},null,2));return}let a=i.ufw===`active`?b.green(`on`):i.ufw===`missing`?b.yellow(`not installed`):b.yellow(`off`);x(`firewall on ${n}: ${a}${i.ufw===`active`?b.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${b.bold(`Rules`)}`),i.rules.length||C(b.dim(`none`)),M(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?b.green(e.action):b.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${b.bold(`Listening ports`)}`);let o={open:b.green,limited:b.yellow,blocked:b.red,local:b.dim};M(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?w(`enabling would block ${i.blocked.join(`, `)}: allow them first`):C(b.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),Ti=F({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:I,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 li(r);if(i.ufw===`active`)return S(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await k(`${e}. Turn it on anyway?`)||E(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await vi({...i,ufw:`active`},[...i.rules,gi([`allow`,`${i.sshPort}/tcp`])],t.force),await R(i.login,`${K}
740
+ `));let f=ot(s);T(S.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)||O(`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)||O(`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)&&O(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&O(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!Ji.test(e.from)&&O(`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
741
+ 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 re(`${i}. Do it anyway?`)||O(`${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]||O(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const ra={server:I,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 F({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||O(`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)}`),w(`${r.join(` `)}${i.ufw===`active`?``:S.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const aa=ia(`allow`),oa=ia(`deny`),sa=F({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:I,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`?S.green(`on`):i.ufw===`missing`?S.yellow(`not installed`):S.yellow(`off`);C(`firewall on ${n}: ${a}${i.ufw===`active`?S.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${S.bold(`Rules`)}`),i.rules.length||T(S.dim(`none`)),N(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?S.green(e.action):S.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${S.bold(`Listening ports`)}`);let o={open:S.green,limited:S.yellow,blocked:S.red,local:S.dim};N(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?E(`enabling would block ${i.blocked.join(`, `)}: allow them first`):T(S.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),ca=F({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:I,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 w(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await re(`${e}. Turn it on anyway?`)||O(`${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}
641
742
  command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
642
- ${pi([`allow`,`${i.sshPort}/tcp`])}
643
- ufw_enable`),S(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),Ei=F({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:I},async run(e,t){let{name:n,server:r}=Z(t.server),i=await li(r);if(i.ufw!==`active`)return S(`firewall on ${n} is already off`);await j(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||E(`cancelled`),await R(i.login,`${K}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),S(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),Di=F({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:I,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||E(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=Z(t.server),i=await li(r),{rule:a,n:o}=yi(i,e);await vi(i,i.rules.filter(e=>e!==a),t.force),await A(`Remove rule ${o} (${a.spec}) on ${n}?`),await R(i.login,`${K}\n${_i}\n${hi(a)}`),S(`removed rule ${o}: ${a.spec}`)}}),Oi=F({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:{...bi,port:{type:`string`,desc:`port[/proto], e.g. 5432 or 51820/udp`},from:{type:`string`,desc:`only from this address or network; 'any' for everyone`},action:{type:`string`,desc:`allow, deny or limit`},force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||E(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await li(Z(t.server).server),{rule:r,n:i}=yi(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||E(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||E(`rule ${i} (${r.spec}) names no port; pass --port`);let s=fi({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 vi(n,a===`deny`?[gi(s),...c]:[...c,gi(s)],t.force),await R(n.login,`${K}
644
- ${_i}
645
- ${hi(r)}
646
- ${mi(s)} || { $SUDO ufw ${r.spec} >/dev/null; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),S(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}}),ki=e=>e.steps.filter(e=>e.undo&&!e.undone);function Ai(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
647
- `).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===qe)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 ji(e){let{stdout:t}=await R(e,`cat ${G} 2>/dev/null || sudo -n cat ${G} 2>/dev/null || true`,{capture:!0});return Ai(t)}function Mi(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||E(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||E(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function Ni(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}:E(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const Pi=String.raw`
743
+ ${Xi([`allow`,`${i.sshPort}/tcp`])}
744
+ ufw_enable`),w(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),la=F({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:I},async run(e,t){let{name:n,server:r}=Z(t.server),i=await Ki(r);if(i.ufw!==`active`)return w(`firewall on ${n} is already off`);await M(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||O(`cancelled`),await R(i.login,`${K}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),w(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),ua=F({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:I,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||O(`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 j(`Remove rule ${o} (${a.spec}) on ${n}?`),await R(i.login,`${K}\n${ea}\n${Qi(a)}`),w(`removed rule ${o}: ${a.spec}`)}}),da=F({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||O(`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)||O(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||O(`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}
745
+ ${ea}
746
+ ${Qi(r)}
747
+ ${Zi(s)} || { $SUDO ufw ${r.spec} >/dev/null; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),w(`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(`
748
+ `).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||O(`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||O(`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}:O(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const _a=String.raw`
648
749
  undo_step() { # undo_step <line> <undo shell, base64>
649
750
  set +e
650
751
  ( set -e; eval "$(printf %s "$2" | base64 -d)" )
@@ -652,9 +753,9 @@ undo_step() { # undo_step <line> <undo shell, base64>
652
753
  set -e
653
754
  [ "$rc" = 0 ] || { echo "could not undo journal line $1 (exit $rc); later steps are undone, the rest is kept" >&2; exit 1; }
654
755
  _journal undo "$1" ""
655
- }`;async function Fi(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 Ni(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(_e)),await R(s,[K,Pi,...o].join(`
656
- `),{sudo:c}),r.push(s),t=i}return n.length}function Ii(e,t){let n=b.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${b.dim(`↩ `+e.what)}`:e.undone?`${n} ${b.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${b.cyan(`↺`)} ${e.what}`:`${n} ${b.dim(`· `+e.what+` (not reversible)`)}`}const Li=e=>e.trim().replace(/\\(.)/g,`$1`),Ri=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function zi(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await li(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(`
657
- `)){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=Jn(Li(e[1])),n=i.findIndex(e=>Ri(e,t));n>=0&&i.splice(n,1)}else n?i=n[1]?[Jn(Li(n[2])),...i]:[...i,Jn(Li(n[2]))]:/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await vi({...r,ufw:a},i,n)}const Bi=e=>`${b.bold(`#${e.n}`.padEnd(5))} ${b.dim(N(e.time).padEnd(9))} ${b.bold(e.cmd||`?`)} ${b.dim(`${e.actor} · ${e.id}`)}`,Vi=F({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:I,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 ji(r)).reverse().slice(0,Number(t.n)||20);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return C(`nothing in ${G} on ${n} yet`);for(let e of i)console.log(Bi(e)),e.steps.forEach((e,t)=>console.log(Ii(e,t)));let a=i.find(e=>ki(e).length);a&&console.log(b.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),Hi=F({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:I,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 ji(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let t=e?Mi(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>ki(e.run).length);if(!t)return S(`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&&ki(e).length);r.length&&w(`later runs build on it and stay: ${r.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return S(`nothing left to undo in ${o}`);x(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${b.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${b.dim(`· ${e.what} stays (not reversible)`)}`);await zi(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)||E(`cancelled`),await Fi(r,s),S(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&C(b.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),Ui=F({name:`keygen`,args:`<name>`,summary:`Create an ed25519 key pair at ~/.ssh/landfall_<name> (skips if it exists) and print the public key.`,async run([e]){e||E(`usage: lf keygen <name>`);let{path:t,created:n}=qr(e);S(`${n?`created`:`key exists:`} ${et(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Wi(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?w(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await k(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):w(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Gi(e,t){x(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await re(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(C(b.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await 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=ct(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 Ki=F({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
756
+ }`;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(`
757
+ `),{sudo:c}),r.push(s),t=i}return n.length}function ya(e,t){let n=S.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${S.dim(`↩ `+e.what)}`:e.undone?`${n} ${S.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${S.cyan(`↺`)} ${e.what}`:`${n} ${S.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(`
758
+ `)){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=>`${S.bold(`#${e.n}`.padEnd(5))} ${S.dim(P(e.time).padEnd(9))} ${S.bold(e.cmd||`?`)} ${S.dim(`${e.actor} · ${e.id}`)}`,wa=F({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:I,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 T(`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(S.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),Ta=F({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:I,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 w(`nothing to undo on ${n}`);({steps:a}=t),o=`#${t.run.n} ${t.run.cmd} (${P(t.run.time)})`;let r=i.filter(e=>e.n>t.run.n&&fa(e).length);r.length&&E(`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 w(`nothing left to undo in ${o}`);C(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${S.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${S.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 j(l,n):await M(l,!1)||O(`cancelled`),await va(r,s),w(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&T(S.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),Ea=F({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||O(`usage: lf keygen <name>`);let{path:t,created:n}=Ai(e);w(`${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`?E(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await re(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):E(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Oa(e,t){C(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await ne(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(T(S.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await M(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&(t.email=await ne(`Let's Encrypt e-mail (optional)`,``)||void 0);let n=ot(e);t.dashboard??=await ne(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}const ka=F({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
658
759
 
659
760
  Turns a fresh box into a deploy target (safe to re-run):
660
761
  1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
@@ -663,7 +764,7 @@ Turns a fresh box into a deploy target (safe to re-run):
663
764
  4. write \`Host <name>\` to ~/.ssh/config so \`ssh <name>\` just works
664
765
  5. install Traefik with automatic Let's Encrypt certificates
665
766
 
666
- Works on existing ssh aliases too: \`lf setup het\`.
767
+ Works on existing ssh aliases too: \`lf setup vps\`.
667
768
 
668
769
  Before changing anything, setup inspects the server and only does what is
669
770
  missing. Steps that would break services already running there are skipped:
@@ -674,11 +775,11 @@ missing. Steps that would break services already running there are skipped:
674
775
  a Traefik already running there is used instead (lf traefik use)
675
776
  Use --check to only see that report, --force to apply firewall/hardening anyway.
676
777
  In a terminal, a first Traefik install asks for image, host network, e-mail and
677
- 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: ${Ee})`},"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||E(`usage: lf setup <name> [--host <ip>]`);let n=X(),r=n.servers[e],i=bt().includes(e)&&!At(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);x(`setting up ${b.bold(e)} ${b.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>x(`[${++c}/5] ${e}`);l(`ssh key`);let u=await Jr(e,a,{check:t.check,password:await Gr(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 Ye(a,t))return;let[m,ee,te]=await Promise.all([Un(a,p),Nn(),Pn()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let h=Number(m.values.ssh_port)||s.port,g=rr(m,{sshPort:h,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+=b.dim(` · ${ur(lr(r??a,v))}`));let T=It(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(C(`os: ${m.values.os??`unknown`}${m.values.pkg?b.dim(` · ${m.values.pkg}`):``}`),T.note&&(T.level===`ok`?C:w)(T.note),sr(g),t.check||!u)return;let D=await Wi(g,t.force),O=y&&_.status===`todo`&&ne(),k=t.domain?rt(t.domain):r&&ct(r);if(k===void 0&&O){let e=await re(`main domain for sites, e.g. example.com (optional)`,``);k=e.trim()?rt(e):void 0}O&&(await Gi({...r??a,domains:st(r?.domains,k)},v),C(`traefik: ${ur(lr(r??a,v))}${v.dashboard?`, dashboard ${v.dashboard}`:``}`));let A=g.filter(e=>e.status!==`done`&&D.has(e.part)).length;if(A&&!await j(`apply the ${A} change${A===1?``:`s`} above?`))return;let M=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];g.some(e=>M.includes(e.part)&&e.status!==`done`&&D.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await R(a,K+Pe,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:h,HARDEN:D.has(`harden`),FIREWALL:D.has(`firewall`)}}),S(`server bootstrapped`)):(l(`bootstrap`),S(`already bootstrapped, nothing to change`));let N={...a};if(f){let e={...a,user:f};await z(e)?(S(`deploy user ${b.bold(f)} ready`),N=e):w(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let P=Xr(e,N,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});k&&(P.domains=st(P.domains,k)),n.servers[e]=P,n.default??=e,tt(n),l(`traefik`),_?.proxy?D.has(`traefik`)&&_.status===`todo`?await fr(P,_.proxy):S(_.detail):D.has(`traefik`)?await dr(e,P,v):C(b.dim(`skipped`)),k?(console.log(`\n${b.green(`Done.`)} Point ${b.bold(k)} and ${b.bold(`*.${k}`)} (A records) at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${b.dim(`# https://blog.${k}`)}`),console.log(` lf server domain ${e} ${b.dim(`# check the DNS`)}\n`)):(console.log(`\n${b.green(`Done.`)} Point a domain's A record at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),qi=F({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
678
- 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-CqBtraHg.mjs`);return n({port:Number(t.port)||4777,open:t.open})}}),Ji={current:b.green,update:b.yellow,unknown:b.dim,pinned:b.dim,rebuild:b.dim,foreign:b.dim,missing:b.red};function Yi(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return C(`packages: no supported package manager`);let r=e.updates.length?`${b.bold(String(e.updates.length))} package updates${n?`, ${b.yellow(`${n} security`)}`:``}`:b.green(`packages up to date`);console.log(`${r}${e.reboot?b.yellow(` · reboot required`):``}${e.refreshed?``:b.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);M(i.map(e=>[` `+e.name,b.dim(e.from??``),`→ `+e.to,e.security?b.yellow(`security`):``])),i.length<e.updates.length&&console.log(b.dim(` … ${e.updates.length-i.length} more (--all)`))}function Xi(e){e.length&&(console.log(`\n${b.bold(`Images`)}`),M(e.map(e=>[` `+e.name,b.dim(e.ref||`-`),Ji[e.state](e.state===`update`?`newer image`:e.state),b.dim(e.note??``)])))}function Zi(e){let t=In(e);console.log(`\n${b.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||b.red(`missing`)}`:b.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+b.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(b.dim(` newest releases`))}const Qi=F({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:I,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 bn(n,t.refresh),i=await zn(n),a=await Fn(n);if(t.json)return void console.log(JSON.stringify({packages:r,images:i,engine:{...a,...In(a)}},null,2));Yi(r,t.all),Zi(a),Xi(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(b.dim(`\napply with: lf update${o}`)),In(a).composeUpdate&&console.log(b.dim(`newest compose: lf update --compose${o}`))}}),$i=F({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
778
+ 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||O(`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);C(`setting up ${S.bold(e)} ${S.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>C(`[${++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 g=Number(m.values.ssh_port)||s.port,_=Lr(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`},b=v&&!v.proxy&&v.status!==`conflict`&&v.status!==`off`;b&&(v.detail+=S.dim(` · ${Wr(Ur(r??a,y))}`));let x=Pt(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(T(`os: ${m.values.os??`unknown`}${m.values.pkg?S.dim(` · ${m.values.pkg}`):``}`),x.note&&(x.level===`ok`?T:E)(x.note),Vr(_),t.check||!u)return;let D=await Da(_,t.force),k=b&&v.status===`todo`&&te(),A=t.domain?tt(t.domain):r&&ot(r);if(A===void 0&&k){let e=await ne(`main domain for sites, e.g. example.com (optional)`,``);A=e.trim()?tt(e):void 0}k&&(await Oa({...r??a,domains:at(r?.domains,A)},y),T(`traefik: ${Wr(Ur(r??a,y))}${y.dashboard?`, dashboard ${y.dashboard}`:``}`));let re=_.filter(e=>e.status!==`done`&&D.has(e.part)).length;if(re&&!await M(`apply the ${re} change${re===1?``:`s`} above?`))return;let j=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];_.some(e=>j.includes(e.part)&&e.status!==`done`&&D.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await R(a,K+Ie,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:g,HARDEN:D.has(`harden`),FIREWALL:D.has(`firewall`)}}),w(`server bootstrapped`)):(l(`bootstrap`),w(`already bootstrapped, nothing to change`));let N={...a};if(f){let e={...a,user:f};await z(e)?(w(`deploy user ${S.bold(f)} ready`),N=e):E(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let P=Ni(e,N,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});A&&(P.domains=at(P.domains,A)),n.servers[e]=P,n.default??=e,$e(n),l(`traefik`),v?.proxy?D.has(`traefik`)&&v.status===`todo`?await Kr(P,v.proxy):w(v.detail):D.has(`traefik`)?await Gr(e,P,y):T(S.dim(`skipped`)),A?(console.log(`\n${S.green(`Done.`)} Point ${S.bold(A)} and ${S.bold(`*.${A}`)} (A records) at ${S.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${S.dim(`# https://blog.${A}`)}`),console.log(` lf server domain ${e} ${S.dim(`# check the DNS`)}\n`)):(console.log(`\n${S.green(`Done.`)} Point a domain's A record at ${S.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),Aa=F({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
779
+ 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:S.green,update:S.yellow,unknown:S.dim,pinned:S.dim,rebuild:S.dim,foreign:S.dim,missing:S.red};function Ma(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return T(`packages: no supported package manager`);let r=e.updates.length?`${S.bold(String(e.updates.length))} package updates${n?`, ${S.yellow(`${n} security`)}`:``}`:S.green(`packages up to date`);console.log(`${r}${e.reboot?S.yellow(` · reboot required`):``}${e.refreshed?``:S.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);N(i.map(e=>[` `+e.name,S.dim(e.from??``),`→ `+e.to,e.security?S.yellow(`security`):``])),i.length<e.updates.length&&console.log(S.dim(` … ${e.updates.length-i.length} more (--all)`))}function Na(e){e.length&&(console.log(`\n${S.bold(`Images`)}`),N(e.map(e=>[` `+e.name,S.dim(e.ref||`-`),ja[e.state](e.state===`update`?`newer image`:e.state),S.dim(e.note??``)])))}function Pa(e){let t=gr(e);console.log(`\n${S.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||S.red(`missing`)}`:S.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+S.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(S.dim(` newest releases`))}const Fa=F({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:I,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(S.dim(`\napply with: lf update${o}`)),gr(a).composeUpdate&&console.log(S.dim(`newest compose: lf update --compose${o}`))}}),Ia=F({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
679
780
 
680
781
  Without --packages/--images both are updated. Package upgrades cannot be undone;
681
782
  image updates can (\`lf undo\` puts the old image back). --compose replaces the
682
- compose plugin setup installed with the newest release (undoable too).`,options:{server:I,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf)`},reboot:{type:`boolean`,desc:`reboot afterwards if the upgrade needs it`},images:{type:`boolean`,desc:`pull newer images and recreate their containers`},compose:{type:`boolean`,desc:`update the compose plugin landfall installed to the newest release`}},async run(e,t){let{name:n,server:r}=Z(t.server);if(t.compose){(e.length||t.packages||t.images)&&E(`--compose goes alone`);let i=await _n(r,`updating docker compose`),a=await Fn(i),o=In(a);if(!o.composeUpdate)return void(o.composeNote?C(o.composeNote):S(`compose ${a.compose} is the newest`));await j(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(x(`downloading docker compose`),await Ln(i),S(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images;e.length&&t.packages&&E(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await _n(r,`upgrading packages`),i=await bn(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?w(`no supported package manager, skipping packages`):a.length?(Yi({...i,updates:a},!1),t.security&&i.mgr===`apk`&&E(`apk has no security-only upgrades; drop --security`),await j(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(x(`upgrading packages on ${n}`),await Sn(e,{only:t.security&&i.mgr===`apt-get`?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),S(`packages upgraded`),t.reboot&&C(`rebooting if required; give it a minute`))):S(t.security?`no security updates`:`packages up to date`)}if(o){let t=await zn(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&E(`no site or service ${r.join(`, `)} on ${n}`)}let i=e.length?t.filter(t=>e.includes(t.name)):t;for(let e of i.filter(e=>e.state!==`update`&&e.state!==`current`&&e.state!==`unknown`))C(b.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(Xi(a),await j(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(x(`updating images`),await Bn(r,a),S(`images updated (lf undo puts the old ones back)`))):S(`images up to date`)}}});var ea=`0.2.0`;const ta=[Ki,Rr,xr,Sr,Cr,Ar,Or,Tr,Er,Dr,kr,Nr,Pr,Fr,Qi,$i,Zr,Qr,ni,ii,ci,ei,$r,ti,mr,hr,gr,_r,vr,wi,Ti,Ei,Si,Ci,Oi,Di,Vi,Hi,Ui,qi],na={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`},ra=new Map(ta.map(e=>[e.name,e]));function ia(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=ra.get(na[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function aa(){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=[`${b.bold(`landfall`)} ${b.dim(ea)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${b.bold(`Usage:`)} lf ${b.dim(`web dashboard in your browser (same as lf ui)`)}`,` lf tui ${b.dim(`the dashboard in the terminal instead`)}`,` lf <command> [options] ${b.dim(`lf <command> --help for details`)}`];for(let[n,r]of e){t.push(``,b.bold(n));for(let e of r){let n=ra.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
683
- `)[0]}`)}}t.push(``,b.bold(`Quick start`),` lf setup het --check ${b.dim(`# what is on het, what setup would change`)}`,` lf setup het ${b.dim("# docker + traefik on a server you reach with `ssh het`")}`,` lf setup box --host 203.0.113.7 ${b.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s het ${b.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${b.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${b.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${b.dim(`# put a password in front of a site`)}`,` lf updates -s het && lf update -s het ${b.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${b.dim(`# what runs where`)}`,` lf history -s het && lf undo -s het ${b.dim(`# what landfall changed, and revert the last of it`)}`,``,b.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
684
- `))}async function oa(e){if(e[0]===`tui`&&e.length===1){process.stdin.isTTY&&process.stdout.isTTY||E("the terminal dashboard needs a terminal; `lf ui` opens the web one"),process.env.NODE_ENV??=`production`;let{dashboard:e}=await import(`./tui-BDvEukfL.mjs`);return e(sa)}if(!e.length&&process.stdin.isTTY&&process.stdout.isTTY&&(e=[`ui`]),!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return aa(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(ea),0;let{cmd:t,rest:n}=ia(e);if(!t)return console.error(`${b.red(`error:`)} unknown command "${e.join(` `)}"\n`),aa(),2;let{values:r,positionals:i,rest:a}=oe(t,n);if(r.help)return console.log(se(t)),0;O(!!r.yes),be.cmd=ce(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function sa(e){try{return await oa(e)}catch(e){return e instanceof T?console.error(`${b.red(`error:`)} ${e.message}`):console.error(e),1}}sa(process.argv.slice(2)).then(e=>process.exit(e));export{ht as A,N as B,Q as C,Qe as D,nt as E,W as F,ie as H,V as I,H as L,tt as M,Ee as N,Y as O,G as P,U as R,zt as S,$e as T,b as V,Yt as _,ai as a,Pt as b,Wr as c,Fn as d,zn as f,$ as g,sn as h,li as i,Z as j,X as k,jr as l,an as m,ki as n,ri as o,bn as p,ji as r,si as s,ea as t,In as u,Zt as v,bt as w,Bt as x,Ht as y,ue as z};
783
+ compose plugin setup installed with the newest release (undoable too).`,options:{server:I,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)&&O(`--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?T(o.composeNote):w(`compose ${a.compose} is the newest`));await M(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(C(`downloading docker compose`),await _r(i),w(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images;e.length&&t.packages&&O(`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`?E(`no supported package manager, skipping packages`):a.length?(Ma({...i,updates:a},!1),t.security&&i.mgr===`apk`&&O(`apk has no security-only upgrades; drop --security`),await M(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(C(`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}),w(`packages upgraded`),t.reboot&&T(`rebooting if required; give it a minute`))):w(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&&O(`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`))T(S.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 M(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(C(`updating images`),await br(r,a),w(`images updated (lf undo puts the old ones back)`))):w(`images up to date`)}}});var La=`0.3.1`;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=[`${S.bold(`landfall`)} ${S.dim(La)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${S.bold(`Usage:`)} lf <command> [options] ${S.dim(`lf <command> --help for details`)}`,` lf ui ${S.dim(`web dashboard in your browser`)}`,` lf tui ${S.dim(`the dashboard in the terminal`)}`];for(let[n,r]of e){t.push(``,S.bold(n));for(let e of r){let n=Ba.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
784
+ `)[0]}`)}}t.push(``,S.bold(`Quick start`),` lf setup vps --check ${S.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${S.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${S.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${S.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${S.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${S.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${S.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${S.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${S.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${S.dim(`# what landfall changed, and revert the last of it`)}`,``,S.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
785
+ `))}async function Ua(e){if(e[0]===`tui`&&e.length===1){process.stdin.isTTY&&process.stdout.isTTY||O("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(`${S.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;A(!!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 D?console.error(`${S.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,P 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,S as U,de as V,ae 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};