create-wordjs 2.7.0 → 2.8.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 +4 -1
  2. package/index.js +138 -31
  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
 
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
  }
@@ -581,17 +581,94 @@ async function obtainBundleZip(opts) {
581
581
  return { zipPath, tag, cleanup };
582
582
  }
583
583
 
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,
584
+ // --- dependency install with retries -------------------------------------------------------------
585
+ //
586
+ // `release:install` downloads prebuilt native binaries (better-sqlite3 above all). When that download
587
+ // times out (`prebuild-install warn install Request timed out`), npm falls back to compiling from
588
+ // source, and a server without a C/C++ toolchain ends in a raw `gyp ERR!`. The same command run a
589
+ // minute later usually succeeds, so it is retried with backoff; if it still fails, the operator gets a
590
+ // plain explanation instead of the compiler log.
591
+
592
+ const INSTALL_ATTEMPTS = 3;
593
+ // Waits before the 2nd and 3rd attempts. CREATE_WORDJS_RETRY_BASE_MS scales them (tests use a few ms).
594
+ const INSTALL_RETRY_BASE_MS = /^\d+$/.test(process.env.CREATE_WORDJS_RETRY_BASE_MS || '') ? Number(process.env.CREATE_WORDJS_RETRY_BASE_MS) : 15000;
595
+ // Left in a site whose dependency install did not finish; `create-wordjs <dir>` resumes from it.
596
+ const INCOMPLETE_MARKER = '.create-wordjs-incomplete';
597
+
598
+ /** What a failed install's output says about why it failed. */
599
+ function classifyInstallFailure(output) {
600
+ const text = String(output || '');
601
+ const prebuiltTimeout = /prebuild-install (warn|WARN|ERR!).*(timed out|ETIMEDOUT|ECONNRESET|ESOCKETTIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up)/i.test(text);
602
+ 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);
603
+ const network = prebuiltTimeout || /\b(ETIMEDOUT|ECONNRESET|ESOCKETTIMEDOUT|EAI_AGAIN|ENOTFOUND|ECONNREFUSED)\b|network (timeout|request)|Request timed out/i.test(text);
604
+ return { prebuiltTimeout, nativeBuild, network };
605
+ }
606
+
607
+ /** The operator-facing explanation of an install that failed every attempt. */
608
+ function installFailureAdvice(cwd, why, attempts) {
609
+ const lines = [`The runtime dependencies could not be installed (${attempts} attempt${attempts === 1 ? '' : 's'}).`];
610
+ if (why.prebuiltTimeout || why.nativeBuild) {
611
+ lines.push(
612
+ why.prebuiltTimeout
613
+ ? 'Downloading a prebuilt native module (better-sqlite3) timed out, and npm then tried to compile it'
614
+ : 'A native module (better-sqlite3) had to be compiled from source,',
615
+ why.prebuiltTimeout
616
+ ? 'from source, which needs a C/C++ toolchain and Python that this machine does not have.'
617
+ : 'which needs a C/C++ toolchain and Python that this machine does not have.',
618
+ );
619
+ } else if (why.network) {
620
+ lines.push('The package downloads kept failing on the network (timeouts or refused connections).');
621
+ }
622
+ lines.push(
623
+ '',
624
+ 'Nothing is lost — the site files are in place. Either:',
625
+ ` • retry later (the download usually works on a second try): cd ${cwd} && npm run release:install`,
626
+ ' (or run the same create-wordjs command again: it resumes this install), or',
627
+ ' • install a build toolchain so the module can be compiled locally, then retry:',
628
+ ' Debian/Ubuntu: sudo apt-get install -y build-essential python3',
629
+ ' Fedora/RHEL: sudo dnf install -y gcc-c++ make python3',
630
+ ' Alpine: sudo apk add build-base python3',
631
+ );
632
+ return lines.join('\n');
633
+ }
634
+
635
+ /**
636
+ * `npm run release:install` with up to INSTALL_ATTEMPTS attempts and growing waits between them. The
637
+ * output still streams to the terminal; its tail is kept to explain a failure. Returns
638
+ * { ok, attempts, why } — never exits, so the caller can still write what the next attempt needs.
639
+ */
640
+ async function installDependencies(cwd, { attempts = INSTALL_ATTEMPTS, baseDelayMs = INSTALL_RETRY_BASE_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
641
+ let why = { prebuiltTimeout: false, nativeBuild: false, network: false };
642
+ for (let attempt = 1; attempt <= attempts; attempt++) {
643
+ if (attempt > 1) {
644
+ const wait = baseDelayMs * Math.pow(3, attempt - 2); // 15 s, then 45 s
645
+ console.log(`\n ↻ Retrying the dependency install in ${Math.round(wait / 1000)} s (attempt ${attempt} of ${attempts})…\n`);
646
+ await sleep(wait);
647
+ }
648
+ const r = await runReleaseInstallOnce(cwd);
649
+ if (r.ok) return { ok: true, attempts: attempt, why };
650
+ why = classifyInstallFailure(r.output);
651
+ const reason = why.prebuiltTimeout ? 'the prebuilt binary download timed out'
652
+ : why.nativeBuild ? 'a native module could not be compiled'
653
+ : why.network ? 'a network error' : (r.error ? r.error : `exit code ${r.status}`);
654
+ console.error(`\n ⚠️ Dependency install attempt ${attempt} of ${attempts} failed: ${reason}.`);
655
+ if (r.status === 127 || (r.error && /ENOENT/.test(r.error))) return { ok: false, attempts: attempt, why, fatal: 'Is npm on your PATH?' };
656
+ }
657
+ return { ok: false, attempts, why };
658
+ }
659
+
660
+ function runReleaseInstallOnce(cwd) {
661
+ return new Promise((resolve) => {
662
+ let tail = '';
663
+ const keep = (b) => { tail = (tail + b.toString()).slice(-256 * 1024); };
664
+ // A single fixed command string with shell:true resolves npm/npm.cmd on every platform (and
665
+ // avoids Node's DEP0190 warning about args-array + shell). No user input goes into it.
666
+ const child = spawn('npm run release:install', { cwd, shell: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
667
+ child.stdout.on('data', (b) => { process.stdout.write(b); keep(b); });
668
+ child.stderr.on('data', (b) => { process.stderr.write(b); keep(b); });
669
+ child.on('error', (e) => resolve({ ok: false, status: null, error: String(e && e.message || e), output: tail }));
670
+ child.on('close', (code) => resolve({ ok: code === 0, status: code, output: tail }));
592
671
  });
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
672
  }
596
673
 
597
674
  // First non-internal IPv4 — a sensible default advertise/host when the user doesn't pass one.
@@ -1178,7 +1255,13 @@ async function upgrade(opts) {
1178
1255
  // Re-sync dependencies (a new version may add/upgrade packages). Skippable for a code-only swap.
1179
1256
  if (opts.install) {
1180
1257
  console.log('\n📦 Syncing runtime dependencies (npm run release:install)…\n');
1181
- runNpmScript('release:install', installDir);
1258
+ const installed = await installDependencies(installDir);
1259
+ if (!installed.ok) {
1260
+ restoreInstallOwner(installDir, siteOwner);
1261
+ // The new code is in place and the unit matches it: stage it for when the install is re-run.
1262
+ if (opts.systemd) { try { emitSystemd(installDir, opts); } catch { /* the advice below still applies */ } }
1263
+ fail(installFailureAdvice(opts.dir === '.' ? installDir : opts.dir, installed.why, installed.attempts), installed.fatal);
1264
+ }
1182
1265
  } else {
1183
1266
  console.log('\n --no-install: skipped dependency sync. Run "npm run release:install" yourself if deps changed.');
1184
1267
  }
@@ -1229,7 +1312,8 @@ async function scaffoldBundle(opts, targetDir) {
1229
1312
  }
1230
1313
 
1231
1314
  console.log('\n📦 Installing runtime dependencies (this downloads prebuilt binaries — a few minutes)…\n');
1232
- runNpmScript('release:install', targetDir);
1315
+ const installed = await installDependencies(targetDir);
1316
+ if (!installed.ok) fail(installFailureAdvice(opts.dir, installed.why, installed.attempts), installed.fatal);
1233
1317
  }
1234
1318
 
1235
1319
  // `create-wordjs gateway` — set this machine up as the cluster gateway: install, mint the cluster CA +
@@ -1347,28 +1431,37 @@ async function main() {
1347
1431
  if (opts.mode === 'join') return join(opts);
1348
1432
  const targetDir = path.resolve(process.cwd(), opts.dir);
1349
1433
 
1434
+ // A site whose dependency install failed earlier is RESUMED: its release files are already extracted
1435
+ // (and were verified then), so only the install and what follows it run again. The marker is only
1436
+ // ever written by this installer, into a directory it extracted a bundle into.
1437
+ const resuming = fs.existsSync(path.join(targetDir, INCOMPLETE_MARKER));
1438
+
1350
1439
  // Refuse to scribble over anything that already exists (an existing EMPTY dir is fine).
1351
- if (fs.existsSync(targetDir)) {
1440
+ if (!resuming && fs.existsSync(targetDir)) {
1352
1441
  if (!fs.statSync(targetDir).isDirectory()) fail(`"${opts.dir}" already exists and is not a directory.`);
1353
1442
  if (fs.readdirSync(targetDir).length > 0) {
1354
1443
  fail(`Directory "${opts.dir}" already exists and is not empty.`, 'Pick a new directory name, or empty it first.');
1355
1444
  }
1356
- } else {
1445
+ } else if (!resuming) {
1357
1446
  fs.mkdirSync(targetDir, { recursive: true });
1358
1447
  }
1359
1448
 
1360
1449
  console.log('\n🚀 create-wordjs\n');
1361
1450
 
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);
1451
+ if (resuming) {
1452
+ console.log(` Resuming the unfinished install in ${targetDir} (the release files are already in place).`);
1453
+ } else {
1454
+ // 1) Obtain the release ZIP (local path, https URL, or GitHub latest/tagged release) and verify
1455
+ // its SHA-256 before anything is extracted.
1456
+ const { zipPath, cleanup } = await obtainBundleZip(opts);
1365
1457
 
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();
1458
+ // 2) Extract + sanity-check that this really is a WordJS release bundle.
1459
+ console.log(` Extracting into ${targetDir}…`);
1460
+ try {
1461
+ extractZip(zipPath, targetDir);
1462
+ } finally {
1463
+ cleanup();
1464
+ }
1372
1465
  }
1373
1466
  const pkgPath = path.join(targetDir, 'package.json');
1374
1467
  let pkg = {};
@@ -1378,13 +1471,26 @@ async function main() {
1378
1471
  `Expected a wordjs-*.zip from https://github.com/${REPO}/releases.`);
1379
1472
  }
1380
1473
 
1381
- // 3) Install runtime dependencies (pre-compiled bundle — no build step).
1474
+ // 3) Install runtime dependencies (pre-compiled bundle — no build step), retried with backoff. The
1475
+ // marker stays until it succeeds, so running this same command again resumes from here.
1476
+ 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
1477
  console.log('\n📦 Installing runtime dependencies (this downloads prebuilt binaries — a few minutes)…\n');
1383
- runNpmScript('release:install', targetDir);
1478
+ const installed = await installDependencies(targetDir);
1384
1479
 
1385
- // 4) Default to self-signed HTTPS unless the user explicitly asked for HTTP.
1480
+ // 4) Default to self-signed HTTPS unless the user explicitly asked for HTTP. Written whether or not
1481
+ // the install worked: the config belongs to the site, not to its node_modules.
1386
1482
  if (!opts.http) ensureHttpsConfig(targetDir);
1387
1483
 
1484
+ if (!installed.ok) {
1485
+ // Leave everything the next attempt needs: the files, the config and (with --systemd) the unit,
1486
+ // none of which depends on node_modules. Only the install has to run again.
1487
+ if (opts.systemd) {
1488
+ try { emitSystemd(targetDir, opts); } catch (e) { console.error(` (systemd unit not staged: ${e && e.message || e})`); }
1489
+ }
1490
+ fail(installFailureAdvice(opts.dir, installed.why, installed.attempts), installed.fatal);
1491
+ }
1492
+ fs.rmSync(path.join(targetDir, INCOMPLETE_MARKER), { force: true });
1493
+
1388
1494
  const proto = opts.http ? 'http' : 'https';
1389
1495
  const line = '━'.repeat(64);
1390
1496
 
@@ -1471,4 +1577,5 @@ module.exports = {
1471
1577
  copyMerge, PRESERVE_ON_UPGRADE,
1472
1578
  sysctlDropIn, serviceUserProblem, systemdPreflight, createSystemdStagingDir, LOW_PORT_ADVICE, HELP, installedFileMode,
1473
1579
  installDirOwner, restoreInstallOwner,
1580
+ installDependencies, classifyInstallFailure, installFailureAdvice, INCOMPLETE_MARKER,
1474
1581
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-wordjs",
3
- "version": "2.7.0",
3
+ "version": "2.8.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",