landfall 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import s,{homedir as c,hostname as l,tmpdir as u,userInfo as d}from"node:os";import{basename as f,dirname as p,join as m,normalize as ee,relative as te,resolve as ne}from"node:path";import{promises as h}from"node:dns";import g,{isIP as _}from"node:net";import{parse as re}from"yaml";import{randomBytes as v}from"node:crypto";import y from"bcryptjs";import ie from"node:http";import b from"node:https";const x=async()=>{process.stdin.isTTY&&k(`expected the value on stdin, e.g. printf %s "$SECRET" | lf …`);let e=``;for await(let t of process.stdin)e+=t;return e},ae=process.stdout.isTTY&&!process.env.NO_COLOR,S=e=>t=>ae?`\x1b[${e}m${t}\x1b[0m`:t,C={bold:S(`1`),dim:S(`2`),red:S(`31`),green:S(`32`),yellow:S(`33`),blue:S(`34`),cyan:S(`36`)},w=e=>console.error(`${C.cyan(`==>`)} ${C.bold(e)}`),T=e=>console.error(`${C.green(`✓`)} ${e}`),E=e=>console.error(` ${e}`),D=e=>console.error(`${C.yellow(`!`)} ${e}`);var O=class extends Error{};function oe(e){process.env.LANDFALL_REPORT&&process.stderr.write(`${JSON.stringify(e)}\n`)}const k=e=>{throw new O(e)};let A=!1;const j=e=>A=e,se=()=>!A&&!!process.stdin.isTTY;async function ce(e,t){if(A||!process.stdin.isTTY){if(t!==void 0)return t;k(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${C.blue(`?`)} ${e}${t?C.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function le(e){return A||!process.stdin.isTTY?!1:N(e,!1)}async function M(e,t){A||(process.stdin.isTTY||k(`${e} Pass -y to confirm (no terminal to ask).`),t?await ce(`${e} Type ${C.bold(t)} to confirm:`)!==t&&k(`cancelled`):await N(e,!1)||k(`cancelled`))}async function N(e,t=!0){return A?!0:(process.stdin.isTTY||k(`${e} Pass -y to confirm (no terminal to ask).`),(await ce(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`))}function P(e){if(!e.length)return;let t=e[0].map((t,n)=>Math.max(...e.map(e=>a(e[n]??``))));for(let n of e)console.log(n.map((e,n)=>e+` `.repeat(t[n]-a(e))).join(` `).trimEnd())}const ue=e=>{let t=(Date.now()-Date.parse(e))/1e3;return t<90?`${Math.round(t)}s ago`:t<5400?`${Math.round(t/60)}m ago`:t<129600?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`};async function de(e){(A||!process.stdin.isTTY)&&k(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${C.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
2
+ import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import s,{homedir as c,hostname as l,tmpdir as u,userInfo as d}from"node:os";import{basename as f,dirname as p,join as m,normalize as ee,relative as te,resolve as ne}from"node:path";import{promises as h}from"node:dns";import g,{isIP as _}from"node:net";import{parse as re}from"yaml";import{randomBytes as v}from"node:crypto";import y from"bcryptjs";import ie from"node:http";import b from"node:https";const x=async()=>{process.stdin.isTTY&&k(`expected the value on stdin, e.g. printf %s "$SECRET" | lf …`);let e=``;for await(let t of process.stdin)e+=t;return e},ae=process.stdout.isTTY&&!process.env.NO_COLOR,S=e=>t=>ae?`\x1b[${e}m${t}\x1b[0m`:t,C={bold:S(`1`),dim:S(`2`),red:S(`31`),green:S(`32`),yellow:S(`33`),blue:S(`34`),cyan:S(`36`)},w=e=>console.error(`${C.cyan(`==>`)} ${C.bold(e)}`),T=e=>console.error(`${C.green(`✓`)} ${e}`),E=e=>console.error(` ${e}`),D=e=>console.error(`${C.yellow(`!`)} ${e}`);var O=class extends Error{};function oe(e){process.env.LANDFALL_REPORT&&process.stderr.write(`${JSON.stringify(e)}\n`)}const k=e=>{throw new O(e)};let A=!1;const j=e=>A=e,se=()=>!A&&!!process.stdin.isTTY;async function ce(e,t){if(A||!process.stdin.isTTY){if(t!==void 0)return t;k(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${C.blue(`?`)} ${e}${t?C.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function le(e){return A||!process.stdin.isTTY?!1:N(e,!1)}async function M(e,t){A||(process.stdin.isTTY||k(`${e} Pass -y to confirm (no terminal to ask).`),t?await ce(`${e} Type ${C.bold(t)} to confirm:`)!==t&&k(`cancelled`):await N(e,!1)||k(`cancelled`))}async function N(e,t=!0){return A?!0:(process.stdin.isTTY||k(`${e} Pass -y to confirm (no terminal to ask).`),(await ce(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`))}function ue(e){if(!e.length)return;let t=e[0].map((t,n)=>Math.max(...e.map(e=>a(e[n]??``))));for(let n of e)console.log(n.map((e,n)=>e+` `.repeat(t[n]-a(e))).join(` `).trimEnd())}const de=e=>{let t=(Date.now()-Date.parse(e))/1e3;return t<90?`${Math.round(t)}s ago`:t<5400?`${Math.round(t/60)}m ago`:t<129600?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`};async function fe(e){(A||!process.stdin.isTTY)&&k(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${C.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
3
3
  `)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new O(`cancelled`));o===``||o===`\b`?n=n.slice(0,-1):n+=o}};t.on(`data`,i)})}finally{t.setRawMode(!1),t.pause(),process.stderr.write(`
4
- `)}}function fe(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const F=e=>e,I={type:`string`,short:`s`,desc:`server name or ssh alias`},L={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}},pe=e=>`LANDFALL_SECRET_${e.toUpperCase().replace(/-/g,`_`)}`;function me(t,n){let r=n.indexOf(`--`),i=t.passthrough&&r>=0?n.slice(0,r):n,a=t.passthrough&&r>=0?n.slice(r+1):[],o={};for(let[e,{desc:n,...r}]of Object.entries({...L,...t.options}))o[e]=r;try{let{values:n,positionals:r}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});for(let e of Object.keys(t.options??{}))/(^|-)password$/.test(e)&&n[e]===void 0&&process.env[pe(e)]&&(n[e]=process.env[pe(e)]);return{values:n,positionals:r,rest:a}}catch(e){return k(`${e.message}\n\n${he(t)}`)}}function he(e){let t=[`${C.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,C.bold(`Options:`));let n=Object.entries({...e.options,yes:L.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?C.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?C.dim(` (repeatable)`):``)+r]}),r=Math.max(...n.map(e=>e[0].length));for(let[e,i]of n)t.push(` ${e.padEnd(r)} ${i}`);return t.join(`
5
- `)}function ge(e,t,n){let r=e=>e.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.*/s,`$1=…`),i=Object.entries(n).flatMap(([t,n])=>t===`server`||t===`yes`||t===`help`||n===void 0||n===e.options?.[t]?.default?[]:n===!1?[`--no-${t}`]:n===!0?[`--${t}`]:(Array.isArray(n)?n:[n]).map(e=>/pass|secret|token/i.test(t)?`--${t} …`:`--${t} ${r(String(e))}`));return[e.name,...t.map(r),...i].join(` `)}const _e=()=>V().defaults??{},ve=e=>e.tunnelOnly??_e().tunnelOnly??!1,ye=e=>process.env[`LANDFALL_${e}`],R=process.env.XDG_CONFIG_HOME??m(c(),`.config`),be=ye(`CONFIG_DIR`)??m(R,`landfall`),xe=m(be,`config.json`),z=e=>e.startsWith(`~/`)?m(c(),e.slice(2)):e,B=e=>{let t=c();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function V(){try{let e=JSON.parse(o.readFileSync(xe,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:k(`cannot read ${xe}: ${e.message}`)}}function Se(e){o.mkdirSync(p(xe),{recursive:!0,mode:448}),o.writeFileSync(xe,JSON.stringify(e,null,2)+`
4
+ `)}}function pe(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const P=e=>e,F={type:`string`,short:`s`,desc:`server name or ssh alias`},I={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}},me=e=>`LANDFALL_SECRET_${e.toUpperCase().replace(/-/g,`_`)}`;function he(t,n){let r=n.indexOf(`--`),i=t.passthrough&&r>=0?n.slice(0,r):n,a=t.passthrough&&r>=0?n.slice(r+1):[],o={};for(let[e,{desc:n,...r}]of Object.entries({...I,...t.options}))o[e]=r;try{let{values:n,positionals:r}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});for(let e of Object.keys(t.options??{}))/(^|-)password$/.test(e)&&n[e]===void 0&&process.env[me(e)]&&(n[e]=process.env[me(e)]);return{values:n,positionals:r,rest:a}}catch(e){return k(`${e.message}\n\n${ge(t)}`)}}function ge(e){let t=[`${C.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,C.bold(`Options:`));let n=Object.entries({...e.options,yes:I.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?C.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?C.dim(` (repeatable)`):``)+r]}),r=Math.max(...n.map(e=>e[0].length));for(let[e,i]of n)t.push(` ${e.padEnd(r)} ${i}`);return t.join(`
5
+ `)}function _e(e,t,n){let r=e=>e.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.*/s,`$1=…`),i=Object.entries(n).flatMap(([t,n])=>t===`server`||t===`yes`||t===`help`||n===void 0||n===e.options?.[t]?.default?[]:n===!1?[`--no-${t}`]:n===!0?[`--${t}`]:(Array.isArray(n)?n:[n]).map(e=>/pass|secret|token/i.test(t)?`--${t} …`:`--${t} ${r(String(e))}`));return[e.name,...t.map(r),...i].join(` `)}const ve=()=>V().defaults??{},ye=e=>e.tunnelOnly??ve().tunnelOnly??!1,be=e=>process.env[`LANDFALL_${e}`],L=process.env.XDG_CONFIG_HOME??m(c(),`.config`),xe=be(`CONFIG_DIR`)??m(L,`landfall`),R=m(xe,`config.json`),z=e=>e.startsWith(`~/`)?m(c(),e.slice(2)):e,B=e=>{let t=c();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function V(){try{let e=JSON.parse(o.readFileSync(R,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:k(`cannot read ${R}: ${e.message}`)}}function Se(e){o.mkdirSync(p(R),{recursive:!0,mode:448}),o.writeFileSync(R,JSON.stringify(e,null,2)+`
6
6
  `,{mode:384})}function Ce(e){let t=V();delete t.servers[e],t.default===e&&delete t.default,Se(t)}function we(e){let t=V();t.servers[e]??={host:e},t.default=e,Se(t)}function H(e){let t=V();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?k("no server given. Use --server <ssh-alias> or `lf server add`"):k(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}(!/^[A-Za-z0-9._@:\[\]-]+$/.test(e)||e.startsWith(`-`))&&k(`not a server name: ${e}`);let n=t.servers[e]??{host:e};return{name:e,server:n}}const Te=e=>(/^[A-Za-z0-9_@:\[\]][A-Za-z0-9._@:\[\]-]*$/.test(e)||k(`not a server name: ${e} (letters, digits and . _ - @ : only)`),e),Ee=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,De=/^[A-Za-z0-9._@-]{1,64}$/,Oe=(e,t=`user`)=>(De.test(e)||k(`${t} may only have letters, digits and . _ @ -`),e),ke=e=>(/^[^\s@"'`$\\]+@[^\s@"'`$\\]+\.[^\s@"'`$\\]+$/.test(e)||k(`not an e-mail address: ${e}`),e),Ae=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return Ee.test(t)||k(`not a domain: ${e}`),t},je=/^[a-z0-9][a-z0-9._\-/:@]*$/i,Me=e=>je.test(e)?e:k(`not an image: ${e}`);function Ne(e,t,n,r=2**53-1){let i=typeof e==`number`?e:/^\s*\d+\s*$/.test(String(e))?Number(e):NaN;return(!Number.isInteger(i)||i<n||i>r)&&k(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const Pe=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,Fe=e=>e.domains?.[0];function Ie(e,t){let n=Fe(t);return e===`@`?n??k("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const Le=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),Re=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 ze(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!Ee.test(n))continue;let e=Re(n);Le.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 Be=`.landfall.json`,Ve=e=>m(e,Be);function He(e){try{return JSON.parse(o.readFileSync(Ve(e),`utf8`))}catch{return}}function Ue(e,t){o.writeFileSync(m(e,Be),JSON.stringify(t,null,2)+`
7
7
  `)}const U=`/opt/landfall`,W=`${U}/sites`,G=`${U}/traefik`,We=`${G}/dynamic`,Ge=8080,Ke=`dashboard-tunnel`,qe=`traefik:v3`,K=`${U}/proxy.env`,q=`${U}/journal.log`,Je=`/etc/ssh/sshd_config.d/00-landfall.conf`,Ye={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},Xe=`"$(sed -n 's/^container=//p' ${K} 2>/dev/null | grep . || echo traefik)"`;function Ze(e){let{container:t,network:n,entrypoint:r,resolver:i}=Object.fromEntries(e.split(/\s+/).filter(e=>e.includes(`=`)&&!e.startsWith(`#`)).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)]));return t&&n&&r&&i?{container:t,network:n,entrypoint:r,resolver:i}:void 0}const Qe=e=>`# landfall routes its sites through this existing traefik; delete the file to let landfall run its own\ncontainer=${e.container}\nnetwork=${e.network}\nentrypoint=${e.entrypoint}\nresolver=${e.resolver}\n`,J=String.raw`
8
8
  log() { printf '\033[36m ->\033[0m %s\n' "$*" >&2; }
@@ -353,16 +353,17 @@ ${e.hostNetwork?``:` networks:
353
353
  `)}\n`:``}${e.hostNetwork?``:`networks:
354
354
  web:
355
355
  external: true
356
- `}`}let ot=!1;const st=e=>ot=e,Y=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,ct=(e=ye(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],lt=e=>((/^-|\s/.test(e.host)||e.user&&/^-|\s/.test(e.user))&&k(`not a server: ${e.user?`${e.user}@`:``}${e.host}`),e.user?`${e.user}@${e.host}`:e.host);function ut(e){let t=[...ct(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,z(e.key),`-o`,`IdentitiesOnly=yes`),t}let dt;function ft(){return dt||(dt=m(process.platform===`darwin`?`/tmp`:u(),`landfall-${d().uid}`),o.mkdirSync(dt,{recursive:!0,mode:448})),dt}function pt(e,t={}){let n=[...ut(e),`-o`,`ServerAliveInterval=15`];return(t.batch||ot)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${ft()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,lt(e)),n}function mt(e){return new Promise(t=>{let r=[...ut(e),`-o`,`ControlPath=${ft()}/%C`,`-O`,`exit`,lt(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function ht(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`,ot?`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 gt(e,t=[]){let{code:n}=await ht(`ssh`,[...pt(e,{tty:process.stdin.isTTY}),...t],{});return n}const _t=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,vt={run:process.env.LANDFALL_RUN||_t(),actor:`${d().username}@${l().split(`.`)[0]}`,cmd:``};async function X(e,t,n={}){let r={LANDFALL_RUN:vt.run,LANDFALL_ACTOR:vt.actor,LANDFALL_CMD:vt.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${Y(typeof t==`boolean`?+!!t:t)}`).join(`
356
+ `}`}let ot=!1;const st=e=>ot=e,Y=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,ct=(e=be(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],lt=e=>((/^-|\s/.test(e.host)||e.user&&/^-|\s/.test(e.user))&&k(`not a server: ${e.user?`${e.user}@`:``}${e.host}`),e.user?`${e.user}@${e.host}`:e.host);function ut(e){let t=[...ct(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,z(e.key),`-o`,`IdentitiesOnly=yes`),t}let dt;function ft(){return dt||(dt=m(process.platform===`darwin`?`/tmp`:u(),`landfall-${d().uid}`),o.mkdirSync(dt,{recursive:!0,mode:448})),dt}function pt(e,t={}){let n=[...ut(e),`-o`,`ServerAliveInterval=15`];return(t.batch||ot)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${ft()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,lt(e)),n}function mt(e){return new Promise(t=>{let r=[...ut(e),`-o`,`ControlPath=${ft()}/%C`,`-O`,`exit`,lt(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function ht(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`,ot?`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 gt(e,t=[]){let{code:n}=await ht(`ssh`,[...pt(e,{tty:process.stdin.isTTY}),...t],{});return n}const _t=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,vt={run:process.env.LANDFALL_RUN||_t(),actor:`${d().username}@${l().split(`.`)[0]}`,cmd:``};async function X(e,t,n={}){let r={LANDFALL_RUN:vt.run,LANDFALL_ACTOR:vt.actor,LANDFALL_CMD:vt.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${Y(typeof t==`boolean`?+!!t:t)}`).join(`
357
357
  `),a=await ht(`ssh`,[...pt(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${i}\n${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
358
- `).at(-1);k(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function yt(e){let{code:t}=await ht(`ssh`,[...pt(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}async function bt(e){let{code:t}=await ht(`ssh`,[...pt(e,{batch:!0}),`test -d ${U}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const xt=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`];let St;async function Ct(){if(!St){let{stdout:e}=await ht(`tar`,[`--version`],{capture:!0});St=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return St}async function wt(e,t,r,i=xt){o.existsSync(t)||k(`directory not found: ${t}`);let a=[...await Ct(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${Y(r)} && tar -xzof - -C ${Y(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[...pt(e),s],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(o.stdin),i.on(`error`,r),o.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(o)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const Z=(e,t,n=`644`)=>`printf %s ${Y(Buffer.from(t).toString(`base64`))} | base64 -d > ${Y(e)} && chmod ${n} ${Y(e)}`,Tt=ye(`SSH_CONFIG`)??m(c(),`.ssh`,`config`),Et=/* @__PURE__ */ new Map;function Q(e){let n=[...ut(e),`-G`,lt(e)],r=Et.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return k(`ssh cannot resolve ${lt(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>z(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return Et.set(n.join(`\0`),s),s}function Dt(e){return(e.key?[z(e.key)]:Q(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function Ot(e=Tt,t=/* @__PURE__ */ new Set){if(t.has(e)||!o.existsSync(e))return[];t.add(e);let n=[];for(let r of o.readFileSync(e,`utf8`).split(`
359
- `)){let e=r.trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!e)continue;let[,i,a]=e;if(i.toLowerCase()===`host`)n.push(...a.split(/\s+/).filter(e=>!/[*?!]/.test(e)));else if(i.toLowerCase()===`include`)for(let e of a.split(/\s+/)){let r=z(e),i=r.startsWith(`/`)?r:m(c(),`.ssh`,r);for(let e of kt(i))n.push(...Ot(e,t))}}return[...new Set(n)]}function kt(e){if(!/[*?[]/.test(e))return[e];let t=p(e);return o.existsSync(t)?o.globSync(f(e),{cwd:t}).map(e=>m(t,e)):[]}const At=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],jt=(e,t)=>RegExp(`# >>> landfall:${zt(e)}\\n[\\s\\S]*?# <<< landfall:${zt(e)}${t}`),Mt=(e,t)=>e===`# >>> landfall:${t}`;function Nt(e){let[t,n]=At(e.alias);return[t,`Host ${e.alias}`,` HostName ${e.hostname}`,` User ${e.user}`,...e.port===22?[]:[` Port ${e.port}`],` IdentityFile ${e.key}`,` IdentitiesOnly yes`,n].join(`
360
- `)}function Pt(e,t=Tt){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`);Te(e.alias);let i=Nt(e),a=jt(e.alias,`\\n?`),s=a.test(r)?r.replace(a,()=>i+`
361
- `):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),Et.clear(),{created:n,block:i}}function Ft(e,t=Tt,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
362
- `);for(let i=0;i<r.length;i++){let a=r[i].trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!a)continue;let o=a[1].toLowerCase();if(o===`host`&&a[2].split(/\s+/).includes(e)){let n=i+1;for(;n<r.length&&!/^\s*(host|match)\b/i.test(r[n])&&!r[n].startsWith(`# <<< landfall:`);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:Mt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=z(t);for(let t of kt(r.startsWith(`/`)?r:m(c(),`.ssh`,r))){let r=Ft(e,t,n);if(r)return r}}}}function It(e,t){let n=Ft(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
358
+ `).at(-1);k(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function yt(e){let{code:t}=await ht(`ssh`,[...pt(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}function bt(e,t=5){let r=Date.now(),i=[...ut(e),`-o`,`ConnectTimeout=${t}`,`-o`,`BatchMode=yes`,`-o`,`ControlPath=none`,`-T`,lt(e),`true`];return new Promise(e=>{let a=(t,n)=>e({state:t,detail:n,ms:Date.now()-r}),o=n(`ssh`,i,{stdio:[`ignore`,`ignore`,`pipe`]}),s=``;o.stderr.on(`data`,e=>s+=e);let c=setTimeout(()=>o.kill(),(t+5)*1e3);o.on(`error`,e=>(clearTimeout(c),a(`down`,e.message))),o.on(`close`,e=>{clearTimeout(c);let t=s.trim().split(`
359
+ `).filter(e=>!/^Warning: Permanently added/.test(e)).pop()??``;e===0?a(`ok`,`key login works`):e===null?a(`down`,`no answer`):/resolve|not known|nodename|no address/i.test(t)?a(`down`,`host name not found`):/timed out|refused|no route|unreachable|network is down/i.test(t)?a(`down`,t.replace(/^ssh: /,``)):a(`login`,t||`ssh exited ${e}`)})})}async function xt(e){let{code:t}=await ht(`ssh`,[...pt(e,{batch:!0}),`test -d ${U}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const St=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`];let Ct;async function wt(){if(!Ct){let{stdout:e}=await ht(`tar`,[`--version`],{capture:!0});Ct=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return Ct}async function Tt(e,t,r,i=St){o.existsSync(t)||k(`directory not found: ${t}`);let a=[...await wt(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${Y(r)} && tar -xzof - -C ${Y(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[...pt(e),s],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(o.stdin),i.on(`error`,r),o.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(o)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const Z=(e,t,n=`644`)=>`printf %s ${Y(Buffer.from(t).toString(`base64`))} | base64 -d > ${Y(e)} && chmod ${n} ${Y(e)}`,Et=be(`SSH_CONFIG`)??m(c(),`.ssh`,`config`),Dt=/* @__PURE__ */ new Map;function Q(e){let n=[...ut(e),`-G`,lt(e)],r=Dt.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return k(`ssh cannot resolve ${lt(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>z(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return Dt.set(n.join(`\0`),s),s}function Ot(e){return(e.key?[z(e.key)]:Q(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function kt(e=Et,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(`
360
+ `)){let e=r.trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!e)continue;let[,i,a]=e;if(i.toLowerCase()===`host`)n.push(...a.split(/\s+/).filter(e=>!/[*?!]/.test(e)));else if(i.toLowerCase()===`include`)for(let e of a.split(/\s+/)){let r=z(e),i=r.startsWith(`/`)?r:m(c(),`.ssh`,r);for(let e of At(i))n.push(...kt(e,t))}}return[...new Set(n)]}function At(e){if(!/[*?[]/.test(e))return[e];let t=p(e);return o.existsSync(t)?o.globSync(f(e),{cwd:t}).map(e=>m(t,e)):[]}const jt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],Mt=(e,t)=>RegExp(`# >>> landfall:${Bt(e)}\\n[\\s\\S]*?# <<< landfall:${Bt(e)}${t}`),Nt=(e,t)=>e===`# >>> landfall:${t}`;function Pt(e){let[t,n]=jt(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(`
361
+ `)}function Ft(e,t=Et){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`);Te(e.alias);let i=Pt(e),a=Mt(e.alias,`\\n?`),s=a.test(r)?r.replace(a,()=>i+`
362
+ `):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),Dt.clear(),{created:n,block:i}}function It(e,t=Et,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
363
+ `);for(let i=0;i<r.length;i++){let a=r[i].trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!a)continue;let o=a[1].toLowerCase();if(o===`host`&&a[2].split(/\s+/).includes(e)){let n=i+1;for(;n<r.length&&!/^\s*(host|match)\b/i.test(r[n])&&!r[n].startsWith(`# <<< landfall:`);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:Nt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=z(t);for(let t of At(r.startsWith(`/`)?r:m(c(),`.ssh`,r))){let r=It(e,t,n);if(r)return r}}}}function Lt(e,t){let n=It(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
363
364
  `),i=r.slice(n.start+1,n.end),a=e=>e.trim().match(/^identityfile\s*=?\s*(.+)$/i)?.[1].replace(/^"(.*)"$/,`$1`),s=i.filter(e=>{let n=a(e);return n!==void 0&&(z(n)===t||!/[%$]/.test(n)&&!o.existsSync(z(n)))}),c=`${i.find(e=>e.trim())?.match(/^\s*/)?.[0]||` `}IdentityFile ${t}`,l=i.filter(e=>!s.includes(e));r.splice(n.start+1,i.length,c,...l);let u=`${n.file}.landfall-bak`;return o.existsSync(u)||o.copyFileSync(n.file,u),o.writeFileSync(n.file,r.join(`
364
- `)),Et.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function Lt(e,t=Tt){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=jt(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const Rt=(e,t=Tt)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
365
- `).some(t=>Mt(t,e)),zt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Bt=String.raw`
365
+ `)),Dt.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function Rt(e,t=Et){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=Mt(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const zt=(e,t=Et)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
366
+ `).some(t=>Nt(t,e)),Bt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Vt=String.raw`
366
367
  set +e
367
368
  kv() { printf '%s=%s\n' "$1" "$2"; }
368
369
  PRETTY_NAME=unknown; ID=; VERSION_ID=; ID_LIKE=; . /etc/os-release 2>/dev/null
@@ -384,10 +385,10 @@ kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\
384
385
  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)"
385
386
  kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
386
387
  exit 0
387
- `;function Vt(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
388
- `)){let e=t.indexOf(`=`);e>0&&(n[t.slice(0,e)]=t.slice(e+1).trim())}let r=(e=``)=>e.split(/\s+/).map(e=>Number(e)*1024),[i=0,a=0]=r(n.mem),[o=0,s=0,c=0]=r(n.disk);return{os:n.os||`unknown`,id:n.id??``,version:n.version??``,like:n.like??``,arch:n.arch??``,kernel:n.kernel??``,uptime:Math.round(Number(n.uptime)||0),cpus:Number(n.cpus)||0,load:n.load??``,mem:{total:i||0,available:a||0},disk:{total:o||0,used:s||0,available:c||0},pkg:n.pkg||`none`,docker:n.docker??``,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:Wt(n.id??``,n.version??``,n.pkg||`none`,t)}}async function Ht(e){let{stdout:t}=await X(e,J+Bt,{capture:!0});return Vt(t)}const Ut={"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 Wt(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=Ut[`${e} ${t}`]??Ut[`${e} ${a}`]??Ut[`${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 Gt=/^(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])\.)/,Kt=e=>_(e)===4&&!Gt.test(e);async function qt(e,t){let n=Q(e).hostname,r=_(n)?[n]:await h.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const Jt=e=>e.find(Kt)??e[0],Yt=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function Xt(e,t){return Promise.all(e.map(async e=>{let n=await h.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const Zt=e=>{let t=[`B`,`K`,`M`,`G`,`T`],n=0;for(;e>=1024&&n<t.length-1;)e/=1024,n++;return`${e<10&&n?e.toFixed(1):Math.round(e)}${t[n]}`},Qt=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`},$t=e=>e.v!==2,en=e=>e.project??e.name,tn=e=>$t(e)?void 0:e.releases.find(t=>t.id===e.current),nn=e=>!!(e.port||e.own),rn=e=>!!e.port&&!e.domains&&!e.own,an=e=>e?.services.find(rn),on=e=>e.adopted?void 0:an(tn(e)),sn=e=>`landfall-${e}`,cn=e=>e.adopted?`adopted`:$t(e)?`old`:`managed`,ln=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[cn(e)],un=e=>/^up/i.test(e),dn=e=>/^exited \(0\)/i.test(e);function fn(e){let t=tn(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...an(t)?e.domains:[],...n])]}const pn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,mn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],hn=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],gn=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),_n=/^traefik\.http\.routers\.[^.]+\.rule$/,vn=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function yn(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])=>_n.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])=>gn(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=>!Ee.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&&(nn(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(nn)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(rn);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 bn=it,xn=`landfall-proxy`;function Sn(e,t,n,r,i=[]){let a=`${e}_${t.name.toLowerCase().replace(/\./g,`-`)}`,o={"traefik.enable":`true`,"traefik.docker.network":r.network,[`traefik.http.routers.${a}.rule`]:n.map(e=>`Host(\`${e}\`)`).join(` || `),[`traefik.http.routers.${a}.entrypoints`]:r.entrypoint,[`traefik.http.routers.${a}.tls.certresolver`]:r.resolver,[`traefik.http.routers.${a}.service`]:a,[`traefik.http.services.${a}.loadbalancer.server.port`]:String(t.port)};return i.length&&(o[`traefik.http.middlewares.${e}-auth.basicauth.users`]=i.join(`,`),o[`traefik.http.routers.${a}.middlewares`]=`${e}-auth`),o}function Cn(e,t,n=Ye){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${bn(en(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${bn(a.name)}:`),a.build&&r.push(` image: ${bn(pn(e.name,a.name,t.id))}`);let o={};nn(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${bn(e)}: {}`),` ${xn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=Sn(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])=>` ${bn(e)}: ${bn(rt(t))}`))}return i&&r.push(`networks:`,` ${xn}:`,` name: ${bn(n.network)}`,` external: true`),r.join(`
388
+ `;function Ht(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
389
+ `)){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:Gt(n.id??``,n.version??``,n.pkg||`none`,t)}}async function Ut(e){let{stdout:t}=await X(e,J+Vt,{capture:!0});return Ht(t)}const Wt={"ubuntu 18.04":`2023-05-31`,"ubuntu 20.04":`2025-05-31`,"ubuntu 22.04":`2027-06-01`,"ubuntu 23.10":`2024-07-11`,"ubuntu 24.04":`2029-05-31`,"ubuntu 24.10":`2025-07-10`,"ubuntu 25.04":`2026-01-15`,"ubuntu 25.10":`2026-07-09`,"ubuntu 26.04":`2031-05-31`,"debian 9":`2022-06-30`,"debian 10":`2024-06-30`,"debian 11":`2026-08-31`,"debian 12":`2028-06-30`,"debian 13":`2030-06-30`,"centos 7":`2024-06-30`,"centos 8":`2021-12-31`,"rhel 8":`2029-05-31`,"rhel 9":`2032-05-31`,"rocky 8":`2029-05-31`,"rocky 9":`2032-05-31`,"almalinux 8":`2029-05-31`,"almalinux 9":`2032-05-31`,"alpine 3.17":`2024-11-22`,"alpine 3.18":`2025-05-09`,"alpine 3.19":`2025-11-01`,"alpine 3.20":`2026-04-01`,"alpine 3.21":`2026-11-01`,"alpine 3.22":`2027-05-01`};function Gt(e,t,n,r=/* @__PURE__ */ new Date){if(n===`none`)return{level:`unsupported`,note:`no apt, dnf, yum, apk or zypper: setup cannot install packages`};let i=t.split(`.`)[0],a=t.split(`.`).slice(0,2).join(`.`),o=Wt[`${e} ${t}`]??Wt[`${e} ${a}`]??Wt[`${e} ${i}`];if(!o)return{level:`ok`};let s=(Date.parse(o)-r.getTime())/864e5;return s<0?{level:`eol`,note:`${e} ${t} reached end of life on ${o}: no more security updates, upgrade the release`}:s<90?{level:`ok`,note:`${e} ${t} reaches end of life on ${o}`}:{level:`ok`}}const Kt=/^(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])\.)/,qt=e=>_(e)===4&&!Kt.test(e);async function Jt(e,t){let n=Q(e).hostname,r=_(n)?[n]:await h.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const Yt=e=>e.find(qt)??e[0],Xt=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function Zt(e,t){return Promise.all(e.map(async e=>{let n=await h.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const Qt=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]}`},$t=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`},en=e=>e.v!==2,tn=e=>e.project??e.name,nn=e=>en(e)?void 0:e.releases.find(t=>t.id===e.current),rn=e=>!!(e.port||e.own),an=e=>!!e.port&&!e.domains&&!e.own,on=e=>e?.services.find(an),sn=e=>e.adopted?void 0:on(nn(e)),cn=e=>`landfall-${e}`,ln=e=>e.adopted?`adopted`:en(e)?`old`:`managed`,un=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[ln(e)],dn=e=>/^up/i.test(e),fn=e=>/^exited \(0\)/i.test(e);function pn(e){let t=nn(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...on(t)?e.domains:[],...n])]}const mn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,hn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],gn=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],_n=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),vn=/^traefik\.http\.routers\.[^.]+\.rule$/,yn=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function bn(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])=>vn.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])=>_n(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=>!Ee.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&&(rn(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(rn)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(an);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 xn=it,Sn=`landfall-proxy`;function Cn(e,t,n,r,i=[]){let a=`${e}_${t.name.toLowerCase().replace(/\./g,`-`)}`,o={"traefik.enable":`true`,"traefik.docker.network":r.network,[`traefik.http.routers.${a}.rule`]:n.map(e=>`Host(\`${e}\`)`).join(` || `),[`traefik.http.routers.${a}.entrypoints`]:r.entrypoint,[`traefik.http.routers.${a}.tls.certresolver`]:r.resolver,[`traefik.http.routers.${a}.service`]:a,[`traefik.http.services.${a}.loadbalancer.server.port`]:String(t.port)};return i.length&&(o[`traefik.http.middlewares.${e}-auth.basicauth.users`]=i.join(`,`),o[`traefik.http.routers.${a}.middlewares`]=`${e}-auth`),o}function wn(e,t,n=Ye){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${xn(tn(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${xn(a.name)}:`),a.build&&r.push(` image: ${xn(mn(e.name,a.name,t.id))}`);let o={};rn(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${xn(e)}: {}`),` ${Sn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=Cn(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])=>` ${xn(e)}: ${xn(rt(t))}`))}return i&&r.push(`networks:`,` ${Sn}:`,` name: ${xn(n.network)}`,` external: true`),r.join(`
389
390
  `)+`
390
- `}function wn(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:
391
+ `}function Tn(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:
391
392
  image: nginx:alpine
392
393
  restart: unless-stopped
393
394
  volumes:
@@ -396,14 +397,14 @@ exit 0
396
397
  x-landfall:
397
398
  port: 80
398
399
  `:`${n} app:
399
- ${e===`image`?`image: ${bn(t.image)}`:`build: .`}
400
+ ${e===`image`?`image: ${xn(t.image)}`:`build: .`}
400
401
  restart: unless-stopped
401
402
  env_file:
402
403
  - path: .env
403
404
  required: false
404
405
  x-landfall:
405
406
  port: ${t.port??3e3}
406
- `}function Tn(e){return`# generated by landfall
407
+ `}function En(e){return`# generated by landfall
407
408
  server {
408
409
  listen 80;
409
410
  server_name _;
@@ -427,11 +428,11 @@ server {
427
428
  error_page 404 /404.html;
428
429
  location = /404.html { internal; }
429
430
  }
430
- `}const En=[`dist`,`build`,`out`,`public`,`_site`,`site`],Dn=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``);function On(e){let t=t=>o.existsSync(m(e,t)),n=t=>{try{return o.readFileSync(m(e,t),`utf8`)}catch{return``}},r=e=>{for(let r of e)if(t(r))return n(r);return``},i=e=>[`ts`,`mts`,`js`,`mjs`,`cjs`].map(t=>`${e}.${t}`),a={};try{a=JSON.parse(n(`package.json`)||`{}`)}catch{}if(a.scripts?.build){let e=t(`bun.lock`)||t(`bun.lockb`)?`bun run build`:t(`pnpm-lock.yaml`)?`pnpm build`:t(`yarn.lock`)?`yarn build`:`npm run build`,o=e=>e in{...a.dependencies,...a.devDependencies},s=(t,n)=>({tool:t,dir:n,build:e});if(o(`next`))return s(`Next.js (output: 'export')`,Dn(r(i(`next.config`)),`distDir`)??`out`);if(o(`nuxt`))return s(`Nuxt (nuxt generate)`,`.output/public`);if(o(`astro`))return s(`Astro`,Dn(r(i(`astro.config`)),`outDir`)??`dist`);if(o(`@sveltejs/kit`))return s(`SvelteKit (adapter-static)`,Dn(r(i(`svelte.config`)),`pages`)??`build`);if(o(`@angular/core`)){let e=`dist`;try{let t=Object.values(JSON.parse(n(`angular.json`)).projects)[0]?.architect?.build,r=t?.options?.outputPath;typeof r==`string`?e=/:application$/.test(t.builder??``)?`${r}/browser`:r:r?.base&&(e=r.base)}catch{}return s(`Angular`,e)}return o(`gatsby`)?s(`Gatsby`,`public`):o(`@docusaurus/core`)?s(`Docusaurus`,`build`):o(`@11ty/eleventy`)?s(`Eleventy`,Dn(r([`.eleventy.js`,`eleventy.config.js`,`eleventy.config.mjs`,`eleventy.config.cjs`]),`output`)??`_site`):o(`react-scripts`)?s(`Create React App`,`build`):o(`@vue/cli-service`)?s(`Vue CLI`,Dn(n(`vue.config.js`),`outputDir`)??`dist`):o(`vite`)?s(`Vite`,Dn(r(i(`vite.config`)),`outDir`)??`dist`):o(`parcel`)?s(`Parcel`,`dist`):{build:e}}return[`hugo.toml`,`hugo.yaml`,`hugo.json`].some(t)?{tool:`Hugo`,dir:`public`,build:`hugo --minify`}:t(`_config.yml`)&&n(`Gemfile`).includes(`jekyll`)?{tool:`Jekyll`,dir:`_site`,build:`bundle exec jekyll build`}:t(`mkdocs.yml`)?{tool:`MkDocs`,dir:Dn(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function kn(e,t=On(e)){return t.dir?t.dir:o.existsSync(m(e,`index.html`))?`.`:En.find(t=>o.existsSync(m(e,t,`index.html`)))??`.`}function An(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const jn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function Mn(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return mn.find(t=>o.existsSync(m(e,t)))}function Nn(e){let t=He(e),n=t?.name??jn(f(e)),r=Mn(e,t);if(r)return{path:e,name:n,kind:`compose`,compose:r,project:t};if(t?.image&&!t.static)return{path:e,name:n,kind:`image`,port:t.port,project:t};let i=t=>o.existsSync(m(e,t));if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??An(m(e,`Dockerfile`))??3e3,project:t};let a=On(e),s=t?.dir??kn(e,a);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:t?.build??a.build,tool:!t?.dir||t.dir===a.dir?a.tool:void 0,project:t}}const Pn=m(be,`projects.json`);function Fn(){try{return JSON.parse(o.readFileSync(Pn,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function In(e){let t=ne(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...Fn().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(be,{recursive:!0,mode:448}),o.writeFileSync(Pn,JSON.stringify(n,null,2)+`
431
- `,{mode:384})}function Ln(e,t,n){let r=Fn();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=He(i.path);e?.name===t&&Ue(i.path,{...e,domains:n})}r.length&&o.writeFileSync(Pn,JSON.stringify(r,null,2)+`
432
- `,{mode:384})}const $=e=>`${W}/${e}`;function Rn(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||k(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const zn=`
431
+ `}const Dn=[`dist`,`build`,`out`,`public`,`_site`,`site`],On=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``);function kn(e){let t=t=>o.existsSync(m(e,t)),n=t=>{try{return o.readFileSync(m(e,t),`utf8`)}catch{return``}},r=e=>{for(let r of e)if(t(r))return n(r);return``},i=e=>[`ts`,`mts`,`js`,`mjs`,`cjs`].map(t=>`${e}.${t}`),a={};try{a=JSON.parse(n(`package.json`)||`{}`)}catch{}if(a.scripts?.build){let e=t(`bun.lock`)||t(`bun.lockb`)?`bun run build`:t(`pnpm-lock.yaml`)?`pnpm build`:t(`yarn.lock`)?`yarn build`:`npm run build`,o=e=>e in{...a.dependencies,...a.devDependencies},s=(t,n)=>({tool:t,dir:n,build:e});if(o(`next`))return s(`Next.js (output: 'export')`,On(r(i(`next.config`)),`distDir`)??`out`);if(o(`nuxt`))return s(`Nuxt (nuxt generate)`,`.output/public`);if(o(`astro`))return s(`Astro`,On(r(i(`astro.config`)),`outDir`)??`dist`);if(o(`@sveltejs/kit`))return s(`SvelteKit (adapter-static)`,On(r(i(`svelte.config`)),`pages`)??`build`);if(o(`@angular/core`)){let e=`dist`;try{let t=Object.values(JSON.parse(n(`angular.json`)).projects)[0]?.architect?.build,r=t?.options?.outputPath;typeof r==`string`?e=/:application$/.test(t.builder??``)?`${r}/browser`:r:r?.base&&(e=r.base)}catch{}return s(`Angular`,e)}return o(`gatsby`)?s(`Gatsby`,`public`):o(`@docusaurus/core`)?s(`Docusaurus`,`build`):o(`@11ty/eleventy`)?s(`Eleventy`,On(r([`.eleventy.js`,`eleventy.config.js`,`eleventy.config.mjs`,`eleventy.config.cjs`]),`output`)??`_site`):o(`react-scripts`)?s(`Create React App`,`build`):o(`@vue/cli-service`)?s(`Vue CLI`,On(n(`vue.config.js`),`outputDir`)??`dist`):o(`vite`)?s(`Vite`,On(r(i(`vite.config`)),`outDir`)??`dist`):o(`parcel`)?s(`Parcel`,`dist`):{build:e}}return[`hugo.toml`,`hugo.yaml`,`hugo.json`].some(t)?{tool:`Hugo`,dir:`public`,build:`hugo --minify`}:t(`_config.yml`)&&n(`Gemfile`).includes(`jekyll`)?{tool:`Jekyll`,dir:`_site`,build:`bundle exec jekyll build`}:t(`mkdocs.yml`)?{tool:`MkDocs`,dir:On(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function An(e,t=kn(e)){return t.dir?t.dir:o.existsSync(m(e,`index.html`))?`.`:Dn.find(t=>o.existsSync(m(e,t,`index.html`)))??`.`}function jn(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const Mn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function Nn(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return hn.find(t=>o.existsSync(m(e,t)))}function Pn(e){let t=He(e),n=t?.name??Mn(f(e)),r=Nn(e,t);if(r)return{path:e,name:n,kind:`compose`,compose:r,project:t};if(t?.image&&!t.static)return{path:e,name:n,kind:`image`,port:t.port,project:t};let i=t=>o.existsSync(m(e,t));if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??jn(m(e,`Dockerfile`))??3e3,project:t};let a=kn(e),s=t?.dir??An(e,a);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:t?.build??a.build,tool:!t?.dir||t.dir===a.dir?a.tool:void 0,project:t}}const Fn=m(xe,`projects.json`);function In(){try{return JSON.parse(o.readFileSync(Fn,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function Ln(e){let t=ne(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...In().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(xe,{recursive:!0,mode:448}),o.writeFileSync(Fn,JSON.stringify(n,null,2)+`
432
+ `,{mode:384})}function Rn(e,t,n){let r=In();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=He(i.path);e?.name===t&&Ue(i.path,{...e,domains:n})}r.length&&o.writeFileSync(Fn,JSON.stringify(r,null,2)+`
433
+ `,{mode:384})}const $=e=>`${W}/${e}`;function zn(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||k(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const Bn=`
433
434
  --8<--landfall--8<--
434
- `;async function Bn(e,t){let n=$(t),{stdout:r}=await X(e,[...[`${n}/site.json`,`${n}/.env`,K].map(e=>`cat ${Y(e)} 2>/dev/null || true`),`[ ! -d ${Y(n)} ] || echo 1`].join(`; printf %s ${Y(zn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(zn),c;if(i.trim())try{c=JSON.parse(i)}catch{k(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:Ze(o)??Ye,exists:s.trim()===`1`}}async function Vn(e,t,n={}){let r=await Bn(e,t);r.meta||k(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return $t(i)&&k(`${t} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`),n.deployed&&i.adopted&&k(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const Hn=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),Un=(e,t,n)=>gt(e,[`docker logs --tail ${Y(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]),Wn=e=>[...new Set([...e.matchAll(/(?:^|[\s>])(\d+)\/tcp/g)].map(e=>Number(e[1])))].sort((e,t)=>e-t);function Gn(e){return Kn(e).flatMap(e=>e.hosts)}function Kn(e){return Object.entries(e).map(([e,t])=>[e.match(/^traefik\.http\.routers\.([^.]+)\.service$/)?.[1],t]).filter(([e,t])=>e&&t===`api@internal`).map(([t])=>({router:t,hosts:gn(e[`traefik.http.routers.${t}.rule`]??``)}))}const qn=e=>e[`traefik.http.routers.${Ke}.service`]===`api@internal`;function Jn(e,t,n,r=/* @__PURE__ */ new Set){let i=/* @__PURE__ */ new Map;for(let{name:a,labels:o}of e){if(a===n||o[`landfall.managed`]||o[`traefik.enable`]===`false`)continue;let e=o[`com.docker.compose.project`];if(e&&r.has(e))continue;let s=Object.entries(o).filter(([e])=>_n.test(e)).flatMap(([,e])=>gn(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&&un(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function Yn(e,t){if(!t.length)return`not running`;let n=tn(e),r=new Set(n?.services.filter(nn).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!un(e.status))??t.find(e=>!r.has(e.service)&&!un(e.status)&&!dn(e.status))??i[0]??t[0]).status}const Xn=e=>$t(e)?sn(e.name):en(e);async function Zn(e){let{stdout:t}=await X(e,`printf 'READY\\t%s\\n' "$([ -d ${W} ] && command -v docker >/dev/null 2>&1 && echo 1)"
435
+ `;async function Vn(e,t){let n=$(t),{stdout:r}=await X(e,[...[`${n}/site.json`,`${n}/.env`,K].map(e=>`cat ${Y(e)} 2>/dev/null || true`),`[ ! -d ${Y(n)} ] || echo 1`].join(`; printf %s ${Y(Bn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(Bn),c;if(i.trim())try{c=JSON.parse(i)}catch{k(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:Ze(o)??Ye,exists:s.trim()===`1`}}async function Hn(e,t,n={}){let r=await Vn(e,t);r.meta||k(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return en(i)&&k(`${t} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`),n.deployed&&i.adopted&&k(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const Un=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),Wn=(e,t,n)=>gt(e,[`docker logs --tail ${Y(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]),Gn=e=>[...new Set([...e.matchAll(/(?:^|[\s>])(\d+)\/tcp/g)].map(e=>Number(e[1])))].sort((e,t)=>e-t);function Kn(e){return qn(e).flatMap(e=>e.hosts)}function qn(e){return Object.entries(e).map(([e,t])=>[e.match(/^traefik\.http\.routers\.([^.]+)\.service$/)?.[1],t]).filter(([e,t])=>e&&t===`api@internal`).map(([t])=>({router:t,hosts:_n(e[`traefik.http.routers.${t}.rule`]??``)}))}const Jn=e=>e[`traefik.http.routers.${Ke}.service`]===`api@internal`;function Yn(e,t,n,r=/* @__PURE__ */ new Set){let i=/* @__PURE__ */ new Map;for(let{name:a,labels:o}of e){if(a===n||o[`landfall.managed`]||o[`traefik.enable`]===`false`)continue;let e=o[`com.docker.compose.project`];if(e&&r.has(e))continue;let s=Object.entries(o).filter(([e])=>vn.test(e)).flatMap(([,e])=>_n(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&&dn(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function Xn(e,t){if(!t.length)return`not running`;let n=nn(e),r=new Set(n?.services.filter(rn).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!dn(e.status))??t.find(e=>!r.has(e.service)&&!dn(e.status)&&!fn(e.status))??i[0]??t[0]).status}const Zn=e=>en(e)?cn(e.name):tn(e);async function Qn(e){let{stdout:t}=await X(e,`printf 'READY\\t%s\\n' "$([ -d ${W} ] && command -v docker >/dev/null 2>&1 && echo 1)"
435
436
  P=${Xe}
436
437
  printf 'TRAEFIK\\t%s\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P" "$([ -f ${K} ] && echo 1)"
437
438
  for f in ${W}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
@@ -439,22 +440,22 @@ docker ps -a --format 'CTR\\t{{.Names}}\\t{{.Status}}\\t{{.Label "com.docker.com
439
440
  ids=$(docker ps -aq 2>/dev/null || true)
440
441
  # inspect, unlike ps, does not expand \\t: hence {{"\\t"}}.
441
442
  [ -z "$ids" ] || docker inspect -f 'LABELS{{"\\t"}}{{.Name}}{{"\\t"}}{{json .Config.Labels}}' $ids 2>/dev/null || true`,{capture:!0}),n=/* @__PURE__ */ new Map,r=/* @__PURE__ */ new Map,i=[],a=[],o={ready:!1,traefik:``,proxy:`traefik`,adopted:!1,sites:[],others:[],dashboard:{hosts:[],tunnel:!1},unrouted:[]},s=[];for(let c of t.split(`
442
- `)){let[t,l=``,u=``,d=``,f=``,p=``,m=``]=c.split(` `);if(t===`READY`&&(o.ready=l===`1`),t===`TRAEFIK`&&(o.traefik=l,o.proxy=u||`traefik`,o.adopted=d===`1`),t===`SITE`)try{i.push(JSON.parse(c.slice(t.length+1)))}catch{D(`skipped a corrupt site.json on ${e.host}: ${c.slice(5,80)}`)}if(t===`CTR`&&(n.set(l,u),s.push({container:l,status:u,image:p,ports:Wn(m),...d&&{project:d,service:f}}),d&&r.set(d,[...r.get(d)??[],{service:f||l,container:l,status:u}])),t===`LABELS`)try{a.push({name:l.replace(/^\//,``),labels:JSON.parse(u??`null`)??{}})}catch{}}o.others=Jn(a,n,o.proxy,new Set(i.map(Xn))),o.dashboard={hosts:[...new Set(a.flatMap(e=>Gn(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&(qn(e.labels)||Gn(e.labels).length>0))};let c=/* @__PURE__ */ new Set([o.proxy,...o.others.flatMap(e=>e.containers)]),l=new Set(i.map(Xn));return o.unrouted=s.filter(e=>un(e.status)&&!c.has(e.container)&&!(e.project&&l.has(e.project))).sort((e,t)=>e.container.localeCompare(t.container)),o.sites=i.map(e=>{let t=(r.get(Xn(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:Yn(e,t),services:t,domains:fn(e),main:on(e)?.name,state:cn(e),kind:ln(e)}}),o}async function Qn(e,t,n,r={}){let i=`${$(t)}/releases/${n}`,a=r.file?.replace(/^(\.\/)+/,``);a?.includes(`/`)&&k(`-f takes a compose file in the project folder itself, not ${r.file}`);let o=[...mn,...hn].filter(e=>e!==`compose.yaml`),{stdout:s}=await X(e,[J,`v=$(dc version --short 2>/dev/null | sed 's/^v//'); compose_ok "$v" || { echo "sites need docker compose 2.24 or newer, the server has \${v:-none}: run lf update --compose" >&2; exit 1; }`,`mkdir -p ${Y(i)} && cd -P ${Y(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>Z(e,t)),...a&&a!==`compose.yaml`?[`mv -f ${Y(a)} compose.yaml`]:[],`rm -f ${o.map(Y).join(` `)}`,...r.env===void 0?[]:[Z(`.env.next`,r.env,`600`)],`[ -f compose.yaml ] || { echo "no compose.yaml in the upload" >&2; exit 1; }`,`cfg=$(dc ${r.env===void 0?``:`--env-file .env.next `}-f compose.yaml config 2>.config.err) || { cat .config.err >&2; rm -f .config.err; exit 1; }`,`cat .config.err >&2; rm -f .config.err`,`rm -f .env.next`,`pwd -P; printf %s ${Y(zn)}; printf '%s\\n' "$cfg"`].join(`
443
- `),{capture:!0}),[c,l=``]=s.split(zn),u;try{u=re(l)}catch(e){k(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:c.trim(),config:u}}async function $n(e,t){let n=$(t.name);await X(e,[J,`[ -r ${Y(t.adopted)} ] && [ -x ${Y(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${Y(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${Y(n)} && cd ${Y(n)}`,`ln -s ${Y(t.adopted)} current`,Z(`site.json`,JSON.stringify(t,null,2)+`
443
+ `)){let[t,l=``,u=``,d=``,f=``,p=``,m=``]=c.split(` `);if(t===`READY`&&(o.ready=l===`1`),t===`TRAEFIK`&&(o.traefik=l,o.proxy=u||`traefik`,o.adopted=d===`1`),t===`SITE`)try{i.push(JSON.parse(c.slice(t.length+1)))}catch{D(`skipped a corrupt site.json on ${e.host}: ${c.slice(5,80)}`)}if(t===`CTR`&&(n.set(l,u),s.push({container:l,status:u,image:p,ports:Gn(m),...d&&{project:d,service:f}}),d&&r.set(d,[...r.get(d)??[],{service:f||l,container:l,status:u}])),t===`LABELS`)try{a.push({name:l.replace(/^\//,``),labels:JSON.parse(u??`null`)??{}})}catch{}}o.others=Yn(a,n,o.proxy,new Set(i.map(Zn))),o.dashboard={hosts:[...new Set(a.flatMap(e=>Kn(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&(Jn(e.labels)||Kn(e.labels).length>0))};let c=/* @__PURE__ */ new Set([o.proxy,...o.others.flatMap(e=>e.containers)]),l=new Set(i.map(Zn));return o.unrouted=s.filter(e=>dn(e.status)&&!c.has(e.container)&&!(e.project&&l.has(e.project))).sort((e,t)=>e.container.localeCompare(t.container)),o.sites=i.map(e=>{let t=(r.get(Zn(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:Xn(e,t),services:t,domains:pn(e),main:sn(e)?.name,state:ln(e),kind:un(e)}}),o}async function $n(e,t,n,r={}){let i=`${$(t)}/releases/${n}`,a=r.file?.replace(/^(\.\/)+/,``);a?.includes(`/`)&&k(`-f takes a compose file in the project folder itself, not ${r.file}`);let o=[...hn,...gn].filter(e=>e!==`compose.yaml`),{stdout:s}=await X(e,[J,`v=$(dc version --short 2>/dev/null | sed 's/^v//'); compose_ok "$v" || { echo "sites need docker compose 2.24 or newer, the server has \${v:-none}: run lf update --compose" >&2; exit 1; }`,`mkdir -p ${Y(i)} && cd -P ${Y(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>Z(e,t)),...a&&a!==`compose.yaml`?[`mv -f ${Y(a)} compose.yaml`]:[],`rm -f ${o.map(Y).join(` `)}`,...r.env===void 0?[]:[Z(`.env.next`,r.env,`600`)],`[ -f compose.yaml ] || { echo "no compose.yaml in the upload" >&2; exit 1; }`,`cfg=$(dc ${r.env===void 0?``:`--env-file .env.next `}-f compose.yaml config 2>.config.err) || { cat .config.err >&2; rm -f .config.err; exit 1; }`,`cat .config.err >&2; rm -f .config.err`,`rm -f .env.next`,`pwd -P; printf %s ${Y(Bn)}; printf '%s\\n' "$cfg"`].join(`
444
+ `),{capture:!0}),[c,l=``]=s.split(Bn),u;try{u=re(l)}catch(e){k(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:c.trim(),config:u}}async function er(e,t){let n=$(t.name);await X(e,[J,`[ -r ${Y(t.adopted)} ] && [ -x ${Y(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${Y(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${Y(n)} && cd ${Y(n)}`,`ln -s ${Y(t.adopted)} current`,Z(`site.json`,JSON.stringify(t,null,2)+`
444
445
  `),`record ${Y(`adopted ${t.project} as site ${t.name}`)} ${Y(`rm -rf ${n}`)}`].join(`
445
- `))}async function er(e,t,n){let r=$(t);await X(e,`cd / && rm -rf ${Y(`${r}/releases/${n}`)}; [ -f ${Y(r+`/site.json`)} ] || { rmdir ${Y(r+`/releases`)} 2>/dev/null; { [ -s ${Y(r+`/.env`)} ] || rm -f ${Y(r+`/.env`)}; }; rmdir ${Y(r)} 2>/dev/null; } || true`)}const tr=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; }`],nr=e=>`docker image rm ${Y(e)} >/dev/null 2>&1 || true`;function rr(e,t,n={}){let r=$(e.name),i=tn(e),a=Y(`releases/`+e.current),o=en(e),s=[`mkdir -p ${Y(r)} && cd ${Y(r)}`,`here=$(pwd -P)/`,`other=$(docker ps -a --filter ${Y(`label=com.docker.compose.project=${o}`)} --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null | while read -r d; do case "$d/" in ("$here"*) ;; (*) echo "\${d:-a container without a folder}"; break ;; esac; done || true)`,`[ -z "$other" ] || { echo ${Y(`a Compose project named ${o} already runs from`)} "$other:" ${Y(`deploying would replace its containers. Pick another site name, or make it a site with lf adopt ${o}`)} >&2; exit 1; }`,n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:Z(`.env`,n.env,`600`),`(cd -P ${a} && ${Z(`compose.override.yaml`,Cn(e,i,t))}) || exit 1`];return n.build&&i.services.some(e=>e.build)&&s.push(`log "building ${e.name}"`,`(cd -P ${a} && dc build -q) || exit 1`),n.pull&&s.push(`log "pulling images of ${e.name}"`,`(cd -P ${a} && dc pull -q --ignore-buildable) || exit 1`),s.push(Z(`site.json`,JSON.stringify(e,null,2)+`
446
- `)),s.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...tr(e.name)),s}const ir=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>pn(e.name,n.name,t.id)),ar=(e,t)=>[`rmf ${Y(`${$(e.name)}/releases/${t.id}`)}`,...ir(e,t).map(nr)];function or(e,t){let n=$(e.name),r=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${Y(n)}`,`log "removing the old container of ${e.name}"`,`dc -p ${Y(sn(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${Y(sn(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.filter(e=>e.id!==t).map(e=>`rm -rf ${Y(`releases/`+e.id)}`),...r.map(nr)]}async function sr(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await cr(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function cr(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=Hn(t),o=a.filter((e,n)=>n<i||e.id===t.current||e.id===r.prev?.current),s=a.filter(e=>!o.includes(e));t.releases=o;let c=$(t.name),l=[J];if(r.takeover){l.push(`record ${Y(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...or(r.takeover,t.current),...rr(t,n,r)),await X(e,l.join(`
447
- `));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${Y(c+`/.env`)})`),!r.prev)u=`[ ! -d ${Y(c)} ] || {\n${dr(t,{keepDir:r.existed}).join(`
446
+ `))}async function tr(e,t,n){let r=$(t);await X(e,`cd / && rm -rf ${Y(`${r}/releases/${n}`)}; [ -f ${Y(r+`/site.json`)} ] || { rmdir ${Y(r+`/releases`)} 2>/dev/null; { [ -s ${Y(r+`/.env`)} ] || rm -f ${Y(r+`/.env`)}; }; rmdir ${Y(r)} 2>/dev/null; } || true`)}const nr=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; }`],rr=e=>`docker image rm ${Y(e)} >/dev/null 2>&1 || true`;function ir(e,t,n={}){let r=$(e.name),i=nn(e),a=Y(`releases/`+e.current),o=tn(e),s=[`mkdir -p ${Y(r)} && cd ${Y(r)}`,`here=$(pwd -P)/`,`other=$(docker ps -a --filter ${Y(`label=com.docker.compose.project=${o}`)} --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null | while read -r d; do case "$d/" in ("$here"*) ;; (*) echo "\${d:-a container without a folder}"; break ;; esac; done || true)`,`[ -z "$other" ] || { echo ${Y(`a Compose project named ${o} already runs from`)} "$other:" ${Y(`deploying would replace its containers. Pick another site name, or make it a site with lf adopt ${o}`)} >&2; exit 1; }`,n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:Z(`.env`,n.env,`600`),`(cd -P ${a} && ${Z(`compose.override.yaml`,wn(e,i,t))}) || exit 1`];return n.build&&i.services.some(e=>e.build)&&s.push(`log "building ${e.name}"`,`(cd -P ${a} && dc build -q) || exit 1`),n.pull&&s.push(`log "pulling images of ${e.name}"`,`(cd -P ${a} && dc pull -q --ignore-buildable) || exit 1`),s.push(Z(`site.json`,JSON.stringify(e,null,2)+`
447
+ `)),s.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...nr(e.name)),s}const ar=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>mn(e.name,n.name,t.id)),or=(e,t)=>[`rmf ${Y(`${$(e.name)}/releases/${t.id}`)}`,...ar(e,t).map(rr)];function sr(e,t){let n=$(e.name),r=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${Y(n)}`,`log "removing the old container of ${e.name}"`,`dc -p ${Y(cn(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${Y(cn(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.filter(e=>e.id!==t).map(e=>`rm -rf ${Y(`releases/`+e.id)}`),...r.map(rr)]}async function cr(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await lr(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function lr(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=Un(t),o=a.filter((e,n)=>n<i||e.id===t.current||e.id===r.prev?.current),s=a.filter(e=>!o.includes(e));t.releases=o;let c=$(t.name),l=[J];if(r.takeover){l.push(`record ${Y(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...sr(r.takeover,t.current),...ir(t,n,r)),await X(e,l.join(`
448
+ `));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${Y(c+`/.env`)})`),!r.prev)u=`[ ! -d ${Y(c)} ] || {\n${fr(t,{keepDir:r.existed}).join(`
448
449
  `)}\n`,d=`
449
450
  }`;else{let e=r.prev,i={...e,releases:e.releases.filter(e=>o.some(t=>t.id===e.id))},a=o.filter(t=>!e.releases.some(e=>e.id===t.id));u=`[ -d ${Y(c)} ] || { log ${Y(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
450
- `+[...rr(i,n),...a.flatMap(e=>ar(t,e))].join(`
451
- `)}l.push(`record ${Y(r.what)} ${Y(u)}${f?`"$u"`:``}${Y(d)}`),l.push(...rr(t,n,r));for(let e of s)l.push(...ar(t,e));await X(e,l.join(`
452
- `))}async function lr(e,t,n,r=``){await X(e,`${J}\ncd ${Y($(t))}\n${r}\ncd -P current && dc ${n}`)}const ur=(e,t,n)=>gt(e,[`cd -P ${Y($(t)+`/current`)} && docker compose logs --tail ${Y(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?Y(n.service):``}`]);function dr(e,t={}){let n=$(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>ir(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(Y).join(` `);return[`cd ${Y(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${Y(Xn(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(nr),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${Y(n)}`]]}async function fr(e,t,n={}){let r=`removed site ${t.name} (${fn(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await X(e,[J,...dr(t,n),`record ${Y(r)}`].join(`
453
- `))}function pr(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
451
+ `+[...ir(i,n),...a.flatMap(e=>or(t,e))].join(`
452
+ `)}l.push(`record ${Y(r.what)} ${Y(u)}${f?`"$u"`:``}${Y(d)}`),l.push(...ir(t,n,r));for(let e of s)l.push(...or(t,e));await X(e,l.join(`
453
+ `))}async function ur(e,t,n,r=``){await X(e,`${J}\ncd ${Y($(t))}\n${r}\ncd -P current && dc ${n}`)}const dr=(e,t,n)=>gt(e,[`cd -P ${Y($(t)+`/current`)} && docker compose logs --tail ${Y(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?Y(n.service):``}`]);function fr(e,t={}){let n=$(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>ar(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(Y).join(` `);return[`cd ${Y(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${Y(Zn(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(rr),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${Y(n)}`]]}async function pr(e,t,n={}){let r=`removed site ${t.name} (${pn(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await X(e,[J,...fr(t,n),`record ${Y(r)}`].join(`
454
+ `))}function mr(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
454
455
  `)){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(`
455
456
  `)+`
456
- `:``}const mr=e=>e.split(`
457
- `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`)),hr=async e=>gr(mr(e===`-`?await x():o.readFileSync(e,`utf8`)));function gr(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&k(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function _r(e,t){let{stdout:n}=await X(e,`{ [ "$(id -u)" = 0 ] || sudo -n true 2>/dev/null; } && echo yes || true`,{capture:!0});if(n.trim()===`yes`)return e;let r=Q(e).user;for(let t of[`root`,e.admin])if(t&&t!==r&&await yt({...e,user:t}))return{...e,user:t};return k(`${t} needs root or passwordless sudo; ${r} has neither and root's key login does not work`)}const vr=String.raw`
457
+ `:``}const hr=e=>e.split(`
458
+ `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`)),gr=async e=>_r(hr(e===`-`?await x():o.readFileSync(e,`utf8`)));function _r(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&k(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function vr(e,t){let{stdout:n}=await X(e,`{ [ "$(id -u)" = 0 ] || sudo -n true 2>/dev/null; } && echo yes || true`,{capture:!0});if(n.trim()===`yes`)return e;let r=Q(e).user;for(let t of[`root`,e.admin])if(t&&t!==r&&await yt({...e,user:t}))return{...e,user:t};return k(`${t} needs root or passwordless sudo; ${r} has neither and root's key login does not work`)}const yr=String.raw`
458
459
  set +e
459
460
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
460
461
  m=$(pkg_mgr); echo "mgr=$m"
@@ -478,8 +479,8 @@ case "$m" in
478
479
  esac
479
480
  [ -f /var/run/reboot-required ] && echo reboot=1
480
481
  exit 0
481
- `;function yr(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
482
- `)){let e=r.indexOf(`=`);if(e<0)continue;let[i,a]=[r.slice(0,e),r.slice(e+1)];if(i===`mgr`&&(t.mgr=a.trim()),i===`refreshed`&&(t.refreshed=!0),i===`reboot`&&(t.reboot=!0),i===`sec`&&n.push(a.trim()),i===`pkg`){let[e,n,r,i=``]=a.split(` `);t.updates.push({name:e,from:n||void 0,to:r,security:/security/i.test(i)})}if(i===`apk`){let[e,n]=a.split(` `),r=e.match(/^(.*)-(\d[^-]*-r\d+)$/);t.updates.push({name:r?.[1]??e,from:r?.[2],to:n,security:!1})}}for(let e of t.updates){let t=e.name.replace(/\.[^.]+$/,``);n.some(e=>e.startsWith(t+`-`))&&(e.security=!0)}return t}async function br(e,t=!0){let{stdout:n}=await X(e,J+vr,{capture:!0,env:{REFRESH:t}});return yr(n)}const xr=String.raw`
482
+ `;function br(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
483
+ `)){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 xr(e,t=!0){let{stdout:n}=await X(e,J+yr,{capture:!0,env:{REFRESH:t}});return br(n)}const Sr=String.raw`
483
484
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
484
485
  keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
485
486
  case "$(pkg_mgr)" in
@@ -499,7 +500,7 @@ if [ "$REBOOT" = 1 ] && { [ -f /var/run/reboot-required ] || { command -v needs-
499
500
  # Detached, so this ssh session can end cleanly first.
500
501
  (sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
501
502
  fi
502
- `;async function Sr(e,t){await X(e,J+xr,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function Cr(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 wr(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 Tr(e){let t=Cr(e);return t.digest?t.digest:(await wr(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function Er(e){let t=Cr(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await wr(t,r);if(!e)break;n.push(...(await e.json().catch(()=>({}))).tags??[]);let i=e.headers.get(`link`)?.match(/<([^>]+)>;\s*rel="next"/)?.[1];r=i?`https://${t.registry}${i}`:void 0}return n}function Dr(e,t){let n=e.replace(/^v/,``).split(/[.+-]/).map(Number),r=t.replace(/^v/,``).split(/[.+-]/).map(Number);for(let e=0;e<Math.max(n.length,r.length);e++){let t=(n[e]||0)-(r[e]||0);if(t)return t}return 0}const Or=/^(v?)(\d+(?:\.\d+){0,2})$/;function kr(e,t){let n=Or.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=Or.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&Dr(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const Ar=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,jr=/* @__PURE__ */ new Map;function Mr(e){return jr.has(e)||jr.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)),jr.get(e)}const Nr=()=>Mr(`moby/moby`),Pr=()=>Mr(`docker/compose`);async function Fr(e){let t=String.raw`
503
+ `;async function Cr(e,t){await X(e,J+Sr,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function wr(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 Tr(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 Er(e){let t=wr(e);return t.digest?t.digest:(await Tr(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function Dr(e){let t=wr(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await Tr(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 Or(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 kr=/^(v?)(\d+(?:\.\d+){0,2})$/;function Ar(e,t){let n=kr.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=kr.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&Or(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const jr=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,Mr=/* @__PURE__ */ new Map;function Nr(e){return Mr.has(e)||Mr.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)),Mr.get(e)}const Pr=()=>Nr(`moby/moby`),Fr=()=>Nr(`docker/compose`);async function Ir(e){let t=String.raw`
503
504
  ${J}
504
505
  set +e
505
506
  printf 'docker=%s
@@ -510,9 +511,9 @@ printf 'path=%s
510
511
  ' "$(docker info --format '{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}' 2>/dev/null)"
511
512
  printf 'ce=%s
512
513
  ' "$(pkg_has docker-ce && echo 1)"
513
- exit 0`,[{stdout:n},r,i]=await Promise.all([X(e,t,{capture:!0}),Nr(),Pr()]),a=Object.fromEntries(n.split(`
514
- `).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1).trim()]));return{docker:a.docker??``,compose:a.compose??``,composePath:a.path??``,dockerCe:a.ce===`1`,latestDocker:r,latestCompose:i}}function Ir(e){let t=(e,t)=>!!e&&!!t&&Dr(e,t)<0,n=t(e.docker,e.latestDocker)?`docker ${e.docker}, ${e.latestDocker} is current${e.dockerCe?` (comes with the package upgrades)`:` (distro package: switch to Docker's repo for the newest)`}`:void 0,r=t(e.compose,e.latestCompose)&&e.composePath===`/usr/local/lib/docker/cli-plugins/docker-compose`;return{dockerNote:n,composeNote:e.compose?t(e.compose,e.latestCompose)?`compose ${e.compose}, ${e.latestCompose} is current${r?``:e.dockerCe?` (comes with the package upgrades)`:` (from ${e.composePath||`the distro`})`}`:void 0:`docker compose missing: lf setup installs it`,composeUpdate:r}}async function Lr(e){await X(e,J+$e+`compose_install || { log "could not download docker compose"; exit 1; }
515
- `)}const Rr=String.raw`
514
+ exit 0`,[{stdout:n},r,i]=await Promise.all([X(e,t,{capture:!0}),Pr(),Fr()]),a=Object.fromEntries(n.split(`
515
+ `).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 Lr(e){let t=(e,t)=>!!e&&!!t&&Or(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 Rr(e){await X(e,J+$e+`compose_install || { log "could not download docker compose"; exit 1; }
516
+ `)}const zr=String.raw`
516
517
  set +e
517
518
  P=${Xe}
518
519
  img() { # img <kind> <name> <container> <dir> [service]
@@ -536,11 +537,11 @@ for f in ${W}/*/site.json; do
536
537
  printf '%s\n' "$PS" | while read -r c svc pr; do [ "$pr" != "$p" ] || img site "$n" "$c" "$d/current" "$svc"; done
537
538
  done
538
539
  exit 0
539
- `;async function zr(e){let{stdout:t}=await X(e,Rr,{capture:!0}),n=t.split(`
540
- `).filter(e=>e.startsWith(`IMG `)).map(e=>e.split(` `)),r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,a=async e=>{let t=e.slice(0,e.lastIndexOf(`:`));if(!Or.test(Cr(e).tag))return;i.has(t)||i.set(t,Er(e));let n=kr(Cr(e).tag,await i.get(t));return n&&Ar(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(pn(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,Tr(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>Cr(e).tag.replace(/^v/,``).split(`.`)[0],m=f===`traefik`?`lf traefik setup --image ${d}`:`redeploy with it`,ee=d?{newer:d,note:`newer version ${d} (${p(d)===p(s)?``:`new major, read its upgrade notes; `}${m})`}:{};return u?{...l,kind:f,state:c.split(` `).some(e=>e.endsWith(`@${u}`))?`current`:`update`,...ee}:{...l,kind:f,state:`unknown`,note:`registry did not answer (private image?)`,...ee}}))}async function Br(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[J];for(let[e,t]of n){r.push(`log ${Y(`pulling ${e}`)}`,`docker pull -q ${Y(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${Y(e)})`);for(let n of t){let t=n.service?` ${Y(n.service)}`:``,i=`docker tag $old ${Y(e)} && cd -P ${Y(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${Y(n.container)})`,`if [ "$old" = "$new" ]; then log ${Y(`${n.name} already runs the newest ${e}`)}; else`,` record ${Y(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${Y(`recreating ${n.container}`)}`,` (cd -P ${Y(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${Y(n.container)} || exit 1`,`fi`)}}await X(e,r.join(`
541
- `))}function Vr(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 Hr(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
542
- `)){let e=n.indexOf(`=`);if(e<0)continue;let r=n.slice(0,e),i=n.slice(e+1).trim();r===`listen`||r===`published`?t[r].push(Vr(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function Ur(e,t={}){let{stdout:n}=await X(e,J+nt,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return Hr(n)}const Wr=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),Gr=e=>e.includes(`:`),Kr=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,qr=[68,546,5353];function Jr(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=ti(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 Yr(e,t){let n=e.findIndex(e=>!e.out&&(!e.proto||e.proto===t.proto)&&!!e.port?.split(`,`).some(e=>{let[n,r=n]=e.split(`:`).map(Number);return t.port>=n&&t.port<=r}));return n<0?void 0:{rule:e[n],n:n+1}}const Xr=(e,t)=>{let n=Yr(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Zr=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[Kr(e),t(e)])).values()];function Qr(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=$r(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 $r(e,t){let n=e.values,r=e.rules.map(Jr),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&qr.includes(e.port)||Xr(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[Gr(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Zr(o,e=>`${Kr(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Zr(l,e=>`${Kr(e)} (docker${Gr(e.addr)?`, IPv6`:``})`)],kept:c?[]:Zr(s.filter(e=>!l.includes(e)),Kr)}}function ei(e){let t=e.values,n=e.rules.map(Jr),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[Gr(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=Yr(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`&&qr.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=`${Kr(e)} ${t?`docker`:e.proc}`,r={port:Kr(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 ti=(e=``)=>e.split(/\s+/).filter(Boolean);function ni(e){let t=(e,t=``)=>({part:`docker`,label:`docker`,status:e,detail:t});if(!e.docker)return t(`todo`,`install docker and compose`);let n=e.docker.match(/\d+(\.\d+)+/)?.[0]??e.docker;if(e.docker_podman===`1`)return t(`conflict`,`${e.docker} is podman's docker shim; landfall needs Docker Engine (remove podman-docker, then re-run setup)`);let r=[e.docker_running===`1`?``:`start it`,Number(e.compose?.split(`.`)[0])>=2?``:`install compose`].filter(Boolean);if(r.length)return t(`todo`,`docker ${n}, ${r.join(` + `)}`);let i=(e,t=``)=>t&&Dr(e,t)<0?` (${t} is current)`:``;return t(`done`,`docker ${n}${i(n,e.docker_latest)}, compose ${e.compose}${i(e.compose,e.compose_latest)}`)}function ri(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(ni(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,U,o?`done`:`todo`,o?`${s} site${s===1?``:`s`} (never touched by setup)`:`create dir and network`)),t.deployUser){let e=`deploy user ${t.deployUser}`;if(n.user!==`1`)i.push(r(`user`,e,`todo`,`create`));else{let t=[n.user_docker===`1`?``:`docker group`,n.user_key===`1`?``:`ssh key`].filter(Boolean);i.push(r(`user`,e,t.length?`todo`:`done`,t.length?`exists, add ${t.join(` + `)}`:``))}}if(t.firewall?n.ufw===`missing`?i.push(r(`firewall`,`firewall`,`done`,`ufw not available, skipped`)):n.ufw===`active`?i.push(r(`firewall`,`firewall`,`done`,`ufw active (landfall only adds ssh/80/443)`)):i.push(Qr(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=ti(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(oi(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function ii(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 ai=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function oi(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=Ze(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${ai(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${G}/docker-compose.yml was not written by landfall`);let[i,a]=ti(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=ii(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=ti(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=ii(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${ai(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 si(e){let t={done:C.green(`✓`),todo:C.cyan(`→`),conflict:C.yellow(`!`),off:C.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?C.dim(`disabled`):r.status===`conflict`?C.yellow(r.detail):C.dim(r.detail);E(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function ci(e,t){let{stdout:n}=await X(e,`id=$(docker ps -q ${t.map(e=>`--filter ${Y(e)}`).join(` `)} | head -n1)
543
- [ -z "$id" ] || docker inspect -f '{{json .Name}}{{"\\t"}}{{json .HostConfig.NetworkMode}}{{"\\t"}}{{json .NetworkSettings.Networks}}{{"\\t"}}{{json .Config.Labels}}{{"\\t"}}{{json .Args}}{{"\\t"}}{{json .Config.ExposedPorts}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s,c,l]=r.split(` `).map(e=>JSON.parse(e)),u=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e)),d=Object.keys(l??{}).map(e=>e.match(/^(\d+)\/tcp$/)?.[1]).filter(e=>!!e).map(Number).sort((e,t)=>e-t);return{name:String(i).replace(/^\//,``),host:a===`host`,ips:u,labels:s??{},args:c??[],ports:d}}function li(e,t){if(e.host)return`127.0.0.1`;let n=e.ips[t]??Object.values(e.ips)[0];return n||k(`${e.name} has no IP address to tunnel to`),n}function ui(e,t){let n=n=>e.labels[`traefik.http.routers.${t}.${n}`],r=new Map(e.args.flatMap(e=>{let t=e.match(/^--entrypoints\.([^.=]+)\.address=.*:(\d+)/i);return t?[[t[1].toLowerCase(),Number(t[2])]]:[]})),i=n(`entrypoints`)?.split(`,`).map(e=>e.trim().toLowerCase())??[...r.keys()],a=n(`tls`)===`true`||!!n(`tls.certresolver`),o=i.map(e=>r.get(e)).filter(e=>e!==void 0),s=o.find(e=>e===443===a)??o[0];return s===void 0?void 0:{port:s,tls:a}}const di={25:`smtp`,110:`pop3`,143:`imap`,465:`smtps`,587:`smtp`,993:`imaps`,995:`pop3s`,3306:`mysql`,5432:`postgresql`,5672:`amqp`,6379:`redis`,11211:`memcached`,27017:`mongodb`},fi=e=>di[e]??`http`;async function pi(e,t=`127.0.0.1`){let n=e=>new Promise(n=>{let r=g.createServer().once(`error`,()=>n(void 0));r.listen(e,t,()=>{let e=r.address().port;r.close(()=>n(e))})});return e&&await n(e)||await n(0)}const mi=e=>new Promise(t=>{let n=g.connect(e,`127.0.0.1`);n.once(`connect`,()=>(n.destroy(),t(!0))),n.once(`error`,()=>t(!1))});function hi(e,t,n,r){let i=n.tls?b:ie,a=ie.createServer((e,r)=>{let a=i.request({host:`127.0.0.1`,port:t,servername:n.name,rejectUnauthorized:!1,method:e.method,path:e.url,headers:{...e.headers,host:n.name}},e=>(r.writeHead(e.statusCode??502,e.headers),e.pipe(r)));a.on(`error`,e=>r.headersSent?r.destroy():r.writeHead(502).end(`${n.name}: ${e.message}\n`)),e.pipe(a)});return new Promise((t,n)=>a.once(`error`,n).listen(e,r,()=>t(a)))}const gi=()=>Object.values(s.networkInterfaces()).flatMap(e=>e??[]).filter(e=>e.family===`IPv4`&&!e.internal).map(e=>e.address);async function _i(e,t,r,i){let a=i.lan?`0.0.0.0`:`127.0.0.1`,o=i.local===void 0?void 0:Ne(i.local,`--local`,1,65535),s=await pi(o??(r>=1024?r:8080),a);o&&s!==o&&k(`local port ${o} is taken; pick another with --local`);let c=i.as?await pi():s,l=[...ut(e),`-o`,`ControlPath=none`,`-o`,`BatchMode=yes`,`-o`,`ExitOnForwardFailure=yes`,`-o`,`ServerAliveInterval=15`],u=n(`ssh`,[...l,`-N`,`-L`,`${i.as?`127.0.0.1`:a}:${c}:${t}:${r}`,lt(e)],{stdio:[`ignore`,`inherit`,`inherit`]}),d=new Promise(e=>u.on(`close`,t=>e(t??1))),f=!1;for(let e of[`SIGINT`,`SIGTERM`])process.once(e,()=>(f=!0,u.kill()));for(let e=0;e<100&&u.exitCode===null&&!await mi(c);e++)await new Promise(e=>setTimeout(e,150));let p;if(u.exitCode===null){i.as&&(p=await hi(s,c,i.as,a));let e=i.scheme??`http`,t=t=>`${e}://${t}:${s}${e===`http`?i.path??`/`:``}`;T(`${i.what}: ${C.cyan(t(`localhost`))}`);let n=i.lan?gi():[];oe({url:t(`localhost`),lan:n.map(t)}),i.lan&&(E(n.length?`on your network: ${n.map(e=>C.cyan(t(e))).join(` `)}`:`on your network: this machine has no network address yet`),D(`anyone on that network reaches it too: close it when you are done`)),i.note&&E(C.dim(i.note)),E(C.dim(`the tunnel stays open until Ctrl-C`))}let m=await d;p?.close(),!f&&m!==0&&k(`the tunnel closed (ssh exit ${m})`)}async function vi(e,t){return`${e}:${(await y.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const yi=e=>e.slice(0,e.indexOf(`:`)),bi=`admin`;async function xi(e,t,n=`user`){Oe(e,n);let r=t??await de(`Password for ${e}:`);return r.length<8&&k(`use a password of at least 8 characters`),vi(e,r)}function Si(e,t){return{email:t.email??e.email??_e().email,staging:!!(t.staging??e.staging),image:t.image||e.traefikImage||`traefik:v3`,hostNetwork:!!(t.hostNetwork??e.hostNetwork),http3:(t.http3??e.http3)!==!1}}const Ci=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.http3?`HTTP/3`:`no HTTP/3`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function wi(e,t,n){let r=Si(t,n);Me(r.image),r.email&&ke(r.email);let{email:i,staging:a}=r;w(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?Ie(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){Ee.test(c)||k(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=Oe(n.dashboard?n.dashboardUser:o?.user??n.dashboardUser,`--dashboard-user`);s=n.dashboardPassword??v(12).toString(`base64url`),o={host:c,user:e,htpasswd:await vi(e,s)}}let l=at({...r,dashboard:o});r.hostNetwork&&await Di(t,r.http3),await X(t,`${J}
540
+ `;async function Br(e){let{stdout:t}=await X(e,zr,{capture:!0}),n=t.split(`
541
+ `).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(!kr.test(wr(e).tag))return;i.has(t)||i.set(t,Dr(e));let n=Ar(wr(e).tag,await i.get(t));return n&&jr(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(mn(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,Er(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>wr(e).tag.replace(/^v/,``).split(`.`)[0],m=f===`traefik`?`lf traefik setup --image ${d}`:`redeploy with it`,ee=d?{newer:d,note:`newer version ${d} (${p(d)===p(s)?``:`new major, read its upgrade notes; `}${m})`}:{};return u?{...l,kind:f,state:c.split(` `).some(e=>e.endsWith(`@${u}`))?`current`:`update`,...ee}:{...l,kind:f,state:`unknown`,note:`registry did not answer (private image?)`,...ee}}))}async function Vr(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[J];for(let[e,t]of n){r.push(`log ${Y(`pulling ${e}`)}`,`docker pull -q ${Y(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${Y(e)})`);for(let n of t){let t=n.service?` ${Y(n.service)}`:``,i=`docker tag $old ${Y(e)} && cd -P ${Y(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${Y(n.container)})`,`if [ "$old" = "$new" ]; then log ${Y(`${n.name} already runs the newest ${e}`)}; else`,` record ${Y(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${Y(`recreating ${n.container}`)}`,` (cd -P ${Y(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${Y(n.container)} || exit 1`,`fi`)}}await X(e,r.join(`
542
+ `))}function Hr(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 Ur(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
543
+ `)){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(Hr(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function Wr(e,t={}){let{stdout:n}=await X(e,J+nt,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return Ur(n)}const Gr=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),Kr=e=>e.includes(`:`),qr=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,Jr=[68,546,5353];function Yr(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=ni(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 Xr(e,t){let n=e.findIndex(e=>!e.out&&(!e.proto||e.proto===t.proto)&&!!e.port?.split(`,`).some(e=>{let[n,r=n]=e.split(`:`).map(Number);return t.port>=n&&t.port<=r}));return n<0?void 0:{rule:e[n],n:n+1}}const Zr=(e,t)=>{let n=Xr(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Qr=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[qr(e),t(e)])).values()];function $r(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=ei(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 ei(e,t){let n=e.values,r=e.rules.map(Yr),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&Jr.includes(e.port)||Zr(r,e),a=e=>!Gr(e.addr)&&!i(e),o=e.listen.filter(e=>e.proc!==`docker-proxy`&&a(e)),s=[...e.published,...e.listen.filter(e=>e.proc===`docker-proxy`)].filter(a),c=n.docker_fw===`nftables`,l=s.filter(e=>n[Kr(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Qr(o,e=>`${qr(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Qr(l,e=>`${qr(e)} (docker${Kr(e.addr)?`, IPv6`:``})`)],kept:c?[]:Qr(s.filter(e=>!l.includes(e)),qr)}}function ti(e){let t=e.values,n=e.rules.map(Yr),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[Kr(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(Gr(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=Xr(n,e);if(o){let{rule:e,n:t}=o;return e.action===`deny`||e.action===`reject`?{state:`blocked`,why:`blocked by rule ${t}`}:e.from?{state:`limited`,why:`only from ${e.from} (rule ${t})`}:{state:`open`,why:`allowed by rule ${t}${e.action===`limit`?`, rate-limited`:``}`}}return e.proto===`udp`&&Jr.includes(e.port)?{state:`open`,why:`ufw default rule`}:t.ufw_in===`ACCEPT`?{state:`open`,why:`default policy accepts`}:{state:`blocked`,why:`blocked, no rule`}},o=[...e.published,...e.listen.filter(e=>e.proc===`docker-proxy`)],s=[...e.listen.filter(e=>e.proc!==`docker-proxy`).map(e=>({l:e,docker:!1})),...o.map(e=>({l:e,docker:!0}))],c=/* @__PURE__ */ new Map;for(let{l:e,docker:t}of s.sort((e,t)=>e.l.port-t.l.port||e.l.proto.localeCompare(t.l.proto))){let n=`${qr(e)} ${t?`docker`:e.proc}`,r={port:qr(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 ni=(e=``)=>e.split(/\s+/).filter(Boolean);function ri(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&&Or(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 ii(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(ri(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,U,o?`done`:`todo`,o?`${s} site${s===1?``:`s`} (never touched by setup)`:`create dir and network`)),t.deployUser){let e=`deploy user ${t.deployUser}`;if(n.user!==`1`)i.push(r(`user`,e,`todo`,`create`));else{let t=[n.user_docker===`1`?``:`docker group`,n.user_key===`1`?``:`ssh key`].filter(Boolean);i.push(r(`user`,e,t.length?`todo`:`done`,t.length?`exists, add ${t.join(` + `)}`:``))}}if(t.firewall?n.ufw===`missing`?i.push(r(`firewall`,`firewall`,`done`,`ufw not available, skipped`)):n.ufw===`active`?i.push(r(`firewall`,`firewall`,`done`,`ufw active (landfall only adds ssh/80/443)`)):i.push($r(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=ni(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(si(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function ai(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 oi=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function si(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=Ze(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${oi(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${G}/docker-compose.yml was not written by landfall`);let[i,a]=ni(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=ai(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=ni(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=ai(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${oi(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 ci(e){let t={done:C.green(`✓`),todo:C.cyan(`→`),conflict:C.yellow(`!`),off:C.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?C.dim(`disabled`):r.status===`conflict`?C.yellow(r.detail):C.dim(r.detail);E(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function li(e,t){let{stdout:n}=await X(e,`id=$(docker ps -q ${t.map(e=>`--filter ${Y(e)}`).join(` `)} | head -n1)
544
+ [ -z "$id" ] || docker inspect -f '{{json .Name}}{{"\\t"}}{{json .HostConfig.NetworkMode}}{{"\\t"}}{{json .NetworkSettings.Networks}}{{"\\t"}}{{json .Config.Labels}}{{"\\t"}}{{json .Args}}{{"\\t"}}{{json .Config.ExposedPorts}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s,c,l]=r.split(` `).map(e=>JSON.parse(e)),u=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e)),d=Object.keys(l??{}).map(e=>e.match(/^(\d+)\/tcp$/)?.[1]).filter(e=>!!e).map(Number).sort((e,t)=>e-t);return{name:String(i).replace(/^\//,``),host:a===`host`,ips:u,labels:s??{},args:c??[],ports:d}}function ui(e,t){if(e.host)return`127.0.0.1`;let n=e.ips[t]??Object.values(e.ips)[0];return n||k(`${e.name} has no IP address to tunnel to`),n}function di(e,t){let n=n=>e.labels[`traefik.http.routers.${t}.${n}`],r=new Map(e.args.flatMap(e=>{let t=e.match(/^--entrypoints\.([^.=]+)\.address=.*:(\d+)/i);return t?[[t[1].toLowerCase(),Number(t[2])]]:[]})),i=n(`entrypoints`)?.split(`,`).map(e=>e.trim().toLowerCase())??[...r.keys()],a=n(`tls`)===`true`||!!n(`tls.certresolver`),o=i.map(e=>r.get(e)).filter(e=>e!==void 0),s=o.find(e=>e===443===a)??o[0];return s===void 0?void 0:{port:s,tls:a}}const fi={25:`smtp`,110:`pop3`,143:`imap`,465:`smtps`,587:`smtp`,993:`imaps`,995:`pop3s`,3306:`mysql`,5432:`postgresql`,5672:`amqp`,6379:`redis`,11211:`memcached`,27017:`mongodb`},pi=e=>fi[e]??`http`;async function mi(e,t=`127.0.0.1`){let n=e=>new Promise(n=>{let r=g.createServer().once(`error`,()=>n(void 0));r.listen(e,t,()=>{let e=r.address().port;r.close(()=>n(e))})});return e&&await n(e)||await n(0)}const hi=e=>new Promise(t=>{let n=g.connect(e,`127.0.0.1`);n.once(`connect`,()=>(n.destroy(),t(!0))),n.once(`error`,()=>t(!1))});function gi(e,t,n,r){let i=n.tls?b:ie,a=ie.createServer((e,r)=>{let a=i.request({host:`127.0.0.1`,port:t,servername:n.name,rejectUnauthorized:!1,method:e.method,path:e.url,headers:{...e.headers,host:n.name}},e=>(r.writeHead(e.statusCode??502,e.headers),e.pipe(r)));a.on(`error`,e=>r.headersSent?r.destroy():r.writeHead(502).end(`${n.name}: ${e.message}\n`)),e.pipe(a)});return new Promise((t,n)=>a.once(`error`,n).listen(e,r,()=>t(a)))}const _i=()=>Object.values(s.networkInterfaces()).flatMap(e=>e??[]).filter(e=>e.family===`IPv4`&&!e.internal).map(e=>e.address);async function vi(e,t,r,i){let a=i.lan?`0.0.0.0`:`127.0.0.1`,o=i.local===void 0?void 0:Ne(i.local,`--local`,1,65535),s=await mi(o??(r>=1024?r:8080),a);o&&s!==o&&k(`local port ${o} is taken; pick another with --local`);let c=i.as?await mi():s,l=[...ut(e),`-o`,`ControlPath=none`,`-o`,`BatchMode=yes`,`-o`,`ExitOnForwardFailure=yes`,`-o`,`ServerAliveInterval=15`],u=n(`ssh`,[...l,`-N`,`-L`,`${i.as?`127.0.0.1`:a}:${c}:${t}:${r}`,lt(e)],{stdio:[`ignore`,`inherit`,`inherit`]}),d=new Promise(e=>u.on(`close`,t=>e(t??1))),f=!1;for(let e of[`SIGINT`,`SIGTERM`])process.once(e,()=>(f=!0,u.kill()));for(let e=0;e<100&&u.exitCode===null&&!await hi(c);e++)await new Promise(e=>setTimeout(e,150));let p;if(u.exitCode===null){i.as&&(p=await gi(s,c,i.as,a));let e=i.scheme??`http`,t=t=>`${e}://${t}:${s}${e===`http`?i.path??`/`:``}`;T(`${i.what}: ${C.cyan(t(`localhost`))}`);let n=i.lan?_i():[];oe({url:t(`localhost`),lan:n.map(t)}),i.lan&&(E(n.length?`on your network: ${n.map(e=>C.cyan(t(e))).join(` `)}`:`on your network: this machine has no network address yet`),D(`anyone on that network reaches it too: close it when you are done`)),i.note&&E(C.dim(i.note)),E(C.dim(`the tunnel stays open until Ctrl-C`))}let m=await d;p?.close(),!f&&m!==0&&k(`the tunnel closed (ssh exit ${m})`)}async function yi(e,t){return`${e}:${(await y.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const bi=e=>e.slice(0,e.indexOf(`:`)),xi=`admin`;async function Si(e,t,n=`user`){Oe(e,n);let r=t??await fe(`Password for ${e}:`);return r.length<8&&k(`use a password of at least 8 characters`),yi(e,r)}function Ci(e,t){return{email:t.email??e.email??ve().email,staging:!!(t.staging??e.staging),image:t.image||e.traefikImage||`traefik:v3`,hostNetwork:!!(t.hostNetwork??e.hostNetwork),http3:(t.http3??e.http3)!==!1}}const wi=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.http3?`HTTP/3`:`no HTTP/3`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function Ti(e,t,n){let r=Ci(t,n);Me(r.image),r.email&&ke(r.email);let{email:i,staging:a}=r;w(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?Ie(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){Ee.test(c)||k(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=Oe(n.dashboard?n.dashboardUser:o?.user??n.dashboardUser,`--dashboard-user`);s=n.dashboardPassword??v(12).toString(`base64url`),o={host:c,user:e,htpasswd:await yi(e,s)}}let l=at({...r,dashboard:o});r.hostNetwork&&await Oi(t,r.http3),await X(t,`${J}
544
545
  ensure_network web
545
546
  [ -w ${G} ] || { $SUDO install -d -o "$(id -un)" ${G}; }
546
547
  mkdir -p ${G}/letsencrypt ${We} && cd ${G}
@@ -567,31 +568,31 @@ if [ -e docker-compose.yml ]; then
567
568
  else
568
569
  u=$(saved docker-compose.yml)
569
570
  mv .docker-compose.yml.new docker-compose.yml
570
- record ${Y(`reconfigured traefik (${Ci(r)})`)} "cd ${G} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
571
+ record ${Y(`reconfigured traefik (${wi(r)})`)} "cd ${G} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
571
572
  fi
572
573
  else
573
574
  ${Z(`docker-compose.yml`,l)}
574
575
  # Certificates in letsencrypt/ stay, a later install reuses them.
575
- record ${Y(`installed traefik (${Ci(r)})`)} "cd ${G} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
576
+ record ${Y(`installed traefik (${wi(r)})`)} "cd ${G} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
576
577
  fi
577
578
  # Written now: a start that fails below must not make landfall's own file look edited by hand.
578
579
  sha256sum docker-compose.yml > .landfall.sha256
579
580
  log "starting traefik"
580
581
  dc up -d --remove-orphans --quiet-pull
581
582
  sleep 2 # traefik exits shortly after start on a bad config
582
- ensure_running traefik`);let u=V();u.servers[e]&&(Object.assign(u.servers[e],{email:i===_e().email?void 0:i,staging:a||void 0,traefikImage:r.image===`traefik:v3`?void 0:r.image,hostNetwork:r.hostNetwork||void 0,http3:r.http3?void 0:!1,dashboard:o}),Se(u)),T(`traefik running (${Ci(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&E(C.dim(`host network: ufw now decides who reaches 80/443`)),o&&(T(`dashboard: https://${o.host}`),E(s?`user ${C.bold(o.user)} password ${C.bold(s)}`:C.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function Ti(e,t){w(`using traefik ${C.bold(t.container)} for lf sites`),await X(e,`${J}
583
+ ensure_running traefik`);let u=V();u.servers[e]&&(Object.assign(u.servers[e],{email:i===ve().email?void 0:i,staging:a||void 0,traefikImage:r.image===`traefik:v3`?void 0:r.image,hostNetwork:r.hostNetwork||void 0,http3:r.http3?void 0:!1,dashboard:o}),Se(u)),T(`traefik running (${wi(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&E(C.dim(`host network: ufw now decides who reaches 80/443`)),o&&(T(`dashboard: https://${o.host}`),E(s?`user ${C.bold(o.user)} password ${C.bold(s)}`:C.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function Ei(e,t){w(`using traefik ${C.bold(t.container)} for lf sites`),await X(e,`${J}
583
584
  docker network inspect ${Y(t.network)} >/dev/null 2>&1 || { echo ${Y(`docker network ${t.network} does not exist`)} >&2; exit 1; }
584
585
  [ -w ${U} ] || $SUDO install -d -o "$(id -un)" ${U}
585
586
  u=$(saved ${K})
586
587
  ${Z(K,Qe(t))}
587
- record ${Y(`sites use traefik ${t.container}`)} "$u"`),T(`sites join network ${C.bold(t.network)} with entrypoint ${C.bold(t.entrypoint)} and resolver ${C.bold(t.resolver)}`),E(C.dim(`redeploy existing sites to switch them over; delete ${K} to let landfall run its own traefik again`))}async function Ei(e){let{stdout:t}=await X(e,`[ ! -f ${K} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${K})"
588
+ record ${Y(`sites use traefik ${t.container}`)} "$u"`),T(`sites join network ${C.bold(t.network)} with entrypoint ${C.bold(t.entrypoint)} and resolver ${C.bold(t.resolver)}`),E(C.dim(`redeploy existing sites to switch them over; delete ${K} to let landfall run its own traefik again`))}async function Di(e){let{stdout:t}=await X(e,`[ ! -f ${K} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${K})"
588
589
  f=${G}/docker-compose.yml
589
590
  [ ! -f $f ] || { grep -q "managed by landfall" $f && echo compose ours || echo compose foreign; }
590
591
  for d in ${W}/*/; do [ -d "$d" ] && printf 'site\\t%s\\n' "$(basename "$d")"; done; true`,{capture:!0}),n={sites:[]};for(let e of t.split(`
591
- `)){let[t,r]=e.split(/[\t ]/);t===`proxy`&&(n.proxy=r||`?`),t===`compose`&&(n.compose=r),t===`site`&&n.sites.push(r)}return n}async function Di(e,t){let n=[`80/tcp`,`443/tcp`,...t?[`443/udp`]:[]],r;try{r=await _r(e,`checking ufw`)}catch{return D(`can't check ufw without root; if it is on, allow ${n.join(`, `)} (lf firewall add)`)}await X(r,`${J}
592
+ `)){let[t,r]=e.split(/[\t ]/);t===`proxy`&&(n.proxy=r||`?`),t===`compose`&&(n.compose=r),t===`site`&&n.sites.push(r)}return n}async function Oi(e,t){let n=[`80/tcp`,`443/tcp`,...t?[`443/udp`]:[]],r;try{r=await vr(e,`checking ufw`)}catch{return D(`can't check ufw without root; if it is on, allow ${n.join(`, `)} (lf firewall add)`)}await X(r,`${J}
592
593
  command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -q "Status: active" || exit 0
593
594
  ${n.map(e=>`ufw_add allow ${e}`).join(`
594
- `)}`)}async function Oi(e,t={}){w(`removing traefik`),await X(e,`${J}
595
+ `)}`)}async function ki(e,t={}){w(`removing traefik`),await X(e,`${J}
595
596
  cd ${G}
596
597
  log "stopping traefik"
597
598
  dc down --remove-orphans
@@ -603,10 +604,10 @@ u="$u1; $u2; $u3"
603
604
  rm -f docker-compose.yml .landfall.sha256 ${We}/landfall.yml
604
605
  ${t.certs?`rmf letsencrypt`:``}
605
606
  # Certificates removed with --certs come back from Let's Encrypt on the next start.
606
- record ${Y(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${G} && dc up -d --remove-orphans"`)}const ki={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},Ai=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: ${qe})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},http3:{type:`boolean`,desc:`HTTP/3 over QUIC on 443/udp, on by default; remembered (--no-http3 turns it off)`},dashboard:{type:`string`,desc:"serve the traefik dashboard on this domain (basic auth), remembered; `traefik` = traefik.<main domain>, `off` removes it"},"dashboard-user":{type:`string`,desc:`dashboard user`,default:bi},"dashboard-password":{type:`string`,desc:`dashboard password (default: generated)`},force:{type:`boolean`,desc:`overwrite a docker-compose.yml that was edited by hand`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await Ur(r),a=oi(i);if(a.status===`conflict`&&k(`not touching ${n}: ${a.detail}`),a.proxy&&k(`${n} already has its own traefik: ${a.detail}. landfall does not reconfigure it (lf traefik use)`),Si(r,{hostNetwork:t[`host-network`]}).hostNetwork){let e=i.listen.find(e=>e.port===8080&&e.proc!==`traefik`);e&&k(`port ${Ge} is in use by ${e.proc}; traefik on the host network needs it for lf tunnel`)}await wi(n,r,{email:t.email&&ke(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`],force:t.force})}}),ji=F({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
607
+ record ${Y(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${G} && dc up -d --remove-orphans"`)}const Ai={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},ji=P({name:`traefik setup`,summary:`Install or reconfigure Traefik (reverse proxy + automatic HTTPS) on a server. Safe to re-run; refuses if ports 80/443 belong to something else.`,options:{server:F,email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},image:{type:`string`,desc:`traefik image, remembered (default: ${qe})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},http3:{type:`boolean`,desc:`HTTP/3 over QUIC on 443/udp, on by default; remembered (--no-http3 turns it off)`},dashboard:{type:`string`,desc:"serve the traefik dashboard on this domain (basic auth), remembered; `traefik` = traefik.<main domain>, `off` removes it"},"dashboard-user":{type:`string`,desc:`dashboard user`,default:xi},"dashboard-password":{type:`string`,desc:`dashboard password (default: generated)`},force:{type:`boolean`,desc:`overwrite a docker-compose.yml that was edited by hand`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await Wr(r),a=si(i);if(a.status===`conflict`&&k(`not touching ${n}: ${a.detail}`),a.proxy&&k(`${n} already has its own traefik: ${a.detail}. landfall does not reconfigure it (lf traefik use)`),Ci(r,{hostNetwork:t[`host-network`]}).hostNetwork){let e=i.listen.find(e=>e.port===8080&&e.proc!==`traefik`);e&&k(`port ${Ge} is in use by ${e.proc}; traefik on the host network needs it for lf tunnel`)}await Ti(n,r,{email:t.email&&ke(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`],force:t.force})}}),Mi=P({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
607
608
 
608
609
  landfall reads network, entrypoint and cert resolver from the container's flags;
609
- 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}=H(t.server),i=ii((await Ur(r)).values.foreign_proxy),a=e??i?.name;a||k(`no traefik container publishes port 443 on ${n}; name it: lf traefik use <container>`);let o=i?.name===a?i:void 0,s={container:a,network:t.network??o?.proxy?.network,entrypoint:t.entrypoint??o?.proxy?.entrypoint,resolver:t.resolver??o?.proxy?.resolver},c=[`network`,`entrypoint`,`resolver`].filter(e=>!s[e]);c.length&&k(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await Ti(r,s)}}),Mi=F({name:`traefik rm`,summary:"Stop landfall's Traefik and remove its config. Sites stay but are unreachable until `lf traefik setup`.\n\nCertificates in letsencrypt/ stay for a later install unless --certs. Undo with `lf undo`.\nWith a Traefik of your own (lf traefik use), landfall only stops routing sites through it.",options:{server:I,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await Ei(r);if(i.proxy)return await N(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||k(`cancelled`),await X(r,`${J}\nu=$(saved ${K})\n_as ${U} rm -f ${K}\nrecord ${Y(`sites stop using traefik ${i.proxy}`)} "$u"`),T(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),E(C.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&k(`${G}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return T(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await M(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await Oi(r,{certs:t.certs}),T(`traefik removed${t.certs?``:C.dim(` (certificates stay for a later install; --certs deletes them)`)}`),E(C.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),Ni=F({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:I},async run(e,t){let{server:n}=H(t.server);await X(n,`P=${Xe}
610
+ pass them when it is configured by a file. That Traefik is never changed.`,options:{server:F,network:{type:`string`,desc:`docker network traefik watches (providers.docker.network)`},entrypoint:{type:`string`,desc:`HTTPS entrypoint (the one on :443)`},resolver:{type:`string`,desc:`certificate resolver`}},async run([e],t){let{name:n,server:r}=H(t.server),i=ai((await Wr(r)).values.foreign_proxy),a=e??i?.name;a||k(`no traefik container publishes port 443 on ${n}; name it: lf traefik use <container>`);let o=i?.name===a?i:void 0,s={container:a,network:t.network??o?.proxy?.network,entrypoint:t.entrypoint??o?.proxy?.entrypoint,resolver:t.resolver??o?.proxy?.resolver},c=[`network`,`entrypoint`,`resolver`].filter(e=>!s[e]);c.length&&k(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await Ei(r,s)}}),Ni=P({name:`traefik rm`,summary:"Stop landfall's Traefik and remove its config. Sites stay but are unreachable until `lf traefik setup`.\n\nCertificates in letsencrypt/ stay for a later install unless --certs. Undo with `lf undo`.\nWith a Traefik of your own (lf traefik use), landfall only stops routing sites through it.",options:{server:F,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await Di(r);if(i.proxy)return await N(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||k(`cancelled`),await X(r,`${J}\nu=$(saved ${K})\n_as ${U} rm -f ${K}\nrecord ${Y(`sites stop using traefik ${i.proxy}`)} "$u"`),T(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),E(C.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&k(`${G}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return T(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await M(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await ki(r,{certs:t.certs}),T(`traefik removed${t.certs?``:C.dim(` (certificates stay for a later install; --certs deletes them)`)}`),E(C.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),Pi=P({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:F},async run(e,t){let{server:n}=H(t.server);await X(n,`P=${Xe}
610
611
  if ! docker inspect "$P" >/dev/null 2>&1; then echo "traefik is not installed (lf traefik setup)"; exit 0; fi
611
612
  [ -f ${K} ] && echo "your own traefik, landfall only routes sites through it (${K})"
612
613
  docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
@@ -632,33 +633,33 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
632
633
  printf " %s %s (%s)\\n", n, x, s
633
634
  }
634
635
  }' || true
635
- done | sort`)}}),Pi=F({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:I,...ki},async run(e,t){return Un(H(t.server).server,Xe,t)}}),Fi=F({name:`traefik restart`,summary:`Restart traefik.`,options:{server:I},async run(e,t){let{server:n}=H(t.server);await X(n,`${J}\ndocker restart ${Xe} >/dev/null\nrecord "restarted traefik"`),T(`traefik restarted`)}}),Ii=F({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
636
+ done | sort`)}}),Fi=P({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:F,...Ai},async run(e,t){return Wn(H(t.server).server,Xe,t)}}),Ii=P({name:`traefik restart`,summary:`Restart traefik.`,options:{server:F},async run(e,t){let{server:n}=H(t.server);await X(n,`${J}\ndocker restart ${Xe} >/dev/null\nrecord "restarted traefik"`),T(`traefik restarted`)}}),Li=P({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
636
637
 
637
638
  No domain or password needed: landfall's Traefik serves the dashboard to the server
638
639
  itself, and only there. To also serve it on a domain behind a password:
639
640
  lf traefik setup --dashboard traefik (traefik.<main domain>).
640
641
 
641
642
  A Traefik of your own works too when it routes the dashboard to a domain: the tunnel
642
- goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:I,local:{type:`string`,short:`l`,desc:`local port (default: 8080, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`},port:{type:`string`,desc:`with a Traefik of your own: the port of its API (api.insecure: 8080)`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await Ei(r),a=await ci(r,[`name=^${i.proxy??`traefik`}$`]);a||k(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=Gn(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?Ne(t.port,`--port`,1,65535):!i.proxy&&qn(a.labels)?Ge:void 0,l=`traefik dashboard of ${n}`;if(c)return _i(r,li(a,`web`),c,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:s});let u=Kn(a.labels).flatMap(e=>e.hosts[0]?[{host:e.hosts[0],at:ui(a,e.router)}]:[]).find(e=>e.at);u||k(i.proxy?`${i.proxy} is your own traefik and serves no dashboard on a domain; if it serves its API (api.insecure), pass its port: --port 8080`:`traefik on ${n} was set up by an older landfall: \`lf traefik setup -s ${n}\` adds the dashboard tunnel`);let{host:d,at:f}=u;await _i(r,li(a,`web`),f.port,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:`through ${d}'s router (it asks for its login)`,as:{name:d,tls:f.tls}})}});function Li(e,t){let n=He(process.cwd()),r=e??n?.name;return r||k(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...H(t??n?.server)}}const Ri=e=>un(e)?C.green(e):dn(e)?C.dim(e):C.red(e),zi=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}=H(t.server??He(process.cwd())?.server),i=await Zn(r),{sites:a,others:o}=i;if(t.json)return console.log(JSON.stringify(t.others?i:a,null,2));if(o.length&&(console.log(C.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),P([[C.dim(`NAME`),C.dim(`DOMAINS`),C.dim(`FOLDER`),C.dim(`STATUS`)],...o.map(e=>[e.name,e.domains.join(`, `),e.dir??C.dim(e.containers.join(`, `)),Ri(e.status)])]),a.length&&console.log()),!a.length)return console.log(`no landfall sites on ${n} yet — ${C.bold(`lf deploy --domain example.com`)}`);P([[C.dim(`NAME`),C.dim(`KIND`),C.dim(`DOMAINS`),C.dim(`SERVICES`),C.dim(`RELEASE`),C.dim(`UPDATED`),C.dim(`STATUS`)],...a.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[C.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||C.dim(`-`),e.current||C.dim(`-`),ue(e.updatedAt),Ri(t)])])}}),Bi=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}=Li(e,t.server),i=(await Zn(r)).sites.find(e=>e.meta.name===n);i||k(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${C.bold(a.name)} ${i.kind} ${Ri(i.status)}`);for(let e of i.domains)console.log(` ${C.cyan(`https://`+e)}`);if(console.log(C.dim(` ${a.adopted??$(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${C.bold(`Services`)}`),P(i.services.map(e=>[` ${e.service}`,C.dim(e.container),Ri(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${C.bold(`Releases`)}`);for(let e of Hn(a)){let t=e.id===a.current?C.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${C.dim(ue(e.at))} ${C.dim(e.services.map(e=>e.name).join(`, `))}${n.length?C.dim(` → ${n.join(`, `)}`):``}`)}}}}),Vi=F({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:I,...ki},async run([e,t],n){let{site:r,server:i}=Li(e,n.server);return ur(i,r,{...n,service:t})}}),Hi=(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}=Li(a,s.server);await Vn(l,c);let u=o?` ${Y(o)}`:``,d=`record ${Y(`${e} ${c}${o?` `+o:``}`)}`,f=`[ -n "$(cd -P current && dc ps -q --status running${u} 2>/dev/null)" ]`,p=r?`if ${i?``:`! `}${f}; then ${d} ${Y(`cd -P ${$(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await lr(l,c,t+u,p),T(`${o?`${c} ${o}`:c} ${n}`)}}),Ui=Hi(`restart`,`restart`,`restarted`),Wi=Hi(`stop`,`stop`,`stopped`,`start`,!0),Gi=Hi(`start`,`up -d --wait`,`started`,`stop`,!1),Ki=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}=Li(e,t.server),{meta:i,proxy:a}=await Vn(r,n,{deployed:`roll back`}),o=Hn(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&k(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||k(t.to?`no release ${t.to}`:`no release older than ${i.current}`),await sr(r,i,a,`rollback ${n} from ${i.current} to ${c.id}`,e=>void(e.current=c.id)),T(`${n} rolled back to ${C.bold(c.id)}`)}}),qi=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||k("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=H(t.server),i=await Zn(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>en(t.meta)===e);k(t?`${e} is already the site ${t.meta.name}`:`no Compose project "${e}" with Traefik routes on ${r}. See \`lf sites\``)}let o=a;o.dir||k(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=Rn(t.name??jn(e));i.sites.some(e=>e.meta.name===s)&&k(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...mn,...hn].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&k(`${e} was started with ${l.join(`, `)}; rename it to compose.yaml in ${o.dir} (or adopt it after moving it there)`);let u=(/* @__PURE__ */ new Date()).toISOString();await $n(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),T(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(C.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),Ji=F({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
643
+ goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:F,local:{type:`string`,short:`l`,desc:`local port (default: 8080, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`},port:{type:`string`,desc:`with a Traefik of your own: the port of its API (api.insecure: 8080)`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await Di(r),a=await li(r,[`name=^${i.proxy??`traefik`}$`]);a||k(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=Kn(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?Ne(t.port,`--port`,1,65535):!i.proxy&&Jn(a.labels)?Ge:void 0,l=`traefik dashboard of ${n}`;if(c)return vi(r,ui(a,`web`),c,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:s});let u=qn(a.labels).flatMap(e=>e.hosts[0]?[{host:e.hosts[0],at:di(a,e.router)}]:[]).find(e=>e.at);u||k(i.proxy?`${i.proxy} is your own traefik and serves no dashboard on a domain; if it serves its API (api.insecure), pass its port: --port 8080`:`traefik on ${n} was set up by an older landfall: \`lf traefik setup -s ${n}\` adds the dashboard tunnel`);let{host:d,at:f}=u;await vi(r,ui(a,`web`),f.port,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:`through ${d}'s router (it asks for its login)`,as:{name:d,tls:f.tls}})}});function Ri(e,t){let n=He(process.cwd()),r=e??n?.name;return r||k(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...H(t??n?.server)}}const zi=e=>dn(e)?C.green(e):fn(e)?C.dim(e):C.red(e),Bi=P({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:F,json:{type:`boolean`,desc:`machine-readable output`},others:{type:`boolean`,desc:`with --json: {sites, others}, others = containers Traefik routes that landfall did not deploy`}},async run(e,t){let{name:n,server:r}=H(t.server??He(process.cwd())?.server),i=await Qn(r),{sites:a,others:o}=i;if(t.json)return console.log(JSON.stringify(t.others?i:a,null,2));if(o.length&&(console.log(C.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),ue([[C.dim(`NAME`),C.dim(`DOMAINS`),C.dim(`FOLDER`),C.dim(`STATUS`)],...o.map(e=>[e.name,e.domains.join(`, `),e.dir??C.dim(e.containers.join(`, `)),zi(e.status)])]),a.length&&console.log()),!a.length)return console.log(`no landfall sites on ${n} yet — ${C.bold(`lf deploy --domain example.com`)}`);ue([[C.dim(`NAME`),C.dim(`KIND`),C.dim(`DOMAINS`),C.dim(`SERVICES`),C.dim(`RELEASE`),C.dim(`UPDATED`),C.dim(`STATUS`)],...a.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[C.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||C.dim(`-`),e.current||C.dim(`-`),de(e.updatedAt),zi(t)])])}}),Vi=P({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:F},async run([e],t){let{site:n,server:r}=Ri(e,t.server),i=(await Qn(r)).sites.find(e=>e.meta.name===n);i||k(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${C.bold(a.name)} ${i.kind} ${zi(i.status)}`);for(let e of i.domains)console.log(` ${C.cyan(`https://`+e)}`);if(console.log(C.dim(` ${a.adopted??$(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${C.bold(`Services`)}`),ue(i.services.map(e=>[` ${e.service}`,C.dim(e.container),zi(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${C.bold(`Releases`)}`);for(let e of Un(a)){let t=e.id===a.current?C.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${C.dim(de(e.at))} ${C.dim(e.services.map(e=>e.name).join(`, `))}${n.length?C.dim(` → ${n.join(`, `)}`):``}`)}}}}),Hi=P({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:F,...Ai},async run([e,t],n){let{site:r,server:i}=Ri(e,n.server);return dr(i,r,{...n,service:t})}}),Ui=(e,t,n,r,i)=>P({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:F},async run([a,o],s){let{site:c,server:l}=Ri(a,s.server);await Hn(l,c);let u=o?` ${Y(o)}`:``,d=`record ${Y(`${e} ${c}${o?` `+o:``}`)}`,f=`[ -n "$(cd -P current && dc ps -q --status running${u} 2>/dev/null)" ]`,p=r?`if ${i?``:`! `}${f}; then ${d} ${Y(`cd -P ${$(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await ur(l,c,t+u,p),T(`${o?`${c} ${o}`:c} ${n}`)}}),Wi=Ui(`restart`,`restart`,`restarted`),Gi=Ui(`stop`,`stop`,`stopped`,`start`,!0),Ki=Ui(`start`,`up -d --wait`,`started`,`stop`,!1),qi=P({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:F,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=Ri(e,t.server),{meta:i,proxy:a}=await Hn(r,n,{deployed:`roll back`}),o=Un(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&k(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||k(t.to?`no release ${t.to}`:`no release older than ${i.current}`),await cr(r,i,a,`rollback ${n} from ${i.current} to ${c.id}`,e=>void(e.current=c.id)),T(`${n} rolled back to ${C.bold(c.id)}`)}}),Ji=P({name:`adopt`,args:`<project>`,summary:"Make a Compose project landfall did not deploy a site: `lf sites`, logs, restart,\nstop and start then work for it. It keeps running from its folder, with its own files and\nTraefik labels; landfall does not deploy, roll back or change its domains. `lf sites rm`\nstops it and forgets it again. The projects you can adopt are listed under `lf sites`.",options:{server:F,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||k("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=H(t.server),i=await Qn(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>tn(t.meta)===e);k(t?`${e} is already the site ${t.meta.name}`:`no Compose project "${e}" with Traefik routes on ${r}. See \`lf sites\``)}let o=a;o.dir||k(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=zn(t.name??Mn(e));i.sites.some(e=>e.meta.name===s)&&k(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...hn,...gn].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&k(`${e} was started with ${l.join(`, `)}; rename it to compose.yaml in ${o.dir} (or adopt it after moving it there)`);let u=(/* @__PURE__ */ new Date()).toISOString();await er(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),T(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(C.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),Yi=P({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
643
644
 
644
645
  Named volumes (databases, uploads) stay, so deploying the site again finds its data;
645
- --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||k(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=Li(e,t.server),{meta:a}=await Bn(r,n);a||k(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=fn(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await M(o.adopted?`Stop ${n} (${s}) on ${i} and forget it${c}? ${o.adopted} stays.`:`Delete ${n} (${s})${c} from ${i}? This cannot be undone.`,n),await fr(r,o,{volumes:t.volumes}),T(`removed ${n}${t.volumes?` and its volumes`:C.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),Yi=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`},"env-file":{type:`string`,desc:`also set the KEY=VALUE lines of this file; - reads stdin (keeps values out of ps)`}},async run(e,t){let[n]=e.filter(e=>!e.includes(`=`)),r={...t[`env-file`]?await hr(t[`env-file`]):{},...gr(e.filter(e=>e.includes(`=`)))},{site:i,server:a}=Li(n,t.server),{env:o}=await Vn(a,i,{deployed:`manage the .env of`});if(!Object.keys(r).length&&!t.unset?.length)return void process.stdout.write(o||C.dim(`(empty)
646
- `));let s=pr(o,r,t.unset??[]),c=[...Object.keys(r),...(t.unset??[]).map(e=>`-${e}`)];await lr(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
646
+ --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:F,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||k(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=Ri(e,t.server),{meta:a}=await Vn(r,n);a||k(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=pn(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await M(o.adopted?`Stop ${n} (${s}) on ${i} and forget it${c}? ${o.adopted} stays.`:`Delete ${n} (${s})${c} from ${i}? This cannot be undone.`,n),await pr(r,o,{volumes:t.volumes}),T(`removed ${n}${t.volumes?` and its volumes`:C.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),Xi=P({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:F,unset:{type:`string`,short:`u`,multiple:!0,desc:`remove KEY`},"env-file":{type:`string`,desc:`also set the KEY=VALUE lines of this file; - reads stdin (keeps values out of ps)`}},async run(e,t){let[n]=e.filter(e=>!e.includes(`=`)),r={...t[`env-file`]?await gr(t[`env-file`]):{},..._r(e.filter(e=>e.includes(`=`)))},{site:i,server:a}=Ri(n,t.server),{env:o}=await Hn(a,i,{deployed:`manage the .env of`});if(!Object.keys(r).length&&!t.unset?.length)return void process.stdout.write(o||C.dim(`(empty)
647
+ `));let s=mr(o,r,t.unset??[]),c=[...Object.keys(r),...(t.unset??[]).map(e=>`-${e}`)];await ur(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
647
648
  record ${Y(`env ${i} ${c.join(` `)}`)} "cd ${$(i)} && $u && cd -P current && dc up -d"
648
- ${Z(`.env`,s,`600`)}`),T(`updated env of ${i}`)}}),Xi=F({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
649
+ ${Z(`.env`,s,`600`)}`),T(`updated env of ${i}`)}}),Zi=P({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
649
650
 
650
651
  Nothing changes on the server: no port is published and its domains stay as they are,
651
652
  so it also works for a site with no domain. Traefik is bypassed: no HTTPS and no login.
652
653
  Works for any Compose project or container on the server too, not only landfall's sites.
653
- The tunnel stays open until Ctrl-C.`,options:{server:I,port:{type:`string`,desc:`container port (default: the one Traefik routes to)`},local:{type:`string`,short:`l`,desc:`local port (default: the container port, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`}},async run([e,t],n){let{site:r,server:i}=Li(e,n.server),{meta:a,proxy:o}=await Bn(i,r);if(!a)return Zi(i,r,t,o.network,n);$t(a)&&k(`${r} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`);let s=tn(a),c=t?s?.services.find(e=>e.name===t):on(a)??s?.services.find(nn);t&&s&&!c&&k(`${r} has no service ${t} (${s.services.map(e=>e.name).join(`, `)})`);let l=c?.name??t;l||k(`which service of ${r}? lf tunnel ${r} <service>`);let u=await ci(i,[`label=com.docker.compose.project=${en(a)}`,`label=com.docker.compose.service=${l}`]);u||k(`${l} of ${r} is not running (lf start ${r})`),await Qi(i,u,o.network,n.port??c?.port,{local:n.local,lan:n.lan,what:`${r}${t?` (${l})`:``}`})}});async function Zi(e,t,n,r,i){let a=[`label=com.docker.compose.project=${t}`,...n?[`label=com.docker.compose.service=${n}`]:[]],o=await ci(e,[...a,`label=traefik.enable=true`])??await ci(e,a)??(n?void 0:await ci(e,[`name=^/?${t}$`]));o||k(`no site, Compose project or container "${t}"${n?` with a service ${n}`:``} running on ${e.host}. See \`lf sites list\``);let s=o.labels[`com.docker.compose.service`];await Qi(e,o,r,i.port,{local:i.local,lan:i.lan,what:`${t}${s&&s!==t?` (${s})`:``}`})}async function Qi(e,t,n,r,i){let a=Object.entries(t.labels).find(([e])=>vn.test(e))?.[1],o=r??a??t.ports[0];o===void 0&&k(`which port of ${t.name}? pass --port`);let s=Ne(String(o),`--port`,1,65535);r===void 0&&a===void 0&&t.ports.length>1&&E(C.dim(`${t.name} also exposes ${t.ports.slice(1).join(`, `)}: pick one with --port`));let c=fi(s);await _i(e,li(t,n),s,{...i,scheme:c,note:c===`http`?`straight to the container: no HTTPS, no login, and the app sees Host: ${i.lan?`localhost or this machine's address`:`localhost`}`:`straight to the container: point a ${c} client at it, with the container's own credentials`})}async function $i(e){let t=e,n=await Ur(t);n.values.sudo!==`1`&&(t=await _r(e,`the firewall`),n=await Ur(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(Jr),ports:ei(n),blocked:$r(n,[i]).blocked,probe:n}}const ea=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,ta=/^[0-9a-fA-F.:]+(\/\d+)?$/;function na(e){ea.test(e.port)||k(`not a port: ${e.port} (e.g. 5432, 51820/udp, 6000:6007/tcp)`);let[t,n]=e.port.split(`/`);t.split(/[,:]/).some(e=>Number(e)<1||Number(e)>65535)&&k(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&k(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!ta.test(e.from)&&k(`not an address or network: ${e.from} (e.g. 203.0.113.7 or 10.0.0.0/8)`);let r=e.from?[e.action,`from`,e.from,`to`,`any`,`port`,t,...n?[`proto`,n]:[]]:[e.action,e.port];return e.comment?[...r,`comment`,e.comment]:r}const ra=e=>`ufw_add ${e.map(Y).join(` `)}`,ia=e=>e[0]===`deny`?`${ra([`prepend`,...e])} 2>/dev/null || ${ra(e)}`:ra(e),aa=(e,t)=>`${t?`$SUDO ufw insert ${t} ${e.spec} >/dev/null 2>&1 || `:``}$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`,oa=(e,t)=>`$SUDO ufw --force delete ${e.spec} >/dev/null
654
- record ${Y(`ufw delete ${e.spec}`)} ${Y(aa(e,t))}`,sa=(e,t)=>e.rules.slice(0,t).some(e=>e.from?.includes(`:`))?void 0:t,ca=e=>Jr(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),la=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function ua(e,t,n){if(e.ufw!==`active`)return;let r=Yr(t,{proto:`tcp`,port:e.sshPort})?.rule;if(r&&(r.action===`allow`||r.action===`limit`)&&!r.from)return;let i=r?.from?`only ${r.from} could still reach ssh port ${e.sshPort}`:`this blocks ssh port ${e.sshPort} and can lock you out`;n||await le(`${i}. Do it anyway?`)||k(`${i} (--force to do it anyway)`)}function da(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||k(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const fa={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 pa(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:{...fa,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||k(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=na({action:e,port:t,from:n.from,comment:n.comment}),i=await $i(H(n.server).server);e===`deny`&&await ua(i,[ca(r),...i.rules],n.force),await X(i.login,`${J}\n${la}\n${ia(r)}`),T(`${r.join(` `)}${i.ufw===`active`?``:C.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const ma=pa(`allow`),ha=pa(`deny`),ga=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}=H(t.server);if(t.json)return void console.log(JSON.stringify(await po(r),null,2));let i=await $i(r),a=i.ufw===`active`?C.green(`on`):i.ufw===`missing`?C.yellow(`not installed`):C.yellow(`off`);w(`firewall on ${n}: ${a}${i.ufw===`active`?C.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${C.bold(`Rules`)}`),i.rules.length||E(C.dim(`none`)),P(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?C.green(e.action):C.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${C.bold(`Listening ports`)}`);let o={open:C.green,limited:C.yellow,blocked:C.red,local:C.dim};P(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?D(`enabling would block ${i.blocked.join(`, `)}: allow them first`):E(C.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),_a=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}=H(t.server),i=await $i(r);if(i.ufw===`active`)return T(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await le(`${e}. Turn it on anyway?`)||k(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await ua({...i,ufw:`active`},[...i.rules,ca([`allow`,`${i.sshPort}/tcp`])],t.force),await X(i.login,`${J}
654
+ The tunnel stays open until Ctrl-C.`,options:{server:F,port:{type:`string`,desc:`container port (default: the one Traefik routes to)`},local:{type:`string`,short:`l`,desc:`local port (default: the container port, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`}},async run([e,t],n){let{site:r,server:i}=Ri(e,n.server),{meta:a,proxy:o}=await Vn(i,r);if(!a)return Qi(i,r,t,o.network,n);en(a)&&k(`${r} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`);let s=nn(a),c=t?s?.services.find(e=>e.name===t):sn(a)??s?.services.find(rn);t&&s&&!c&&k(`${r} has no service ${t} (${s.services.map(e=>e.name).join(`, `)})`);let l=c?.name??t;l||k(`which service of ${r}? lf tunnel ${r} <service>`);let u=await li(i,[`label=com.docker.compose.project=${tn(a)}`,`label=com.docker.compose.service=${l}`]);u||k(`${l} of ${r} is not running (lf start ${r})`),await $i(i,u,o.network,n.port??c?.port,{local:n.local,lan:n.lan,what:`${r}${t?` (${l})`:``}`})}});async function Qi(e,t,n,r,i){let a=[`label=com.docker.compose.project=${t}`,...n?[`label=com.docker.compose.service=${n}`]:[]],o=await li(e,[...a,`label=traefik.enable=true`])??await li(e,a)??(n?void 0:await li(e,[`name=^/?${t}$`]));o||k(`no site, Compose project or container "${t}"${n?` with a service ${n}`:``} running on ${e.host}. See \`lf sites list\``);let s=o.labels[`com.docker.compose.service`];await $i(e,o,r,i.port,{local:i.local,lan:i.lan,what:`${t}${s&&s!==t?` (${s})`:``}`})}async function $i(e,t,n,r,i){let a=Object.entries(t.labels).find(([e])=>yn.test(e))?.[1],o=r??a??t.ports[0];o===void 0&&k(`which port of ${t.name}? pass --port`);let s=Ne(String(o),`--port`,1,65535);r===void 0&&a===void 0&&t.ports.length>1&&E(C.dim(`${t.name} also exposes ${t.ports.slice(1).join(`, `)}: pick one with --port`));let c=pi(s);await vi(e,ui(t,n),s,{...i,scheme:c,note:c===`http`?`straight to the container: no HTTPS, no login, and the app sees Host: ${i.lan?`localhost or this machine's address`:`localhost`}`:`straight to the container: point a ${c} client at it, with the container's own credentials`})}async function ea(e){let t=e,n=await Wr(t);n.values.sudo!==`1`&&(t=await vr(e,`the firewall`),n=await Wr(t));let r=n.values,i=Number(r.ssh_port)||Q(e).port;return{login:t,ufw:r.ufw===`active`?`active`:r.ufw===`missing`?`missing`:`inactive`,policy:r.ufw_in||`DROP`,sshPort:i,rules:n.rules.map(Yr),ports:ti(n),blocked:ei(n,[i]).blocked,probe:n}}const ta=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,na=/^[0-9a-fA-F.:]+(\/\d+)?$/;function ra(e){ta.test(e.port)||k(`not a port: ${e.port} (e.g. 5432, 51820/udp, 6000:6007/tcp)`);let[t,n]=e.port.split(`/`);t.split(/[,:]/).some(e=>Number(e)<1||Number(e)>65535)&&k(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&k(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!na.test(e.from)&&k(`not an address or network: ${e.from} (e.g. 203.0.113.7 or 10.0.0.0/8)`);let r=e.from?[e.action,`from`,e.from,`to`,`any`,`port`,t,...n?[`proto`,n]:[]]:[e.action,e.port];return e.comment?[...r,`comment`,e.comment]:r}const ia=e=>`ufw_add ${e.map(Y).join(` `)}`,aa=e=>e[0]===`deny`?`${ia([`prepend`,...e])} 2>/dev/null || ${ia(e)}`:ia(e),oa=(e,t)=>`${t?`$SUDO ufw insert ${t} ${e.spec} >/dev/null 2>&1 || `:``}$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`,sa=(e,t)=>`$SUDO ufw --force delete ${e.spec} >/dev/null
655
+ record ${Y(`ufw delete ${e.spec}`)} ${Y(oa(e,t))}`,ca=(e,t)=>e.rules.slice(0,t).some(e=>e.from?.includes(`:`))?void 0:t,la=e=>Yr(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),ua=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function da(e,t,n){if(e.ufw!==`active`)return;let r=Xr(t,{proto:`tcp`,port:e.sshPort})?.rule;if(r&&(r.action===`allow`||r.action===`limit`)&&!r.from)return;let i=r?.from?`only ${r.from} could still reach ssh port ${e.sshPort}`:`this blocks ssh port ${e.sshPort} and can lock you out`;n||await le(`${i}. Do it anyway?`)||k(`${i} (--force to do it anyway)`)}function fa(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||k(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const pa={server:F,from:{type:`string`,desc:`only from this address or network (e.g. 10.0.0.0/8)`},comment:{type:`string`,desc:`note stored with the rule`}};function ma(e){return P({name:`firewall ${e}`,args:`<port[/proto]>`,summary:e===`allow`?"Open a port in ufw, e.g. `lf firewall allow 5432 --from 10.0.0.0/8` or `lf firewall allow 51820/udp`.":"Block a port in ufw, e.g. `lf firewall deny 5432`. Goes before the other rules, so it wins over an allow.",options:{...pa,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||k(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=ra({action:e,port:t,from:n.from,comment:n.comment}),i=await ea(H(n.server).server);e===`deny`&&await da(i,[la(r),...i.rules],n.force),await X(i.login,`${J}\n${ua}\n${aa(r)}`),T(`${r.join(` `)}${i.ufw===`active`?``:C.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const ha=ma(`allow`),ga=ma(`deny`),_a=P({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:F,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=H(t.server);if(t.json)return void console.log(JSON.stringify(await _o(r),null,2));let i=await ea(r),a=i.ufw===`active`?C.green(`on`):i.ufw===`missing`?C.yellow(`not installed`):C.yellow(`off`);w(`firewall on ${n}: ${a}${i.ufw===`active`?C.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${C.bold(`Rules`)}`),i.rules.length||E(C.dim(`none`)),ue(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?C.green(e.action):C.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${C.bold(`Listening ports`)}`);let o={open:C.green,limited:C.yellow,blocked:C.red,local:C.dim};ue(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?D(`enabling would block ${i.blocked.join(`, `)}: allow them first`):E(C.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),va=P({name:`firewall enable`,summary:`Turn ufw on (installs it if missing). Always allows the ssh port first; refuses if it would block a port in use.`,options:{server:F,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=H(t.server),i=await ea(r);if(i.ufw===`active`)return T(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await le(`${e}. Turn it on anyway?`)||k(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await da({...i,ufw:`active`},[...i.rules,la([`allow`,`${i.sshPort}/tcp`])],t.force),await X(i.login,`${J}
655
656
  command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
656
- ${ra([`allow`,`${i.sshPort}/tcp`])}
657
- ufw_enable`),T(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),va=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}=H(t.server),i=await $i(r);if(i.ufw!==`active`)return T(`firewall on ${n} is already off`);await N(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||k(`cancelled`),await X(i.login,`${J}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),T(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),ya=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||k(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=H(t.server),i=await $i(r),{rule:a,n:o}=da(i,e);await ua(i,i.rules.filter(e=>e!==a),t.force),await M(`Remove rule ${o} (${a.spec}) on ${n}?`),await X(i.login,`${J}\n${la}\n${oa(a,sa(i,o))}`),T(`removed rule ${o}: ${a.spec}`)}}),ba=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:{...fa,port:{type:`string`,desc:`port[/proto], e.g. 5432 or 51820/udp`},from:{type:`string`,desc:`only from this address or network; 'any' for everyone`},action:{type:`string`,desc:`allow, deny or limit`},force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||k(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await $i(H(t.server).server),{rule:r,n:i}=da(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||k(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||k(`rule ${i} (${r.spec}) names no port; pass --port`);let s=na({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=sa(n,i),l=n.rules.filter(e=>e!==r);await ua(n,c?n.rules.map(e=>e===r?ca(s):e):a===`deny`?[ca(s),...l]:[...l,ca(s)],t.force);let u=c&&c<n.rules.length?`${ra([`insert`,String(c),...s])} || ${ia(s)}`:ia(s);await X(n.login,`${J}
658
- ${la}
659
- ${oa(r,c)}
660
- ${u} || { ${aa(r,c)}; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),T(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}}),xa=e=>e.steps.filter(e=>e.undo&&!e.undone);function Sa(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
661
- `).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}const Ca=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),wa=RegExp(`${Ca(W)}/[A-Za-z0-9._-]+|${Ca(G)}`,`g`);function Ta(e){let t=/* @__PURE__ */ new Set;for(let n of e.matchAll(wa))t.add(n[0]);for(let n of e.matchAll(/restore_file \S+ '?(\/[^\s';]+)/g))t.add(n[1]);return t}function Ea(e,t,n){let r=new Set(n.filter(e=>e.undo&&!e.undone).flatMap(e=>[...Ta(e.undo)])),i=[];for(let n of e.filter(e=>e.n>t)){let e=xa(n).flatMap(e=>[...Ta(e.undo)]).find(e=>r.has(e));e&&i.push({run:n,subject:e})}return i}async function Da(e){let{stdout:t}=await X(e,`cat ${q} 2>/dev/null || sudo -n cat ${q} 2>/dev/null || true`,{capture:!0});return Sa(t)}function Oa(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||k(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||k(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function ka(e,t){let n=Q(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await X(e,`sudo -n true 2>/dev/null && echo yes || true`,{capture:!0});if(r.trim()===`yes`)return{login:e,sudo:!0};let i={...e,user:`root`};return await yt(i)?{login:i}:k(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const Aa=String.raw`
657
+ ${ia([`allow`,`${i.sshPort}/tcp`])}
658
+ ufw_enable`),T(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),ya=P({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:F},async run(e,t){let{name:n,server:r}=H(t.server),i=await ea(r);if(i.ufw!==`active`)return T(`firewall on ${n} is already off`);await N(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||k(`cancelled`),await X(i.login,`${J}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),T(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),ba=P({name:`firewall rm`,args:`<number|rule>`,summary:"Remove a rule by its number in `lf firewall`, or by its text (e.g. 'allow 5432').",options:{server:F,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||k(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=H(t.server),i=await ea(r),{rule:a,n:o}=fa(i,e);await da(i,i.rules.filter(e=>e!==a),t.force),await M(`Remove rule ${o} (${a.spec}) on ${n}?`),await X(i.login,`${J}\n${ua}\n${sa(a,ca(i,o))}`),T(`removed rule ${o}: ${a.spec}`)}}),xa=P({name:`firewall update`,args:`<number|rule>`,summary:`Change a rule: what you pass replaces that part, the rest stays. If ufw rejects the new rule, the old one is restored.`,options:{...pa,port:{type:`string`,desc:`port[/proto], e.g. 5432 or 51820/udp`},from:{type:`string`,desc:`only from this address or network; 'any' for everyone`},action:{type:`string`,desc:`allow, deny or limit`},force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||k(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await ea(H(t.server).server),{rule:r,n:i}=fa(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||k(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||k(`rule ${i} (${r.spec}) names no port; pass --port`);let s=ra({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=ca(n,i),l=n.rules.filter(e=>e!==r);await da(n,c?n.rules.map(e=>e===r?la(s):e):a===`deny`?[la(s),...l]:[...l,la(s)],t.force);let u=c&&c<n.rules.length?`${ia([`insert`,String(c),...s])} || ${aa(s)}`:aa(s);await X(n.login,`${J}
659
+ ${ua}
660
+ ${sa(r,c)}
661
+ ${u} || { ${oa(r,c)}; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),T(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}}),Sa=e=>e.steps.filter(e=>e.undo&&!e.undone);function Ca(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
662
+ `).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}const wa=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Ta=RegExp(`${wa(W)}/[A-Za-z0-9._-]+|${wa(G)}`,`g`);function Ea(e){let t=/* @__PURE__ */ new Set;for(let n of e.matchAll(Ta))t.add(n[0]);for(let n of e.matchAll(/restore_file \S+ '?(\/[^\s';]+)/g))t.add(n[1]);return t}function Da(e,t,n){let r=new Set(n.filter(e=>e.undo&&!e.undone).flatMap(e=>[...Ea(e.undo)])),i=[];for(let n of e.filter(e=>e.n>t)){let e=Sa(n).flatMap(e=>[...Ea(e.undo)]).find(e=>r.has(e));e&&i.push({run:n,subject:e})}return i}async function Oa(e){let{stdout:t}=await X(e,`cat ${q} 2>/dev/null || sudo -n cat ${q} 2>/dev/null || true`,{capture:!0});return Ca(t)}function ka(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||k(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||k(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function Aa(e,t){let n=Q(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await X(e,`sudo -n true 2>/dev/null && echo yes || true`,{capture:!0});if(r.trim()===`yes`)return{login:e,sudo:!0};let i={...e,user:`root`};return await yt(i)?{login:i}:k(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const ja=String.raw`
662
663
  undo_step() { # undo_step <line> <undo shell, base64>
663
664
  # Two undos at once must not both revert a step: the second waits here, then finds it done.
664
665
  [ -e ${U}/.undo.lock ] || touch ${U}/.undo.lock 2>/dev/null || true
@@ -674,12 +675,12 @@ undo_step() { # undo_step <line> <undo shell, base64>
674
675
  _journal undo "$1" ""
675
676
  fi
676
677
  exec 9<&-
677
- }`;async function ja(e,t){let n=t.filter(e=>e.undo&&!e.undone).sort((e,t)=>t.line-e.line),r=[e];for(let t=0;t<n.length;){let i=t;for(;i<n.length&&n[i].user===n[t].user;)i++;let a=n.slice(t,i),o=a.map(e=>`undo_step ${e.line} ${Y(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await ka(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(mt)),await X(s,[J,Aa,...o].join(`
678
- `),{sudo:c}),r.push(s),t=i}return n.length}function Ma(e,t){let n=C.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${C.dim(`↩ `+e.what)}`:e.undone?`${n} ${C.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${C.cyan(`↺`)} ${e.what}`:`${n} ${C.dim(`· `+e.what+` (not reversible)`)}`}const Na=e=>e.trim().replace(/\\(.)/g,`$1`),Pa=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function Fa(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await $i(e),i=[...r.rules],a=r.ufw;for(let e of[...t].sort((e,t)=>t.line-e.line))for(let t of e.undo.split(`
679
- `)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend |insert (\d+) )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=Jr(Na(e[1])),n=i.findIndex(e=>Pa(e,t));n>=0&&i.splice(n,1)}else if(n){let e=Jr(Na(n[3]));n[2]?i.splice(Number(n[2])-1,0,e):i=n[1]?[e,...i]:[...i,e]}else/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await ua({...r,ufw:a},i,n)}const Ia=e=>`${C.bold(`#${e.n}`.padEnd(5))} ${C.dim(ue(e.time).padEnd(9))} ${C.bold(e.cmd||`?`)} ${C.dim(`${e.actor} · ${e.id}`)}`,La=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}=H(t.server);if(t.json)return console.log(JSON.stringify(await go(r,Number(t.n)||20),null,2));let i=(await Da(r)).reverse().slice(0,Number(t.n)||20);if(!i.length)return E(`nothing in ${q} on ${n} yet`);for(let e of i)console.log(Ia(e)),e.steps.forEach((e,t)=>console.log(Ma(e,t)));let a=i.find(e=>xa(e).length);a&&console.log(C.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),Ra=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, or a later run changed the same site or file`}},async run([e],t){let{name:n,server:r}=H(t.server),i=await Da(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let r=e?Oa(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>xa(e.run).length);if(!r)return T(`nothing to undo on ${n}`);({steps:a}=r),o=`#${r.run.n} ${r.run.cmd} (${ue(r.run.time)})`;let s=Ea(i,r.run.n,a);s.length&&!t.force&&k(`later runs changed the same things: ${s.map(e=>`#${e.run.n} ${e.run.cmd} (${e.subject})`).join(`, `)}. Undo those first, newest first, or pass --force`);let c=i.filter(e=>e.n>r.run.n&&xa(e).length);c.length&&D(`later runs build on it and stay: ${c.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return T(`nothing left to undo in ${o}`);w(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${C.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${C.dim(`· ${e.what} stays (not reversible)`)}`);await Fa(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await M(l,n):await N(l,!1)||k(`cancelled`),await ja(r,s),T(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&E(C.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),za=/^(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org)$/;function Ba(){let e=V(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=Ot().filter(t=>!e.servers[t]&&!za.test(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}function Va(e){try{let t=Q(H(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function Ha(e){let[t,n]=e;return e.includes(`--check`)||e.includes(`--key-only`)?!1:t===`firewall`?[`enable`,`disable`,`allow`,`deny`,`update`,`rm`,`remove`].includes(n):t===`traefik`?n===`setup`||n===`use`:t===`env`?e.some(e=>e.includes(`=`)||e===`-u`||e===`--unset`):t===`domains`?e.some(e=>[`-a`,`--add`,`--rm`,`--set`].includes(e)):t===`auth`?e.some(e=>[`-u`,`--user`,`--rm-user`,`--off`].includes(e)):t===`update`||t===`upgrade`||[`setup`,`deploy`,`rollback`,`stop`,`start`,`restart`,`adopt`].includes(t)}let Ua;function Wa(){if(Ua)return Ua;let e=o.mkdtempSync(m(u(),`landfall-`));return Ua=m(e,`askpass`),o.writeFileSync(Ua,`#!/bin/sh
678
+ }`;async function Ma(e,t){let n=t.filter(e=>e.undo&&!e.undone).sort((e,t)=>t.line-e.line),r=[e];for(let t=0;t<n.length;){let i=t;for(;i<n.length&&n[i].user===n[t].user;)i++;let a=n.slice(t,i),o=a.map(e=>`undo_step ${e.line} ${Y(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await Aa(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(mt)),await X(s,[J,ja,...o].join(`
679
+ `),{sudo:c}),r.push(s),t=i}return n.length}function Na(e,t){let n=C.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${C.dim(`↩ `+e.what)}`:e.undone?`${n} ${C.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${C.cyan(`↺`)} ${e.what}`:`${n} ${C.dim(`· `+e.what+` (not reversible)`)}`}const Pa=e=>e.trim().replace(/\\(.)/g,`$1`),Fa=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function Ia(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await ea(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(`
680
+ `)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend |insert (\d+) )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=Yr(Pa(e[1])),n=i.findIndex(e=>Fa(e,t));n>=0&&i.splice(n,1)}else if(n){let e=Yr(Pa(n[3]));n[2]?i.splice(Number(n[2])-1,0,e):i=n[1]?[e,...i]:[...i,e]}else/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await da({...r,ufw:a},i,n)}const La=e=>`${C.bold(`#${e.n}`.padEnd(5))} ${C.dim(de(e.time).padEnd(9))} ${C.bold(e.cmd||`?`)} ${C.dim(`${e.actor} · ${e.id}`)}`,Ra=P({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:F,n:{type:`string`,short:`n`,desc:`runs to show`,default:`20`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=H(t.server);if(t.json)return console.log(JSON.stringify(await bo(r,Number(t.n)||20),null,2));let i=(await Oa(r)).reverse().slice(0,Number(t.n)||20);if(!i.length)return E(`nothing in ${q} on ${n} yet`);for(let e of i)console.log(La(e)),e.steps.forEach((e,t)=>console.log(Na(e,t)));let a=i.find(e=>Sa(e).length);a&&console.log(C.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),za=P({name:`undo`,args:`[run|run.step]`,summary:"Revert what landfall did: the latest run, run <n> from `lf history`, one step <n>.<m>, or --all.",options:{server:F,all:{type:`boolean`,desc:`everything landfall did that can be reverted`},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule, or a later run changed the same site or file`}},async run([e],t){let{name:n,server:r}=H(t.server),i=await Oa(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let r=e?ka(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>Sa(e.run).length);if(!r)return T(`nothing to undo on ${n}`);({steps:a}=r),o=`#${r.run.n} ${r.run.cmd} (${de(r.run.time)})`;let s=Da(i,r.run.n,a);s.length&&!t.force&&k(`later runs changed the same things: ${s.map(e=>`#${e.run.n} ${e.run.cmd} (${e.subject})`).join(`, `)}. Undo those first, newest first, or pass --force`);let c=i.filter(e=>e.n>r.run.n&&Sa(e).length);c.length&&D(`later runs build on it and stay: ${c.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return T(`nothing left to undo in ${o}`);w(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${C.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${C.dim(`· ${e.what} stays (not reversible)`)}`);await Ia(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await M(l,n):await N(l,!1)||k(`cancelled`),await Ma(r,s),T(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&E(C.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),Ba=/(^|\.)(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org|sr\.ht|dev\.azure\.com|visualstudio\.com)$|^(git|gitlab|gitea|forgejo)\./i;function Va(e){if(Ba.test(e))return!0;try{let t=Q({host:e});return t.user===`git`||Ba.test(t.hostname)}catch{return!1}}function Ha(){let e=V(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=kt().filter(t=>!e.servers[t]&&!Va(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}const Ua=/* @__PURE__ */ new Map;async function Wa(e,t=!1){let n={},r=[...e];return await Promise.all(Array.from({length:Math.min(8,e.length)},async()=>{for(let e=r.shift();e!==void 0;e=r.shift()){let r=Ua.get(e);if(t||!r||Date.now()-r.at>3e4){let t=(async()=>{try{return await bt(H(e).server)}catch(e){return{state:`down`,detail:e.message,ms:0}}})();Ua.set(e,r={at:Date.now(),r:t})}n[e]=await r.r}})),n}function Ga(e){try{let t=Q(H(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function Ka(e){let[t,n]=e;return e.includes(`--check`)||e.includes(`--key-only`)?!1:t===`firewall`?[`enable`,`disable`,`allow`,`deny`,`update`,`rm`,`remove`].includes(n):t===`traefik`?n===`setup`||n===`use`:t===`env`?e.some(e=>e.includes(`=`)||e===`-u`||e===`--unset`):t===`domains`?e.some(e=>[`-a`,`--add`,`--rm`,`--set`].includes(e)):t===`auth`?e.some(e=>[`-u`,`--user`,`--rm-user`,`--off`].includes(e)):t===`update`||t===`upgrade`||[`setup`,`deploy`,`rollback`,`stop`,`start`,`restart`,`adopt`].includes(t)}let qa;function Ja(){if(qa)return qa;let e=o.mkdtempSync(m(u(),`landfall-`));return qa=m(e,`askpass`),o.writeFileSync(qa,`#!/bin/sh
680
681
  printf '%s\\n' "$LANDFALL_PASSWORD"
681
- `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Ua}const Ga=e=>({SSH_ASKPASS:Wa(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Ka(e){if(e){let e=(await x()).replace(/\r?\n$/,``);return e||k(`--password-stdin: nothing on stdin`),e}return ye(`PASSWORD`)||void 0}const qa=e=>m(c(),`.ssh`,`landfall_${Te(e)}`);function Ja(e){let t=qa(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(m(c(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&k(`ssh-keygen failed`),{path:t,created:!0})}async function Ya(e,t,n){let i=Q(t),a=t.key?z(t.key):void 0;if(a&&!o.existsSync(a)&&k(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&k(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await yt(t);if(!s&&!t.key&&o.existsSync(qa(e))&&await yt({...t,key:qa(e)})&&(t.key=a=qa(e),s=!0),s)T(`key login works`),a??=Dt(t);else if(n.check){D("no key login yet — `lf setup` would install a key first"),oe({needsKey:!0});return}let c=!1;if(!a&&!n.check){let t=Ja(e);a=t.path,c=t.created,c&&T(`generated key ${B(a)}`)}let l=t.key&&z(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await yt(t))){w(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Ga(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...ut(t),lt(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await yt(t)||k(`could not log in with the key — check host, user and password`),T(`key installed`)}return a}const Xa=e=>{for(let t of e)console.log(C.dim(` `)+t)};function Za(e,t,n,r){let i=Q(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Pt({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});T(`${r.created?`created`:`updated`} ${B(Tt)} with ${C.bold(`Host ${e}`)} — try \`ssh ${e}\``),Xa(r.block.split(`
682
- `)),a.host=e,delete a.user,delete a.port,delete a.key}else{t.user&&(a.user=t.user),r.port&&(a.port=Number(r.port));let o=r.keyGiven||n!==Dt({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?It(e,n):void 0;s?(T(`added the key to your ${C.bold(`Host ${e}`)} in ${B(s.file)} ${C.dim(`(backup: ${B(s.backup)})`)}`),Xa([...s.removed.map(e=>C.red(`- ${e}`)),C.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=B(n)),r.ownAlias&&E(C.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Qa=F({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
682
+ `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),qa}const Ya=e=>({SSH_ASKPASS:Ja(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Xa(e){if(e){let e=(await x()).replace(/\r?\n$/,``);return e||k(`--password-stdin: nothing on stdin`),e}return be(`PASSWORD`)||void 0}const Za=e=>m(c(),`.ssh`,`landfall_${Te(e)}`);function Qa(e){let t=Za(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(m(c(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&k(`ssh-keygen failed`),{path:t,created:!0})}async function $a(e,t,n){let i=Q(t),a=t.key?z(t.key):void 0;if(a&&!o.existsSync(a)&&k(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&k(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await yt(t);if(!s&&!t.key&&o.existsSync(Za(e))&&await yt({...t,key:Za(e)})&&(t.key=a=Za(e),s=!0),s)T(`key login works`),a??=Ot(t);else if(n.check){D("no key login yet — `lf setup` would install a key first"),oe({needsKey:!0});return}let c=!1;if(!a&&!n.check){let t=Qa(e);a=t.path,c=t.created,c&&T(`generated key ${B(a)}`)}let l=t.key&&z(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await yt(t))){w(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Ya(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...ut(t),lt(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await yt(t)||k(`could not log in with the key — check host, user and password`),T(`key installed`)}return a}const eo=e=>{for(let t of e)console.log(C.dim(` `)+t)};function to(e,t,n,r){let i=Q(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Ft({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});T(`${r.created?`created`:`updated`} ${B(Et)} with ${C.bold(`Host ${e}`)} — try \`ssh ${e}\``),eo(r.block.split(`
683
+ `)),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!==Ot({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Lt(e,n):void 0;s?(T(`added the key to your ${C.bold(`Host ${e}`)} in ${B(s.file)} ${C.dim(`(backup: ${B(s.backup)})`)}`),eo([...s.removed.map(e=>C.red(`- ${e}`)),C.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=B(n)),r.ownAlias&&E(C.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const no=P({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
683
684
 
684
685
  Without --host, <name> is used as an ssh alias from ~/.ssh/config,
685
686
  so \`lf server add vps\` works whenever \`ssh vps\` works.
@@ -687,19 +688,25 @@ so \`lf server add vps\` works whenever \`ssh vps\` works.
687
688
  With --host lf logs in with --key, or installs its own key
688
689
  (~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
689
690
  to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
690
- The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||k(`usage: lf server add <name> [--host ...]`);let n=t.domain&&Ae(t.domain),r=V(),i=r.servers[e],a=Ot().includes(e)&&!Rt(e);t.host&&a&&k(`${e} is already a Host in your ssh config; pick another name or drop --host`);let o={host:t.host??i?.host??e,user:t.user??(t.host?`root`:i?.user),port:t.port?Number(t.port):i?.port,key:t.key??i?.key},s=await Ka(t[`password-stdin`]),c=await Ya(e,o,{check:!s&&!se(),password:s}),l=c?Za(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=ke(t.email)),n&&(l.domains=Pe(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),Se(r),T(`added ${C.bold(e)}${r.default===e?C.dim(` (default)`):``}`),E(c?C.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${C.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),$a=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`},json:{type:`boolean`,desc:`machine-readable output (what the web UI lists)`}},async run(e,t){let n=V();if(t.json){let e=Ba().filter(e=>e.registered||t.all),r=e=>`${e.user?e.user+`@`:``}${e.host}${e.port?`:`+e.port:``}`;console.log(JSON.stringify(e.map(e=>({...e,target:e.registered?r(n.servers[e.name]):e.name})),null,2));return}let r=[[C.dim(`NAME`),C.dim(`TARGET`),C.dim(`SOURCE`)]];for(let[e,t]of Object.entries(n.servers)){let i=`${t.user?t.user+`@`:``}${t.host}${t.port?`:`+t.port:``}`;r.push([e+(n.default===e?C.green(` *`):``),i,`landfall`])}if(t.all)for(let e of Ot())n.servers[e]||r.push([e,e,Rt(e)?`ssh (landfall)`:`ssh`]);r.length===1?console.log(`no servers yet — ${C.bold(`lf server add <ssh-alias>`)} or ${C.bold(`lf setup <name> --host <ip>`)}`):(P(r),t.all||console.log(C.dim(`
691
- Any ~/.ssh/config alias works too (--all to list them).`)))}}),eo=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||k(`usage: lf server rm <name>`),!V().servers[e]&&!Rt(e)&&k(`unknown server "${e}"`),await M(`Forget server ${e}? Nothing on the server changes.`),Ce(e),t[`ssh-config`]&&Lt(e)&&T(`removed Host ${e} from ~/.ssh/config`),T(`removed ${e}`)}}),to=/^(setup|traefik setup|traefik use)( |$)/,no=F({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${U} and what setup changed.
691
+ The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||k(`usage: lf server add <name> [--host ...]`);let n=t.domain&&Ae(t.domain),r=V(),i=r.servers[e],a=kt().includes(e)&&!zt(e);t.host&&a&&k(`${e} is already a Host in your ssh config; pick another name or drop --host`);let o={host:t.host??i?.host??e,user:t.user??(t.host?`root`:i?.user),port:t.port?Number(t.port):i?.port,key:t.key??i?.key},s=await Xa(t[`password-stdin`]),c=await $a(e,o,{check:!s&&!se(),password:s}),l=c?to(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=ke(t.email)),n&&(l.domains=Pe(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),Se(r),T(`added ${C.bold(e)}${r.default===e?C.dim(` (default)`):``}`),E(c?C.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${C.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),ro=P({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.
692
+
693
+ Git hosts (github.com, gitlab.*, User git …) are left out: they are not servers.
694
+ --check logs in to each with its key (5s timeout, 8 at a time):
695
+ ok key login works
696
+ login sshd answers but refuses the key (lf setup installs one)
697
+ down no ssh: host off, wrong port, firewall, or name not found`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`},check:{type:`boolean`,short:`c`,desc:`try an ssh login to each and show which are reachable`},json:{type:`boolean`,desc:`machine-readable output (what the web UI lists)`}},async run(e,t){let n=V(),r=Ha().filter(e=>e.registered||t.all),i=e=>`${e.user?e.user+`@`:``}${e.host}${e.port?`:`+e.port:``}`,a=t.check?await Wa(r.map(e=>e.name)):{};if(t.json){console.log(JSON.stringify(r.map(e=>({...e,target:e.registered?i(n.servers[e.name]):e.name,...t.check&&{reach:a[e.name]}})),null,2));return}if(!r.length){console.log(`no servers yet — ${C.bold(`lf server add <ssh-alias>`)} or ${C.bold(`lf setup <name> --host <ip>`)}`);return}let o={ok:C.green,login:C.yellow,down:C.red},s=[[C.dim(`NAME`),C.dim(`TARGET`),C.dim(`SOURCE`),...t.check?[C.dim(`STATUS`)]:[]]];for(let e of r){let t=a[e.name];s.push([e.name+(e.isDefault?C.green(` *`):``),e.registered?i(n.servers[e.name]):e.name,e.registered?`landfall`:zt(e.name)?`ssh (landfall)`:`ssh`,...t?[`${o[t.state](t.state)} ${C.dim(t.state===`ok`?`${t.ms}ms`:t.detail)}`]:[]])}ue(s),t.all||console.log(C.dim(`
698
+ Any ~/.ssh/config alias works too (--all to list them).`))}}),io=P({name:`server rm`,args:`<name>`,summary:`Forget a server (nothing is changed on the server itself).`,options:{"ssh-config":{type:`boolean`,desc:`also remove the landfall-managed Host block from ~/.ssh/config`}},async run([e],t){e||k(`usage: lf server rm <name>`),!V().servers[e]&&!zt(e)&&k(`unknown server "${e}"`),await M(`Forget server ${e}? Nothing on the server changes.`),Ce(e),t[`ssh-config`]&&Rt(e)&&T(`removed Host ${e} from ~/.ssh/config`),T(`removed ${e}`)}}),ao=/^(setup|traefik setup|traefik use)( |$)/,oo=P({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${U} and what setup changed.
692
699
 
693
700
  Deletes every site landfall deployed (named volumes stay unless --volumes) and forgets
694
701
  adopted ones, which keep running. Stops landfall's Traefik (a Traefik of your own is left
695
702
  alone), reverts what \`lf setup\` changed (deploy user, ufw, ssh hardening, docker network)
696
703
  and deletes ${U}, its journal included. Then forgets the server locally.
697
- Docker, packages, landfall's key for root and rules from \`lf firewall\` stay.`,options:{volumes:{type:`boolean`,desc:`also delete the sites' named volumes (their data)`},"keep-setup":{type:`boolean`,desc:`keep the deploy user, ufw and ssh hardening; only remove sites, traefik and `+U},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||k(`usage: lf server uninstall <name>`);let{name:n,server:r}=H(e),i=await ka(r,`root`),[a,s,c]=await Promise.all([Zn(r),Ei(r),Da(r)]);s.compose===`foreign`&&k(`${G}/docker-compose.yml was not written by landfall: move it out of ${U} first`);let l=t[`keep-setup`]?[]:c.filter(e=>to.test(e.cmd)).flatMap(xa).filter(e=>!e.undo.includes(U));w(`uninstall landfall from ${C.bold(n)}`);for(let e of a.sites){let n=e.meta.adopted?`forget ${e.meta.name} (keeps running from ${e.meta.adopted})`:`delete ${e.meta.name} (${e.domains.join(`, `)||`no domains`})`;console.error(` ${C.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${C.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${C.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${C.cyan(`↺`)} ${e.what}`);console.error(` ${C.red(`-`)} delete ${U}, its journal included`),console.error(` ${C.red(`-`)} forget ${n} locally`),console.error(` ${C.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await Fa(r,l,t.force),await M(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[J];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${Y($(e.name))}`]:dr(e,{volumes:t.volumes});u.push(`log ${Y(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${Y(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${G} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await X(r,u.join(`
698
- `)),l.length&&(w(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await ja(r,l)),await X(i.login,`rm -rf ${U}`,{sudo:i.sudo}),T(`landfall removed from ${n}`),Ce(n),Lt(n)&&T(`removed Host ${n} from ~/.ssh/config`),T(`forgot ${n}`),o.existsSync(qa(n))&&E(C.dim(`${B(qa(n))} stays: root on ${n} still accepts it`))}}),ro=F({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||k(`usage: lf server use <name>`),we(e),T(`default server: ${C.bold(e)}`)}}),io=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}=H(e);return gt(r,n)}}),ao=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}=H(e);if(t.json)return void console.log(JSON.stringify(await ho(n,r),null,2));let i=await Ht(r),a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(C.bold(n)+C.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:C.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Qt(i.uptime)}${i.reboot?C.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${Zt(o)} / ${Zt(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${Zt(i.disk.used)} / ${Zt(i.disk.total)}${a(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`docker`,i.docker||`not installed or no access (run lf setup)`]];for(let[e,t]of s)console.log(`${C.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:D)(i.support.note),i.docker&&(console.log(),await X(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function oo(e){let t=V(),n=t.servers[e],r=n??{host:e},i=Q(r),a=Ft(r.host),s=Si(r,{});return{name:e,isDefault:t.default===e,configFile:B(xe),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],tunnelOnly:ve(r),tunnelOnlyOwn:r.tunnelOnly!==void 0,traefik:{...s,summary:Ci(s),dashboard:r.dashboard?.host??null},ssh:{hostname:i.hostname,user:i.user,port:i.port,keys:(r.key?[r.key]:i.identityFiles).map(e=>({file:B(e),exists:o.existsSync(z(e))})),block:a?{file:B(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
699
- `)}:null}}}const so=F({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
704
+ Docker, packages, landfall's key for root and rules from \`lf firewall\` stay.`,options:{volumes:{type:`boolean`,desc:`also delete the sites' named volumes (their data)`},"keep-setup":{type:`boolean`,desc:`keep the deploy user, ufw and ssh hardening; only remove sites, traefik and `+U},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||k(`usage: lf server uninstall <name>`);let{name:n,server:r}=H(e),i=await Aa(r,`root`),[a,s,c]=await Promise.all([Qn(r),Di(r),Oa(r)]);s.compose===`foreign`&&k(`${G}/docker-compose.yml was not written by landfall: move it out of ${U} first`);let l=t[`keep-setup`]?[]:c.filter(e=>ao.test(e.cmd)).flatMap(Sa).filter(e=>!e.undo.includes(U));w(`uninstall landfall from ${C.bold(n)}`);for(let e of a.sites){let n=e.meta.adopted?`forget ${e.meta.name} (keeps running from ${e.meta.adopted})`:`delete ${e.meta.name} (${e.domains.join(`, `)||`no domains`})`;console.error(` ${C.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${C.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${C.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${C.cyan(`↺`)} ${e.what}`);console.error(` ${C.red(`-`)} delete ${U}, its journal included`),console.error(` ${C.red(`-`)} forget ${n} locally`),console.error(` ${C.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await Ia(r,l,t.force),await M(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[J];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${Y($(e.name))}`]:fr(e,{volumes:t.volumes});u.push(`log ${Y(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${Y(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${G} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await X(r,u.join(`
705
+ `)),l.length&&(w(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await Ma(r,l)),await X(i.login,`rm -rf ${U}`,{sudo:i.sudo}),T(`landfall removed from ${n}`),Ce(n),Rt(n)&&T(`removed Host ${n} from ~/.ssh/config`),T(`forgot ${n}`),o.existsSync(Za(n))&&E(C.dim(`${B(Za(n))} stays: root on ${n} still accepts it`))}}),so=P({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||k(`usage: lf server use <name>`),we(e),T(`default server: ${C.bold(e)}`)}}),co=P({name:`ssh`,args:`[name] [-- command...]`,summary:`Open a shell on a server, or run a command there.`,passthrough:!0,async run([e],t,n){let{server:r}=H(e);return gt(r,n)}}),lo=P({name:`server info`,args:`[name]`,summary:`Show OS, hardware, load, memory, disk, addresses and containers of a server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=H(e);if(t.json)return void console.log(JSON.stringify(await yo(n,r),null,2));let i=await Ut(r),a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(C.bold(n)+C.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:C.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${$t(i.uptime)}${i.reboot?C.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${Qt(o)} / ${Qt(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${Qt(i.disk.used)} / ${Qt(i.disk.total)}${a(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`docker`,i.docker||`not installed or no access (run lf setup)`]];for(let[e,t]of s)console.log(`${C.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:D)(i.support.note),i.docker&&(console.log(),await X(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function uo(e){let t=V(),n=t.servers[e],r=n??{host:e},i=Q(r),a=It(r.host),s=Ci(r,{});return{name:e,isDefault:t.default===e,configFile:B(R),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],tunnelOnly:ye(r),tunnelOnlyOwn:r.tunnelOnly!==void 0,traefik:{...s,summary:wi(s),dashboard:r.dashboard?.host??null},ssh:{hostname:i.hostname,user:i.user,port:i.port,keys:(r.key?[r.key]:i.identityFiles).map(e=>({file:B(e),exists:o.existsSync(z(e))})),block:a?{file:B(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
706
+ `)}:null}}}const fo=P({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
700
707
  the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
701
- and the Host block in ~/.ssh/config. Nothing connects to the server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=H(e);if(t.json)return void console.log(JSON.stringify(await mo(n,r),null,2));let i=oo(n),a=(e,t)=>console.log(` ${C.dim(e.padEnd(10))} ${t}`);if(console.log(`${C.bold(n)}${i.isDefault?C.dim(` (default)`):``}`),console.log(`\n${C.bold(`landfall`)} ${C.dim(i.configFile)}`),!i.saved)a(`entry`,C.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(i.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&e!==`tunnelOnly`&&a(e,String(t));i.domains.length||a(`domain`,C.dim(`none (lf server domain ${n} example.com)`)),i.domains.forEach((e,t)=>a(t?``:i.domains.length>1?`domains`:`domain`,t?e:`${e} ${C.dim(`(primary: new sites get <site>.${e})`)}`)),a(`new sites`,`${i.tunnelOnly?`no domain, tunnel only`:`get a domain`}${i.tunnelOnlyOwn?``:C.dim(` (landfall default, lf config)`)}`),a(`traefik`,`${i.traefik.summary}${i.traefik.email?`, ${i.traefik.email}`:``} ${C.dim(`(when landfall installs it)`)}`),a(`dashboard`,i.traefik.dashboard?`https://${i.traefik.dashboard}`:C.dim(`off`)),console.log(`\n${C.bold(`ssh`)} ${C.dim(`ssh -G ${i.saved?.host??n}`)}`),a(`login`,`${i.ssh.user}@${i.ssh.hostname}:${i.ssh.port}`);for(let e of i.ssh.keys)a(`key`,e.exists?e.file:`${e.file} ${C.dim(`(missing)`)}`);if(i.ssh.block){console.log(`\n${C.bold(`ssh config`)} ${C.dim(`${i.ssh.block.file}:${i.ssh.block.line}${i.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of i.ssh.block.text.split(`
702
- `))console.log(` ${e}`)}else console.log(`\n${C.bold(`ssh config`)} ${C.dim(`no Host block; ssh uses its defaults`)}`)}});async function co(e,t){let n=await qt(e),[r,i]=await Xt([t,`landfall-${v(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function lo(e){let{stdout:t}=await X(e,`docker ps -q | xargs -r docker inspect -f '{{range $k, $v := .Config.Labels}}{{println $v}}{{end}}' 2>/dev/null || true`,{capture:!0});return[...t.matchAll(/Host\(`([^`]+)`\)/g)].map(e=>e[1])}const uo=async e=>ze(await lo(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),fo=F({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
708
+ and the Host block in ~/.ssh/config. Nothing connects to the server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=H(e);if(t.json)return void console.log(JSON.stringify(await vo(n,r),null,2));let i=uo(n),a=(e,t)=>console.log(` ${C.dim(e.padEnd(10))} ${t}`);if(console.log(`${C.bold(n)}${i.isDefault?C.dim(` (default)`):``}`),console.log(`\n${C.bold(`landfall`)} ${C.dim(i.configFile)}`),!i.saved)a(`entry`,C.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(i.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&e!==`tunnelOnly`&&a(e,String(t));i.domains.length||a(`domain`,C.dim(`none (lf server domain ${n} example.com)`)),i.domains.forEach((e,t)=>a(t?``:i.domains.length>1?`domains`:`domain`,t?e:`${e} ${C.dim(`(primary: new sites get <site>.${e})`)}`)),a(`new sites`,`${i.tunnelOnly?`no domain, tunnel only`:`get a domain`}${i.tunnelOnlyOwn?``:C.dim(` (landfall default, lf config)`)}`),a(`traefik`,`${i.traefik.summary}${i.traefik.email?`, ${i.traefik.email}`:``} ${C.dim(`(when landfall installs it)`)}`),a(`dashboard`,i.traefik.dashboard?`https://${i.traefik.dashboard}`:C.dim(`off`)),console.log(`\n${C.bold(`ssh`)} ${C.dim(`ssh -G ${i.saved?.host??n}`)}`),a(`login`,`${i.ssh.user}@${i.ssh.hostname}:${i.ssh.port}`);for(let e of i.ssh.keys)a(`key`,e.exists?e.file:`${e.file} ${C.dim(`(missing)`)}`);if(i.ssh.block){console.log(`\n${C.bold(`ssh config`)} ${C.dim(`${i.ssh.block.file}:${i.ssh.block.line}${i.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of i.ssh.block.text.split(`
709
+ `))console.log(` ${e}`)}else console.log(`\n${C.bold(`ssh config`)} ${C.dim(`no Host block; ssh uses its defaults`)}`)}});async function po(e,t){let n=await Jt(e),[r,i]=await Zt([t,`landfall-${v(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function mo(e){let{stdout:t}=await X(e,`docker ps -q | xargs -r docker inspect -f '{{range $k, $v := .Config.Labels}}{{println $v}}{{end}}' 2>/dev/null || true`,{capture:!0});return[...t.matchAll(/Host\(`([^`]+)`\)/g)].map(e=>e[1])}const ho=async e=>ze(await mo(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),go=P({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
703
710
 
704
711
  The first main domain is the primary: \`lf deploy\` serves a new site at
705
712
  <site>.<primary>, \`--domain blog\` means blog.<primary>, and
@@ -715,20 +722,20 @@ the server's sites already use.
715
722
  --tunnel-only makes new sites deploy with no domain unless -d gives one:
716
723
  nothing is served on the internet, \`lf tunnel <site>\` reaches them.
717
724
  --no-tunnel-only goes back to <site>.<primary>. Existing sites keep theirs.
718
- Without either, the server follows landfall's default (\`lf config --tunnel-only\`).`,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`},"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=V(),r=e.length>0&&!(e=>!!n.servers[e]||Ot().includes(e))(e[0])&&Ee.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=H(i),c=t[`tunnel-only`],l=!!(a.length||t.add||t.rm||t.primary||t.clear);if(c!==void 0){let e=n.servers[o]??={host:o};if(c===(_e().tunnelOnly??!1)?delete e.tunnelOnly:e.tunnelOnly=c,Se(n),s.tunnelOnly=e.tunnelOnly,T(c?`new sites on ${C.bold(o)} get no domain: reach them with lf tunnel, or deploy with -d`:`new sites on ${C.bold(o)} get a domain again`),!l)return}if(l){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(Ae),i=t.clear?[]:a.length?a.map(Ae):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(Ae)])].filter(e=>!r.includes(e)),i=Pe(i,t.primary&&Ae(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&D(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await M(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,Se(n),s.domains=e.domains,T(i.length?`main domains of ${C.bold(o)}: ${i.map((e,t)=>t?e:C.bold(e)).join(`, `)}${i.length>1?C.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let u=s.domains??[];if(!u.length){let e=await uo(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:u,suggest:e}));E(`${o} has no main domain yet`);for(let t of e)E(`${t.hosts} routed hosts use ${C.bold(t.domain)} — ${C.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||E(`set one: ${C.bold(`lf server domain ${o} example.com`)}`);return}let d=await Promise.all(u.map(e=>co(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:u.map((e,t)=>({domain:e,primary:t===0,...d[t]}))},null,2));let f=Math.max(...u.map(e=>e.length))+2,p=[];u.forEach((e,t)=>{let n=d[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?C.green(`dns ok`):e.ips.length?C.yellow(`dns → ${e.ips.join(`, `)}`):C.red(`no dns`);console.log(`${e.domain.padEnd(f)} ${r}${e===n.apex&&t===0&&u.length>1?C.dim(` primary`):``}`),e.ok||p.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),p.length&&console.log(C.dim(`
725
+ Without either, the server follows landfall's default (\`lf config --tunnel-only\`).`,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`},"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=V(),r=e.length>0&&!(e=>!!n.servers[e]||kt().includes(e))(e[0])&&Ee.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=H(i),c=t[`tunnel-only`],l=!!(a.length||t.add||t.rm||t.primary||t.clear);if(c!==void 0){let e=n.servers[o]??={host:o};if(c===(ve().tunnelOnly??!1)?delete e.tunnelOnly:e.tunnelOnly=c,Se(n),s.tunnelOnly=e.tunnelOnly,T(c?`new sites on ${C.bold(o)} get no domain: reach them with lf tunnel, or deploy with -d`:`new sites on ${C.bold(o)} get a domain again`),!l)return}if(l){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(Ae),i=t.clear?[]:a.length?a.map(Ae):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(Ae)])].filter(e=>!r.includes(e)),i=Pe(i,t.primary&&Ae(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&D(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await M(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,Se(n),s.domains=e.domains,T(i.length?`main domains of ${C.bold(o)}: ${i.map((e,t)=>t?e:C.bold(e)).join(`, `)}${i.length>1?C.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let u=s.domains??[];if(!u.length){let e=await ho(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:u,suggest:e}));E(`${o} has no main domain yet`);for(let t of e)E(`${t.hosts} routed hosts use ${C.bold(t.domain)} — ${C.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||E(`set one: ${C.bold(`lf server domain ${o} example.com`)}`);return}let d=await Promise.all(u.map(e=>po(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:u.map((e,t)=>({domain:e,primary:t===0,...d[t]}))},null,2));let f=Math.max(...u.map(e=>e.length))+2,p=[];u.forEach((e,t)=>{let n=d[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?C.green(`dns ok`):e.ips.length?C.yellow(`dns → ${e.ips.join(`, `)}`):C.red(`no dns`);console.log(`${e.domain.padEnd(f)} ${r}${e===n.apex&&t===0&&u.length>1?C.dim(` primary`):``}`),e.ok||p.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),p.length&&console.log(C.dim(`
719
726
  add these DNS records at your DNS provider:
720
727
  `)+p.join(`
721
- `));let m=Fe(s);E(C.dim(`sites: lf deploy -d blog → blog.${m} · dashboard: lf traefik setup --dashboard traefik`))}});async function po(e){let{login:t,probe:n,...r}=await $i(e);return{...r,rules:r.rules.map((e,t)=>({n:t+1,...e})),asRoot:Q(t).user===`root`}}async function mo(e,t){let n=oo(e),[r,i]=await Promise.all([Promise.all(n.domains.map(e=>co(t,e))),uo(t)]);return{...n,dns:r,suggest:i}}async function ho(e,t){let n=await Ht(t),r=await qt(t,n),i=Jt(r);return{name:e,...n,ips:r,sslipIp:i??null,sslipSuffix:i?Yt(``,i).slice(1):null}}async function go(e,t){return(await Da(e)).reverse().slice(0,t).map(e=>({...e,pending:xa(e).length}))}async function _o(e,t=!1){let n=await br(e,t),r=await Fr(e);return{packages:n,images:await zr(e),engine:{...r,...Ir(r)}}}async function vo(e,t){let n=await qt(e),[r,i]=await Promise.all([Xt(t,n),Promise.all(t.map(yo))]);return{ips:n,domains:r.map((e,t)=>({...e,https:i[t]}))}}async function yo(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 bo(e,t,n,r,i,a){let o=await Zn(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&D(`${t.join(`, `)} ${t.length>1?`are`:`is`} also routed to ${e.name}${e.dir?` (${e.dir})`:``}, which landfall did not deploy: Traefik sends visitors to only one of them. Remove the labels there to hand ${t.length>1?`them`:`it`} over.`)}let s=o.sites.map(e=>e.meta).filter(e=>e.name!==n&&fn(e).some(e=>r.includes(e)));for(let e of s){let t=fn(e).filter(e=>r.includes(e)),i=on(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=$t(e)?`redeploy it first (lf deploy) so it can hand them over`:e.adopted?`its compose files in ${e.adopted} route ${i.length>1?`them`:`it`}`:`its compose.yaml routes ${i.length>1?`them`:`it`} (x-landfall.domains)`;k(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&k(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`);let o=t.length===e.domains.length?` ${e.name} keeps running with no domain.`:``;await M(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?${o}`)}return async()=>{for(let a of s)await sr(e,a,i,`moved ${a.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`,e=>void(e.domains=e.domains.filter(e=>!r.includes(e)))),Ln(t,a.name,a.domains),E(C.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const xo=F({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
728
+ `));let m=Fe(s);E(C.dim(`sites: lf deploy -d blog → blog.${m} · dashboard: lf traefik setup --dashboard traefik`))}});async function _o(e){let{login:t,probe:n,...r}=await ea(e);return{...r,rules:r.rules.map((e,t)=>({n:t+1,...e})),asRoot:Q(t).user===`root`}}async function vo(e,t){let n=uo(e),[r,i]=await Promise.all([Promise.all(n.domains.map(e=>po(t,e))),ho(t)]);return{...n,dns:r,suggest:i}}async function yo(e,t){let n=await Ut(t),r=await Jt(t,n),i=Yt(r);return{name:e,...n,ips:r,sslipIp:i??null,sslipSuffix:i?Xt(``,i).slice(1):null}}async function bo(e,t){return(await Oa(e)).reverse().slice(0,t).map(e=>({...e,pending:Sa(e).length}))}async function xo(e,t=!1){let n=await xr(e,t),r=await Ir(e);return{packages:n,images:await Br(e),engine:{...r,...Lr(r)}}}async function So(e,t){let n=await Jt(e),[r,i]=await Promise.all([Zt(t,n),Promise.all(t.map(Co))]);return{ips:n,domains:r.map((e,t)=>({...e,https:i[t]}))}}async function Co(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 wo(e,t,n,r,i,a){let o=await Qn(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&D(`${t.join(`, `)} ${t.length>1?`are`:`is`} also routed to ${e.name}${e.dir?` (${e.dir})`:``}, which landfall did not deploy: Traefik sends visitors to only one of them. Remove the labels there to hand ${t.length>1?`them`:`it`} over.`)}let s=o.sites.map(e=>e.meta).filter(e=>e.name!==n&&pn(e).some(e=>r.includes(e)));for(let e of s){let t=pn(e).filter(e=>r.includes(e)),i=sn(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=en(e)?`redeploy it first (lf deploy) so it can hand them over`:e.adopted?`its compose files in ${e.adopted} route ${i.length>1?`them`:`it`}`:`its compose.yaml routes ${i.length>1?`them`:`it`} (x-landfall.domains)`;k(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&k(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`);let o=t.length===e.domains.length?` ${e.name} keeps running with no domain.`:``;await M(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?${o}`)}return async()=>{for(let a of s)await cr(e,a,i,`moved ${a.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`,e=>void(e.domains=e.domains.filter(e=>!r.includes(e)))),Rn(t,a.name,a.domains),E(C.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const To=P({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
722
729
 
723
730
  Without flags it lists and checks; --add/--rm/--set change the site (undoable)
724
- 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}=Li(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await Vn(i,n,a?{deployed:`change the domains of`}:{});if(!a){let{ips:e,domains:r}=await vo(i,fn(o));if(t.json)return void console.log(JSON.stringify({site:n,ips:e,domains:r},null,2));for(let e of r){let t=e.ok?C.green(`dns ok`):e.ips.length?C.yellow(`dns → ${e.ips.join(`, `)}`):C.red(`no dns`),n=e.https.status?(e.https.status<400?C.green:e.https.status===401?C.cyan:C.yellow)(`https ${e.https.status}`):C.red(`https ${e.https.error}`);console.log(`${C.cyan(`https://${e.domain}`)} ${t} ${n}`)}r.some(e=>!e.ok)&&console.log(C.dim(`\npoint an A record at ${e.join(` or `)} for each domain`));return}on(o)||k(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>Ie(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=>!Ee.test(e));u.length&&k(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&D(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return T(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));if(f.length){let e=l.length?``:` ${n} keeps running, unreachable until it gets a domain again.`;await M(`Remove ${f.join(`, `)} from ${n}? ${f.length>1?`They stop`:`It stops`} serving it.${e}`)}let p=l.filter(e=>!o.domains.includes(e));await(await bo(i,r,n,p,s))(),await sr(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),Ln(r,n,l);let m=He(process.cwd());m?.name===n&&Ue(process.cwd(),{...m,domains:l}),l.length?T(`${n} serves ${l.map(e=>C.cyan(`https://${e}`)).join(`, `)}`):T(`${n} serves no domain now ${C.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let ee=await qt(i);for(let e of await Xt(p,ee))e.ok||D(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),So=F({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
731
+ and update .landfall.json of remembered projects.`,options:{server:F,add:{type:`string`,short:`a`,multiple:!0,desc:"add a domain (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},rm:{type:`string`,multiple:!0,desc:`remove a domain`},set:{type:`string`,multiple:!0,desc:`replace all domains`},json:{type:`boolean`,desc:`machine-readable output (listing)`}},async run([e],t){let{site:n,name:r,server:i}=Ri(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await Hn(i,n,a?{deployed:`change the domains of`}:{});if(!a){let{ips:e,domains:r}=await So(i,pn(o));if(t.json)return void console.log(JSON.stringify({site:n,ips:e,domains:r},null,2));for(let e of r){let t=e.ok?C.green(`dns ok`):e.ips.length?C.yellow(`dns → ${e.ips.join(`, `)}`):C.red(`no dns`),n=e.https.status?(e.https.status<400?C.green:e.https.status===401?C.cyan:C.yellow)(`https ${e.https.status}`):C.red(`https ${e.https.error}`);console.log(`${C.cyan(`https://${e.domain}`)} ${t} ${n}`)}r.some(e=>!e.ok)&&console.log(C.dim(`\npoint an A record at ${e.join(` or `)} for each domain`));return}sn(o)||k(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>Ie(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=>!Ee.test(e));u.length&&k(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&D(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return T(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));if(f.length){let e=l.length?``:` ${n} keeps running, unreachable until it gets a domain again.`;await M(`Remove ${f.join(`, `)} from ${n}? ${f.length>1?`They stop`:`It stops`} serving it.${e}`)}let p=l.filter(e=>!o.domains.includes(e));await(await wo(i,r,n,p,s))(),await cr(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),Rn(r,n,l);let m=He(process.cwd());m?.name===n&&Ue(process.cwd(),{...m,domains:l}),l.length?T(`${n} serves ${l.map(e=>C.cyan(`https://${e}`)).join(`, `)}`):T(`${n} serves no domain now ${C.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let ee=await Jt(i);for(let e of await Zt(p,ee))e.ok||D(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),Eo=P({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
725
732
 
726
733
  lf auth my-site --user anna add or change a login (asks the password)
727
734
  lf auth my-site --rm-user anna remove a login
728
735
  lf auth my-site --off remove all logins: the site is public again
729
- lf auth my-site show who can log in`,options:{server:I,user:{type:`string`,short:`u`,desc:`login to add or change (default with --password: admin)`},password:{type:`string`,short:`p`,desc:`its password (asked when missing; visible in ps, prefer --password-stdin)`},"password-stdin":{type:`boolean`,desc:`read the password from stdin`},"rm-user":{type:`string`,multiple:!0,desc:`remove a login`},off:{type:`boolean`,desc:`remove every login`}},async run([e],t){let{site:n,server:r}=Li(e,t.server),{meta:i,proxy:a}=await Vn(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(yi),s=t[`password-stdin`]?(await x()).replace(/\r?\n$/,``):t.password,c=t.user??(s?`admin`:void 0);if(!c&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${C.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>C.bold(e)).join(`, `)}`);return}let l=t.off?[]:[...i.auth??[]],u=new Set(t[`rm-user`]??[]);for(let e of u)o.includes(e)||D(`no login ${e} on ${n}`);if(c&&u.add(c),l=l.filter(e=>!u.has(yi(e))),c&&l.push(await xi(c,s)),l.join(`
736
+ lf auth my-site show who can log in`,options:{server:F,user:{type:`string`,short:`u`,desc:`login to add or change (default with --password: admin)`},password:{type:`string`,short:`p`,desc:`its password (asked when missing; visible in ps, prefer --password-stdin)`},"password-stdin":{type:`boolean`,desc:`read the password from stdin`},"rm-user":{type:`string`,multiple:!0,desc:`remove a login`},off:{type:`boolean`,desc:`remove every login`}},async run([e],t){let{site:n,server:r}=Ri(e,t.server),{meta:i,proxy:a}=await Hn(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(bi),s=t[`password-stdin`]?(await x()).replace(/\r?\n$/,``):t.password,c=t.user??(s?`admin`:void 0);if(!c&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${C.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>C.bold(e)).join(`, `)}`);return}let l=t.off?[]:[...i.auth??[]],u=new Set(t[`rm-user`]??[]);for(let e of u)o.includes(e)||D(`no login ${e} on ${n}`);if(c&&u.add(c),l=l.filter(e=>!u.has(bi(e))),c&&l.push(await Si(c,s)),l.join(`
730
737
  `)===(i.auth??[]).join(`
731
- `))return T(`nothing to change`);let d=l.map(yi),f=o.filter(e=>!d.includes(e));d.length?f.length&&await M(`Remove the login${f.length>1?`s`:``} ${f.join(`, `)} from ${n}?`):await M(`Remove every login from ${n}? Anyone can open it then.`),await sr(r,i,a,d.length?`login for ${n}: ${d.join(`, `)}`:`removed the login of ${n}`,e=>void(e.auth=l.length?l:void 0)),T(d.length?`${n} asks for a login (${d.join(`, `)})`:`${n} is public again`)}}),Co=F({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:I},async run([e],t){let{site:n,server:r}=Li(e,t.server),{meta:i}=await Vn(r,n),a=fn(i)[0];a||k(`${n} serves no domain`);let o=`https://${a}`;console.log(C.cyan(o)),fe(o)}}),wo=[`tunnel-only`,`email`,`keep`,`default`];function To(){return{local:[{path:B(xe),what:`servers and these defaults`},{path:B(m(be,`projects.json`)),what:`folders deployed from here (web UI Recent)`},{path:B(m(be,`ui-token`)),what:`web UI login token`},{path:B(m(be,`uploads`)),what:`folders dropped into the web UI, removed after a day`},{path:B(Tt),what:`Host <name> blocks between # >>> landfall:<name> markers`},{path:`~/.ssh/landfall_<name>`,what:`ssh key per server (lf keygen, lf setup)`},{path:`./.landfall.json`,what:`a project's deploy settings, saved by lf deploy`}],server:[{path:`${U}/README`,what:`how to run it all by hand, without landfall`},{path:`${W}/<site>`,what:`a site: .env, site.json, current -> releases/<id>`},{path:G,what:`landfall's Traefik: docker-compose.yml, letsencrypt/`},{path:We,what:`Traefik file provider; files you add here are kept`},{path:K,what:`only when sites use a Traefik the server already had`},{path:q,what:`every change with its undo (lf log, lf undo)`},{path:Je,what:`ssh hardening from lf setup`},{path:`user deploy`,what:`lf setup's deploy user (--deploy-user), in group docker`},{path:`packages`,what:`Docker + compose plugin, ufw, fail2ban, unattended-upgrades`},{path:`.<file>.landfall-*`,what:`backups next to each file landfall changed`}]}}function Eo(){let e=V(),t=e.defaults??{};return{configFile:B(xe),default:e.default??null,defaults:t,tunnelOnly:t.tunnelOnly??!1,email:t.email??null,keep:t.keep??5,locations:To()}}const Do=F({name:`config`,summary:`Show or change landfall's defaults for every server.
738
+ `))return T(`nothing to change`);let d=l.map(bi),f=o.filter(e=>!d.includes(e));d.length?f.length&&await M(`Remove the login${f.length>1?`s`:``} ${f.join(`, `)} from ${n}?`):await M(`Remove every login from ${n}? Anyone can open it then.`),await cr(r,i,a,d.length?`login for ${n}: ${d.join(`, `)}`:`removed the login of ${n}`,e=>void(e.auth=l.length?l:void 0)),T(d.length?`${n} asks for a login (${d.join(`, `)})`:`${n} is public again`)}}),Do=P({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:F},async run([e],t){let{site:n,server:r}=Ri(e,t.server),{meta:i}=await Hn(r,n),a=pn(i)[0];a||k(`${n} serves no domain`);let o=`https://${a}`;console.log(C.cyan(o)),pe(o)}}),Oo=[`tunnel-only`,`email`,`keep`,`default`];function ko(){return{local:[{path:B(R),what:`servers and these defaults`},{path:B(m(xe,`projects.json`)),what:`folders deployed from here (web UI Recent)`},{path:B(m(xe,`ui-token`)),what:`web UI login token`},{path:B(m(xe,`uploads`)),what:`folders dropped into the web UI, removed after a day`},{path:B(Et),what:`Host <name> blocks between # >>> landfall:<name> markers`},{path:`~/.ssh/landfall_<name>`,what:`ssh key per server (lf keygen, lf setup)`},{path:`./.landfall.json`,what:`a project's deploy settings, saved by lf deploy`}],server:[{path:`${U}/README`,what:`how to run it all by hand, without landfall`},{path:`${W}/<site>`,what:`a site: .env, site.json, current -> releases/<id>`},{path:G,what:`landfall's Traefik: docker-compose.yml, letsencrypt/`},{path:We,what:`Traefik file provider; files you add here are kept`},{path:K,what:`only when sites use a Traefik the server already had`},{path:q,what:`every change with its undo (lf log, lf undo)`},{path:Je,what:`ssh hardening from lf setup`},{path:`user deploy`,what:`lf setup's deploy user (--deploy-user), in group docker`},{path:`packages`,what:`Docker + compose plugin, ufw, fail2ban, unattended-upgrades`},{path:`.<file>.landfall-*`,what:`backups next to each file landfall changed`}]}}function Ao(){let e=V(),t=e.defaults??{};return{configFile:B(R),default:e.default??null,defaults:t,tunnelOnly:t.tunnelOnly??!1,email:t.email??null,keep:t.keep??5,locations:ko()}}const jo=P({name:`config`,summary:`Show or change landfall's defaults for every server.
732
739
 
733
740
  A server's own setting wins over these (lf server config shows which applies):
734
741
  --tunnel-only new sites get no domain unless -d gives one; reach them with lf tunnel
@@ -737,7 +744,7 @@ A server's own setting wins over these (lf server config shows which applies):
737
744
  --keep releases a deploy keeps for rollback (per deploy: lf deploy --keep)
738
745
  --default the server commands use without --server (same as lf server use)
739
746
 
740
- --unset <key> goes back to landfall's built-in value.`,options:{"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},email:{type:`string`,desc:`Let's Encrypt contact e-mail`},keep:{type:`string`,desc:`releases to keep for rollback (built in: 5)`},default:{type:`string`,desc:`default server`},unset:{type:`string`,multiple:!0,desc:`forget a setting: ${wo.join(`, `)}`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=t.unset??[],r=n.filter(e=>!wo.includes(e));if(r.length&&k(`cannot unset ${r.join(`, `)}: one of ${wo.join(`, `)}`),t[`tunnel-only`]!==void 0||t.email!==void 0||t.keep!==void 0||t.default!==void 0||n.length>0){t.default!==void 0&&we(t.default);let e=V(),r=e.defaults??={};t[`tunnel-only`]!==void 0&&(r.tunnelOnly=t[`tunnel-only`]),t.email!==void 0&&(r.email=ke(t.email)),t.keep!==void 0&&(r.keep=Ne(t.keep,`--keep`,1)),n.includes(`tunnel-only`)&&delete r.tunnelOnly,n.includes(`email`)&&delete r.email,n.includes(`keep`)&&delete r.keep,n.includes(`default`)&&delete e.default,Object.keys(r).length||delete e.defaults,Se(e),T(`saved`)}let i=Eo();if(t.json)return void console.log(JSON.stringify(i,null,2));let a=e=>i.defaults[e]===void 0?C.dim(` (built in)`):``,o=(e,t)=>console.log(` ${C.dim(e.padEnd(12))} ${t}`);console.log(`${C.bold(`landfall defaults`)} ${C.dim(i.configFile)}`),o(`default`,i.default??C.dim(`none (lf config --default <server>)`)),o(`new sites`,`${i.tunnelOnly?`no domain, tunnel only`:`get a domain`}${a(`tunnelOnly`)}`),o(`e-mail`,i.email??C.dim(`none`)),o(`keep`,`${i.keep} releases${a(`keep`)}`);for(let[e,t]of[[`on this machine`,i.locations.local],[`on each server`,i.locations.server]]){console.log(`\n${C.bold(e)}`);let n=Math.max(...t.map(e=>e.path.length));for(let e of t)console.log(` ${e.path.padEnd(n)} ${C.dim(e.what)}`)}}}),Oo=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||k(`usage: lf keygen <name>`);let{path:t,created:n}=Ja(e);T(`${n?`created`:`key exists:`} ${B(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function ko(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?D(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await le(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):D(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Ao(e,t){if(w(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await ce(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(E(C.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await N(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&_e().email===void 0){let e=(await ce(`Let's Encrypt e-mail (optional)`,``)).trim();t.email=e?ke(e):void 0}let n=Fe(e);t.dashboard??=await ce(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function jo(e,t){let n=Object.fromEntries(Object.entries(Mo.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return Mo.run([e],{...n,...t},[])}const Mo=F({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
747
+ --unset <key> goes back to landfall's built-in value.`,options:{"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},email:{type:`string`,desc:`Let's Encrypt contact e-mail`},keep:{type:`string`,desc:`releases to keep for rollback (built in: 5)`},default:{type:`string`,desc:`default server`},unset:{type:`string`,multiple:!0,desc:`forget a setting: ${Oo.join(`, `)}`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=t.unset??[],r=n.filter(e=>!Oo.includes(e));if(r.length&&k(`cannot unset ${r.join(`, `)}: one of ${Oo.join(`, `)}`),t[`tunnel-only`]!==void 0||t.email!==void 0||t.keep!==void 0||t.default!==void 0||n.length>0){t.default!==void 0&&we(t.default);let e=V(),r=e.defaults??={};t[`tunnel-only`]!==void 0&&(r.tunnelOnly=t[`tunnel-only`]),t.email!==void 0&&(r.email=ke(t.email)),t.keep!==void 0&&(r.keep=Ne(t.keep,`--keep`,1)),n.includes(`tunnel-only`)&&delete r.tunnelOnly,n.includes(`email`)&&delete r.email,n.includes(`keep`)&&delete r.keep,n.includes(`default`)&&delete e.default,Object.keys(r).length||delete e.defaults,Se(e),T(`saved`)}let i=Ao();if(t.json)return void console.log(JSON.stringify(i,null,2));let a=e=>i.defaults[e]===void 0?C.dim(` (built in)`):``,o=(e,t)=>console.log(` ${C.dim(e.padEnd(12))} ${t}`);console.log(`${C.bold(`landfall defaults`)} ${C.dim(i.configFile)}`),o(`default`,i.default??C.dim(`none (lf config --default <server>)`)),o(`new sites`,`${i.tunnelOnly?`no domain, tunnel only`:`get a domain`}${a(`tunnelOnly`)}`),o(`e-mail`,i.email??C.dim(`none`)),o(`keep`,`${i.keep} releases${a(`keep`)}`);for(let[e,t]of[[`on this machine`,i.locations.local],[`on each server`,i.locations.server]]){console.log(`\n${C.bold(e)}`);let n=Math.max(...t.map(e=>e.path.length));for(let e of t)console.log(` ${e.path.padEnd(n)} ${C.dim(e.what)}`)}}}),Mo=P({name:`keygen`,args:`<name>`,summary:`Create an ed25519 key pair at ~/.ssh/landfall_<name> (skips if it exists) and print the public key.`,async run([e]){e||k(`usage: lf keygen <name>`);let{path:t,created:n}=Qa(e);T(`${n?`created`:`key exists:`} ${B(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function No(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?D(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await le(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):D(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Po(e,t){if(w(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await ce(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(E(C.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await N(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&ve().email===void 0){let e=(await ce(`Let's Encrypt e-mail (optional)`,``)).trim();t.email=e?ke(e):void 0}let n=Fe(e);t.dashboard??=await ce(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function Fo(e,t){let n=Object.fromEntries(Object.entries(Io.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return Io.run([e],{...n,...t},[])}const Io=P({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
741
748
 
742
749
  Turns a fresh box into a deploy target (safe to re-run):
743
750
  1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
@@ -757,7 +764,7 @@ missing. Steps that would break services already running there are skipped:
757
764
  a Traefik already running there is used instead (lf traefik use)
758
765
  Use --check to only see that report, --force to apply firewall/hardening anyway.
759
766
  In a terminal, a first Traefik install asks for image, host network, e-mail and
760
- 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: ${qe})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},http3:{type:`boolean`,desc:`traefik serves HTTP/3 on 443/udp, on by default; remembered (--no-http3)`},check:{type:`boolean`,desc:`only report what is set up and what setup would change`},force:{type:`boolean`,desc:`apply firewall/ssh hardening despite conflicts`},"key-only":{type:`boolean`,desc:`only install the ssh key, change nothing else`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin instead of asking (also: $LANDFALL_PASSWORD)`}},async run([e],t){e||k(`usage: lf setup <name> [--host <ip>]`);let n=V(),r=n.servers[e],i=Ot().includes(e)&&!Rt(e),a={host:t.host??r?.host??e,user:t.user??(t.host?`root`:r?.user),port:t.port?Number(t.port):r?.port,key:t.key??r?.key};if(!t.user&&t[`deploy-user`]&&Q(a).user===t[`deploy-user`]){let e;for(let t of[`root`,r?.admin])!e&&t&&await yt({...a,user:t})&&(e=t);e?a.user=e:D(`${t[`deploy-user`]} can't change the system and root's key login does not work; pass -u <a sudo user> if setup fails`)}let s=Q(a);w(`setting up ${C.bold(e)} ${C.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>w(`[${++c}/5] ${e}`);l(`ssh key`);let u=await Ya(e,a,{check:t.check,password:await Ka(t[`password-stdin`])});if(!u||t[`key-only`])return;let d=o.readFileSync(`${u}.pub`,`utf8`).trim(),f=t[`deploy-user`]===s.user?``:t[`deploy-user`];l(`checking what is already set up`);let p={PUBKEY:d,DEPLOY_USER:f},[m,ee,te]=await Promise.all([Ur(a,p),Nr(),Pr()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let ne=Number(m.values.ssh_port)||s.port,h=ri(m,{sshPort:ne,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),g=h.find(e=>e.part===`traefik`),_={email:t.email&&ke(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:bi},re=g&&!g.proxy&&g.status!==`conflict`&&g.status!==`off`;re&&(g.detail+=C.dim(` · ${Ci(Si(r??a,_))}`));let v=Wt(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(E(`os: ${m.values.os??`unknown`}${m.values.pkg?C.dim(` · ${m.values.pkg}`):``}`),v.note&&(v.level===`ok`?E:D)(v.note),si(h),oe({checks:h.map(({part:e,label:t,status:n,detail:r})=>({part:e,label:t,status:n,detail:r}))}),t.check)return;let y=await ko(h,t.force),ie=re&&g.status===`todo`&&se(),b=t.domain?Ae(t.domain):r&&Fe(r);if(b===void 0&&ie){let e=await ce(`main domain for sites, e.g. example.com (optional)`,``);b=e.trim()?Ae(e):void 0}ie&&(await Ao({...r??a,domains:Pe(r?.domains,b)},_),E(`traefik: ${Ci(Si(r??a,_))}${_.dashboard?`, dashboard ${_.dashboard}`:``}`));let x=h.filter(e=>e.status!==`done`&&y.has(e.part)).length;if(x&&!await N(`apply the ${x} change${x===1?``:`s`} above?`))return;let ae=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];h.some(e=>ae.includes(e.part)&&e.status!==`done`&&y.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await X(a,J+tt,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:ne,HARDEN:y.has(`harden`),FIREWALL:y.has(`firewall`),HTTP3:(_.http3??r?.http3)!==!1}}),T(`server bootstrapped`)):(l(`bootstrap`),T(`already bootstrapped, nothing to change`));let S={...a};if(f){let e={...a,user:f};await yt(e)?(T(`deploy user ${C.bold(f)} ready`),S=e):D(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let O=Za(e,S,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});b&&(O.domains=Pe(O.domains,b)),s.user!==`root`&&Q(S).user!==s.user&&(O.admin=s.user),n.servers[e]=O,n.default??=e,Se(n),l(`traefik`),g?.proxy?y.has(`traefik`)&&g.status===`todo`?await Ti(O,g.proxy):T(g.detail):y.has(`traefik`)?await wi(e,O,_):E(C.dim(`skipped`)),b?(console.log(`\n${C.green(`Done.`)} Point ${C.bold(b)} and ${C.bold(`*.${b}`)} (A records) at ${C.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${C.dim(`# https://blog.${b}`)}`),console.log(` lf server domain ${e} ${C.dim(`# check the DNS`)}\n`)):(console.log(`\n${C.green(`Done.`)} Point a domain's A record at ${C.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),No={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function Po(e,t){if(!t)return e;let n=yi(t);return[...(e??[]).filter(e=>yi(e)!==n),t]}const Fo=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function Io(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(Fo(new Date(n)));)n+=1e3;return Fo(new Date(n))}const Lo=F({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
767
+ 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: ${qe})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},http3:{type:`boolean`,desc:`traefik serves HTTP/3 on 443/udp, on by default; remembered (--no-http3)`},check:{type:`boolean`,desc:`only report what is set up and what setup would change`},force:{type:`boolean`,desc:`apply firewall/ssh hardening despite conflicts`},"key-only":{type:`boolean`,desc:`only install the ssh key, change nothing else`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin instead of asking (also: $LANDFALL_PASSWORD)`}},async run([e],t){e||k(`usage: lf setup <name> [--host <ip>]`);let n=V(),r=n.servers[e],i=kt().includes(e)&&!zt(e),a={host:t.host??r?.host??e,user:t.user??(t.host?`root`:r?.user),port:t.port?Number(t.port):r?.port,key:t.key??r?.key};if(!t.user&&t[`deploy-user`]&&Q(a).user===t[`deploy-user`]){let e;for(let t of[`root`,r?.admin])!e&&t&&await yt({...a,user:t})&&(e=t);e?a.user=e:D(`${t[`deploy-user`]} can't change the system and root's key login does not work; pass -u <a sudo user> if setup fails`)}let s=Q(a);w(`setting up ${C.bold(e)} ${C.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>w(`[${++c}/5] ${e}`);l(`ssh key`);let u=await $a(e,a,{check:t.check,password:await Xa(t[`password-stdin`])});if(!u||t[`key-only`])return;let d=o.readFileSync(`${u}.pub`,`utf8`).trim(),f=t[`deploy-user`]===s.user?``:t[`deploy-user`];l(`checking what is already set up`);let p={PUBKEY:d,DEPLOY_USER:f},[m,ee,te]=await Promise.all([Wr(a,p),Pr(),Fr()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let ne=Number(m.values.ssh_port)||s.port,h=ii(m,{sshPort:ne,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),g=h.find(e=>e.part===`traefik`),_={email:t.email&&ke(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:xi},re=g&&!g.proxy&&g.status!==`conflict`&&g.status!==`off`;re&&(g.detail+=C.dim(` · ${wi(Ci(r??a,_))}`));let v=Gt(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(E(`os: ${m.values.os??`unknown`}${m.values.pkg?C.dim(` · ${m.values.pkg}`):``}`),v.note&&(v.level===`ok`?E:D)(v.note),ci(h),oe({checks:h.map(({part:e,label:t,status:n,detail:r})=>({part:e,label:t,status:n,detail:r}))}),t.check)return;let y=await No(h,t.force),ie=re&&g.status===`todo`&&se(),b=t.domain?Ae(t.domain):r&&Fe(r);if(b===void 0&&ie){let e=await ce(`main domain for sites, e.g. example.com (optional)`,``);b=e.trim()?Ae(e):void 0}ie&&(await Po({...r??a,domains:Pe(r?.domains,b)},_),E(`traefik: ${wi(Ci(r??a,_))}${_.dashboard?`, dashboard ${_.dashboard}`:``}`));let x=h.filter(e=>e.status!==`done`&&y.has(e.part)).length;if(x&&!await N(`apply the ${x} change${x===1?``:`s`} above?`))return;let ae=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];h.some(e=>ae.includes(e.part)&&e.status!==`done`&&y.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await X(a,J+tt,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:ne,HARDEN:y.has(`harden`),FIREWALL:y.has(`firewall`),HTTP3:(_.http3??r?.http3)!==!1}}),T(`server bootstrapped`)):(l(`bootstrap`),T(`already bootstrapped, nothing to change`));let S={...a};if(f){let e={...a,user:f};await yt(e)?(T(`deploy user ${C.bold(f)} ready`),S=e):D(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let O=to(e,S,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});b&&(O.domains=Pe(O.domains,b)),s.user!==`root`&&Q(S).user!==s.user&&(O.admin=s.user),n.servers[e]=O,n.default??=e,Se(n),l(`traefik`),g?.proxy?y.has(`traefik`)&&g.status===`todo`?await Ei(O,g.proxy):T(g.detail):y.has(`traefik`)?await Ti(e,O,_):E(C.dim(`skipped`)),b?(console.log(`\n${C.green(`Done.`)} Point ${C.bold(b)} and ${C.bold(`*.${b}`)} (A records) at ${C.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${C.dim(`# https://blog.${b}`)}`),console.log(` lf server domain ${e} ${C.dim(`# check the DNS`)}\n`)):(console.log(`\n${C.green(`Done.`)} Point a domain's A record at ${C.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),Lo={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function Ro(e,t){if(!t)return e;let n=bi(t);return[...(e??[]).filter(e=>bi(e)!==n),t]}const zo=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function Bo(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(zo(new Date(n)));)n+=1e3;return zo(new Date(n))}const Vo=P({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
761
768
 
762
769
  Kinds:
763
770
  compose the folder has a compose.yaml: its services run on the server as one
@@ -771,15 +778,15 @@ so \`docker compose\` works there without landfall. Settings are saved to
771
778
  ./.landfall.json, so later deploys are just \`lf deploy\`.
772
779
 
773
780
  A server landfall has not set up yet is set up first (\`lf setup\`), asking
774
- before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,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"},"no-domain":{type:`boolean`,desc:`serve no domain: reach it with lf tunnel until lf domains --add gives it one`},file:{type:`string`,short:`f`,desc:`compose file to deploy (default: compose.yaml)`},spa:{type:`boolean`,desc:`single-page app: unknown paths serve index.html`},image:{type:`string`,desc:`deploy a docker image instead of files`},port:{type:`string`,desc:`container port (image/dockerfile; default: EXPOSE or 3000)`},static:{type:`boolean`,desc:`force static even if a Dockerfile or compose file exists`},env:{type:`string`,short:`e`,multiple:!0,desc:`set KEY=VALUE in the site's .env`},"env-file":{type:`string`,desc:`merge a local .env file into the site's .env (- reads stdin)`},build:{type:`string`,short:`b`,desc:`run this command locally first (e.g. 'npm run build')`},keep:{type:`string`,desc:`releases to keep for rollback (default: 5, lf config --keep)`},save:{type:`boolean`,desc:`write .landfall.json (--no-save to skip)`,default:!0},host:{type:`string`,desc:`IP or hostname of a new server: set it up as --server first`},user:{type:`string`,short:`u`,desc:`with --host: initial login user (default: root)`},"ssh-port":{type:`string`,desc:`with --host: ssh port`},"password-stdin":{type:`boolean`,desc:`setting up first: read the login password from stdin (also: $LANDFALL_PASSWORD)`},"auth-user":{type:`string`,desc:`protect the site with this login (HTTP basic auth, as lf auth; default with --auth-password: admin)`},"auth-password":{type:`string`,desc:`password of the login (asked when only --auth-user is given)`}},async run([e],t){let n=process.cwd(),i=He(n)??{};i.name&&E(C.dim(`using ${Be}`));let{name:a,server:s}=H(t.server??i.server);t.host&&!t.server&&!i.server&&!V().default&&k(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await bt(s);if(c!==`ready`){let e=c===`bare`?`is not set up by landfall yet`:`does not let landfall log in with a key`;!t.host&&!(se()&&await N(`${a} ${e}. Set it up now (lf setup)?`))&&k(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await jo(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=H(a),await bt(s)!==`ready`&&k(`${a} is still not set up`)}let l=Rn(t.name??i.name??jn(f(n))),u=Ne(t.keep??_e().keep??5,`--keep`,1),d=!!t[`no-domain`]||(t.domain?.includes(!1)??!1),h=t.domain?.filter(e=>typeof e==`string`);d&&h?.length&&k(`--no-domain and --domain exclude each other`);let g=(h?.length?h:i.domains??[]).map(e=>Ie(e,s)).filter(e=>!Ee.test(e));g.length&&k(`not a domain: ${g.join(`, `)}`);let _=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),re=_?await xi(_,t[`auth-password`],`--auth-user`):void 0,v=t.build??i.build;if(v){w(`building locally: ${v}`);let e=r(v,{shell:!0,stdio:`inherit`});e.status!==0&&k(`build failed (exit ${e.status})`)}let y=t.static??i.static,ie=t.image??(y?void 0:i.image),b=ie&&Me(ie),x=ne(n,e??i.dir??`.`),ae=b||y?void 0:t.file??Mn(x,i),S=ae&&ee(ae);S?.includes(`/`)&&k(`${ae} is not in the folder deployed; deploy its folder instead: lf deploy ${p(m(te(n,x)||`.`,S))} -f ${f(S)}`),S&&!o.existsSync(m(x,S))&&k(`no ${S} in ${te(n,x)||`.`}`);let O=On(n),A=S||b?x:ne(n,e??i.dir??kn(n,O)),j=S?`compose`:b?`image`:!y&&o.existsSync(m(A,`Dockerfile`))?`dockerfile`:`static`,le=j===`static`&&!e&&!i.dir?O.tool:void 0;le&&E(C.dim(`publishing ${te(n,A)}: where ${le} builds the site (another folder: lf deploy <dir>)`)),j===`static`&&!o.existsSync(m(A,`index.html`))&&D(`no index.html in ${te(n,A)||`.`}${le&&!v?`: build first, or let deploy do it (--build "${O.build}")`:``}`);let M=No[j].port?Ne(t.port??i.port??(j===`dockerfile`?An(m(A,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,P=j===`static`&&!!(t.spa??i.spa);w(`deploying ${C.bold(l)} (${j}) to ${C.bold(a)}`);let ue=await Bn(s,l),{env:de,proxy:fe,exists:F}=ue,I=ue.meta&&$t(ue.meta)?ue.meta:void 0,L=I?void 0:ue.meta;L?.adopted&&k(`${l} is adopted (${L.adopted}): deploy it there with docker compose, or pick another --name`),I?D(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):L&&L.kind!==j&&E(C.dim(`site was ${L.kind}, now ${j}`));let pe;(t.env?.length||t[`env-file`])&&(pe=de,t[`env-file`]&&(pe=pr(pe,await hr(t[`env-file`]))),t.env?.length&&(pe=pr(pe,gr(t.env))));let me=Io((ue.meta?.releases??[]).map(e=>e.id)),he=`${$(l)}/releases/${me}`,ge=j===`compose`?{}:{"compose.yaml":wn(j,{port:M,image:b}),...j===`static`&&{"nginx.conf":Tn(P)}},ye,R,be;try{let e=No[j].files;e!==void 0&&(w(`uploading ${te(n,A)||`.`}`),await wt(s,A,he+e));let t=await Qn(s,l,me,{file:S,generated:ge,env:pe}),r=yn(t.config,t.dir,e=>Ie(e,s));for(let e of r.warnings)D(e);r.errors.length&&k(`${S??`compose.yaml`} cannot be deployed:\n${r.errors.map(e=>` - ${e}`).join(`
775
- `)}`),ye={id:me,at:(/* @__PURE__ */ new Date()).toISOString(),services:r.services};let o=r.services.flatMap(e=>e.domains??[]);R=[];let c=!1;if(an(ye)){c=!h?.length&&!!i.domains?.length,R=d?[]:[h,i.domains,L?.domains,I?.domains].find(e=>e?.length)??[];let e=d||!R.length&&(!!L&&!!on(L)&&!L.domains.length||i.domains?.length===0);if(!R.length&&!e&&ve(s)&&(e=!0),!R.length&&!e){let t=Fe(s),n=t?void 0:Jt(await qt(s)),r=await ce(`Domain(s) to serve (comma separated; none: reach it by tunnel only):`,t?`${l}.${t}`:n?Yt(l,n):void 0);e=r===`none`,e||(R=r.split(/[\s,]+/).filter(Boolean))}R=[...new Set(R.map(e=>Ie(e,s)))];let t=R.filter(e=>!Ee.test(e)),n=R.filter(e=>o.includes(e));!R.length&&!e&&k(`at least one --domain is required`),e&&E(C.dim(`${l} serves no domain: reach it with lf tunnel ${l}, give it one with lf domains ${l} --add <domain>`)),t.length&&k(`not a domain: ${t.join(`, `)}`),n.length&&k(`${n.join(`, `)} is already routed by x-landfall.domains`)}else h?.length&&D(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);be=await bo(s,a,l,[...R,...o],fe,c?`${Be} lists it; deploy with -d to move it`:void 0)}catch(e){throw await er(s,l,me).catch(()=>{}),e}let xe=(/* @__PURE__ */ new Date()).toISOString(),z={v:2,name:l,kind:j,domains:R,auth:Po(L?.auth??I?.auth,re),current:me,releases:[...L?.releases??[],ye],createdAt:L?.createdAt??I?.createdAt??xe,updatedAt:xe};await be(),await cr(s,z,fe,{env:pe,build:!0,pull:!0,keep:u,prev:L,takeover:I,existed:!L&&!I&&F,what:`deploy ${l} release ${me}${L?``:` (new site, ${fn(z).join(`, `)})`}`});let B=fn(z);if(t.save){let e={name:l,server:a,domains:R};if(j===`image`)Object.assign(e,{image:b,port:M});else{let r=te(n,A)||`.`;j===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=S)):r!==kn(n,O)&&(e.dir=r),j===`dockerfile`&&(e.port=M),P&&(e.spa=!0),y&&j===`static`&&(e.static=!0)}v&&(e.build=v),Ue(n,e),In({path:n,name:l,server:a,domains:B,kind:j})}T(`release ${C.bold(me)} live`);for(let e of B)console.log(` ${C.cyan(`https://${e}`)}`);B[0]&&oe({url:`https://${B[0]}`}),B[0]&&await Ro(B[0])}});async function Ro(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?T(`https://${e} responded ${t.status}`):D(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);D(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const zo=F({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
781
+ before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:F,name:{type:`string`,short:`n`,desc:`site name (default: folder name)`},domain:{type:`string`,short:`d`,multiple:!0,desc:"domain to serve (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},"no-domain":{type:`boolean`,desc:`serve no domain: reach it with lf tunnel until lf domains --add gives it one`},file:{type:`string`,short:`f`,desc:`compose file to deploy (default: compose.yaml)`},spa:{type:`boolean`,desc:`single-page app: unknown paths serve index.html`},image:{type:`string`,desc:`deploy a docker image instead of files`},port:{type:`string`,desc:`container port (image/dockerfile; default: EXPOSE or 3000)`},static:{type:`boolean`,desc:`force static even if a Dockerfile or compose file exists`},env:{type:`string`,short:`e`,multiple:!0,desc:`set KEY=VALUE in the site's .env`},"env-file":{type:`string`,desc:`merge a local .env file into the site's .env (- reads stdin)`},build:{type:`string`,short:`b`,desc:`run this command locally first (e.g. 'npm run build')`},keep:{type:`string`,desc:`releases to keep for rollback (default: 5, lf config --keep)`},save:{type:`boolean`,desc:`write .landfall.json (--no-save to skip)`,default:!0},host:{type:`string`,desc:`IP or hostname of a new server: set it up as --server first`},user:{type:`string`,short:`u`,desc:`with --host: initial login user (default: root)`},"ssh-port":{type:`string`,desc:`with --host: ssh port`},"password-stdin":{type:`boolean`,desc:`setting up first: read the login password from stdin (also: $LANDFALL_PASSWORD)`},"auth-user":{type:`string`,desc:`protect the site with this login (HTTP basic auth, as lf auth; default with --auth-password: admin)`},"auth-password":{type:`string`,desc:`password of the login (asked when only --auth-user is given)`}},async run([e],t){let n=process.cwd(),i=He(n)??{};i.name&&E(C.dim(`using ${Be}`));let{name:a,server:s}=H(t.server??i.server);t.host&&!t.server&&!i.server&&!V().default&&k(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await xt(s);if(c!==`ready`){let e=c===`bare`?`is not set up by landfall yet`:`does not let landfall log in with a key`;!t.host&&!(se()&&await N(`${a} ${e}. Set it up now (lf setup)?`))&&k(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await Fo(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=H(a),await xt(s)!==`ready`&&k(`${a} is still not set up`)}let l=zn(t.name??i.name??Mn(f(n))),u=Ne(t.keep??ve().keep??5,`--keep`,1),d=!!t[`no-domain`]||(t.domain?.includes(!1)??!1),h=t.domain?.filter(e=>typeof e==`string`);d&&h?.length&&k(`--no-domain and --domain exclude each other`);let g=(h?.length?h:i.domains??[]).map(e=>Ie(e,s)).filter(e=>!Ee.test(e));g.length&&k(`not a domain: ${g.join(`, `)}`);let _=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),re=_?await Si(_,t[`auth-password`],`--auth-user`):void 0,v=t.build??i.build;if(v){w(`building locally: ${v}`);let e=r(v,{shell:!0,stdio:`inherit`});e.status!==0&&k(`build failed (exit ${e.status})`)}let y=t.static??i.static,ie=t.image??(y?void 0:i.image),b=ie&&Me(ie),x=ne(n,e??i.dir??`.`),ae=b||y?void 0:t.file??Nn(x,i),S=ae&&ee(ae);S?.includes(`/`)&&k(`${ae} is not in the folder deployed; deploy its folder instead: lf deploy ${p(m(te(n,x)||`.`,S))} -f ${f(S)}`),S&&!o.existsSync(m(x,S))&&k(`no ${S} in ${te(n,x)||`.`}`);let O=kn(n),A=S||b?x:ne(n,e??i.dir??An(n,O)),j=S?`compose`:b?`image`:!y&&o.existsSync(m(A,`Dockerfile`))?`dockerfile`:`static`,le=j===`static`&&!e&&!i.dir?O.tool:void 0;le&&E(C.dim(`publishing ${te(n,A)}: where ${le} builds the site (another folder: lf deploy <dir>)`)),j===`static`&&!o.existsSync(m(A,`index.html`))&&D(`no index.html in ${te(n,A)||`.`}${le&&!v?`: build first, or let deploy do it (--build "${O.build}")`:``}`);let M=Lo[j].port?Ne(t.port??i.port??(j===`dockerfile`?jn(m(A,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,ue=j===`static`&&!!(t.spa??i.spa);w(`deploying ${C.bold(l)} (${j}) to ${C.bold(a)}`);let de=await Vn(s,l),{env:fe,proxy:pe,exists:P}=de,F=de.meta&&en(de.meta)?de.meta:void 0,I=F?void 0:de.meta;I?.adopted&&k(`${l} is adopted (${I.adopted}): deploy it there with docker compose, or pick another --name`),F?D(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):I&&I.kind!==j&&E(C.dim(`site was ${I.kind}, now ${j}`));let me;(t.env?.length||t[`env-file`])&&(me=fe,t[`env-file`]&&(me=mr(me,await gr(t[`env-file`]))),t.env?.length&&(me=mr(me,_r(t.env))));let he=Bo((de.meta?.releases??[]).map(e=>e.id)),ge=`${$(l)}/releases/${he}`,_e=j===`compose`?{}:{"compose.yaml":Tn(j,{port:M,image:b}),...j===`static`&&{"nginx.conf":En(ue)}},be,L,xe;try{let e=Lo[j].files;e!==void 0&&(w(`uploading ${te(n,A)||`.`}`),await Tt(s,A,ge+e));let t=await $n(s,l,he,{file:S,generated:_e,env:me}),r=bn(t.config,t.dir,e=>Ie(e,s));for(let e of r.warnings)D(e);r.errors.length&&k(`${S??`compose.yaml`} cannot be deployed:\n${r.errors.map(e=>` - ${e}`).join(`
782
+ `)}`),be={id:he,at:(/* @__PURE__ */ new Date()).toISOString(),services:r.services};let o=r.services.flatMap(e=>e.domains??[]);L=[];let c=!1;if(on(be)){c=!h?.length&&!!i.domains?.length,L=d?[]:[h,i.domains,I?.domains,F?.domains].find(e=>e?.length)??[];let e=d||!L.length&&(!!I&&!!sn(I)&&!I.domains.length||i.domains?.length===0);if(!L.length&&!e&&ye(s)&&(e=!0),!L.length&&!e){let t=Fe(s),n=t?void 0:Yt(await Jt(s)),r=await ce(`Domain(s) to serve (comma separated; none: reach it by tunnel only):`,t?`${l}.${t}`:n?Xt(l,n):void 0);e=r===`none`,e||(L=r.split(/[\s,]+/).filter(Boolean))}L=[...new Set(L.map(e=>Ie(e,s)))];let t=L.filter(e=>!Ee.test(e)),n=L.filter(e=>o.includes(e));!L.length&&!e&&k(`at least one --domain is required`),e&&E(C.dim(`${l} serves no domain: reach it with lf tunnel ${l}, give it one with lf domains ${l} --add <domain>`)),t.length&&k(`not a domain: ${t.join(`, `)}`),n.length&&k(`${n.join(`, `)} is already routed by x-landfall.domains`)}else h?.length&&D(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);xe=await wo(s,a,l,[...L,...o],pe,c?`${Be} lists it; deploy with -d to move it`:void 0)}catch(e){throw await tr(s,l,he).catch(()=>{}),e}let R=(/* @__PURE__ */ new Date()).toISOString(),z={v:2,name:l,kind:j,domains:L,auth:Ro(I?.auth??F?.auth,re),current:he,releases:[...I?.releases??[],be],createdAt:I?.createdAt??F?.createdAt??R,updatedAt:R};await xe(),await lr(s,z,pe,{env:me,build:!0,pull:!0,keep:u,prev:I,takeover:F,existed:!I&&!F&&P,what:`deploy ${l} release ${he}${I?``:` (new site, ${pn(z).join(`, `)})`}`});let B=pn(z);if(t.save){let e={name:l,server:a,domains:L};if(j===`image`)Object.assign(e,{image:b,port:M});else{let r=te(n,A)||`.`;j===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=S)):r!==An(n,O)&&(e.dir=r),j===`dockerfile`&&(e.port=M),ue&&(e.spa=!0),y&&j===`static`&&(e.static=!0)}v&&(e.build=v),Ue(n,e),Ln({path:n,name:l,server:a,domains:B,kind:j})}T(`release ${C.bold(he)} live`);for(let e of B)console.log(` ${C.cyan(`https://${e}`)}`);B[0]&&oe({url:`https://${B[0]}`}),B[0]&&await Ho(B[0])}});async function Ho(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?T(`https://${e} responded ${t.status}`):D(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);D(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const Uo=P({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
776
783
 
777
784
  compose.yaml what runs on the server; x-landfall routes a service through Traefik
778
785
  compose.override.yaml local only (docker compose merges it, lf deploy leaves it out):
779
786
  publishes the port on localhost
780
787
 
781
788
  \`docker compose up\` then runs the site locally, \`lf deploy\` on a server. Existing
782
- files are never overwritten.`,options:{port:{type:`string`,desc:`container port (default: EXPOSE, the saved port or 3000)`},postgres:{type:`boolean`,desc:`add a Postgres service with a volume; the app gets DATABASE_URL`}},async run([e],t){let n=ne(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||k(`${e} is not a folder`);let r=Nn(n);r.kind===`compose`&&k(`${r.compose} exists already: add x-landfall: { port: <port> } to the service Traefik should serve`);let i={"compose.yaml":``,"compose.override.yaml":``},a=Object.keys(i).filter(e=>o.existsSync(m(n,e)));a.length&&k(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:Ne(t.port??r.port??3e3,`--port`,1,65535),c=r.kind===`static`?8080:s,l=!!t.postgres,u;if(r.kind===`static`){let e=r.dir??`dist`;r.dir||E(C.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
789
+ files are never overwritten.`,options:{port:{type:`string`,desc:`container port (default: EXPOSE, the saved port or 3000)`},postgres:{type:`boolean`,desc:`add a Postgres service with a volume; the app gets DATABASE_URL`}},async run([e],t){let n=ne(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||k(`${e} is not a folder`);let r=Pn(n);r.kind===`compose`&&k(`${r.compose} exists already: add x-landfall: { port: <port> } to the service Traefik should serve`);let i={"compose.yaml":``,"compose.override.yaml":``},a=Object.keys(i).filter(e=>o.existsSync(m(n,e)));a.length&&k(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:Ne(t.port??r.port??3e3,`--port`,1,65535),c=r.kind===`static`?8080:s,l=!!t.postgres,u;if(r.kind===`static`){let e=r.dir??`dist`;r.dir||E(C.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
783
790
  image: nginx:alpine
784
791
  restart: unless-stopped
785
792
  volumes:
@@ -824,11 +831,11 @@ services:
824
831
  ${d}:
825
832
  ports:
826
833
  - "127.0.0.1:${c}:${s}"
827
- `;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=te(process.cwd(),n)||`.`;T(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(m(n,`.env`))&&E(`locally: put POSTGRES_PASSWORD=… in ${m(f,`.env`)} (keep it out of git)`),E(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),E(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),Bo=F({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
828
- Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on (default: 4777, else the next free one)`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-BxTFpFmw.mjs`);return n({port:t.port===void 0?void 0:Ne(t.port,`--port`,1,65535),open:t.open})}}),Vo={current:C.green,update:C.yellow,unknown:C.dim,pinned:C.dim,rebuild:C.dim,foreign:C.dim,missing:C.red};function Ho(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return E(`packages: no supported package manager`);let r=e.updates.length?`${C.bold(String(e.updates.length))} package updates${n?`, ${C.yellow(`${n} security`)}`:``}`:C.green(`packages up to date`);console.log(`${r}${e.reboot?C.yellow(` · reboot required`):``}${e.refreshed?``:C.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);P(i.map(e=>[` `+e.name,C.dim(e.from??``),`→ `+e.to,e.security?C.yellow(`security`):``])),i.length<e.updates.length&&console.log(C.dim(` … ${e.updates.length-i.length} more (--all)`))}function Uo(e){e.length&&(console.log(`\n${C.bold(`Images`)}`),P(e.map(e=>[` `+e.name,C.dim(e.ref||`-`),Vo[e.state](e.state===`update`?`newer image`:e.state),C.dim(e.note??``)])))}function Wo(e){let t=Ir(e);console.log(`\n${C.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||C.red(`missing`)}`:C.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+C.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(C.dim(` newest releases`))}const Go=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}=H(t.server);if(t.json)return void console.log(JSON.stringify(await _o(n,t.refresh),null,2));let r=await br(n,t.refresh),i=await zr(n),a=await Fr(n);Ho(r,t.all),Wo(a),Uo(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(C.dim(`\napply with: lf update${o}`)),Ir(a).composeUpdate&&console.log(C.dim(`newest compose: lf update --compose${o}`))}}),Ko=F({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
834
+ `;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=te(process.cwd(),n)||`.`;T(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(m(n,`.env`))&&E(`locally: put POSTGRES_PASSWORD=… in ${m(f,`.env`)} (keep it out of git)`),E(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),E(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),Wo=P({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
835
+ Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on (default: 4777, else the next free one)`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-Cj2NicZ7.mjs`);return n({port:t.port===void 0?void 0:Ne(t.port,`--port`,1,65535),open:t.open})}}),Go={current:C.green,update:C.yellow,unknown:C.dim,pinned:C.dim,rebuild:C.dim,foreign:C.dim,missing:C.red};function Ko(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return E(`packages: no supported package manager`);let r=e.updates.length?`${C.bold(String(e.updates.length))} package updates${n?`, ${C.yellow(`${n} security`)}`:``}`:C.green(`packages up to date`);console.log(`${r}${e.reboot?C.yellow(` · reboot required`):``}${e.refreshed?``:C.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);ue(i.map(e=>[` `+e.name,C.dim(e.from??``),`→ `+e.to,e.security?C.yellow(`security`):``])),i.length<e.updates.length&&console.log(C.dim(` … ${e.updates.length-i.length} more (--all)`))}function qo(e){e.length&&(console.log(`\n${C.bold(`Images`)}`),ue(e.map(e=>[` `+e.name,C.dim(e.ref||`-`),Go[e.state](e.state===`update`?`newer image`:e.state),C.dim(e.note??``)])))}function Jo(e){let t=Lr(e);console.log(`\n${C.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||C.red(`missing`)}`:C.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+C.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(C.dim(` newest releases`))}const Yo=P({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:F,refresh:{type:`boolean`,desc:`refresh package lists first (apt update; --no-refresh to skip)`,default:!0},all:{type:`boolean`,desc:`list every package`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{server:n}=H(t.server);if(t.json)return void console.log(JSON.stringify(await xo(n,t.refresh),null,2));let r=await xr(n,t.refresh),i=await Br(n),a=await Ir(n);Ko(r,t.all),Jo(a),qo(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(C.dim(`\napply with: lf update${o}`)),Lr(a).composeUpdate&&console.log(C.dim(`newest compose: lf update --compose${o}`))}}),Xo=P({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
829
836
 
830
837
  Without --packages/--images both are updated. Package upgrades cannot be undone;
831
838
  image updates can (\`lf undo\` puts the old image back). --compose replaces the
832
- 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/yum); implies --packages`},reboot:{type:`boolean`,desc:`reboot afterwards if the upgrade needs it`},images:{type:`boolean`,desc:`pull newer images and recreate their containers`},compose:{type:`boolean`,desc:`update the compose plugin landfall installed to the newest release`}},async run(e,t){let{name:n,server:r}=H(t.server);if(t.compose){(e.length||t.packages||t.images)&&k(`--compose goes alone`);let i=await _r(r,`updating docker compose`),a=await Fr(i),o=Ir(a);if(!o.composeUpdate)return void(o.composeNote?E(o.composeNote):T(`compose ${a.compose} is the newest`));await N(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(w(`downloading docker compose`),await Lr(i),T(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images&&!t.security&&!t.reboot;e.length&&t.packages&&k(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await _r(r,`upgrading packages`),i=await br(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?D(`no supported package manager, skipping packages`):a.length?(Ho({...i,updates:a},!1),t.security&&(i.mgr===`apk`||i.mgr===`zypper`)&&k(`${i.mgr} has no security-only upgrades here; drop --security`),await N(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(w(`upgrading packages on ${n}`),await Sr(e,{only:t.security?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),T(`packages upgraded`),t.reboot&&E(`rebooting if required; give it a minute`))):T(t.security?`no security updates`:`packages up to date`)}if(o){let t=await zr(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&k(`no site or service ${r.join(`, `)} on ${n}`)}let i=e.length?t.filter(t=>e.includes(t.name)):t;for(let e of i.filter(e=>e.state!==`update`&&e.state!==`current`&&e.state!==`unknown`))E(C.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(Uo(a),await N(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(w(`updating images`),await Br(r,a),T(`images updated (lf undo puts the old ones back)`))):T(`images up to date`)}}});var qo=`0.5.3`;const Jo=[Mo,zo,Lo,zi,Bi,Vi,Yi,Ki,Ui,Wi,Gi,Ji,qi,Xi,xo,So,Co,Go,Ko,Qa,$a,ao,so,fo,ro,eo,no,io,Ai,ji,Ni,Pi,Fi,Mi,Ii,ga,_a,va,ma,ha,ba,ya,La,Ra,Oo,Do,Bo],Yo={ls:`sites list`,sites:`sites list`,servers:`server list`,"server ls":`server list`,"server ssh":`ssh`,"sites logs":`logs`,"sites env":`env`,"sites rollback":`rollback`,"sites restart":`restart`,"sites stop":`stop`,"sites start":`start`,"sites ls":`sites list`,"sites remove":`sites rm`,"server remove":`server rm`,uninstall:`server uninstall`,"traefik remove":`traefik rm`,traefik:`traefik status`,firewall:`firewall status`,"firewall remove":`firewall rm`,log:`history`,revert:`undo`,web:`ui`,info:`server info`,"sites domains":`domains`,"sites auth":`auth`,"sites open":`open`,"sites tunnel":`tunnel`,upgrade:`update`},Xo=new Map(Jo.map(e=>[e.name,e]));function Zo(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=Xo.get(Yo[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function Qo(){let e=[[`Servers`,[`setup`,`server add`,`server list`,`server info`,`server config`,`server domain`,`server use`,`server rm`,`server uninstall`,`ssh`,`keygen`]],[`Traefik`,[`traefik setup`,`traefik status`,`traefik dashboard`,`traefik logs`,`traefik restart`,`traefik rm`]],[`Firewall`,[`firewall status`,`firewall enable`,`firewall disable`,`firewall allow`,`firewall deny`,`firewall update`,`firewall rm`]],[`Sites`,[`deploy`,`sites list`,`sites info`,`domains`,`auth`,`open`,`tunnel`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`landfall`,[`config`,`ui`]]],t=[`${C.bold(`landfall`)} ${C.dim(qo)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${C.bold(`Usage:`)} lf <command> [options] ${C.dim(`lf <command> --help for details`)}`,` lf ui ${C.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,C.bold(n));for(let e of r){let n=Xo.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
839
+ compose plugin setup installed with the newest release (undoable too).`,options:{server:F,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf/yum); implies --packages`},reboot:{type:`boolean`,desc:`reboot afterwards if the upgrade needs it`},images:{type:`boolean`,desc:`pull newer images and recreate their containers`},compose:{type:`boolean`,desc:`update the compose plugin landfall installed to the newest release`}},async run(e,t){let{name:n,server:r}=H(t.server);if(t.compose){(e.length||t.packages||t.images)&&k(`--compose goes alone`);let i=await vr(r,`updating docker compose`),a=await Ir(i),o=Lr(a);if(!o.composeUpdate)return void(o.composeNote?E(o.composeNote):T(`compose ${a.compose} is the newest`));await N(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(w(`downloading docker compose`),await Rr(i),T(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images&&!t.security&&!t.reboot;e.length&&t.packages&&k(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await vr(r,`upgrading packages`),i=await xr(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?D(`no supported package manager, skipping packages`):a.length?(Ko({...i,updates:a},!1),t.security&&(i.mgr===`apk`||i.mgr===`zypper`)&&k(`${i.mgr} has no security-only upgrades here; drop --security`),await N(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(w(`upgrading packages on ${n}`),await Cr(e,{only:t.security?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),T(`packages upgraded`),t.reboot&&E(`rebooting if required; give it a minute`))):T(t.security?`no security updates`:`packages up to date`)}if(o){let t=await Br(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&k(`no site or service ${r.join(`, `)} on ${n}`)}let i=e.length?t.filter(t=>e.includes(t.name)):t;for(let e of i.filter(e=>e.state!==`update`&&e.state!==`current`&&e.state!==`unknown`))E(C.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(qo(a),await N(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(w(`updating images`),await Vr(r,a),T(`images updated (lf undo puts the old ones back)`))):T(`images up to date`)}}});var Zo=`0.5.4`;const Qo=[Io,Uo,Vo,Bi,Vi,Hi,Xi,qi,Wi,Gi,Ki,Yi,Ji,Zi,To,Eo,Do,Yo,Xo,no,ro,lo,fo,go,so,io,oo,co,ji,Mi,Pi,Fi,Ii,Ni,Li,_a,va,ya,ha,ga,xa,ba,Ra,za,Mo,jo,Wo],$o={ls:`sites list`,sites:`sites list`,servers:`server list`,"server ls":`server list`,"server ssh":`ssh`,"sites logs":`logs`,"sites env":`env`,"sites rollback":`rollback`,"sites restart":`restart`,"sites stop":`stop`,"sites start":`start`,"sites ls":`sites list`,"sites remove":`sites rm`,"server remove":`server rm`,uninstall:`server uninstall`,"traefik remove":`traefik rm`,traefik:`traefik status`,firewall:`firewall status`,"firewall remove":`firewall rm`,log:`history`,revert:`undo`,web:`ui`,info:`server info`,"sites domains":`domains`,"sites auth":`auth`,"sites open":`open`,"sites tunnel":`tunnel`,upgrade:`update`},es=new Map(Qo.map(e=>[e.name,e]));function ts(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=es.get($o[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function ns(){let e=[[`Servers`,[`setup`,`server add`,`server list`,`server info`,`server config`,`server domain`,`server use`,`server rm`,`server uninstall`,`ssh`,`keygen`]],[`Traefik`,[`traefik setup`,`traefik status`,`traefik dashboard`,`traefik logs`,`traefik restart`,`traefik rm`]],[`Firewall`,[`firewall status`,`firewall enable`,`firewall disable`,`firewall allow`,`firewall deny`,`firewall update`,`firewall rm`]],[`Sites`,[`deploy`,`sites list`,`sites info`,`domains`,`auth`,`open`,`tunnel`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`landfall`,[`config`,`ui`]]],t=[`${C.bold(`landfall`)} ${C.dim(Zo)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${C.bold(`Usage:`)} lf <command> [options] ${C.dim(`lf <command> --help for details`)}`,` lf ui ${C.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,C.bold(n));for(let e of r){let n=es.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
833
840
  `)[0]}`)}}t.push(``,C.bold(`Quick start`),` lf setup vps --check ${C.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${C.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${C.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${C.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${C.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${C.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${C.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${C.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${C.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${C.dim(`# what landfall changed, and revert the last of it`)}`,``,C.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
834
- `))}async function $o(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return Qo(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(qo),0;let{cmd:t,rest:n}=Zo(e);if(!t)return console.error(`${C.red(`error:`)} unknown command "${e.join(` `)}"\n`),Qo(),2;let{values:r,positionals:i,rest:a}=me(t,n);if(r.help)return console.log(he(t)),0;j(!!r.yes),vt.cmd=ge(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function es(e){try{return await $o(e)}catch(e){return e instanceof O?(console.error(`${C.red(`error:`)} ${e.message}`),/--force\b/.test(e.message)&&oe({force:e.message})):console.error(e),1}}es(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{He as A,G as C,be as D,De as E,pe as M,C as N,z as O,fe as P,W as S,Ee as T,st as _,ho as a,K as b,_o as c,Ba as d,Va as f,_t as g,Fn as h,vo as i,H as j,V as k,Ga as l,Nn as m,Eo as n,po as o,Zn as p,mo as r,go as s,qo as t,Ha as u,qe as v,xe as w,U as x,q as y};
841
+ `))}async function rs(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return ns(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(Zo),0;let{cmd:t,rest:n}=ts(e);if(!t)return console.error(`${C.red(`error:`)} unknown command "${e.join(` `)}"\n`),ns(),2;let{values:r,positionals:i,rest:a}=he(t,n);if(r.help)return console.log(ge(t)),0;j(!!r.yes),vt.cmd=_e(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function is(e){try{return await rs(e)}catch(e){return e instanceof O?(console.error(`${C.red(`error:`)} ${e.message}`),/--force\b/.test(e.message)&&oe({force:e.message})):console.error(e),1}}is(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{V as A,W as C,De as D,Ee as E,pe as F,H as M,me as N,xe as O,C as P,U as S,R as T,_t as _,yo as a,q as b,xo as c,Ha as d,Wa as f,In as g,Pn as h,So as i,He as j,z as k,Ya as l,Qn as m,Ao as n,_o as o,Ga as p,vo as r,bo as s,Zo as t,Ka as u,st as v,G as w,K as x,qe as y};