create-wordjs 1.5.4 → 1.6.1

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 (2) hide show
  1. package/index.js +195 -10
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -42,17 +42,25 @@ const HELP = `
42
42
  create-wordjs — bootstrap or upgrade a WordJS site with one command
43
43
 
44
44
  Usage:
45
- npx create-wordjs <dir> [options] Create a new site
45
+ npx create-wordjs <dir> [options] Create a new site (monolith — one machine)
46
46
  npx create-wordjs upgrade [dir] [options] Upgrade an existing site (dir defaults to .)
47
+ npx create-wordjs gateway [dir] [options] Set up a SEPARATE-MODE gateway (cluster CA + join tokens)
48
+ npx create-wordjs join <role> [dir] [opts] Join this machine to a gateway as backend|frontend
47
49
 
48
50
  Options:
49
51
  --zip <path-or-url> Use a local release ZIP (or a direct ZIP URL) instead of asking GitHub.
50
52
  --version <tag> Install/upgrade to a specific release tag (e.g. v1.0.0) instead of the latest.
51
53
  --http Serve plain HTTP instead of self-signed HTTPS (sets WORDJS_HTTP=1). (create)
52
- --no-start Scaffold + install dependencies only; don't start the server. (create)
54
+ --no-start Scaffold + install dependencies only; don't start the server.
53
55
  --yes, -y Skip the confirmation prompt (required when upgrading non-interactively).
54
56
  --force Re-apply even if already on the target version. (upgrade)
55
57
  --no-install Swap the code only; skip 'npm run release:install'. (upgrade)
58
+ --host <ip/dns> (gateway) The address other machines dial to reach this gateway.
59
+ --gateway <ip/dns> (join) The gateway's address.
60
+ --token <join-token> (join) A single-use token minted on the gateway (cluster token <role>).
61
+ --ca-hash <sha256> (join) Pin the cluster CA fingerprint the gateway prints (MITM guard).
62
+ --advertise <ip/dns> (join) This node's routable address the gateway will proxy to.
63
+ --enroll-port <port> (join) Gateway token-enrollment port (default 3101).
56
64
  -h, --help Show this help.
57
65
 
58
66
  Examples:
@@ -60,7 +68,15 @@ Examples:
60
68
  npx create-wordjs my-site --version v1.0.0
61
69
  npx create-wordjs upgrade # from inside your site directory
62
70
  npx create-wordjs upgrade ./my-site --yes
63
- npx create-wordjs upgrade --version v1.5.2
71
+
72
+ Separate mode (three machines) — run one command per machine:
73
+ # on the gateway machine (prints ready-to-paste join commands with fresh tokens):
74
+ npx create-wordjs gateway --host 10.0.0.1
75
+ # on the backend machine:
76
+ npx create-wordjs join backend --gateway 10.0.0.1 --token <t> --ca-hash <fp> --advertise 10.0.0.2
77
+ # on the frontend machine:
78
+ npx create-wordjs join frontend --gateway 10.0.0.1 --token <t> --ca-hash <fp> --advertise 10.0.0.3
79
+ (join needs 'openssl' on PATH. See documentation/separate-mode.md.)
64
80
 
65
81
  Upgrading preserves your database (backend/data), uploads (backend/uploads), config
66
82
  (wordjs-config.json + gateway secrets) and any user-installed plugins; it replaces the app code and
@@ -76,9 +92,13 @@ function fail(message, hint) {
76
92
  }
77
93
 
78
94
  function parseArgs(argv) {
79
- const opts = { mode: 'create', dir: null, zip: null, version: null, http: false, start: true, yes: false, force: false, install: true };
80
- // First positional "upgrade" selects the upgrade command (npx create-wordjs upgrade [dir]).
81
- if (argv[0] === 'upgrade') { opts.mode = 'upgrade'; argv = argv.slice(1); }
95
+ const opts = {
96
+ mode: 'create', dir: null, zip: null, version: null, http: false, start: true, yes: false, force: false, install: true,
97
+ role: null, gateway: null, token: null, caHash: null, advertise: null, enrollPort: null, host: null,
98
+ };
99
+ // A leading subcommand selects the mode (default is the monolith create flow).
100
+ if (['upgrade', 'gateway', 'join'].includes(argv[0])) { opts.mode = argv[0]; argv = argv.slice(1); }
101
+ const positionals = [];
82
102
  for (let i = 0; i < argv.length; i++) {
83
103
  const a = argv[i];
84
104
  if (a === '-h' || a === '--help') { console.log(HELP); process.exit(0); }
@@ -89,15 +109,28 @@ function parseArgs(argv) {
89
109
  else if (a === '--yes' || a === '-y') opts.yes = true;
90
110
  else if (a === '--force') opts.force = true;
91
111
  else if (a === '--no-install') opts.install = false;
112
+ else if (a === '--role') opts.role = argv[++i] || fail('--role needs a value (backend or frontend).');
113
+ else if (a === '--gateway') opts.gateway = argv[++i] || fail('--gateway needs the gateway host/ip.');
114
+ else if (a === '--token') opts.token = argv[++i] || fail('--token needs the join token.');
115
+ else if (a === '--ca-hash') opts.caHash = argv[++i] || fail('--ca-hash needs the CA fingerprint.');
116
+ else if (a === '--advertise') opts.advertise = argv[++i] || fail('--advertise needs this node\'s ip/dns.');
117
+ else if (a === '--enroll-port') opts.enrollPort = argv[++i] || fail('--enroll-port needs a port.');
118
+ else if (a === '--host') opts.host = argv[++i] || fail('--host needs the gateway ip/dns.');
92
119
  else if (a.startsWith('-')) fail(`Unknown option: ${a}`, 'Run with --help to see the available options.');
93
- else if (!opts.dir) opts.dir = a;
94
- else fail(`Unexpected extra argument: ${a}`);
120
+ else positionals.push(a);
95
121
  }
122
+ // Positionals: `join <role> [dir]` takes the role first; every other mode takes just [dir].
123
+ if (opts.mode === 'join' && !opts.role) opts.role = positionals.shift() || null;
124
+ opts.dir = positionals.shift() || null;
125
+ if (positionals.length) fail(`Unexpected extra argument: ${positionals[0]}`);
126
+
96
127
  if (!opts.dir) {
97
- if (opts.mode === 'upgrade') opts.dir = '.'; // upgrade defaults to the current directory
128
+ if (opts.mode === 'upgrade') opts.dir = '.'; // upgrade defaults to cwd
129
+ else if (opts.mode === 'gateway') opts.dir = 'wordjs-gateway';
130
+ else if (opts.mode === 'join') opts.dir = opts.role ? `wordjs-${opts.role}` : 'wordjs-node';
98
131
  else fail('Please specify a directory for your new site.', 'Example: npx create-wordjs my-site');
99
132
  }
100
- if (opts.version && /^\d/.test(opts.version)) opts.version = 'v' + opts.version; // accept "1.0.0" for "v1.0.0"
133
+ if (opts.version && /^\d/.test(opts.version)) opts.version = 'v' + opts.version; // accept "1.0.0" for "v1.0.0"
101
134
  return opts;
102
135
  }
103
136
 
@@ -228,6 +261,32 @@ function runNpmScript(script, cwd, extraEnv) {
228
261
  if (r.status !== 0) fail(`"npm run ${script}" exited with code ${r.status}.`, `Fix the error above, then re-run it manually inside ${cwd}.`);
229
262
  }
230
263
 
264
+ // First non-internal IPv4 — a sensible default advertise/host when the user doesn't pass one.
265
+ function firstLanIp() {
266
+ for (const ifaces of Object.values(os.networkInterfaces())) {
267
+ for (const i of ifaces || []) if (!i.internal && (i.family === 'IPv4' || i.family === 4)) return i.address;
268
+ }
269
+ return '127.0.0.1';
270
+ }
271
+
272
+ // Run a BUNDLED node script (scripts/cluster.js, scripts/node-join.js) with an ARGS ARRAY and no shell,
273
+ // so user-supplied values (IPs, tokens) can never be interpreted by a shell. Inherits stdio.
274
+ function runNode(scriptRel, args, cwd, extraEnv) {
275
+ const r = spawnSync(process.execPath, [scriptRel, ...args], {
276
+ cwd, stdio: 'inherit', env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
277
+ });
278
+ if (r.error) fail(`Could not run ${scriptRel}: ${r.error.message}`, `Is node on your PATH?`);
279
+ if (r.status !== 0) fail(`${scriptRel} exited with code ${r.status}.`, `Fix the error above, then re-run it inside ${cwd}.`);
280
+ }
281
+
282
+ // Same, but capture stdout (to read a minted token / CA fingerprint back).
283
+ function runNodeCapture(scriptRel, args, cwd) {
284
+ const r = spawnSync(process.execPath, [scriptRel, ...args], { cwd, encoding: 'utf8' });
285
+ if (r.error) fail(`Could not run ${scriptRel}: ${r.error.message}`);
286
+ if (r.status !== 0) { process.stderr.write((r.stdout || '') + (r.stderr || '')); fail(`${scriptRel} exited with code ${r.status}.`); }
287
+ return r.stdout || '';
288
+ }
289
+
231
290
  /**
232
291
  * A fresh release bundle ships WITHOUT gateway/gateway-config.json (secrets are never bundled), and
233
292
  * without it the monolith would fall back to plain HTTP. Seed a minimal { "ssl": true } so the
@@ -426,11 +485,137 @@ async function upgrade(opts) {
426
485
  }
427
486
  }
428
487
 
488
+ // --- separate mode (gateway + join) ------------------------------------------------------------
489
+
490
+ // Download + extract the release bundle into targetDir and install runtime deps. Shared by the
491
+ // gateway and join flows (a superset of the create flow's steps 1–3, minus the mono-specific bits).
492
+ async function scaffoldBundle(opts, targetDir) {
493
+ if (fs.existsSync(targetDir)) {
494
+ if (!fs.statSync(targetDir).isDirectory()) fail(`"${opts.dir}" already exists and is not a directory.`);
495
+ if (fs.readdirSync(targetDir).length > 0) fail(`Directory "${opts.dir}" already exists and is not empty.`, 'Pick a new directory name, or empty it first.');
496
+ } else {
497
+ fs.mkdirSync(targetDir, { recursive: true });
498
+ }
499
+
500
+ let tmpDir = null, zipPath = null;
501
+ if (opts.zip && !/^https?:\/\//i.test(opts.zip)) {
502
+ zipPath = path.resolve(process.cwd(), opts.zip);
503
+ if (!fs.existsSync(zipPath)) fail(`ZIP not found: ${zipPath}`);
504
+ console.log(` Using local bundle: ${zipPath}`);
505
+ } else {
506
+ let url = opts.zip, name = 'wordjs.zip';
507
+ if (!url) {
508
+ console.log(opts.version ? ` Looking up release ${opts.version} of ${REPO}…` : ` Looking up the latest release of ${REPO}…`);
509
+ const asset = await resolveReleaseAsset(opts.version);
510
+ url = asset.url; name = asset.name;
511
+ console.log(` Found ${asset.tag} → ${asset.name}`);
512
+ }
513
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
514
+ zipPath = path.join(tmpDir, name);
515
+ await download(url, zipPath, name);
516
+ }
517
+
518
+ console.log(` Extracting into ${targetDir}…`);
519
+ try { extractZip(zipPath, targetDir); }
520
+ finally { if (tmpDir) { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } } }
521
+
522
+ let pkg = {};
523
+ try { pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf8')); } catch { /* handled below */ }
524
+ if (!pkg.scripts || !pkg.scripts['release:install']) {
525
+ fail('The extracted ZIP does not look like a WordJS release bundle.', `Expected a wordjs-*.zip from https://github.com/${REPO}/releases.`);
526
+ }
527
+ if (!fs.existsSync(path.join(targetDir, 'scripts', 'cluster.js')) || !fs.existsSync(path.join(targetDir, 'scripts', 'node-join.js'))) {
528
+ fail('This release bundle predates separate mode (missing scripts/cluster.js).', 'Install v1.6.1 or later, e.g. add --version v1.6.1.');
529
+ }
530
+
531
+ console.log('\n📦 Installing runtime dependencies (this downloads prebuilt binaries — a few minutes)…\n');
532
+ runNpmScript('release:install', targetDir);
533
+ }
534
+
535
+ // `create-wordjs gateway` — set this machine up as the cluster gateway: install, mint the cluster CA +
536
+ // config, mint one join token per role, and print the ready-to-paste join commands for the other nodes.
537
+ async function gateway(opts) {
538
+ const targetDir = path.resolve(process.cwd(), opts.dir);
539
+ console.log('\n🚀 create-wordjs · gateway (separate mode)\n');
540
+ await scaffoldBundle(opts, targetDir);
541
+
542
+ const host = opts.host || firstLanIp();
543
+ const line = '━'.repeat(64);
544
+ console.log(`\n🔐 Initializing cluster gateway on ${host}…`);
545
+ runNode('scripts/cluster.js', ['init', '--host', host], targetDir);
546
+
547
+ // Read the CA fingerprint and mint a token per role (capturing the raw token for the join command).
548
+ const fp = (runNodeCapture('scripts/cluster.js', ['info'], targetDir).match(/CA fingerprint:\s*([0-9a-f]{64})/) || [])[1] || '<fingerprint>';
549
+ const mint = (role) => (runNodeCapture('scripts/cluster.js', ['token', role, '--ttl', '120'], targetDir)
550
+ .match(new RegExp(`wjc\\.${role}\\.[A-Za-z0-9_-]+`)) || [])[0] || '<token>';
551
+ const beTok = mint('backend'), feTok = mint('frontend');
552
+
553
+ console.log(`\n${line}`);
554
+ console.log('✅ Gateway ready. Public origin: ' + `https://${host}:3000`);
555
+ console.log('');
556
+ console.log(' Run ONE of these on each other machine (they auto-download + enroll + start):');
557
+ console.log('');
558
+ console.log(' # backend machine:');
559
+ console.log(` npx create-wordjs join backend --gateway ${host} --token ${beTok} \\`);
560
+ console.log(` --ca-hash ${fp} --advertise <this-backend-ip>`);
561
+ console.log('');
562
+ console.log(' # frontend machine:');
563
+ console.log(` npx create-wordjs join frontend --gateway ${host} --token ${feTok} \\`);
564
+ console.log(` --ca-hash ${fp} --advertise <this-frontend-ip>`);
565
+ console.log('');
566
+ console.log(' Tokens are single-use and expire in 120 min. Mint more anytime:');
567
+ console.log(` cd ${opts.dir} && node scripts/cluster.js token <backend|frontend>`);
568
+ console.log(line + '\n');
569
+
570
+ if (!opts.start) {
571
+ console.log(` Start the gateway when ready: cd ${opts.dir} && npm run prod:gateway\n`);
572
+ return;
573
+ }
574
+ console.log(' Starting the gateway below (Ctrl+C to stop) — the join commands above work once it is up.\n');
575
+ const child = spawn('npm run prod:gateway', { cwd: targetDir, stdio: 'inherit', shell: true, env: process.env });
576
+ child.on('error', (e) => fail(`Could not start the gateway: ${e.message}`, `Run it manually: cd ${opts.dir} && npm run prod:gateway`));
577
+ child.on('exit', (code) => process.exit(code || 0));
578
+ }
579
+
580
+ // `create-wordjs join <backend|frontend>` — install the bundle, enroll with the gateway using the
581
+ // single-use token (delegates to scripts/node-join.js), then start + register the service.
582
+ async function join(opts) {
583
+ if (!['backend', 'frontend'].includes(opts.role)) {
584
+ fail('join needs a role: backend or frontend.', 'Example: npx create-wordjs join backend --gateway <ip> --token <t>');
585
+ }
586
+ if (!opts.gateway) fail('--gateway <gateway-ip/dns> is required for join.');
587
+ if (!opts.token) fail('--token <join-token> is required for join.', `Mint one on the gateway: node scripts/cluster.js token ${opts.role}`);
588
+ if (!opts.caHash) console.warn(' ⚠️ No --ca-hash given — skipping the MITM fingerprint check (fine on a trusted network).');
589
+
590
+ const targetDir = path.resolve(process.cwd(), opts.dir);
591
+ console.log(`\n🚀 create-wordjs · join ${opts.role} (separate mode)\n`);
592
+ await scaffoldBundle(opts, targetDir);
593
+
594
+ const advertise = opts.advertise || firstLanIp();
595
+ const args = ['--role', opts.role, '--gateway', opts.gateway, '--enroll-port', String(opts.enrollPort || 3101),
596
+ '--token', opts.token, '--advertise', advertise];
597
+ if (opts.caHash) args.push('--ca-hash', opts.caHash);
598
+ if (opts.start) args.push('--start');
599
+
600
+ console.log(`\n🎟️ Enrolling ${opts.role} with gateway ${opts.gateway} (advertise ${advertise})…\n`);
601
+ runNode('scripts/node-join.js', args, targetDir);
602
+
603
+ const line = '━'.repeat(64);
604
+ console.log(`\n${line}`);
605
+ console.log(`✅ ${opts.role} enrolled${opts.start ? ' and started (registered with the gateway over mTLS)' : ''}.`);
606
+ console.log(opts.start
607
+ ? ` Logs: ${path.join(opts.dir, opts.role, 'cluster-start.log')}`
608
+ : ` Start it: cd ${opts.dir} && npm start`);
609
+ console.log(line + '\n');
610
+ }
611
+
429
612
  // --- main ---------------------------------------------------------------------------------------
430
613
 
431
614
  async function main() {
432
615
  const opts = parseArgs(process.argv.slice(2));
433
616
  if (opts.mode === 'upgrade') return upgrade(opts);
617
+ if (opts.mode === 'gateway') return gateway(opts);
618
+ if (opts.mode === 'join') return join(opts);
434
619
  const targetDir = path.resolve(process.cwd(), opts.dir);
435
620
 
436
621
  // Refuse to scribble over anything that already exists (an existing EMPTY dir is fine).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-wordjs",
3
- "version": "1.5.4",
3
+ "version": "1.6.1",
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)",