create-wordjs 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -2
  2. package/index.js +175 -44
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -29,6 +29,9 @@ That single command takes you from nothing to the browser install wizard:
29
29
  `wordjs-<tag>.zip.sha256` asset published with the release before extracting anything
30
30
  (see [Integrity](#integrity)).
31
31
  2. Extracts it into `my-site/` and installs the runtime dependencies (`npm run release:install`).
32
+ A failed install (typically a timed-out prebuilt `better-sqlite3` download on a server without a
33
+ compiler) is retried with backoff; if it keeps failing you get a plain explanation, and running
34
+ the same command again resumes the install.
32
35
  3. Generates a one-time install token and starts the server (`npm run start:mono`), printing a
33
36
  clickable URL:
34
37
 
@@ -42,7 +45,7 @@ in CI — plus a pure-JS *SQLite (legacy / WASM)* fallback for hosts where the n
42
45
 
43
46
  ## Requirements
44
47
 
45
- - Node.js **>= 20.9** (Node 20 or 22 LTS recommended) with npm on your PATH.
48
+ - Node.js **>= 22** (Node 22 or 24 LTS recommended) with npm on your PATH.
46
49
 
47
50
  ## Options
48
51
 
@@ -59,6 +62,7 @@ in CI — plus a pure-JS *SQLite (legacy / WASM)* fallback for hosts where the n
59
62
  | `--systemd` | (create, `upgrade`; Linux) Also write a systemd unit, `wordjs.service`, to a private staging directory outside the site and print how to install it. The unit runs WordJS as a dedicated non-root account with **no Linux capabilities**. With create it implies `--no-start`. See [Run it as a service](#run-it-as-a-service-linux). |
60
63
  | `--port <n>` | (with `--systemd`) Public port written into the unit (`PORT=<n>`); default: the site's configured port, else `3000`. Below 1024 it also stages `60-wordjs-ports.conf`. |
61
64
  | `--service-user <name>` | (with `--systemd`) The account the service runs as (default `wordjs`; `root` is refused). |
65
+ | `--mail` | (with `--systemd`) The site will receive mail (mail-server plugin, SMTP on 25): the drop-in also allows 25. Implied on `upgrade` when that plugin is installed. |
62
66
  | `-h`, `--help` | Show usage. |
63
67
 
64
68
  Separate-mode options:
@@ -105,7 +109,10 @@ boot must run as the service account so that account owns the database and certi
105
109
 
106
110
  **Ports below 1024 (80/443).** The unit never grants `CAP_NET_BIND_SERVICE`. For a port below 1024,
107
111
  `--systemd` also stages `60-wordjs-ports.conf`, a `sysctl.d` drop-in that sets
108
- `net.ipv4.ip_unprivileged_port_start` to that port. Read its trade-off before installing it: it lets
112
+ `net.ipv4.ip_unprivileged_port_start` low enough for every port the site will bind: that port, `80` too
113
+ for an HTTPS site (Let's Encrypt HTTP-01, turned on later in the admin panel) and `25` with `--mail`
114
+ (incoming mail; implied on `upgrade` when the mail-server plugin is installed). Read its trade-off before
115
+ installing it: it lets
109
116
  **every** unprivileged process on the host bind ports from that value up to 1023, not just WordJS. On a
110
117
  shared host, prefer a reverse proxy (nginx, Caddy) on 80/443 forwarding to WordJS on a high port.
111
118
 
package/index.js CHANGED
@@ -27,14 +27,14 @@
27
27
  const REPO = 'jaimemartinez/wordjs';
28
28
 
29
29
  // ---------------------------------------------------------------------------------------------
30
- // Node preflight — same floor as WordJS itself (Next 16 + native modules need >= 20.9). Failing
30
+ // Node preflight — same floor as WordJS itself (Node >= 22: graphql 17 and the native modules declare it). Failing
31
31
  // here with a clear message beats the cryptic EBADENGINE/native-binding crash mid-install.
32
32
  // ---------------------------------------------------------------------------------------------
33
33
  {
34
- const [maj, min] = process.versions.node.split('.').map(Number);
35
- if (maj < 20 || (maj === 20 && min < 9)) {
36
- console.error(`\n✖ WordJS requires Node.js >= 20.9 — you are running ${process.versions.node}.`);
37
- console.error(' Install Node 20 LTS or 22 LTS from https://nodejs.org and try again.\n');
34
+ const maj = Number(process.versions.node.split('.')[0]);
35
+ if (maj < 22) {
36
+ console.error(`\n✖ WordJS requires Node.js >= 22 — you are running ${process.versions.node}.`);
37
+ console.error(' Install Node 22 LTS or 24 LTS from https://nodejs.org and try again.\n');
38
38
  process.exit(1);
39
39
  }
40
40
  }
@@ -84,8 +84,13 @@ Options:
84
84
  the first boot must run as that account.
85
85
  --port <n> (with --systemd) Public port, written into the unit as PORT=<n> (default:
86
86
  the site's configured port, else 3000). Below 1024 it also stages
87
- 60-wordjs-ports.conf (net.ipv4.ip_unprivileged_port_start), never a capability.
87
+ 60-wordjs-ports.conf (net.ipv4.ip_unprivileged_port_start), never a capability,
88
+ low enough for every port the site will bind: with HTTPS also 80 (Let's
89
+ Encrypt HTTP-01 from Admin -> Security), with --mail also 25.
88
90
  --service-user <name> (with --systemd) The account the service runs as (default: wordjs). Not root.
91
+ --mail (with --systemd) The site will receive mail (the mail-server plugin, SMTP on
92
+ port 25): the drop-in then lowers the floor to 25. Implied on upgrade when the
93
+ mail-server plugin is installed.
89
94
  -h, --help Show this help.
90
95
 
91
96
  Examples:
@@ -126,7 +131,7 @@ function parseArgs(argv) {
126
131
  const opts = {
127
132
  mode: 'create', dir: null, zip: null, version: null, http: false, start: true, yes: false, force: false, install: true,
128
133
  role: null, gateway: null, token: null, caHash: null, advertise: null, enrollPort: null, host: null,
129
- systemd: false, port: null, serviceUser: null,
134
+ systemd: false, port: null, serviceUser: null, mail: false,
130
135
  sha256: null,
131
136
  };
132
137
  // A leading subcommand selects the mode (default is the monolith create flow).
@@ -159,11 +164,12 @@ function parseArgs(argv) {
159
164
  else if (a === '--systemd') opts.systemd = true;
160
165
  else if (a === '--port') opts.port = argv[++i] || fail('--port needs a value (1-65535).');
161
166
  else if (a === '--service-user') opts.serviceUser = argv[++i] || fail('--service-user needs an account name.');
167
+ else if (a === '--mail') opts.mail = true;
162
168
  else if (a.startsWith('-')) fail(`Unknown option: ${a}`, 'Run with --help to see the available options.');
163
169
  else positionals.push(a);
164
170
  }
165
- if ((opts.port !== null || opts.serviceUser !== null) && !opts.systemd) {
166
- fail('--port and --service-user only apply together with --systemd.',
171
+ if ((opts.port !== null || opts.serviceUser !== null || opts.mail) && !opts.systemd) {
172
+ fail('--port, --service-user and --mail only apply together with --systemd.',
167
173
  'Without a unit, the port comes from the PORT environment variable or the site config (default 3000).');
168
174
  }
169
175
  if (opts.systemd) {
@@ -581,17 +587,94 @@ async function obtainBundleZip(opts) {
581
587
  return { zipPath, tag, cleanup };
582
588
  }
583
589
 
584
- function runNpmScript(script, cwd, extraEnv) {
585
- // A single command string with shell:true resolves npm/npm.cmd on every platform (and avoids
586
- // Node's DEP0190 warning about args-array + shell). The string is fixed — no user input in it.
587
- const r = spawnSync(`npm run ${script}`, {
588
- cwd,
589
- stdio: 'inherit',
590
- shell: true,
591
- env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
590
+ // --- dependency install with retries -------------------------------------------------------------
591
+ //
592
+ // `release:install` downloads prebuilt native binaries (better-sqlite3 above all). When that download
593
+ // times out (`prebuild-install warn install Request timed out`), npm falls back to compiling from
594
+ // source, and a server without a C/C++ toolchain ends in a raw `gyp ERR!`. The same command run a
595
+ // minute later usually succeeds, so it is retried with backoff; if it still fails, the operator gets a
596
+ // plain explanation instead of the compiler log.
597
+
598
+ const INSTALL_ATTEMPTS = 3;
599
+ // Waits before the 2nd and 3rd attempts. CREATE_WORDJS_RETRY_BASE_MS scales them (tests use a few ms).
600
+ const INSTALL_RETRY_BASE_MS = /^\d+$/.test(process.env.CREATE_WORDJS_RETRY_BASE_MS || '') ? Number(process.env.CREATE_WORDJS_RETRY_BASE_MS) : 15000;
601
+ // Left in a site whose dependency install did not finish; `create-wordjs <dir>` resumes from it.
602
+ const INCOMPLETE_MARKER = '.create-wordjs-incomplete';
603
+
604
+ /** What a failed install's output says about why it failed. */
605
+ function classifyInstallFailure(output) {
606
+ const text = String(output || '');
607
+ const prebuiltTimeout = /prebuild-install (warn|WARN|ERR!).*(timed out|ETIMEDOUT|ECONNRESET|ESOCKETTIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up)/i.test(text);
608
+ const nativeBuild = /gyp ERR!|node-gyp|not found: (make|python)|No (C|C\+\+) compiler|g\+\+: (command )?not found|Could not find any Python/i.test(text);
609
+ const network = prebuiltTimeout || /\b(ETIMEDOUT|ECONNRESET|ESOCKETTIMEDOUT|EAI_AGAIN|ENOTFOUND|ECONNREFUSED)\b|network (timeout|request)|Request timed out/i.test(text);
610
+ return { prebuiltTimeout, nativeBuild, network };
611
+ }
612
+
613
+ /** The operator-facing explanation of an install that failed every attempt. */
614
+ function installFailureAdvice(cwd, why, attempts) {
615
+ const lines = [`The runtime dependencies could not be installed (${attempts} attempt${attempts === 1 ? '' : 's'}).`];
616
+ if (why.prebuiltTimeout || why.nativeBuild) {
617
+ lines.push(
618
+ why.prebuiltTimeout
619
+ ? 'Downloading a prebuilt native module (better-sqlite3) timed out, and npm then tried to compile it'
620
+ : 'A native module (better-sqlite3) had to be compiled from source,',
621
+ why.prebuiltTimeout
622
+ ? 'from source, which needs a C/C++ toolchain and Python that this machine does not have.'
623
+ : 'which needs a C/C++ toolchain and Python that this machine does not have.',
624
+ );
625
+ } else if (why.network) {
626
+ lines.push('The package downloads kept failing on the network (timeouts or refused connections).');
627
+ }
628
+ lines.push(
629
+ '',
630
+ 'Nothing is lost — the site files are in place. Either:',
631
+ ` • retry later (the download usually works on a second try): cd ${cwd} && npm run release:install`,
632
+ ' (or run the same create-wordjs command again: it resumes this install), or',
633
+ ' • install a build toolchain so the module can be compiled locally, then retry:',
634
+ ' Debian/Ubuntu: sudo apt-get install -y build-essential python3',
635
+ ' Fedora/RHEL: sudo dnf install -y gcc-c++ make python3',
636
+ ' Alpine: sudo apk add build-base python3',
637
+ );
638
+ return lines.join('\n');
639
+ }
640
+
641
+ /**
642
+ * `npm run release:install` with up to INSTALL_ATTEMPTS attempts and growing waits between them. The
643
+ * output still streams to the terminal; its tail is kept to explain a failure. Returns
644
+ * { ok, attempts, why } — never exits, so the caller can still write what the next attempt needs.
645
+ */
646
+ async function installDependencies(cwd, { attempts = INSTALL_ATTEMPTS, baseDelayMs = INSTALL_RETRY_BASE_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
647
+ let why = { prebuiltTimeout: false, nativeBuild: false, network: false };
648
+ for (let attempt = 1; attempt <= attempts; attempt++) {
649
+ if (attempt > 1) {
650
+ const wait = baseDelayMs * Math.pow(3, attempt - 2); // 15 s, then 45 s
651
+ console.log(`\n ↻ Retrying the dependency install in ${Math.round(wait / 1000)} s (attempt ${attempt} of ${attempts})…\n`);
652
+ await sleep(wait);
653
+ }
654
+ const r = await runReleaseInstallOnce(cwd);
655
+ if (r.ok) return { ok: true, attempts: attempt, why };
656
+ why = classifyInstallFailure(r.output);
657
+ const reason = why.prebuiltTimeout ? 'the prebuilt binary download timed out'
658
+ : why.nativeBuild ? 'a native module could not be compiled'
659
+ : why.network ? 'a network error' : (r.error ? r.error : `exit code ${r.status}`);
660
+ console.error(`\n ⚠️ Dependency install attempt ${attempt} of ${attempts} failed: ${reason}.`);
661
+ if (r.status === 127 || (r.error && /ENOENT/.test(r.error))) return { ok: false, attempts: attempt, why, fatal: 'Is npm on your PATH?' };
662
+ }
663
+ return { ok: false, attempts, why };
664
+ }
665
+
666
+ function runReleaseInstallOnce(cwd) {
667
+ return new Promise((resolve) => {
668
+ let tail = '';
669
+ const keep = (b) => { tail = (tail + b.toString()).slice(-256 * 1024); };
670
+ // A single fixed command string with shell:true resolves npm/npm.cmd on every platform (and
671
+ // avoids Node's DEP0190 warning about args-array + shell). No user input goes into it.
672
+ const child = spawn('npm run release:install', { cwd, shell: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
673
+ child.stdout.on('data', (b) => { process.stdout.write(b); keep(b); });
674
+ child.stderr.on('data', (b) => { process.stderr.write(b); keep(b); });
675
+ child.on('error', (e) => resolve({ ok: false, status: null, error: String(e && e.message || e), output: tail }));
676
+ child.on('close', (code) => resolve({ ok: code === 0, status: code, output: tail }));
592
677
  });
593
- if (r.error) fail(`Could not run "npm run ${script}": ${r.error.message}`, 'Is npm on your PATH?');
594
- if (r.status !== 0) fail(`"npm run ${script}" exited with code ${r.status}.`, `Fix the error above, then re-run it manually inside ${cwd}.`);
595
678
  }
596
679
 
597
680
  // First non-internal IPv4 — a sensible default advertise/host when the user doesn't pass one.
@@ -712,9 +795,10 @@ function underAny(p, roots) {
712
795
  * written into the file itself: whoever later finds it in /etc/sysctl.d/ should not have to guess why
713
796
  * it is there or what it costs.
714
797
  */
715
- function sysctlDropIn(start, { http = false } = {}) {
798
+ function sysctlDropIn(start, { http = false, mail = false, ports = [] } = {}) {
716
799
  const lines = [
717
800
  `# WordJS: allow unprivileged processes to bind ports from ${start} up (written by create-wordjs --systemd).`,
801
+ ...ports.map((p) => `# ${p.port}: ${p.why}`),
718
802
  '#',
719
803
  '# This is NOT a grant to WordJS alone. It lowers the privileged-port floor for EVERY unprivileged',
720
804
  `# process in this network namespace, so any local user or service may bind ports ${start}-1023 too, and`,
@@ -725,6 +809,9 @@ function sysctlDropIn(start, { http = false } = {}) {
725
809
  if (!http && start > 80) {
726
810
  lines.push(`# Let's Encrypt HTTP-01 (acme.http01Port: 80 in wordjs-config.json) needs this lowered to 80.`);
727
811
  }
812
+ if (!mail && start > 25) {
813
+ lines.push('# Receiving mail (the mail-server plugin, SMTP on port 25) needs this lowered to 25: re-run with --mail.');
814
+ }
728
815
  lines.push(
729
816
  '#',
730
817
  `# Install: sudo install -o root -g root -m 0644 ${SYSCTL_DROPIN_FILE} /etc/sysctl.d/ && sudo sysctl --system`,
@@ -744,8 +831,14 @@ function sysctlDropIn(start, { http = false } = {}) {
744
831
  * configuredPort the port the site's config already sets (gatewayPort), or null.
745
832
  * acmeHttp01Port acme.http01Port from the config, or null: monolith.js binds it beside an HTTPS
746
833
  * listener, so it counts toward the privileged-port floor.
834
+ * mail the site receives mail: the mail-server plugin listens for SMTP on 25.
835
+ *
836
+ * The floor goes as low as every port the site will bind, not only the ones configured today: the
837
+ * administrator turns on Let's Encrypt (HTTP-01 on 80) and installs the mail server from the panel,
838
+ * and neither may then need a file edited as root. An HTTPS site already on a port below 1024 counts
839
+ * 80 for that reason; a site on a high port gets no drop-in (it sits behind a proxy, or wants none).
747
840
  */
748
- function buildSystemdFiles({ installDir, nodePath, user = DEFAULT_SERVICE_USER, port = null, configuredPort = null, acmeHttp01Port = null, http = false }) {
841
+ function buildSystemdFiles({ installDir, nodePath, user = DEFAULT_SERVICE_USER, port = null, configuredPort = null, acmeHttp01Port = null, http = false, mail = false }) {
749
842
  for (const [what, p] of [['site directory', installDir], ['node binary', nodePath]]) {
750
843
  if (!unitSafePath(p)) {
751
844
  const err = new Error(`The ${what} path "${p}" cannot go into a systemd unit as is: it must be absolute, without spaces, quotes, backslashes, "%" or "$".`);
@@ -759,10 +852,14 @@ function buildSystemdFiles({ installDir, nodePath, user = DEFAULT_SERVICE_USER,
759
852
  if (problem) { const err = new Error(problem.message); err.hint = problem.hint; throw err; }
760
853
 
761
854
  const publicPort = port || configuredPort || 3000;
762
- const bound = [publicPort];
763
- if (!http && acmeHttp01Port) bound.push(acmeHttp01Port);
764
- const privileged = bound.filter((p) => p < PRIVILEGED_PORT_LIMIT);
765
- const sysctlStart = privileged.length ? Math.min(...privileged) : null;
855
+ const bound = [{ port: publicPort, why: 'the site (its public port)' }];
856
+ if (!http && acmeHttp01Port) bound.push({ port: acmeHttp01Port, why: "Let's Encrypt HTTP-01 (acme.http01Port)" });
857
+ if (!http && publicPort < PRIVILEGED_PORT_LIMIT && !bound.some((b) => b.port === 80)) {
858
+ bound.push({ port: 80, why: "Let's Encrypt HTTP-01, when it is turned on in Admin -> Security" });
859
+ }
860
+ if (mail) bound.push({ port: 25, why: 'incoming mail (the mail-server plugin, SMTP)' });
861
+ const privileged = bound.filter((b) => b.port < PRIVILEGED_PORT_LIMIT).sort((a, b) => a.port - b.port);
862
+ const sysctlStart = privileged.length ? privileged[0].port : null;
766
863
 
767
864
  // Two hardening directives depend on WHERE things are, and each is dropped (with a warning) rather
768
865
  // than shipped in a shape that cannot start: ProtectHome= hides /home, /root and /run/user, which
@@ -838,7 +935,9 @@ function buildSystemdFiles({ installDir, nodePath, user = DEFAULT_SERVICE_USER,
838
935
 
839
936
  return {
840
937
  unit,
841
- sysctl: sysctlStart ? { start: sysctlStart, content: sysctlDropIn(sysctlStart, { http }) } : null,
938
+ sysctl: sysctlStart
939
+ ? { start: sysctlStart, ports: privileged.map((b) => b.port), content: sysctlDropIn(sysctlStart, { http, mail, ports: privileged }) }
940
+ : null,
842
941
  publicPort,
843
942
  warnings,
844
943
  };
@@ -860,7 +959,7 @@ function systemdInstallSteps({ installDir, user = DEFAULT_SERVICE_USER, built, h
860
959
  // `install -o root -g root -m 0644` and not `cp`: root takes the staged file's CONTENT into a file it
861
960
  // creates itself, root-owned and not writable by anyone else, whatever the staged copy's mode was.
862
961
  if (built.sysctl) {
863
- step(`Let it bind port ${built.publicPort} without a capability (read the trade-off in the file first):`,
962
+ step(`Let it bind ${built.sysctl.ports.length > 1 ? `ports ${built.sysctl.ports.join(', ')}` : `port ${built.sysctl.start}`} without a capability (read the trade-off in the file first):`,
864
963
  `sudo install -o root -g root -m 0644 ${staged(SYSCTL_DROPIN_FILE)} /etc/sysctl.d/${SYSCTL_DROPIN_FILE} && sudo sysctl --system`);
865
964
  } else if (upgrade) {
866
965
  // A site that moved to a high port (behind a proxy, say) no longer needs the floor lowered, and an
@@ -969,12 +1068,14 @@ function emitSystemd(installDir, opts) {
969
1068
  configuredPort: Number(appConfig.gatewayPort || gwConfig.gatewayPort) || null,
970
1069
  acmeHttp01Port: Number(appConfig.acme && appConfig.acme.http01Port) || null,
971
1070
  http: opts.http,
1071
+ // An installed mail server (an upgrade) receives on 25 whether or not --mail was given.
1072
+ mail: !!opts.mail || fs.existsSync(path.join(installDir, 'backend', 'plugins', 'mail-server')),
972
1073
  });
973
1074
  const stageDir = createSystemdStagingDir();
974
1075
  const written = writeSystemdFiles(stageDir, built);
975
1076
  console.log('');
976
1077
  console.log(` systemd unit staged: ${written.unitPath}`);
977
- if (written.sysctlPath) console.log(` sysctl drop-in for port ${built.publicPort}: ${written.sysctlPath}`);
1078
+ if (written.sysctlPath) console.log(` sysctl drop-in for ports from ${built.sysctl.start} up (${built.sysctl.ports.join(', ')}): ${written.sysctlPath}`);
978
1079
  console.log(' (staged outside the site on purpose: the service account will own the site, and must not be able to edit what root installs)');
979
1080
  for (const w of built.warnings) console.log(` ⚠️ ${w}`);
980
1081
  console.log('');
@@ -1178,7 +1279,13 @@ async function upgrade(opts) {
1178
1279
  // Re-sync dependencies (a new version may add/upgrade packages). Skippable for a code-only swap.
1179
1280
  if (opts.install) {
1180
1281
  console.log('\n📦 Syncing runtime dependencies (npm run release:install)…\n');
1181
- runNpmScript('release:install', installDir);
1282
+ const installed = await installDependencies(installDir);
1283
+ if (!installed.ok) {
1284
+ restoreInstallOwner(installDir, siteOwner);
1285
+ // The new code is in place and the unit matches it: stage it for when the install is re-run.
1286
+ if (opts.systemd) { try { emitSystemd(installDir, opts); } catch { /* the advice below still applies */ } }
1287
+ fail(installFailureAdvice(opts.dir === '.' ? installDir : opts.dir, installed.why, installed.attempts), installed.fatal);
1288
+ }
1182
1289
  } else {
1183
1290
  console.log('\n --no-install: skipped dependency sync. Run "npm run release:install" yourself if deps changed.');
1184
1291
  }
@@ -1229,7 +1336,8 @@ async function scaffoldBundle(opts, targetDir) {
1229
1336
  }
1230
1337
 
1231
1338
  console.log('\n📦 Installing runtime dependencies (this downloads prebuilt binaries — a few minutes)…\n');
1232
- runNpmScript('release:install', targetDir);
1339
+ const installed = await installDependencies(targetDir);
1340
+ if (!installed.ok) fail(installFailureAdvice(opts.dir, installed.why, installed.attempts), installed.fatal);
1233
1341
  }
1234
1342
 
1235
1343
  // `create-wordjs gateway` — set this machine up as the cluster gateway: install, mint the cluster CA +
@@ -1347,28 +1455,37 @@ async function main() {
1347
1455
  if (opts.mode === 'join') return join(opts);
1348
1456
  const targetDir = path.resolve(process.cwd(), opts.dir);
1349
1457
 
1458
+ // A site whose dependency install failed earlier is RESUMED: its release files are already extracted
1459
+ // (and were verified then), so only the install and what follows it run again. The marker is only
1460
+ // ever written by this installer, into a directory it extracted a bundle into.
1461
+ const resuming = fs.existsSync(path.join(targetDir, INCOMPLETE_MARKER));
1462
+
1350
1463
  // Refuse to scribble over anything that already exists (an existing EMPTY dir is fine).
1351
- if (fs.existsSync(targetDir)) {
1464
+ if (!resuming && fs.existsSync(targetDir)) {
1352
1465
  if (!fs.statSync(targetDir).isDirectory()) fail(`"${opts.dir}" already exists and is not a directory.`);
1353
1466
  if (fs.readdirSync(targetDir).length > 0) {
1354
1467
  fail(`Directory "${opts.dir}" already exists and is not empty.`, 'Pick a new directory name, or empty it first.');
1355
1468
  }
1356
- } else {
1469
+ } else if (!resuming) {
1357
1470
  fs.mkdirSync(targetDir, { recursive: true });
1358
1471
  }
1359
1472
 
1360
1473
  console.log('\n🚀 create-wordjs\n');
1361
1474
 
1362
- // 1) Obtain the release ZIP (local path, https URL, or GitHub latest/tagged release) and verify
1363
- // its SHA-256 before anything is extracted.
1364
- const { zipPath, cleanup } = await obtainBundleZip(opts);
1475
+ if (resuming) {
1476
+ console.log(` Resuming the unfinished install in ${targetDir} (the release files are already in place).`);
1477
+ } else {
1478
+ // 1) Obtain the release ZIP (local path, https URL, or GitHub latest/tagged release) and verify
1479
+ // its SHA-256 before anything is extracted.
1480
+ const { zipPath, cleanup } = await obtainBundleZip(opts);
1365
1481
 
1366
- // 2) Extract + sanity-check that this really is a WordJS release bundle.
1367
- console.log(` Extracting into ${targetDir}…`);
1368
- try {
1369
- extractZip(zipPath, targetDir);
1370
- } finally {
1371
- cleanup();
1482
+ // 2) Extract + sanity-check that this really is a WordJS release bundle.
1483
+ console.log(` Extracting into ${targetDir}…`);
1484
+ try {
1485
+ extractZip(zipPath, targetDir);
1486
+ } finally {
1487
+ cleanup();
1488
+ }
1372
1489
  }
1373
1490
  const pkgPath = path.join(targetDir, 'package.json');
1374
1491
  let pkg = {};
@@ -1378,13 +1495,26 @@ async function main() {
1378
1495
  `Expected a wordjs-*.zip from https://github.com/${REPO}/releases.`);
1379
1496
  }
1380
1497
 
1381
- // 3) Install runtime dependencies (pre-compiled bundle — no build step).
1498
+ // 3) Install runtime dependencies (pre-compiled bundle — no build step), retried with backoff. The
1499
+ // marker stays until it succeeds, so running this same command again resumes from here.
1500
+ fs.writeFileSync(path.join(targetDir, INCOMPLETE_MARKER), 'The dependency install of this site has not finished. Run "npm run release:install" here, or the same create-wordjs command again.\n');
1382
1501
  console.log('\n📦 Installing runtime dependencies (this downloads prebuilt binaries — a few minutes)…\n');
1383
- runNpmScript('release:install', targetDir);
1502
+ const installed = await installDependencies(targetDir);
1384
1503
 
1385
- // 4) Default to self-signed HTTPS unless the user explicitly asked for HTTP.
1504
+ // 4) Default to self-signed HTTPS unless the user explicitly asked for HTTP. Written whether or not
1505
+ // the install worked: the config belongs to the site, not to its node_modules.
1386
1506
  if (!opts.http) ensureHttpsConfig(targetDir);
1387
1507
 
1508
+ if (!installed.ok) {
1509
+ // Leave everything the next attempt needs: the files, the config and (with --systemd) the unit,
1510
+ // none of which depends on node_modules. Only the install has to run again.
1511
+ if (opts.systemd) {
1512
+ try { emitSystemd(targetDir, opts); } catch (e) { console.error(` (systemd unit not staged: ${e && e.message || e})`); }
1513
+ }
1514
+ fail(installFailureAdvice(opts.dir, installed.why, installed.attempts), installed.fatal);
1515
+ }
1516
+ fs.rmSync(path.join(targetDir, INCOMPLETE_MARKER), { force: true });
1517
+
1388
1518
  const proto = opts.http ? 'http' : 'https';
1389
1519
  const line = '━'.repeat(64);
1390
1520
 
@@ -1471,4 +1601,5 @@ module.exports = {
1471
1601
  copyMerge, PRESERVE_ON_UPGRADE,
1472
1602
  sysctlDropIn, serviceUserProblem, systemdPreflight, createSystemdStagingDir, LOW_PORT_ADVICE, HELP, installedFileMode,
1473
1603
  installDirOwner, restoreInstallOwner,
1604
+ installDependencies, classifyInstallFailure, installFailureAdvice, INCOMPLETE_MARKER,
1474
1605
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-wordjs",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "Create a WordJS site with one command — the self-hosted CMS where third-party plugins run in an OS-isolated process with per-capability permission grants. SSR/SEO out of the box, SQLite by default, no PHP.",
5
5
  "license": "MIT",
6
6
  "author": "Jaime Martinez (https://github.com/jaimemartinez)",
@@ -14,7 +14,7 @@
14
14
  "LICENSE"
15
15
  ],
16
16
  "engines": {
17
- "node": ">=20.9.0"
17
+ "node": ">=22.0.0"
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",