landfall 0.6.4 → 0.6.5

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/index.html CHANGED
@@ -389,6 +389,7 @@ const S = {
389
389
  tab: "sites",
390
390
  overview: {}, // name -> {data} | {error} | {loading}
391
391
  firewall: {},
392
+ det: {}, // project folder -> what /api/fs detected there (null while reading, false if it failed)
392
393
  system: {}, // name -> {data: lf system --json}
393
394
  history: {},
394
395
  updates: {},
@@ -865,7 +866,7 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
865
866
  if (f.type === "textarea") return `<label>${esc(f.label)}<textarea id="${id}" name="${f.name}" placeholder="${esc(f.placeholder)}">${esc(f.value)}</textarea>${hint}</label>`;
866
867
  return `<label>${esc(f.label)}<input id="${id}" name="${f.name}" type="${f.type || "text"}" value="${esc(f.value)}" placeholder="${esc(f.placeholder)}" autocomplete="off" spellcheck="false">${hint}</label>`;
867
868
  };
868
- const group = (f) => (f.legend ? `<fieldset><legend>${esc(f.legend)}</legend>${f.fields.map(field).join("")}</fieldset>` : field(f));
869
+ const group = (f) => (f.legend ? `<fieldset${f.id ? ` id="fs-${f.id}"` : ""}><legend>${esc(f.legend)}</legend>${f.fields.map(field).join("")}</fieldset>` : field(f));
869
870
  dlg.innerHTML = `<form method="dialog" novalidate>
870
871
  <h2 id="dlg-title">${esc(title)}</h2>
871
872
  ${text ? `<div class="text">${esc(text)}</div>` : ""}
@@ -948,6 +949,8 @@ function setupFlow(args, base = args) {
948
949
  server: args[0],
949
950
  after: (j) => {
950
951
  if (j.info.code !== 0) return;
952
+ // Synology and other systems without a package manager: nothing for setup to apply.
953
+ if (rep(j).refuse) return [{ label: "Back to the server", run: () => ((S.job = null), go(args[0], "sites")) }];
951
954
  if (rep(j).needsKey) {
952
955
  return [{ label: "Enter the password to install a key", run: () => passwordFor(args, base) }];
953
956
  }
@@ -1116,17 +1119,17 @@ function autoDomain(n, name) {
1116
1119
  const slugify = (s) => s.toLowerCase().replace(/\.(zip|tgz|tar(\.gz)?)$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "site";
1117
1120
  const tilde = (p) => p.replace(/^\/(Users|home)\/[^/]+/, "~");
1118
1121
 
1119
- function describe(d) {
1122
+ function describe(d, before = true) {
1120
1123
  const built = d.build ? ` · builds with <code>${esc(d.build)}</code> first` : "";
1121
1124
  const what =
1122
1125
  d.kind === "compose" ? `Compose project <code>${esc(d.compose ?? "compose.yaml")}</code>: built and started on the server; x-landfall picks the routed service`
1123
1126
  : d.kind === "image" ? `Docker image <code>${esc(d.project?.image ?? "")}</code> (from .landfall.json)`
1127
+ : d.kind === "dockerfile" && d.start ? `Node.js app${d.tool ? ` (${esc(d.tool)})` : ""}: built on the server${d.build ? ` with <code>${esc(d.build)}</code>` : ""}, started with <code>${esc(d.start)}</code> on port ${esc(d.port)}`
1124
1128
  : d.kind === "dockerfile" ? `Dockerfile: built on the server, served on port ${esc(d.port)}`
1125
1129
  : d.dir ? `Static files from <code>${esc(d.dir === "." ? "./" : d.dir + "/")}</code>${d.tool ? ` (where ${esc(d.tool)} builds the site)` : ""}${d.spa ? " (single-page app)" : ""}${built}`
1126
1130
  : d.build ? `Static site, built with <code>${esc(d.build)}</code> first`
1127
1131
  : `<span style="color:var(--warn)">No compose.yaml, Dockerfile, index.html or build script here.</span>`;
1128
- const before = d.project?.server ? ` <span class="dim">· deployed before to ${esc(d.project.server)}</span>` : "";
1129
- return what + before;
1132
+ return what + (before && d.project?.server ? ` <span class="dim">· deployed before to ${esc(d.project.server)}</span>` : "");
1130
1133
  }
1131
1134
 
1132
1135
  /**
@@ -1142,6 +1145,8 @@ function deployDialog(det, o = {}) {
1142
1145
  const main = mainDomain(target);
1143
1146
  // A redeploy keeps the site's domains unless .landfall.json or the field names others.
1144
1147
  const keeps = re && !p?.domains ? re.domains : null;
1148
+ // Static files or a Node.js app: what to build and run is landfall's to decide, and editable here.
1149
+ const modal = det.kind === "static" || !!det.start;
1145
1150
  dialog({
1146
1151
  title: re ? `Redeploy ${name}` : `Deploy ${name}`,
1147
1152
  text: (o.upload ? `Uploaded ${plural(o.files ?? 0, "file")}. Not remembered: drop it again to redeploy.` : `From ${tilde(det.path)}${p ? " (settings from .landfall.json)" : ""}`)
@@ -1160,15 +1165,35 @@ function deployDialog(det, o = {}) {
1160
1165
  : "Empty: a free <name>.<ip>.sslip.io address, no DNS needed. Own domains need an A record pointing at the server.",
1161
1166
  },
1162
1167
  { name: "noDomain", label: "No domain: reach it through a tunnel only", type: "check", value: p?.domains ? !p.domains.length : re ? false : !!tunnelOnly(target), hint: "Nothing is served on the internet. Give it a domain later with Domains… on the site." },
1163
- ...(det.kind === "static"
1168
+ ...(modal
1164
1169
  ? [
1165
- { name: "build", label: "Build command (runs here, before upload)", value: det.build ?? "", placeholder: "none" },
1170
+ ...(det.node || det.start
1171
+ ? [{ name: "mode", label: "Deploy as", type: "select", value: det.start ? "node" : "static", options: [{ value: "node", label: "Node.js app (runs on the server)" }, { value: "static", label: "Static files" }] }]
1172
+ : []),
1173
+ { name: "build", label: "Build command", value: det.build ?? "", placeholder: "none", hint: "Static files: runs here, before the upload. A Node.js app: runs on the server, in its image." },
1166
1174
  {
1167
- name: "dir", label: "Folder to publish", value: det.dir ?? ".", placeholder: ".",
1168
- hint: det.tool ? `Detected: ${det.tool} writes the site to ${det.dir}/ (it need not exist before the build).` : "Relative to the project; it needs an index.html after the build.",
1175
+ legend: "Static files", id: "static",
1176
+ fields: [
1177
+ {
1178
+ name: "dir", label: "Folder to publish", value: det.dir ?? ".", placeholder: ".",
1179
+ hint: det.tool && !det.start ? `Detected: ${det.tool} writes the site to ${det.dir}/ (it need not exist before the build).` : "Relative to the project; it needs an index.html after the build.",
1180
+ },
1181
+ { name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
1182
+ ],
1183
+ },
1184
+ {
1185
+ legend: "Node.js app", id: "node",
1186
+ fields: [
1187
+ {
1188
+ name: "start", label: "Start command", value: det.start ?? "", placeholder: "node server.js",
1189
+ hint: `${det.start && det.tool ? `Detected: ${det.tool}. ` : ""}landfall writes the Dockerfile (install, build, start); lf init puts it into the project to change it.`,
1190
+ },
1191
+ { name: "port", label: "Port it listens on", value: String(det.port ?? 3000), hint: "The app gets it as PORT, with HOST=0.0.0.0." },
1192
+ ],
1169
1193
  },
1170
- { name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
1171
1194
  ]
1195
+ : det.kind === "dockerfile" || det.kind === "image"
1196
+ ? [{ name: "port", label: "Port it listens on", value: String(det.port ?? 3000) }]
1172
1197
  : []),
1173
1198
  {
1174
1199
  legend: "Login (optional)",
@@ -1187,11 +1212,26 @@ function deployDialog(det, o = {}) {
1187
1212
  // The server's default would leave it unrouted though the box is unticked.
1188
1213
  if (!v.noDomain && !domains.length && !p?.domains?.length && !(keeps && n === re.name && v.server === re.server) && tunnelOnly(v.server)) return invalid("domains", `${v.server} gives new sites no domain: name one, or tick No domain`);
1189
1214
  const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...(v.noDomain ? ["--no-domain"] : domains.flatMap((d) => ["-d", d]))];
1190
- if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
1191
- if (v.spa && !p?.spa) argv.push("--spa");
1192
- const dir = v.dir?.trim().replace(/\/+$/, "") || ".";
1193
- if (det.kind === "static" && (/^[/-]/.test(dir) || dir.split("/").includes(".."))) return invalid("dir", "A folder inside the project");
1194
- if (det.kind === "static" && dir !== (det.dir ?? ".")) argv.push(dir);
1215
+ const asNode = modal && (v.mode ?? (det.start ? "node" : "static")) === "node";
1216
+ const build = v.build?.trim() ?? "";
1217
+ if (asNode) {
1218
+ const start = v.start.trim();
1219
+ if (!start) return invalid("start", "How does the app start? For example: node server.js");
1220
+ if (start !== det.start) argv.push("--start", start);
1221
+ if (build !== (det.build ?? "")) argv.push("--build", build);
1222
+ } else if (modal) {
1223
+ if (det.start) argv.push("--static");
1224
+ if (build && build !== p?.build) argv.push("--build", build);
1225
+ if (v.spa && !p?.spa) argv.push("--spa");
1226
+ const dir = v.dir.trim().replace(/\/+$/, "") || ".";
1227
+ if (/^[/-]/.test(dir) || dir.split("/").includes("..")) return invalid("dir", "A folder inside the project");
1228
+ if (dir !== (det.dir ?? ".")) argv.push(dir);
1229
+ }
1230
+ if (v.port !== undefined && (asNode || !modal)) {
1231
+ const port = Number(v.port.trim());
1232
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return invalid("port", "A port from 1 to 65535");
1233
+ if (port !== (det.port ?? 3000)) argv.push("--port", String(port));
1234
+ }
1195
1235
  // The login counts once there is a password; the user defaults to admin.
1196
1236
  if (v.authPassword) {
1197
1237
  const user = v.authUser.trim() || "admin";
@@ -1204,6 +1244,20 @@ function deployDialog(det, o = {}) {
1204
1244
  },
1205
1245
  });
1206
1246
  noDomainToggle();
1247
+ modeToggle();
1248
+ }
1249
+
1250
+ /** The open dialog's "deploy as" picks which settings show: the static folder's or the Node.js app's. */
1251
+ function modeToggle() {
1252
+ const mode = $("#f-mode"), stat = $("#fs-static"), node = $("#fs-node");
1253
+ if (!stat || !node) return;
1254
+ const sync = () => {
1255
+ const asNode = mode?.value === "node";
1256
+ stat.hidden = asNode;
1257
+ node.hidden = !asNode;
1258
+ };
1259
+ mode?.addEventListener("change", sync);
1260
+ sync();
1207
1261
  }
1208
1262
 
1209
1263
  /** The open dialog's "no domain" box greys out its domains field: ticked, the site gets none. */
@@ -1226,6 +1280,17 @@ async function browse(path) {
1226
1280
  return http(`/api/fs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
1227
1281
  }
1228
1282
 
1283
+ /** What a site's folder holds and how it builds: detected once per folder, for the site details. */
1284
+ const detected = (path) => {
1285
+ if (!(path in S.det)) (S.det[path] = null), browse(path).then((x) => ((S.det[path] = x.detected), render()), () => (S.det[path] = false));
1286
+ return S.det[path];
1287
+ };
1288
+ /** Edit how a site builds and starts (build, start, port, static or Node.js), then redeploy. */
1289
+ const buildSettings = (path, site) => {
1290
+ delete S.det[path];
1291
+ browse(path).then((x) => deployDialog(x.detected, { site }), (e) => toast(e.message));
1292
+ };
1293
+
1229
1294
  function deployHere() {
1230
1295
  browse(S.state.cwd).then((b) => deployDialog(b.detected), (e) => toast(e.message));
1231
1296
  }
@@ -1751,6 +1816,10 @@ function othersCard(n, o) {
1751
1816
 
1752
1817
  function sitesView(n, o) {
1753
1818
  ensureDns(n, o);
1819
+ if (!o.ready && can(n) && !can(n).setup) {
1820
+ return `<div class="card"><div class="body empty"><h2>Not ready for sites</h2>
1821
+ <p>${esc(S.facts[n]?.data?.why?.setup ?? "")}, so landfall cannot set it up: sites need ${esc("/opt/landfall")} and Traefik, which setup creates.</p></div></div>`;
1822
+ }
1754
1823
  if (!o.ready) {
1755
1824
  return `<div class="card"><div class="body empty"><h2>Not set up for landfall yet</h2>
1756
1825
  <p>Setup installs Docker and Traefik, a deploy user and (if you want) a firewall. It shows what it would change first.</p>
@@ -1793,6 +1862,7 @@ function sitesView(n, o) {
1793
1862
  ? { label: "Stop…", hint: "lf stop", run: () => confirmDlg({ title: `Stop ${x.service} of ${name}?`, text: "It stays stopped until you start it again.", ok: "Stop it", danger: true, onOk: () => run(["stop", name, x.service, ...sv(n)]) }) }
1794
1863
  : { label: "Start", hint: "lf start", run: () => run(["start", name, x.service, ...sv(n)]) },
1795
1864
  ], `${x.service} of ${name}`, "sm ghost");
1865
+ const editBuild = () => buildSettings(proj.path, { server: n, name, domains: m.domains ?? [], old });
1796
1866
  const acts = [
1797
1867
  proj && !m.adopted
1798
1868
  ? btn("Redeploy", () => confirmDlg({ title: `Redeploy ${name}?`, text: `From ${tilde(proj.path)}${proj.kind === "static" ? " (runs its build first, if it has one)" : ""}.`, ok: "Deploy", onOk: () => run(["deploy", "-y", ...sv(n)], { server: n, cwd: proj.path, after: openAfter }) }), "sm primary", `lf deploy in ${proj.path}`)
@@ -1812,6 +1882,7 @@ function sitesView(n, o) {
1812
1882
  main ? { label: "Domains…", hint: "lf domains", run: () => domainsDialog(n, m) } : null,
1813
1883
  managed ? { label: m.auth?.length ? "Login…" : "Password…", hint: "lf auth", title: "Protect the site with a user and password", run: () => authDialog(n, m) } : null,
1814
1884
  managed ? { label: "Environment…", hint: "lf env", run: () => envDialog(n, name) } : null,
1885
+ proj && !m.adopted ? { label: "Build settings…", hint: "lf deploy --build --start", title: "What it builds and starts with, its port, static or Node.js", run: editBuild } : null,
1815
1886
  "-",
1816
1887
  { label: m.adopted ? `Forget ${name}…` : `Delete ${name}…`, cls: "danger", hint: "lf sites rm", run: deleteSite },
1817
1888
  ], `More for ${name}`),
@@ -1819,6 +1890,7 @@ function sitesView(n, o) {
1819
1890
  const toggle = () => (open ? S.open.delete(`${n}/${name}`) : S.open.add(`${n}/${name}`), render());
1820
1891
  const live = m.releases?.find((r) => r.id === m.current);
1821
1892
  const svcDomains = (svc) => (svc === main ? m.domains : (live?.services?.find((x) => x.name === svc)?.domains ?? []));
1893
+ const det = open && proj && !m.adopted ? detected(proj.path) : undefined;
1822
1894
  const detail = open
1823
1895
  ? `<tr class="detail"><td colspan="5"><dl class="kv">
1824
1896
  ${old ? `<dt>Old site</dt><dd class="dim">Deployed before sites were Compose projects: ${proj ? "redeploy it" : `run <span class="mono">lf deploy</span> for it`} once to manage it again.</dd>` : ""}
@@ -1829,6 +1901,7 @@ function sitesView(n, o) {
1829
1901
  ${m.adopted ? "" : `<dt>Source</dt><dd>${proj
1830
1902
  ? `<span class="mono">${esc(tilde(proj.path))}</span> <span class="dim">· redeploy runs there</span>`
1831
1903
  : `<span class="dim">not deployed from this machine${m.kind === "image" ? "" : " (or the folder moved): Redeploy… picks the folder"}</span>`}</dd>`}
1904
+ ${det !== undefined ? `<dt>Build</dt><dd>${det ? `${describe(det, false)} ${btn("Edit…", editBuild, "sm ghost", "Build settings: lf deploy --build --start")}` : `<span class="faint">${det === false ? "cannot read its folder" : "reading its folder…"}</span>`}</dd>` : ""}
1832
1905
  <dt>Kind</dt><dd>${esc(kind)}${main || !managed ? "" : ` <span class="dim">· domains set in compose.yaml</span>`}</dd>
1833
1906
  ${managed ? `<dt>Login</dt><dd>${m.auth?.length ? `${esc(m.auth.map((a) => a.split(":")[0]).join(", "))} (basic auth)` : `<span class="dim">public</span>`}</dd>` : ""}
1834
1907
  <dt>Created</dt><dd>${esc(ago(m.createdAt))} · updated ${esc(ago(m.updatedAt))}</dd>
@@ -1913,6 +1986,10 @@ function refreshUpdates(n) {
1913
1986
  );
1914
1987
  }
1915
1988
 
1989
+ /** "24.0.2 → 29.9.2 · how to get it", or "· newest" when it is (and the newest is known). */
1990
+ const engineLine = (have, latest, old, how) =>
1991
+ `<span class="mono">${esc(have)}</span>${old ? ` <span class="dim">→</span> <span class="mono">${esc(latest)}</span>${how ? ` <span class="faint">· ${esc(how)}</span>` : ""}` : latest ? ` <span class="faint">· newest</span>` : ""}`;
1992
+
1916
1993
  function updatesView(n) {
1917
1994
  const st = S.updates[n] ?? { loading: true };
1918
1995
  if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> ${st.refreshing ? "refreshing package lists…" : "checking packages and images…"}</div>`;
@@ -1974,9 +2051,12 @@ function updatesView(n) {
1974
2051
  ${pkgOk ? "" : `<p class="dim">${esc(S.facts[n]?.data?.why?.packages ?? "")}</p>`}
1975
2052
  </div>`}
1976
2053
  ${!e.docker && can(n) && !can(n).docker ? "" : `<div class="card">
1977
- <h2>Docker <span class="badge ${!e.docker || e.dockerNote || e.composeNote ? "warn" : "ok"}">${e.docker ? `${esc(e.docker)} · compose ${esc(e.compose || "missing")}` : "not installed"}</span>
2054
+ <h2>Docker <span class="badge ${!e.docker || e.dockerNote || e.composeNote ? "warn" : "ok"}">${!e.docker ? "not installed" : e.dockerNote || e.composeNote ? "update available" : "up to date"}</span>
1978
2055
  <span class="actions">${e.composeUpdate ? btn("Update compose", () => run(["update", "--compose", ...s, "-y"], { server: n }), "sm primary") : ""}</span></h2>
1979
- ${[e.dockerNote, e.composeNote].filter(Boolean).map((t) => `<p class="dim">${esc(t)}</p>`).join("")}
2056
+ ${e.docker ? `<div class="body"><dl class="kv">
2057
+ <dt>Docker</dt><dd>${engineLine(e.docker, e.latestDocker, e.dockerNote, e.dockerHow)}</dd>
2058
+ <dt>Compose</dt><dd>${e.compose ? engineLine(e.compose, e.latestCompose, e.composeNote, e.composeHow) : `<span class="badge warn">missing</span> <span class="faint">lf setup installs it</span>`}</dd>
2059
+ </dl></div>` : ""}
1980
2060
  </div>
1981
2061
  <div class="card">
1982
2062
  <h2>Images <span class="dim" style="font-weight:400">Traefik and sites</span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Set up servers and deploy sites behind Traefik with automatic HTTPS",
5
5
  "author": "ph1p <me@ph1p.dev>",
6
6
  "license": "MIT",