landfall 0.6.3 → 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/README.md +4 -2
- package/dist/cli.mjs +215 -167
- package/dist/index.html +96 -16
- package/package.json +1 -1
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
|
-
|
|
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
|
-
...(
|
|
1168
|
+
...(modal
|
|
1164
1169
|
? [
|
|
1165
|
-
|
|
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
|
-
|
|
1168
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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 ?
|
|
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
|
-
${
|
|
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>
|